mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Tyler Longwell
parent
26418e92f5
commit
a4fdc35fa9
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user