fix(desktop): rekey upscroll momentum gate onto rendered scroll (w4a-gate-1)

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 <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
2026-07-09 01:11:30 -04:00
co-authored by Tyler Longwell
parent 44d9edcf2f
commit 2bd2076ebb
5 changed files with 564 additions and 41 deletions
+16 -9
View File
@@ -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.
@@ -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<Element, number>,
): number {
): { aboveShift: number; commit: () => void } {
const wrapper = anchorRow.closest<HTMLElement>(".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<Element, number>,
): 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,
});
}
}
@@ -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<HTMLElement>("[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<HTMLElement>(
`[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);
}
});
@@ -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;
@@ -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;