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.dwellvia 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 viaIntersectionObserverbound inrender-results!. Weight in the signal table below is +4 — betweencodeCopy(+5) andoutbound(+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
readCompletedetection: 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
- attribution-audit/spec.org — the ledger every signal is appended to; R24 provenance guard.
- exit-intent/spec.org — reads
eligible?; R29 gate. - tracker/spec.org — the scroll/click/dwell events feed the signal accumulator.
- stuffing-detectors/spec.org — detectors run over the same ledger
but never read
:provenance. - ORACLE:
src/wal_sh/adtech/intent_signals/core.cljc(pure)src/wal_sh/adtech/intent_signals/browser.cljs(DOM adapter)
7. Open questions
- Weight calibration. The weights are chosen for the site's own
content mix — high value on
repoClickbecause 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
:decayedScoreis 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.