From 2bd2076ebb32b32dbdbeab00c1a05385d32b9fc4 Mon Sep 17 00:00:00 2001 From: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 Date: Thu, 9 Jul 2026 01:11:30 -0400 Subject: [PATCH] fix(desktop): rekey upscroll momentum gate onto rendered scroll (w4a-gate-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mid-history correction's momentum-skip gate keyed on the raw `scrollTop` delta since baseline. On WebKit, momentum is coalesced into `scrollTop` on the compositor's own async clock, so a frame that PAINTED still can read a large raw delta and wrongly skip a genuine above-anchor reflow — the reversal survivor bin (the 204px-class lurch of #1662). Rekey the gate onto the RENDERED scroll — the painted wheel motion this frame — `renderedScroll = aboveShift - ΔtopOffset`: the anchor's painted on-screen move with the reflow's own push removed, both terms read from the painted DOM (getBoundingClientRect), not the possibly-ahead `scrollTop`. A rendered-still frame now reads ~0 and the reflow is corrected instead of dropped. Because the gate now needs `aboveShift` to decide, the band walk runs before the gate. To keep "under-correcting is invisible; the next quiet frame catches up," `sumAboveAnchorShift` no longer refreshes the height cache inline — it STAGES the refresh and returns `{ aboveShift, commit }`. The caller applies `commit()` only past the gate, so a momentum skip leaves the cache for the next frame; a kept correction refreshes it as one indivisible pass (the "second observer no-ops" invariant is intact). Arbiter contract, fresh w4a-gate-1 build, both engines: - raf-correction HARD gate: Chromium 0 reversals, rAF fires 0 / RO 21 (RO sole writer); WebKit 2 reversals max 14.5px (<=34 quantum floor), rAF fires 24 / RO no-op (residual 0.0). Ratified invariants hold. - slow-scroll: 0 reversals both engines (felt low-velocity regime clean). - fast-classify DECOMPOSITION self-test (WebKit): pure-reflow attempts median |signedShift|=18.5 but median |renderedScroll|=0.0 — the reflow does NOT leak into the gated quantity, and 19/28 fire. Pure-scroll |renderedScroll| runs up to 28. The decomposition is proven, not asserted. Guarded WebKit-only: on Chromium the rAF is the passive loser observer (RO corrects first), so renderedScroll there tracks signedShift by construction — characterized, not asserted. KNOWN RESIDUAL (surfaced, not hidden): 2 WebKit survivors remain, but they are a DIFFERENT class — signedShift=0.0 (the band walk sees no above-anchor reflow) while the row still lurches 14.5px. The momentum-gate survivors the rekey targeted are gone; these are cross-check/null-target skips where the reflow is invisible to the band walk. Separate diagnosis. Diagnostic-only `renderedScroll` is threaded through the shared result type + emitted on the rAF probe so the fixture can prove the decomposition; stripped/gated before merge per the file-size exit condition. Raises the useAnchoredScroll.ts size exception 1180->1210 to cover the arm (biome reflows the 4-prop returns); flagged in the override comment for Eva as heavy-for-a-diagnostic. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/scripts/check-file-sizes.mjs | 25 +- .../features/messages/ui/useAnchoredScroll.ts | 109 ++-- .../tests/e2e/upscroll-fast-classify.perf.ts | 467 ++++++++++++++++++ .../tests/e2e/upscroll-raf-correction.perf.ts | 2 +- .../tests/e2e/upscroll-slow-scroll.perf.ts | 2 +- 5 files changed, 564 insertions(+), 41 deletions(-) create mode 100644 desktop/tests/e2e/upscroll-fast-classify.perf.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index d90af7722..b6f99e59e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -189,16 +189,23 @@ const overrides = new Map([ // (Quinn's merge-bar checklist #4), the one-clock/single-writer reasoning, and // the build-stamp/probe contract are all documented AT their sites. The file // was 888 lines at HEAD; this fix's code + load-bearing provenance comments - // crossed 1000. The 1140→1180 bump is the W4a chase instrumentation inflating + // crossed 1000. The 1140→1210 bump is the W4a chase instrumentation inflating // a load-bearing hook mid-investigation: the signed-shift grow/shrink probe - // (classifier arm) and the renderedScroll gate-clock rekey (w4a-gate-1 arm), - // both carrying their provenance inline. EXIT CONDITION: once #1662's fate is - // decided, the diagnostic emits get stripped or gated and the mid-history - // corrector + band walk split into a sibling module — Eva's ruling holds the - // structural split until the arms in flight against this file settle, so it is - // NOT done mid-chase. Comment-dominated overage on a correctness fix, not - // generic debt growth. Approved override; queued to split with the rest. - ["src/features/messages/ui/useAnchoredScroll.ts", 1180], + // (classifier arm, 1140→1156) and the renderedScroll gate-clock rekey + // (w4a-gate-1 arm, ~1156→1206). The gate-arm growth is the deferred-refresh + // commit path + the momentum-gate rationale + the `renderedScroll` field + // threaded through the shared result type and all four returns so the + // classifier fixture can PROVE the scroll/reflow decomposition (Eva required + // this in the arm's first run); biome reflows each 4-prop return object to + // multi-line, which is most of the line cost. FLAGGED to Eva: this is heavy + // for a diagnostic — if the decomposition proof moves behind a lighter + // gate-only probe, this drops back toward 1185. EXIT CONDITION: once #1662's + // fate is decided, the diagnostic emits get stripped or gated and the + // mid-history corrector + band walk split into a sibling module — Eva's ruling + // holds the structural split until the arms in flight settle, so it is NOT + // done mid-chase. Comment-dominated overage on a correctness fix, not generic + // debt growth. Approved override; queued to split. + ["src/features/messages/ui/useAnchoredScroll.ts", 1210], // AgentConfigPanel footer fold into ProfileFieldGroup for the config-bridge // panel — a small overage from load-bearing UI plumbing, not generic debt // growth. Approved override; still queued to split with the rest of this list. diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index d8b6d4d18..4f0bf3f86 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -19,13 +19,16 @@ const TRUE_BOTTOM_THRESHOLD_PX = 1; // position is consistent with the frame the ResizeObserver fires in. Under // WebKit async ("coordinated") scrolling the rAF baseline read and the RO // post-layout read can straddle a compositor commit, so a fragment of the -// user's own momentum can leak into the measured shift. If the scroll moved -// more than this bound since the baseline was captured, momentum is clearly in -// flight and the two reads are not trustworthy together — we SKIP the -// correction rather than risk folding the wheel delta into the pin. Under- -// correcting a single realization is invisible (the next quiet frame catches -// it); fighting the wheel is the visible lurch. Chosen at roughly one frame of -// aggressive trackpad momentum; tune against the gate. +// user's own momentum can leak into the measured shift. If the RENDERED scroll +// (the painted wheel motion this frame — see `applyMidHistoryCorrection`) +// exceeds this bound, momentum is clearly in flight and the two reads are not +// trustworthy together — we SKIP the correction rather than risk folding the +// wheel delta into the pin. We gate on rendered, not raw `scrollTop`, delta +// because WebKit coalesces momentum into `scrollTop` on its own clock: a raw +// delta can read large on a frame that painted still. Under-correcting a single +// realization is invisible (the next quiet frame catches it); fighting the +// wheel is the visible lurch. Chosen at roughly one frame of aggressive +// trackpad momentum; tune against the gate. const COMPENSATION_SCROLL_SKIP_PX = 120; // Distance below the scroller top at which the reading anchor is chosen. We // pick the first row whose top sits at least this far below the viewport top @@ -318,6 +321,14 @@ export function computeAnchorCorrection( * the anchor's net document-position shift (`computeAnchorCorrection`): the two * agree only when the net shift is genuinely an above-anchor reflow, not a * straddling-row miscount or scroll artifact. + * + * DEFERRED REFRESH: the cache write is staged, not applied inline — the walk + * returns `{ aboveShift, commit }` and the caller applies `commit()` ONLY when + * it keeps this frame (does not momentum-skip). This preserves "first observer + * wins, second no-ops" while letting the momentum gate — which now needs + * `aboveShift` to decide — skip a frame WITHOUT refreshing the cache, so the + * next quiet frame still sees the pending reflow. Refreshing on a skip would + * silently swallow it. */ export function sumAboveAnchorShift( container: HTMLElement, @@ -336,9 +347,9 @@ export function sumAboveAnchorShift( // whose realization still shifts the anchor — is included. scrollTop: number, heights: WeakMap, -): number { +): { aboveShift: number; commit: () => void } { const wrapper = anchorRow.closest(".timeline-row-cv"); - if (!wrapper) return 0; + if (!wrapper) return { aboveShift: 0, commit: () => {} }; const containerTop = container.getBoundingClientRect().top; const bandTop = scrollTop - REFLOW_BAND_ABOVE_FOLD_PX; // Document-order walk over `.timeline-row-cv` rows, structure-agnostic: rows @@ -352,6 +363,10 @@ export function sumAboveAnchorShift( }); walker.currentNode = wrapper; let aboveShift = 0; + // Staged cache writes, applied by `commit()` only when the caller keeps this + // frame (does not momentum-skip). Includes first-sighting seeds so a skipped + // frame does not seed either — the next quiet frame does the whole pass. + const staged: Array<[Element, number]> = []; for ( let row = walker.previousNode() as HTMLElement | null; row; @@ -365,11 +380,14 @@ export function sumAboveAnchorShift( if (rowDocTop < bandTop) break; // older than the band; all prior are too. const height = rect.height; const last = heights.get(row); - heights.set(row, height); + staged.push([row, height]); if (last === undefined) continue; // first sighting in band: seed, don't count. aboveShift += height - last; } - return aboveShift; + const commit = () => { + for (const [row, height] of staged) heights.set(row, height); + }; + return { aboveShift, commit }; } /** @@ -394,6 +412,12 @@ type MidHistoryCorrection = { wouldFire: boolean; residual: number; signedShift: number; + // Diagnostic-only, rAF path only: the painted wheel motion the momentum gate + // keyed on this frame (`aboveShift − ΔtopOffset`). Lets the classifier fixture + // PROVE the scroll/reflow decomposition holds — a pure-scroll frame reads + // large here, a pure-reflow (rendered-still) frame reads ~0. The RO path has + // no momentum gate, so it omits this. Not consumed in production. + renderedScroll?: number; }; /** @@ -410,9 +434,9 @@ type MidHistoryCorrection = { * refreshed cache → residual ≈ 0 → no-op. * * "First observer wins, second observer no-ops" is therefore implicit in the - * cache, not coordinated by a flag. `sumAboveAnchorShift` both reads AND - * refreshes the band entries (`heights.set` runs unconditionally as it walks), - * so a correction and its cache refresh are one indivisible pass — the second + * cache, not coordinated by a flag. `sumAboveAnchorShift` reads the band and + * STAGES its refresh, applied by `commit()` only past the momentum gate, so a + * kept correction and its cache refresh are one indivisible pass — the second * observer cannot double-correct because the delta it would sum is already 0. * * `baseline` is the anchor's PRE-realization snapshot (the rAF frame-start read @@ -425,28 +449,42 @@ function applyMidHistoryCorrection( baseline: ReadingAnchor, heights: WeakMap, ): MidHistoryCorrection { - // Momentum in flight: a large scroll delta since baseline means the two reads - // may not describe one coherent state — skip rather than fold the wheel in. const currentScrollTop = container.scrollTop; - if ( - Math.abs(currentScrollTop - baseline.scrollTop) > - COMPENSATION_SCROLL_SKIP_PX - ) { - return { wouldFire: false, residual: 0, signedShift: 0 }; - } const containerTop = container.getBoundingClientRect().top; const currentTopOffset = baseline.row.getBoundingClientRect().top - containerTop; const current = { topOffset: currentTopOffset, scrollTop: currentScrollTop }; - // The band walk both computes `aboveShift` AND refreshes the band cache in - // place — this call is the refresh that zeroes the second observer. - const aboveShift = sumAboveAnchorShift( + // The band walk computes `aboveShift` and STAGES the band-cache refresh; we + // apply the refresh (`commit()`) only past the momentum gate, so a skip does + // not swallow the pending reflow (see `sumAboveAnchorShift`). + const { aboveShift, commit } = sumAboveAnchorShift( container, baseline.row, baseline.scrollTop + baseline.topOffset, currentScrollTop, heights, ); + const residual = Math.abs(aboveShift); + // Momentum in flight: the two reads may not describe one coherent state, so + // skip rather than fold the wheel into the correction. We gate on RENDERED + // scroll, NOT the raw `scrollTop` delta: WebKit coalesces momentum into + // `scrollTop` on its own async clock, so a frame that PAINTED still can read a + // large raw delta and wrongly skip a genuine reflow (the survivor bin). The + // rendered scroll is the anchor's painted move (`ΔtopOffset`) with the + // reflow's own push removed — `renderedScroll = aboveShift − ΔtopOffset`, + // both terms from the painted DOM — so a still frame reads ~0 and corrects. + const renderedScroll = aboveShift - (currentTopOffset - baseline.topOffset); + if (Math.abs(renderedScroll) > COMPENSATION_SCROLL_SKIP_PX) { + return { + wouldFire: false, + residual, + signedShift: aboveShift, + renderedScroll, + }; + } + // Past the gate: keep this frame, so refresh the band cache now (zeroes the + // second observer; first-observer-wins stays intact). + commit(); // Net document-position shift of the anchor since baseline (scroll-invariant), // and the gated correction target — same math, epsilon gate, as the unit- // tested `computeAnchorCorrection`. @@ -454,18 +492,27 @@ function applyMidHistoryCorrection( currentScrollTop + currentTopOffset - (baseline.scrollTop + baseline.topOffset); - const residual = Math.abs(aboveShift); const target = computeAnchorCorrection(baseline, current); if (target === null) - return { wouldFire: false, residual, signedShift: aboveShift }; + return { + wouldFire: false, + residual, + signedShift: aboveShift, + renderedScroll, + }; // Fire only when the two instruments agree — sufficiency cross-check that the // net shift is a real above-anchor reflow, not a straddler miscount. if (Math.abs(aboveShift - observedShift) > 0.5) { - return { wouldFire: false, residual, signedShift: aboveShift }; + return { + wouldFire: false, + residual, + signedShift: aboveShift, + renderedScroll, + }; } // Synchronous setter (not `scrollTo`, which WebKit may defer past paint). container.scrollTop = target; - return { wouldFire: true, residual, signedShift: aboveShift }; + return { wouldFire: true, residual, signedShift: aboveShift, renderedScroll }; } /** @@ -479,7 +526,7 @@ function applyMidHistoryCorrection( * this string whenever the correction mechanism under test changes so a stale * bundle can never masquerade as the current experiment. */ -const ANCHOR_BUILD_STAMP = "w4a-classifier-1"; +const ANCHOR_BUILD_STAMP = "w4a-gate-1"; /** * Test-only tripwire hook. In production `window.__ANCHOR_PROBE__` is undefined @@ -504,6 +551,7 @@ function reportCorrection( wouldFire: boolean; residual: number; signedShift: number; + renderedScroll?: number; }>; __ANCHOR_BUILD_STAMP__?: string; } @@ -517,6 +565,7 @@ function reportCorrection( wouldFire: result.wouldFire, residual: result.residual, signedShift: result.signedShift, + renderedScroll: result.renderedScroll, }); } } diff --git a/desktop/tests/e2e/upscroll-fast-classify.perf.ts b/desktop/tests/e2e/upscroll-fast-classify.perf.ts new file mode 100644 index 000000000..0bcc106dd --- /dev/null +++ b/desktop/tests/e2e/upscroll-fast-classify.perf.ts @@ -0,0 +1,467 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * Fast-corpus reversal characterization — the SKIP admission gate. + * + * WHY THIS EXISTS. The W4a gate (`upscroll-raf-correction.perf.ts`) drives a + * CONSTANT 12px/32ms (~375px/s) upscroll and, on WebKit, leaves 3-4 bounded + * reversal survivors (rows mock-jitter-387/393/381/375) that PASS the ≤4 gate. + * The classifier that typed those survivors as SKIP (fired=false, + * signedShift=0.0 → momentum-skip gate) was run against a fast-drive variant of + * the slow-scroll leg that was NEVER committed — so the SKIP labels were not + * reproducible from the tree. This fixture is that fast-drive classifier, + * committed, so the admission evidence is permanent and re-runnable. It reuses + * the slow leg's `probeLen` append-count join verbatim and adds two Leg-5 + * cross-checks (thread event 2a4e31fa, Eva's admission-gate ruling): + * + * 1. `dev = rowMove − scrollDelta` per reversal — Leg 5's rendered deviation + * from pure scroll-tracking. On a SKIP frame scrollDelta≈0 so dev≈rowMove + * with NO fired write behind it = abandonment, not a corrector footprint. + * 2. A WIDEN-INDEPENDENT neighborhood dump. Dawn's class attribution picks the + * single largest-|signedShift| attempt in a ±1-frame append-count window; + * the ±1 widen is the soft joint Eva flagged. This fixture ALSO reports, + * for every reversal, whether ANY `wouldFire=true` record exists in a + * WIDER ±2-frame window — attribution-free. If no fired write sits near a + * survivor at any reasonable window width, SKIP is robust to the widen; if + * one does, the largest-|shift| rule would have labelled it grow/shrink and + * the SKIP bin is in question. That neighborhood flag is the admission gate. + * + * HONESTY BOUND (unchanged from the slow leg): Playwright `mouse.wheel` is a + * synthetic discrete event; the WebKit `dScroll=0.0` coalesced still frame is a + * real-device phenomenon. But the fast gate corpus DOES surface the bounded + * survivors on Playwright WebKit, so this fixture reproduces the frames the + * admission gate must rule on. It CHARACTERIZES; it does not gate a ceiling. + */ + +// Fast constant drive — identical to the W4a gate (`upscroll-raf-correction`), +// so this fixture surfaces the same bounded survivors the gate leaves. +const WHEEL_DELTA = 12; // px/event — matches the gate's constant velocity +const WHEEL_PERIOD_MS = 32; // gate cadence (~375px/s) +const DURATION_MS = 12_000; +const SAFE_MARGIN = 100; +// Same reversal definition as the gate: row moving against the scroll by more +// than staircase noise. Upscroll → rowMove normally >= 0, so a genuine +// against-direction move is < -REVERSAL_PX. +const REVERSAL_PX = 3; +// Must equal `ANCHOR_BUILD_STAMP` in `useAnchoredScroll.ts` — stale-`dist` +// guard (see the gate fixture). Bump BOTH together per experiment. +const EXPECTED_BUILD_STAMP = "w4a-gate-1"; + +type Frame = { + t: number; + top: number | null; + scrollTop: number; + mounted: number; + rowId: string | null; + probeLen: number; +}; + +test("W4a fast-classify: SKIP admission — no fired write near the survivors", async ({ + page, + browserName, +}) => { + await installMockBridge(page); + page.on("console", (m) => { + if (m.type() === "error") console.log("PAGE ERROR:", m.text()); + }); + page.on("pageerror", (e) => console.log("PAGE EXCEPTION:", e.message)); + await page.addInitScript(() => { + ( + globalThis as unknown as { __ANCHOR_PROBE__: unknown[] } + ).__ANCHOR_PROBE__ = []; + }); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + await page.getByTestId("channel-jitter-corpus").click(); + const timeline = page.getByTestId("message-timeline"); + await page.waitForFunction(() => { + const el = document.querySelector( + '[data-testid="message-timeline"]', + ) as HTMLDivElement | null; + return !!el && el.scrollHeight > el.clientHeight + 1000; + }); + + await timeline.evaluate((element) => { + const el = element as HTMLDivElement; + el.style.overflowAnchor = "none"; + el.scrollTop = el.scrollHeight; + el.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await page.waitForTimeout(200); + await timeline.hover(); + + // Per-frame sampler (identical row-tracking to the gate, plus a join key into + // the hook's correction probe). The fixture sampler and the hook's rAF sampler + // are SEPARATE rAF loops, so "the correction for frame i" is not reliably the + // same-tick probe entry (two rAF callbacks in one frame fire in registration + // order, which we don't control). The order-robust join is by APPEND COUNT: + // each frame records `probeLen` (the correction-probe array length at that + // tick) and the signed shift + fire flag of any attempts that appended since + // the previous frame. A reversal between frame i-1 and i is then attributed to + // the attempts in that interval — no same-tick ordering assumption. + await timeline.evaluate((element, margin: number) => { + const el = element as HTMLDivElement; + const w = window as unknown as { + __PROBE__: { frames: Frame[]; stop: boolean }; + }; + const g = globalThis as unknown as { + __ANCHOR_PROBE__?: Array<{ + wouldFire: boolean; + residual: number; + signedShift: number; + }>; + }; + type Frame = { + t: number; + top: number | null; + scrollTop: number; + mounted: number; + rowId: string | null; + // Correction-probe array length at this tick — the append-count join key. + probeLen: number; + }; + w.__PROBE__ = { frames: [], stop: false }; + let trackedId: string | null = null; + const pick = (): string | null => { + const box = el.getBoundingClientRect(); + for (const row of el.querySelectorAll("[data-message-id]")) { + const rect = row.getBoundingClientRect(); + if (rect.top > box.top + margin && rect.bottom < box.bottom - margin) { + return row.dataset.messageId ?? null; + } + } + return null; + }; + const tick = (t: number) => { + if (w.__PROBE__.stop) return; + const mounted = el.querySelectorAll("[data-message-id]").length; + let top: number | null = null; + if (trackedId) { + const row = el.querySelector( + `[data-message-id="${CSS.escape(trackedId)}"]`, + ); + if (row) { + const rect = row.getBoundingClientRect(); + const box = el.getBoundingClientRect(); + const inBand = + rect.top > box.top + margin && rect.bottom < box.bottom - margin; + top = inBand ? rect.top : null; + } + } + if (top === null) trackedId = pick(); + w.__PROBE__.frames.push({ + t, + top, + scrollTop: el.scrollTop, + mounted, + rowId: trackedId, + probeLen: g.__ANCHOR_PROBE__?.length ?? 0, + }); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }, SAFE_MARGIN); + + // Fast constant drive — matches the W4a gate exactly, so the same bounded + // survivors surface. No decay: this is the fast regime, not Tyler's slow one. + const started = Date.now(); + while (Date.now() - started < DURATION_MS) { + await page.mouse.wheel(0, -WHEEL_DELTA); + await page.waitForTimeout(WHEEL_PERIOD_MS); + } + + const frames: Frame[] = await timeline.evaluate((_el) => { + const w = window as unknown as { + __PROBE__: { frames: Frame[]; stop: boolean }; + }; + type Frame = { + t: number; + top: number | null; + scrollTop: number; + mounted: number; + rowId: string | null; + probeLen: number; + }; + w.__PROBE__.stop = true; + return w.__PROBE__.frames; + }); + + const { corrections, buildStamp } = await page.evaluate(() => { + const g = globalThis as unknown as { + __ANCHOR_PROBE__?: Array<{ + source: "raf" | "ro"; + wouldFire: boolean; + residual: number; + signedShift: number; + renderedScroll?: number; + }>; + __ANCHOR_BUILD_STAMP__?: string; + }; + return { + corrections: g.__ANCHOR_PROBE__ ?? [], + buildStamp: g.__ANCHOR_BUILD_STAMP__ ?? null, + }; + }); + + // Score same-row frame pairs. A reversal is rowMove <= -REVERSAL_PX. For each + // reversal, join to the correction attempt(s) that appended to the hook probe + // BETWEEN frame i-1 and i (append-count window: probe indices [a.probeLen, + // b.probeLen)). Classify by the SIGN of aboveShift + whether the write fired — + // the three-way discriminator Sami specced (thread event 5b46582e): + // • wouldFire == false → SKIP: momentum gate (:29) / cross- + // check (:451) suppressed the write. The reversal is UNCORRECTED reflow; + // absorption never got to act. A 27→27 here = wiring/gate, not physics. + // • fired, signedShift > 0 (GROW) → content above grew, anchor shoved + // DOWN, the correction WRITE is the felt backward snap. Absorption's + // amortizable topology — deferring the pullback into forward frames helps. + // • fired, signedShift < 0 (SHRINK) → content above shrank, the reflow + // ITSELF pulls the anchor up and renders the reversal before any write. + // Structurally uncorrectable by us; only smaller per-frame realization + // (Max's pre-realization band / contain-intrinsic-size) shrinks it. + // A reversal with no attempt in its window is UNATTRIBUTED (the correcting + // observer's attempt landed in an adjacent frame under rAF interleave) — we + // count it separately rather than force it into a class. + let scored = 0; + let reanchors = 0; + type Klass = "skip" | "grow" | "shrink" | "unattributed"; + const reversals: Array<{ + i: number; + rowMove: number; + dScroll: number; + dev: number; // Leg 5: rowMove − scrollDelta (rendered deviation from tracking) + signedShift: number | null; + fired: boolean; + klass: Klass; + // Widen-independent admission flag: any wouldFire=true record in a WIDER + // ±2-frame append-count window than the ±1 attribution window. If false, + // no fired write sits near this reversal at any reasonable width → SKIP is + // robust to the widen. If true, the class attribution's largest-|shift| rule + // could have labelled it grow/shrink and the SKIP bin is in question. + firedNear: boolean; + rowId: string | null; + }> = []; + for (let i = 1; i < frames.length; i += 1) { + const a = frames[i - 1]; + const b = frames[i]; + if ( + a.top === null || + b.top === null || + a.rowId === null || + a.rowId !== b.rowId + ) { + reanchors += 1; + continue; + } + scored += 1; + const rowMove = b.top - a.top; + const dScroll = b.scrollTop - a.scrollTop; + if (rowMove > -REVERSAL_PX) continue; + // Attribution window. The hook's correction attempt for the reflow that + // produced this reversal can append across a ±1-frame span relative to our + // sampler: the two rAF loops interleave in an order we don't control, and on + // WebKit a late RO appends a frame after the reflow paints. So the window is + // [prev-frame probeLen, NEXT-frame probeLen) — attempts from the frame + // before through the frame after. A reversal with NO attempt anywhere in + // that span is genuinely unattributed (the corrector did not run a mid- + // history attempt on those frames at all — e.g. re-pick guard or null cur), + // which is itself a distinct diagnosis from a fired-then-clamped write. + const next = frames[i + 1] ?? b; + const window = corrections.slice(a.probeLen, next.probeLen); + let attempt: (typeof corrections)[number] | null = null; + for (const c of window) { + if ( + attempt === null || + Math.abs(c.signedShift) > Math.abs(attempt.signedShift) + ) { + attempt = c; + } + } + let klass: Klass; + if (attempt === null) { + klass = "unattributed"; + } else if (!attempt.wouldFire) { + klass = "skip"; + } else { + klass = attempt.signedShift >= 0 ? "grow" : "shrink"; + } + // Leg 5 rendered deviation from pure scroll-tracking. A correctly-anchored + // row moves only with scroll (rowMove == scrollDelta), so any deviation is + // the corrector's footprint — or, on a SKIP, its ABSENCE. + const dev = rowMove - dScroll; + // Widen-independent admission check. Look one frame WIDER than the ±1 + // attribution window ([i-2 .. i+2] via probeLen) and ask only: is there ANY + // fired write in that neighborhood? This does not pick a single attempt or + // depend on the largest-|shift| tie-break, so it cannot be flipped by the + // widen. A SKIP survivor must have firedNear=false: no write could be the + // backward mover if none fired near the frame at all. + const lo = frames[i - 2] ?? a; + const hi = frames[i + 2] ?? next; + const neighborhood = corrections.slice(lo.probeLen, hi.probeLen); + const firedNear = neighborhood.some((c) => c.wouldFire); + reversals.push({ + i, + rowMove, + dScroll, + dev, + signedShift: attempt?.signedShift ?? null, + fired: attempt?.wouldFire ?? false, + klass, + firedNear, + rowId: b.rowId, + }); + } + + const maxReversalPx = + reversals.length === 0 + ? 0 + : Math.max(...reversals.map((r) => Math.abs(r.rowMove))); + // A reversal on a near-still frame (|dScroll| < REVERSAL_PX) is the felt case: + // the eye is barely moving, so a backward row snap is maximally visible. + const stillFrameReversals = reversals.filter( + (r) => Math.abs(r.dScroll) < REVERSAL_PX, + ); + const byClass = (k: Klass) => reversals.filter((r) => r.klass === k).length; + + /* eslint-disable no-console */ + console.log("\n=== W4a FAST-CORPUS SKIP ADMISSION ==="); + console.log(`engine: ${browserName}`); + console.log(`build stamp: ${buildStamp ?? "(absent)"}`); + console.log(`frames sampled: ${frames.length}`); + console.log(`frame-pairs scored: ${scored}`); + console.log(`re-anchor/skip frames: ${reanchors}`); + console.log(`reversal frames: ${reversals.length}`); + console.log(` of which still-frame: ${stillFrameReversals.length}`); + console.log(`max reversal px: ${maxReversalPx.toFixed(1)}`); + console.log("--- reversal class mix (Sami's discriminator) ---"); + console.log(` SKIP (gate/xcheck, uncorrected reflow): ${byClass("skip")}`); + console.log( + ` GROW (fired, write is the snap — absorb): ${byClass("grow")}`, + ); + console.log( + ` SHRINK (fired, reflow renders it — Max): ${byClass("shrink")}`, + ); + console.log( + ` UNATTRIBUTED (no attempt in window): ${byClass("unattributed")}`, + ); + for (const r of reversals + .slice() + .sort((x, y) => x.rowMove - y.rowMove) + .slice(0, 12)) { + const s = r.signedShift === null ? "n/a" : r.signedShift.toFixed(1); + console.log( + ` frame ${r.i} rowMove=${r.rowMove.toFixed(1)} dScroll=${r.dScroll.toFixed(1)} dev=${r.dev.toFixed(1)} signedShift=${s} fired=${r.fired} firedNear=${r.firedNear} class=${r.klass} row=${r.rowId}`, + ); + } + console.log("========================================\n"); + /* eslint-enable no-console */ + + // Sanity: the actuation actually produced a scored upscroll. + expect(scored).toBeGreaterThan(50); + // Stale-`dist` guard — a characterization on a stale bundle misleads exactly + // like a stale gate run. Assert the experiment's stamp ran. + expect(buildStamp).toBe(EXPECTED_BUILD_STAMP); + // Liveness: at least one mid-history correction fired, else the corpus + // realized nothing and the distribution above is vacuous. + const anyFired = corrections.some((c) => c.wouldFire); + expect(anyFired).toBe(true); + + // --- SKIP ADMISSION GATE (Eva, thread event 2a4e31fa) ----------------------- + // Every reversal typed SKIP must have NO fired write in its ±2-frame + // neighborhood. This is attribution-free: it does not depend on the ±1 widen + // or the largest-|shift| tie-break, so a SKIP that survives it is robust to + // the soft joint in the classifier. If any SKIP shows firedNear=true, a write + // did land near the frame and the class attribution mis-labelled it — the bin + // is not admissible and this fails loudly rather than passing a stale claim. + // (Characterization otherwise; the reversal count itself is not gated.) + const skips = reversals.filter((r) => r.klass === "skip"); + for (const r of skips) { + expect( + r.firedNear, + `SKIP survivor frame ${r.i} (row ${r.rowId}) has a fired write in its ±2-frame neighborhood — attribution is not widen-robust, bin in question`, + ).toBe(false); + } + + // --- DECOMPOSITION SELF-TEST (Eva: arm's first run must SHOW it holds) ------- + // The w4a-gate-1 fix keys the momentum gate off the RENDERED scroll + // (`renderedScroll = aboveShift − ΔtopOffset`) the hook emits per rAF attempt, + // not the raw `Δscrolltop`. The load-bearing claim is that `renderedScroll` + // ISOLATES the painted wheel component by subtracting the reflow's own push on + // the anchor — so it stays small on a genuine reflow even when `signedShift` + // (the reflow) is large. Prove that independence from the emit, NOT the gate's + // own branch (asserting the gate skips when |renderedScroll|>bound is + // circular). We compare two populations of rAF attempts: + // • PURE-REFLOW — a real above-anchor reflow (`|signedShift|` well past the + // 0.5px epsilon) on a rendered-still frame. If the decomposition works, + // `renderedScroll` here is SMALL (the reflow push was removed), NOT tracking + // `signedShift`. This is the WebKit survivor the raw gate dropped. + // • PURE-SCROLL — no reflow (`signedShift` ≈ 0). `renderedScroll` here is + // free to be large: it is the wheel motion, with nothing to subtract. + // The proof: pure-reflow's median |renderedScroll| is well BELOW its median + // |signedShift| — the reflow did not leak into the gated quantity — while + // pure-scroll shows |renderedScroll| CAN run large. If renderedScroll merely + // echoed signedShift (a broken decomposition) the reflow bin would fail this. + const median = (xs: number[]): number => { + if (xs.length === 0) return 0; + const s = [...xs].sort((p, q) => p - q); + return s[Math.floor(s.length / 2)]; + }; + const rafWithRendered = corrections.filter( + (c): c is typeof c & { renderedScroll: number } => + c.source === "raf" && typeof c.renderedScroll === "number", + ); + const pureReflow = rafWithRendered.filter((c) => Math.abs(c.signedShift) > 5); + const pureScroll = rafWithRendered.filter( + (c) => Math.abs(c.signedShift) <= 0.5, + ); + const reflowMedRendered = median( + pureReflow.map((c) => Math.abs(c.renderedScroll)), + ); + const reflowMedShift = median(pureReflow.map((c) => Math.abs(c.signedShift))); + const scrollMaxRendered = pureScroll.length + ? Math.max(...pureScroll.map((c) => Math.abs(c.renderedScroll))) + : 0; + /* eslint-disable no-console */ + console.log("=== DECOMPOSITION SELF-TEST (w4a-gate-1) ==="); + console.log(`rAF attempts w/ renderedScroll: ${rafWithRendered.length}`); + console.log(`pure-reflow attempts (|shift|>5): ${pureReflow.length}`); + console.log( + ` median |signedShift|: ${reflowMedShift.toFixed(1)}`, + ); + console.log( + ` median |renderedScroll|: ${reflowMedRendered.toFixed(1)}`, + ); + console.log( + ` fired: ${pureReflow.filter((c) => c.wouldFire).length}`, + ); + console.log(`pure-scroll attempts (|shift|<=.5): ${pureScroll.length}`); + console.log( + ` max |renderedScroll|: ${scrollMaxRendered.toFixed(1)}`, + ); + console.log("============================================\n"); + /* eslint-enable no-console */ + // Both populations must be exercised, else the decomposition is untested. + // Assertions are WEBKIT-ONLY: the rAF momentum gate is the ACTIVE corrector + // only on WebKit (the RO is late). On Chromium the on-time RO corrects and + // refreshes the cache first, so the rAF path is the passive loser observer — + // it never fires (ratified mechanism) and reads the reflow BEFORE the RO's + // compensation, so `renderedScroll` there does not cancel and tracks + // `signedShift` instead. That is the correct Chromium behavior, not a + // decomposition failure, so we characterize it (logged above) but only assert + // the decomposition on the engine whose gate the fix rekeyed. + if (browserName === "webkit") { + expect(pureReflow.length).toBeGreaterThan(0); + expect(pureScroll.length).toBeGreaterThan(0); + // THE PROOF: on real reflow frames the reflow does NOT leak into + // renderedScroll — its median stays well below the reflow magnitude (a + // broken decomposition that echoed signedShift would fail this). + expect(reflowMedRendered).toBeLessThan(reflowMedShift); + // And a genuine rendered-still reflow is let through the gate — the survivor + // the raw-delta gate dropped on WebKit's coalesced clock. If none fires the + // rekey did nothing. + expect(pureReflow.some((c) => c.wouldFire)).toBe(true); + } +}); diff --git a/desktop/tests/e2e/upscroll-raf-correction.perf.ts b/desktop/tests/e2e/upscroll-raf-correction.perf.ts index f01687752..54c74c77c 100644 --- a/desktop/tests/e2e/upscroll-raf-correction.perf.ts +++ b/desktop/tests/e2e/upscroll-raf-correction.perf.ts @@ -47,7 +47,7 @@ const REVERSAL_PX = 3; // is `tsc && vite build`, and a tsc failure leaves the PRIOR bundle in `dist/`, // so a fixture can silently exercise stale code. Must equal `ANCHOR_BUILD_STAMP` // in `useAnchoredScroll.ts`; bump BOTH together per experiment. -const EXPECTED_BUILD_STAMP = "w4a-classifier-1"; +const EXPECTED_BUILD_STAMP = "w4a-gate-1"; type Frame = { t: number; diff --git a/desktop/tests/e2e/upscroll-slow-scroll.perf.ts b/desktop/tests/e2e/upscroll-slow-scroll.perf.ts index 24e722c4d..9ce6c3af4 100644 --- a/desktop/tests/e2e/upscroll-slow-scroll.perf.ts +++ b/desktop/tests/e2e/upscroll-slow-scroll.perf.ts @@ -39,7 +39,7 @@ const SAFE_MARGIN = 100; const REVERSAL_PX = 3; // Must equal `ANCHOR_BUILD_STAMP` in `useAnchoredScroll.ts` — stale-`dist` // guard (see the gate fixture). Bump BOTH together per experiment. -const EXPECTED_BUILD_STAMP = "w4a-classifier-1"; +const EXPECTED_BUILD_STAMP = "w4a-gate-1"; type Frame = { t: number;