Intent signals — spec

Table of Contents

1. Purpose

Intent-signals is the behavioural-signal layer. Eleven signals feed a weighted score; the score is a convention, not a measurement, and must be reported alongside the raw signal vector — never instead of it (R26). deadClick carries negative weight on purpose: frustration is not engagement.

engagedTime is modelled on Chartbeat's definition (Chartbeat, n.d.): the reader is visible AND recently active (input within the last 5 s), not wall-clock. R25 encodes this distinction. The :idle signal carries a negative weight to counter-balance the accrual bias — a page left open in a background tab does not accrue score.

Every signal appended to the ledger is observed per R24 — the audit layer's structural guard: no intent signal is ever asserted.

ORACLE: src/wal_sh/adtech/intent_signals/core.cljc (pure), browser.cljs (window._srIntent).

2. Requirements

R24
Every signal enters the attribution-audit ledger with :provenance "observed". No intent signal is ever asserted.
R25
engagedTime accrues when the page is visible AND an input event happened within idle-threshold-ms (5 s). Not wall-clock. Input events: scroll, mousemove, keydown, pointerdown, wheel, touchstart.
R26
The score is a weighted convention, reported alongside the raw vector — never instead of it. Any UI that shows the score must also expose the vector.
R27
Text selections are counted (textCopy) but selection content is never recorded. The signal is that a copy happened; what was copied is not the tracker's business.
R29
The exit-intent eligibility gate — engagedTime ≥ 15 s AND scrollDepth ≥ 50 %.
DR1
A search result visible in the viewport at ≥50 % for ≥8 s emits result.dwell via the tracker. Idempotent per (query, url) pair; a new query resets the dedup set so re-encountering a result under a different query counts as a fresh signal. Fires from pocket-es-integration via IntersectionObserver bound in render-results!. Weight in the signal table below is +4 — between codeCopy (+5) and outbound (+4). Rationale: extended dwell on a result title/description without click is the intent-signal equivalent of an unclicked-but-considered impression; cheaper than click, more specific than pageview.

3. Signal weights

Signal Weight Trigger
engagedTick +1 1 s tick, visible AND input <5 s ago
scrollQuartile +1/quartile 25/50/75/100 % (once each)
readComplete +3 footer IntersectionObserver ≥50 % for ≥2 s
codeCopy +5 copy inside <pre> / <code>
textCopy +2 copy elsewhere (R27: no content)
outbound +4 click on off-site <a>
repoClick +6 click on github/gitlab/sourcehut/codeberg
returnVisit +5 localStorage recurring visitor (>30 min prior)
searchRefine +2 /search query refinement
deadClick −1 click resolved nothing
idle −2 ≥30 s no input while visible
result.dwell +4 search-result visible ≥50 % for ≥8 s (DR1)

Rationale for repoClick > outbound: a click through to a code repo is the highest-value exit for a technical research site — the reader took the source. Rationale for deadClick < 0: frustration ≠ engagement; treating clicks-that-did-nothing as engagement is a classic dashboard failure mode.

4. Contract signature

Pure (core.cljc):

weights                                           ; map keyword -> integer weight
initial-signals                                   ; zero-state accumulator
(score signals-map)                               ; -> integer, higher = more engaged
(eligible? signals-map)                           ; -> bool; R29 gate for exit-intent

input-events                                      ; ["scroll" "mousemove" ...]
idle-threshold-ms                                 ; 5000 (R25)
idle-emit-modulo                                  ; 30 (emit :idle every 30 idle ticks)
engaged-tick-modulo                               ; 10 (:engagedTick per 10 s)
scroll-quartiles                                  ; [25 50 75 100]
read-complete-dwell-ms                            ; 2000
return-visit-window-ms                            ; 30 min

Browser surface:

window._srIntent = {
  signals:  (fn [] -> {signal-key -> count})      ; raw vector
  score:    (fn [] -> integer)                    ; weighted convention
  eligible: (fn [] -> bool)                       ; R29 gate
  reset:    (fn [] -> nil)                        ; test / debug
}

Requires window._srAttribution.Ledger at load; graceful no-op if missing.

5. Related literature

  • Chartbeat's engagedTime definition (Chartbeat, n.d.) is the direct antecedent for R25. Wall-clock dwell is a well-known dashboard failure mode; the Chartbeat formulation is the industry-standard fix.
  • Roesner et al.'s taxonomy of tracker profiles (Roesner, Kohno, and Wetherall 2012) documents the "engagement" bucket that this module lives in.
  • Bounce Exchange (Wunderkind) exit-intent literature (Bounce Exchange (Wunderkind), n.d.) is the commercial antecedent for the eligibility gate; our R29 differs by requiring both time AND scroll depth, not either.
  • IntersectionObserver semantics for readComplete detection: MDN (MDN Web Docs, n.d.).
  • Ali et al. on discrimination through delivery (Ali et al. 2019) motivates the audit-visible signal vector — if only the score ships, bias in the weights is undetectable.
  • Cook et al. on tracker-advertiser topology (Cook, Nithyanand, and Shafiq 2020) — an intent score fed to a real-time bidder without provenance is the ecosystem's worst-case signal.

6. Cross-references

7. Open questions

  • Weight calibration. The weights are chosen for the site's own content mix — high value on repoClick because the corpus is technical. A different corpus would want a different weight table. A per-content-type weight override is not yet built.
  • Score decay. The current score is monotone-non-decreasing over a page-life (subject to the negative signals). A per-session score decay would let us model "stale interest" without changing the weights. Whether to fold this in or expose it as a separate :decayedScore is an open call.
  • Correlation with conversion. There is no conversion event on wal.sh; the score is a signal without a ground truth. Any weight-tuning done against reader feedback rather than measured conversion is by definition a weighted convention — the docstring is honest about this.
Ali, Muhammad, Piotr Sapiezynski, Miranda Bogen, Aleksandra Korolova, Alan Mislove, and Aaron Rieke. 2019. “Discrimination through Optimization: How Facebook’s Ad Delivery Can Lead to Biased Outcomes.” In Proceedings of the Acm on Human-Computer Interaction. Vol. 3. CSCW. https://doi.org/10.1145/3359301.
Bounce Exchange (Wunderkind). n.d. “Exit-Intent Technology.” https://www.wunderkind.co/.
Chartbeat. n.d. “Engaged Time: The Metric That Matters.” Chartbeat Docs. https://support.chartbeat.com/docs/getting-started/engaged-time.
Cook, John, Rishab Nithyanand, and Zubair Shafiq. 2020. “Inferring Tracker-Advertiser Relationships in the Online Advertising Ecosystem Using Header Bidding.” Proceedings on Privacy Enhancing Technologies (Popets) 2020 (1): 65–82. https://doi.org/10.2478/popets-2020-0005.
MDN Web Docs. n.d. “Intersection Observer Api.” https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API.
Roesner, Franziska, Tadayoshi Kohno, and David Wetherall. 2012. “Detecting and Defending against Third-Party Tracking on the Web.” In 9Th Usenix Symposium on Networked Systems Design and Implementation (Nsdi 12), 155–68. https://www.usenix.org/conference/nsdi12/technical-sessions/presentation/roesner.