test(desktop): grow/shrink/skip reversal classifier + signedShift probe field

Adds the discriminator Sami specced (thread 5b46582e): the escape counter is
pure rendered rowMove and residual is |aboveShift|, so neither can recover the
grow-vs-shrink topology that decides the fix. Thread signed aboveShift (rAF path)
/ signed drift (RO path) through the correction probe as a diagnostic-only
'signedShift' field alongside wouldFire; production behavior unchanged.

The slow-scroll classifier joins each reversal to the correction attempt(s) in
its frame window (append-count join, order-robust across the two rAF loops) and
classifies: SKIP (gate/xcheck suppressed the write -> uncorrected reflow),
GROW (fired, signedShift>0 -> write is the felt snap, absorption's topology),
SHRINK (fired, signedShift<0 -> reflow renders it pre-write, only smaller
per-frame realization helps). Bumps ANCHOR_BUILD_STAMP to w4a-classifier-1 so the
stale-dist guard rejects any bundle missing signedShift.

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 00:29:47 -04:00
co-authored by Tyler Longwell
parent 26418e92f5
commit a4fdc35fa9
3 changed files with 123 additions and 18 deletions
@@ -379,10 +379,21 @@ export function sumAboveAnchorShift(
* instruments agreed on a real above-anchor reflow this call; `residual` is the
* `|aboveShift|` the walk saw — the second observer of the same realization
* sees this at ~0 because the first observer's walk already refreshed the cache.
*
* `signedShift` is `aboveShift` WITHOUT the abs — diagnostic-only, read by the
* slow-scroll classifier. Its sign is the grow/shrink discriminator the escape
* counter cannot recover from magnitude alone: `> 0` = content above grew, the
* anchor was pushed down and the correction WRITE is the felt backward snap
* (absorption's amortizable topology); `< 0` = content above shrank, the reflow
* itself pulls the anchor up and renders the reversal BEFORE any write touches
* it (structurally uncorrectable by us — only smaller per-frame realization
* helps). `residual = |signedShift|` throws that sign away, so the classifier
* reads `signedShift` directly. Not consumed in production.
*/
type MidHistoryCorrection = {
wouldFire: boolean;
residual: number;
signedShift: number;
};
/**
@@ -421,7 +432,7 @@ function applyMidHistoryCorrection(
Math.abs(currentScrollTop - baseline.scrollTop) >
COMPENSATION_SCROLL_SKIP_PX
) {
return { wouldFire: false, residual: 0 };
return { wouldFire: false, residual: 0, signedShift: 0 };
}
const containerTop = container.getBoundingClientRect().top;
const currentTopOffset =
@@ -445,15 +456,16 @@ function applyMidHistoryCorrection(
(baseline.scrollTop + baseline.topOffset);
const residual = Math.abs(aboveShift);
const target = computeAnchorCorrection(baseline, current);
if (target === null) return { wouldFire: false, residual };
if (target === null)
return { wouldFire: false, residual, signedShift: aboveShift };
// 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 };
return { wouldFire: false, residual, signedShift: aboveShift };
}
// Synchronous setter (not `scrollTo`, which WebKit may defer past paint).
container.scrollTop = target;
return { wouldFire: true, residual };
return { wouldFire: true, residual, signedShift: aboveShift };
}
/**
@@ -467,7 +479,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-ungated-ro-2";
const ANCHOR_BUILD_STAMP = "w4a-classifier-1";
/**
* Test-only tripwire hook. In production `window.__ANCHOR_PROBE__` is undefined
@@ -491,6 +503,7 @@ function reportCorrection(
source: "raf" | "ro";
wouldFire: boolean;
residual: number;
signedShift: number;
}>;
__ANCHOR_BUILD_STAMP__?: string;
}
@@ -503,6 +516,7 @@ function reportCorrection(
source,
wouldFire: result.wouldFire,
residual: result.residual,
signedShift: result.signedShift,
});
}
}
@@ -944,7 +958,11 @@ export function useAnchoredScroll({
if (Math.abs(height - last) > 0.5) changed = true;
}
if (!changed) {
reportCorrection("ro", { wouldFire: false, residual: 0 });
reportCorrection("ro", {
wouldFire: false,
residual: 0,
signedShift: 0,
});
return;
}
// Correct from the anchor's own measured drift. The layout engine already
@@ -956,7 +974,11 @@ export function useAnchoredScroll({
baseline.row.getBoundingClientRect().top - containerTop;
const drift = currentTopOffset - baseline.topOffset;
if (Math.abs(drift) <= 0.5) {
reportCorrection("ro", { wouldFire: false, residual: Math.abs(drift) });
reportCorrection("ro", {
wouldFire: false,
residual: Math.abs(drift),
signedShift: drift,
});
return;
}
// Synchronous setter (not `scrollTo`, which WebKit may defer past paint).
@@ -964,7 +986,11 @@ export function useAnchoredScroll({
// Re-baseline so a second RO batch this frame measures from where we
// pinned, not the pre-correction position.
readingAnchorRef.current = snapshotReadingAnchor(container);
reportCorrection("ro", { wouldFire: true, residual: Math.abs(drift) });
reportCorrection("ro", {
wouldFire: true,
residual: Math.abs(drift),
signedShift: drift,
});
});
// Observe every timeline row (not the content wrapper): a
// `content-visibility: auto` row realizing to its true height is a resize
@@ -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-ungated-ro-2";
const EXPECTED_BUILD_STAMP = "w4a-classifier-1";
type Frame = {
t: number;
+88 -9
View File
@@ -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-ungated-ro-2";
const EXPECTED_BUILD_STAMP = "w4a-classifier-1";
type Frame = {
t: number;
@@ -47,6 +47,7 @@ type Frame = {
scrollTop: number;
mounted: number;
rowId: string | null;
probeLen: number;
};
test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", async ({
@@ -85,19 +86,35 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a
await page.waitForTimeout(200);
await timeline.hover();
// Per-frame sampler (identical to the gate: one clock, same row-tracking) so
// the two legs' distributions are directly comparable.
// 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;
@@ -134,6 +151,7 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a
scrollTop: el.scrollTop,
mounted,
rowId: trackedId,
probeLen: g.__ANCHOR_PROBE__?.length ?? 0,
});
requestAnimationFrame(tick);
};
@@ -166,6 +184,7 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a
scrollTop: number;
mounted: number;
rowId: string | null;
probeLen: number;
};
w.__PROBE__.stop = true;
return w.__PROBE__.frames;
@@ -177,6 +196,7 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a
source: "raf" | "ro";
wouldFire: boolean;
residual: number;
signedShift: number;
}>;
__ANCHOR_BUILD_STAMP__?: string;
};
@@ -186,15 +206,34 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a
};
});
// Score same-row frame pairs. A reversal is rowMove <= -REVERSAL_PX. Pair each
// with the per-frame rendered scroll delta (dScroll) so we can see whether a
// reversal lands on a near-still frame (the felt case) or rides momentum.
// 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;
signedShift: number | null;
fired: boolean;
klass: Klass;
rowId: string | null;
}> = [];
for (let i = 1; i < frames.length; i += 1) {
@@ -212,9 +251,36 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a
scored += 1;
const rowMove = b.top - a.top;
const dScroll = b.scrollTop - a.scrollTop;
if (rowMove <= -REVERSAL_PX) {
reversals.push({ i, rowMove, dScroll, rowId: b.rowId });
if (rowMove > -REVERSAL_PX) continue;
// Attempts appended in this frame's window; pick the one whose |signedShift|
// is largest (the dominant reflow this frame drives the felt motion).
const window = corrections.slice(a.probeLen, b.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";
}
reversals.push({
i,
rowMove,
dScroll,
signedShift: attempt?.signedShift ?? null,
fired: attempt?.wouldFire ?? false,
klass,
rowId: b.rowId,
});
}
const maxReversalPx =
@@ -226,6 +292,7 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a
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 SLOW-SCROLL CHARACTERIZATION ===");
@@ -237,12 +304,24 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a
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)} row=${r.rowId}`,
` frame ${r.i} rowMove=${r.rowMove.toFixed(1)} dScroll=${r.dScroll.toFixed(1)} signedShift=${s} fired=${r.fired} class=${r.klass} row=${r.rowId}`,
);
}
console.log("========================================\n");