{"id":50364827,"url":"https://github.com/andyed/approach-retreat","last_synced_at":"2026-05-30T03:03:41.750Z","repository":{"id":349924773,"uuid":"1204466804","full_name":"andyed/approach-retreat","owner":"andyed","description":"Cursor approach-retreat dynamics on search result pages. SERP-specific companion to ClickSense.","archived":false,"fork":false,"pushed_at":"2026-05-02T03:34:20.000Z","size":83816,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-02T05:29:50.217Z","etag":null,"topics":["cognitive-modeling","information-retrieval","mouse-tracking","relevance-f"],"latest_commit_sha":null,"homepage":"https://andyed.github.io/approach-retreat/","language":"HTML","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/andyed.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-08T03:15:46.000Z","updated_at":"2026-05-01T04:28:49.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/andyed/approach-retreat","commit_stats":null,"previous_names":["andyed/approach-retreat"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/andyed/approach-retreat","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/andyed%2Fapproach-retreat","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/andyed%2Fapproach-retreat/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/andyed%2Fapproach-retreat/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/andyed%2Fapproach-retreat/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/andyed","download_url":"https://codeload.github.com/andyed/approach-retreat/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/andyed%2Fapproach-retreat/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33678271,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-30T02:00:06.278Z","response_time":92,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cognitive-modeling","information-retrieval","mouse-tracking","relevance-f"],"created_at":"2026-05-30T03:03:41.609Z","updated_at":"2026-05-30T03:03:41.738Z","avatar_url":"https://github.com/andyed.png","language":"HTML","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Approach/Retreat\n\nTells your SERP what users *did* with each result — beyond clicks, without\nan eye tracker. Drop-in instrumentation for ranked-list pages: search\nresult pages, recommendation feeds, comparison tables, product grids.\n\nTwo channels:\n\n1. **Approach-retreat episodes** *(desktop, cursor)* — per-result enter /\n   dwell / exit behaviour, classified into a four-class taxonomy:\n   **clicked**, **deferred** (considered, returned to, eventually skipped),\n   **evaluated-rejected** (approached, decided against), and\n   **not-approached**. These map directly onto the (0/1/2) graded-relevance\n   vocabulary that learning-to-rank consumes natively. Also produces the\n   seven-feature M4-7 vector used by the click-prediction and deferred-class\n   classifiers.\n2. **Viewport dynamics** *(any device, scroll-only)* — per-AOI residence,\n   MRC/IAB viewability, and scroll kinematics. No cursor required; works\n   wherever `scroll` events + DOM bounding boxes are available, including\n   mobile and feed surfaces.\n\nSister library to [ClickSense](https://github.com/andyed/clicksense), which\ncaptures the per-click commitment moment. Approach-retreat captures the\nevaluation phase that precedes it. For the research backing — task model,\nfour-class taxonomy derivation, validation against AdSERP and ACD — see\n[`docs/research.md`](docs/research.md).\n\n## See it in action\n\nThree AdSERP trials replayed against the original screenshots. The labels\n(CLK / DEF / REJ / NA) were inferred from cursor episodes alone — no gaze\ndata at inference time. Boxes are AOIs from the dataset; labels are this\nlibrary's output.\n\n\u003ctable\u003e\n\u003ctr\u003e\n\u003ctd align=\"center\" valign=\"middle\"\u003e\u003ca href=\"https://andyed.github.io/approach-retreat/replay/trials/p006-b4-t7.html\"\u003e\u003cimg src=\"site/assets/hero/p006-b4-t7_tilt.png\" alt=\"Canonical rejected — DEF 9 / REJ 4\" width=\"300\"/\u003e\u003c/a\u003e\u003cbr/\u003e\u003csub\u003e\u003cb\u003eCanonical rejected\u003c/b\u003e\u003cbr/\u003eDEF 9 · REJ 4\u003c/sub\u003e\u003c/td\u003e\n\u003ctd align=\"center\" valign=\"middle\"\u003e\u003ca href=\"https://andyed.github.io/approach-retreat/replay/trials/p047-b6-t1.html\"\u003e\u003cimg src=\"site/assets/hero/p047-b6-t1_tilt.png\" alt=\"Multi-AOI drama — CLK 1 / DEF 9 / REJ 1 / NA 4\" width=\"440\"/\u003e\u003c/a\u003e\u003cbr/\u003e\u003csub\u003e\u003cb\u003eMulti-AOI drama\u003c/b\u003e\u003cbr/\u003eCLK 1 · DEF 9 · REJ 1 · NA 4\u003c/sub\u003e\u003c/td\u003e\n\u003ctd align=\"center\" valign=\"middle\"\u003e\u003ca href=\"https://andyed.github.io/approach-retreat/replay/trials/p019-b1-t8.html\"\u003e\u003cimg src=\"site/assets/hero/p019-b1-t8_tilt.png\" alt=\"Canonical deferred — DEF 11\" width=\"300\"/\u003e\u003c/a\u003e\u003cbr/\u003e\u003csub\u003e\u003cb\u003eCanonical deferred\u003c/b\u003e\u003cbr/\u003eDEF 11 · REJ 1\u003c/sub\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/table\u003e\n\nBackgrounds are raw AdSERP screenshots — what the participant saw, pixel\nfor pixel. Full replay index:\n[andyed.github.io/approach-retreat/replay/](https://andyed.github.io/approach-retreat/replay/) —\n86 curated trials.\n\nThe companion viewer at\n[andyed.github.io/attentional-foraging/](https://andyed.github.io/attentional-foraging/)\nrenders the same trials through a foveated-perception simulator (showing\nwhat the participant could *resolve* at each fixation). Different view of\nthe same data.\n\n## Run it yourself\n\nTwo live deployments of the library you can poke at right now. Same code,\ndifferent surfaces — clone the repo, fork the demos, or just open a URL\nand press `d` to watch the debug overlay light up as you move your cursor.\n\n**[andyed.github.io/approach-retreat/](https://andyed.github.io/approach-retreat/)**\n— 5 SERP layouts × 4 Q\u0026A queries, 20 bookmarkable combinations. Pick a\nlayout, pick a question, browse the answers like a search engine. Press\n`d` on any SERP for the in-page debug overlay showing live episode\nclassification per result. Same `ar_episode` / `ar_click` /\n`ar_session_summary` schema across all 20 — the layout is the variable,\nthe instrumentation is the constant.\n\n**[movies.mindbendingpixels.com](https://movies.mindbendingpixels.com)** —\nthe same library running in a different domain (a film-recommendation\nmission flow) against the same PostHog event contract. Portability\nproof: v0.2.1 and v0.3.0 of this library shipped data-correctness fixes\ndiscovered on that deployment (see [CHANGELOG](CHANGELOG.md)).\n\nTelemetry is live on both. Append `?ph=0` to any URL to opt out of\ncapture. All site source is in [`site/`](site/) — fork it, drop in your\nown results, ship your own deployment.\n\n---\n\n## Install\n\n```bash\nnpm install andyed/approach-retreat\n```\n\nOr via script tag:\n\n```html\n\u003cscript src=\"dist/approach-retreat.js\"\u003e\u003c/script\u003e\n```\n\n## Quick start\n\n```js\nimport { ApproachRetreat } from 'approach-retreat';\n\nconst ar = new ApproachRetreat({\n  resultSelector: '[data-result]',\n  onEpisode: (episode) =\u003e console.log(episode),\n  onClick: ({ position, episode }) =\u003e {\n    console.log(`Clicked position ${position} after ${episode.dwell_ms}ms`);\n  },\n});\n```\n\nMark your results:\n\n```html\n\u003cdiv data-result data-position=\"0\"\u003e\n  \u003ch3\u003eResult title\u003c/h3\u003e\n  \u003cp\u003eSnippet text...\u003c/p\u003e\n\u003c/div\u003e\n```\n\n### Tag the surface type with `data-etype` (recommended)\n\nIf your SERP mixes ads and organics in the result column, tag each so your\ndashboard can slice ad-vs-organic behaviour:\n\n```html\n\u003cdiv data-result data-position=\"0\" data-etype=\"dd_top\"\u003e...\u003c/div\u003e\n\u003cdiv data-result data-position=\"1\" data-etype=\"organic\"\u003e...\u003c/div\u003e\n\u003cdiv data-result data-position=\"2\" data-etype=\"native_ad\"\u003e...\u003c/div\u003e\n```\n\nConventional values: `organic` (first-class result), `dd_top` (top-of-page\nad carousel cell), `native_ad` (inline-text ad). Any `data-*` attribute is\npassed through to PostHog as `target_data_\u003ckey\u003e`. The library is\netype-agnostic at the machinery level — what you give up by going\nuntagged is dashboard-level slicing, not capture.\n\n## What the library emits\n\n### Episode (cursor channel)\n\nEvery completed visit to a result emits a 23-field episode from\n`Episode.toJSON()` (19 cursor fields + 4 banded-viewport fields, the\nlatter null when `trackViewportBands: false`).\n\n```js\n{\n  // --- Identity + outcome ---\n  position: 2,\n  outcome: 'deferred',          // clicked | deferred | evaluated_rejected | not_approached\n  visited: true,\n  clicked: false,\n  retreated: true,\n  visit_number: 2,              // 1 = first visit, 2+ = re-approach\n\n  // --- Timing (ms, performance.now() base) ---\n  dwell_ms: 847,\n  entered_at: 1412.38,\n  exited_at: 2259.77,\n  clicked_at: null,\n\n  // --- Cursor dynamics ---\n  approach_velocity: 0.34,      // px/ms at entry\n  approach_angle: 1.21,         // radians, atan2(vy, vx) at entry\n  peak_velocity: 0.89,\n  min_velocity: 0.02,\n  retreat_distance: 186,        // px from AOI center at max retreat\n  sample_count: 51,\n\n  // --- Scroll context ---\n  direction: 'forward',         // forward | regressive\n  entry_scroll: 420,\n  hwm_at_entry: 420,\n}\n```\n\n#### Raw trajectory (opt-in)\n\nSet `includeSamplesInEpisodeJson: true` to add a `samples` array (one\n`{x,y,t,vx,vy}` per native mousemove sample, ~60 Hz). Research-grade\nmaterial — keep it local unless you're shipping it through the PostHog\nadapter.\n\n### Viewport analytics (cursor-free channel)\n\nOne record per AOI per session, computed from scroll events plus DOM\nbounding boxes alone. Runs anywhere `scroll` is logged.\n\n```js\nar.getViewportAnalytics();\n// [{\n//   position: 0,\n//   // Impression (MRC/IAB)\n//   iab_viewable: true,            // ≥ 50% pixels visible for ≥ 1s continuous\n//   ms_at_50pct_or_more: 2400,\n//   // Residence (continuous)\n//   vt_any_ms: 6200,\n//   vt_center_ms: 1800,\n//   avg_viewport_y_px: 340,\n//   max_overlap_frac: 1.0,\n//   // Kinematics (scroll trajectory while visible)\n//   min_abs_velocity_px_per_s: 0,\n//   n_reversals: 2,\n// }, ...]\n```\n\n| Tier | Field | Meaning |\n|---|---|---|\n| Impression (MRC/IAB) | `iab_viewable` | True iff ≥ 50% pixel overlap held for ≥ 1 continuous second. Display rule. |\n| Impression | `ms_at_50pct_or_more` | Cumulative ms at ≥ 50% overlap, no continuity constraint. |\n| Residence | `vt_any_ms` | Cumulative ms with any viewport overlap. \"Did the user ever see it?\" |\n| Residence | `vt_center_ms` | Cumulative ms with AOI center within ±100 px of viewport center. |\n| Residence | `avg_viewport_y_px` | Mean AOI-center viewport-y during visibility. |\n| Impression / peak | `max_overlap_frac` | Peak fraction visible. 1.0 = fully in view at some point. |\n| Kinematics | `min_abs_velocity_px_per_s` | Slowest scroll speed while AOI was visible. Stabilization marker. |\n| Kinematics | `n_reversals` | Scroll-direction reversals while AOI was visible. EWM-reload signal. |\n\n**Banded decomposition** (`vp_top_ms` / `vp_mid_ms` / `vp_bot_ms`) is also\navailable via `ar.getViewportBands()` — retained for dashboard heatmaps;\nadds no detectable AUC on top of the continuous six (see research index\nfor sourcing).\n\n**Config:** `trackViewportAnalytics` (default `true`),\n`viewportCenterTolPx` (default 100), `iabViewableThresholdMs` (default\n1000 for the MRC display rule; set 2000 for video).\n\n### Library-side classification + signals\n\n```js\nar.classify();\n// { clicked: [{position, ...}], deferred: [...],\n//   evaluated_rejected: [...], not_approached: [...] }\n\nar.getSignals();\n// [{ position, outcome, total_dwell_ms, mean_retreat_distance, ... }, ...]\n\nar.getEpisodes();   // full list, one entry per finalized visit\nar.flush();         // finalize in-flight episodes without clearing history\n```\n\n### Canonical seven-feature M4-7 vector\n\n`ar.getApproachFeatures()` emits the canonical feature vector consumed by\nthe click-prediction (M3) and deferred-class (M5) classifiers. One vector\nper result position per session. The companion paper (submitted)\ndocuments the click-buffer leakage screen that distinguishes the seven\nbuffer-robust features from `final_dist` and `retreat_dist` — see\n[`docs/research.md`](docs/research.md) for the deployment caveat.\n\n\u003cp align=\"center\"\u003e\n\u003cimg src=\"site/assets/feature-dt-panels.png\" alt=\"Seven approach features annotated on a cursor-to-AOI distance trace d(t)\" width=\"420\"/\u003e\n\u003c/p\u003e\n\n*Every feature is a geometric property of one deliberation episode's\ncursor-to-AOI distance `d(t)` — no gaze, no click. **(a) Commitment**,\nproximity: `min_dist`, `mean_dist`, `dwell_in_proximity_ms`.\n**(b) Decisiveness**, approach rate: `mean_approach_velocity`,\n`max_approach_velocity`. **(c) Vacillation**, monotonicity:\n`direction_changes`, `frac_decreasing`. Synthetic trace.*\n\n```js\nar.getApproachFeatures();\n// [\n//   { position: 0,\n//     min_dist: 2.0,\n//     mean_dist: 143.15,\n//     dwell_in_proximity_ms: 466,\n//     mean_approach_velocity: 238,\n//     max_approach_velocity: 966,\n//     direction_changes: 11,\n//     frac_decreasing: 0.62,\n//     // Caveat fields — see research index\n//     final_dist: 200.0,\n//     retreat_dist: 198.0,\n//     sample_count: 64 },\n//   { position: 1, ... },\n// ]\n```\n\n### Sampling rate (`maxSampleHz`)\n\nThe cursor feature path runs on `mousemove`, which fires at ~60 Hz (more\non high-refresh displays). Each event accumulates the approach features\nand does an over-result hit-test that forces one synchronous layout read\nper result — ~60×/s of forced layout for as long as the page is open.\n\n`maxSampleHz` caps that. It defaults to **15** — a fixed-rate throttle\nthat keeps at most one `mousemove` per `1000 / maxSampleHz` ms (~66.7 ms\nat 15 Hz) and drops the rest before any state is touched. This is\n*uniform time-decimation*: a kept event is processed exactly as at full\nrate, velocity and Δt are simply measured over the wider gap.\n\n```js\nconst ar = new ApproachRetreat({\n  maxSampleHz: 15,   // default — cap the mousemove feature path at 15 Hz\n});\n\n// Disable the throttle (process every native event) for research /\n// replication against a native-rate-trained model:\nconst arFullRate = new ApproachRetreat({ maxSampleHz: 0 }); // or Infinity\n```\n\nThe §5.1 cursor sampling-rate ablation downsampled the AdSERP cursor\nstream from ~59 Hz to 1 Hz and re-ran the M4 LOSO click-prediction: AUC\nheld flat at 0.847 ± 0.001 across the whole range. The seven approach\nfeatures are per-episode aggregates and rate-invariant by construction,\nso 15 Hz carries large accuracy headroom while cutting the layout cost\n~4×. `click` is a separate listener and is **never** throttled.\n\n## Sending events to your analytics\n\n### PostHog adapter (bundled)\n\nThree event types, all `ar_`-prefixed:\n\n| Event | Fires on | Key fields |\n|---|---|---|\n| `ar_episode` | every finalized episode | the 19 cursor fields + optional `ar_trajectory` (10% sample rate by default) |\n| `ar_click` | every click on a result | pre-click velocity, angle, direction, retreat distance, dwell |\n| `ar_session_summary` | `visibilitychange` / `pagehide` | four-class counts, positions per class, time-to-first-click |\n\nEvery event is merged with session context: `ar_session_id`, `ar_layout`,\n`ar_query_id`, viewport (`w`, `h`, `dpr`), UA, referrer, page path.\n\n**Kill switch.** Append `?ph=0` to any URL to skip PostHog entirely.\n\n`ar_click` carries the ClickSense v0.2 target vocabulary —\n`target_tag` / `target_id` / `target_label` / `target_href` /\n`target_text` / `target_aria_label` / `target_title` / `target_name` /\n`target_path` / `target_data_\u003ckey\u003e` — so you can JOIN\n`click_confidence ↔ ar_click` on `target_href` or `target_name` when both\nlibraries run on the same page.\n\n### Other adapters\n\n- `approach-retreat/adapters/posthog` — PostHog event flattening.\n- `approach-retreat/adapters/callback` — buffer + flush (`sendBeacon`,\n  custom transport).\n\n## Composing with ClickSense\n\nBoth libraries run on the same page without conflict. ClickSense\ncaptures the commitment moment (per-click confidence); approach-retreat\ncaptures the evaluation phase that precedes it.\n\n```js\nimport { ClickSense } from 'clicksense';\nimport { ApproachRetreat } from 'approach-retreat';\n\nconst cs = new ClickSense({ enableApproachDynamics: true, onCapture: ... });\nconst ar = new ApproachRetreat({ resultSelector: '[data-result]', onEpisode: ... });\n```\n\n## Relevance scoring\n\n```js\nconst scores = ar.computeRelevance();\n// [{ position: 0, score: 0.72, signals: {...} }, ...]\n```\n\nDefault weights: dwell time (40%), re-approaches (30%), clicks (30%),\nsmall penalty for repeated retreats. The four-class taxonomy maps cleanly\nonto the (0/1/2) graded-relevance vocabulary that learning-to-rank\nconsumes natively (clicked = 2, deferred = 1, evaluated-rejected = 0;\nnot-approached excluded as no-evidence).\n\n## Privacy\n\nThe library captures cursor + scroll events that the page's own\nJavaScript already has access to — no new permissions. Raw trajectory\nsamples are opt-in (`includeSamplesInEpisodeJson: true`); without that\nflag, only aggregate-per-episode statistics leave the browser.\n\nFor deployment-grade privacy posture (consent, retention, opt-out):\nfollow your existing PostHog (or other analytics) configuration. The\nlibrary does not introduce a new data plane; it adds events to the one\nyou already operate.\n\nFor a published treatment of the same telemetry primitives' privacy\nimplications, see Leiva, Arapakis \u0026 Iordanou. \"My Mouse, My Rules\"\n(CHIIR 2021).\n\n---\n\n## For researchers\n\nThe library is the runnable form of the cognitive task model in the\ncompanion paper (submitted). The full research index — task model\nderivation, four-class taxonomy validation, click-buffer leakage screen,\nLAB / WILD numbers with provenance, foundation-model rebuttal, and the\nLeiva/Arapakis lineage — lives at\n**[`docs/research.md`](docs/research.md)**.\n\nAncillary docs:\n\n- [`docs/theory.md`](docs/theory.md) — concise theoretical writeup.\n- [`docs/one-pager.md`](docs/one-pager.md) — task model vs 638-feature bag,\n  four-class taxonomy, retreat geometry as deliberation indicator.\n- [`docs/positioning.md`](docs/positioning.md) — four-lane map of related\n  work.\n- [`docs/bbox-attribution-lineage.md`](docs/bbox-attribution-lineage.md) —\n  sequence of AOI extraction flavors (band → bbox-organic → typed →\n  typed_gapfill → cellsplit), which is in use, and how to read flavor\n  tags in cited numbers. **Read before citing any AUC from this repo.**\n- [`docs/history.md`](docs/history.md) — Lucidity 2001 → Optimoz 2001 →\n  Uzilla 2003 → ClickSense 2026 → approach-retreat 2026 lineage with\n  Slashdot front-page screenshot.\n- [`docs/validation/attcur-bruckner.md`](docs/validation/attcur-bruckner.md) —\n  public head-to-head against Brückner, Arapakis \u0026 Leiva (SIGIR 2021).\n  Approach-retreat features beat the scalar mouse-length baseline by\n  +12.5 AUC (0.821 vs 0.696) on their own ad-click-prediction benchmark.\n- [`docs/validation/m5-calibration.md`](docs/validation/m5-calibration.md) —\n  end-to-end calibration methodology for the deferred-class detector.\n- [`docs/validation/viewport-bands-calibration.md`](docs/validation/viewport-bands-calibration.md) —\n  bootstrap protocol for the retreat + bands AUC.\n- [`docs/validation/feature-ablation-cross-stage.md`](docs/validation/feature-ablation-cross-stage.md) —\n  full LOFO + group ablation matrix across the paper's four modeling\n  stages (click classifier, deferred classifier, three LambdaMART\n  rankers). The cross-stage view the paper §4.1/§4.3/§4.6 paragraphs\n  imply but couldn't fit in page budget.\n\n\u003e **AllSERP companion paper.** *AllSERP: Exhaustive Per-Element Enrichment\n\u003e of the Versatile AdSERP Dataset* — [arXiv:2605.04949](https://arxiv.org/abs/2605.04949)\n\u003e (2026). Documents the typed AOI extraction used here for AOI labels in\n\u003e the replay viewer. Local PDF: [`allserp-paper.pdf`](./allserp-paper.pdf).\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fandyed%2Fapproach-retreat","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fandyed%2Fapproach-retreat","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fandyed%2Fapproach-retreat/lists"}