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