Files
buzz/desktop/tests/e2e/upscroll-jitter.perf.ts
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc 104f3cd62e T1.2 upscroll-jitter gate: score a fetchOlder prepend per run
The committed gate certified only the CV-realization half of H1: it
terminated at the ~600px fetchOlder sentinel band, so the pagination
half — cold rows paged in ABOVE the viewport by scrollback — was proven
only by Quinn's throwaway probe, not by the gate as it runs (the
coverage gap Quinn [9] and Dawn [10] flagged, land-now/fast-follow
ratified by Eva [11]).

Root cause: when a prepend lands, scrollTop jumps UP (~100 older rows of
height added above), so `appliedTop = before - after` goes negative and
the loop's top-of-history break fired at the sentinel — before scoring
any paged-in row. On the RED baseline this break was deterministic; on
GREEN it was flaky (the prepend was scored only when the re-anchor
happened to leave the tracked row in the safe band).

Fix: distinguish the two causes of a non-decreasing scrollTop by
mounted-count. A prepend grows it; the true top does not. On a prepend
re-anchor, skip scoring that step (its motion is the multi-thousand-px
jump, not a fixed STEP notch — scoring it would poison the metric) and
CONTINUE; the next iteration re-baselines a fresh safe-band row in the
paged-in window and scoring resumes across the prepend. Assert
`prependObserved` so the gate exercises BOTH jitter sources every run.

Verified on the T3 integration tip 1fa4551a (GREEN) and the T1.1 base
32e41e78 (RED), both rebuilt:
- GREEN: prepend scored (50->200, walks all crossings), gate 0.00/0.00
  PASS, 77-78 scored steps, deterministic across 4 runs.
- RED: prepend scored (50->150), coverage assert PASSES, gate still
  FAILS on jitter (peak 41px > 2.0) — reds for the right reason.
The dual-sided RED/GREEN contract and the anti-cheat floors are
preserved; the change is scoped to the gate file only.

Note: gate-run R is window-dependent (Eva/Max [8]); scoring the deeper
paged-in population raises the non-gating R diagnostic from 75 to ~85.
This is NOT a cross-lane leak — the T2 writer's reserve is untouched;
R moved only because the sample window grew, exactly as the
window-sensitivity caveat predicts.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-08 15:01:06 -04:00

389 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
/**
* UPSCROLL-JITTER GATE (L-E / T1.1) — the RED-today metric the fix train rides on.
*
* Tyler's report: scrolling UP a fully-loaded channel is jumpy in tiny
* variations; scrolling DOWN is smooth. Eva's H1 says why: rows above the
* opening viewport have NEVER painted, so they sit at `estimateRowHeight()`
* reserves under `content-visibility: auto`. Scrolling up, each row realizes
* at its TRUE height the instant it enters the realization band; the delta
* (true - estimate) shifts content, and (on WKWebView) NOTHING corrects it —
* one lurch per realization. Down is smooth because `contain-intrinsic-size:
* auto` has already remembered every passed row's exact size.
*
* WHAT THIS MEASURES — MOTION CONSISTENCY, not scrollTop-referenced residual.
* (This is the T1.1 reframe. The original gate subtracted the REALIZED
* scrollTop delta, which INCLUDES the fix-writer's compensating `scrollBy`, so
* the writer cancelled out of the metric and the gate scored raw estimate error
* R — invariant to whether the fix ran. Dawn found it; Quinn and Eva confirmed
* the algebra. See the diagnostic below for that residual — it is T3's estimator
* acceptance number, kept but NON-GATING.)
*
* The honest signal: during a steady upscroll a comfortably-visible reading row
* must move down the viewport by the SAME amount every notch. We actuate a
* fixed synchronous step (`scrollTop -= STEP`) so the input delta is a known
* constant every notch — this bypasses Blink's wheel-scaling entirely (a wheel
* notch can apply 218/220/222… and median-of-run would misread that per-notch
* scaling as jitter; a fixed step cannot). A perfectly-compensated scroll then
* moves the reading row by EXACTLY STEP every notch — deviation 0. A broken
* scroll lurches: rows realizing ABOVE the reading row shove it by the
* realization delta, which varies notch-to-notch, so its per-notch motion
* scatters around the run median. The metric is that scatter:
*
* rowMove_i = after.top - before.top (reading row's viewport motion; NO scrollTop reference)
* deviation_i = | rowMove_i - median(rowMove) |
* gate = peak deviation (worst lurch) and rms deviation (sustained shimmer)
*
* WHY THIS ESCAPES THE INVARIANCE that killed the old gate and the co-visible
* idea: it references the reading row's own motion ACROSS NOTCHES (temporal
* self-reference), never a neighbouring row (spatial) and never the realized
* scrollTop. The fix-writer's `scrollBy` moves the row and is NOT subtracted
* back out, so it survives into rowMove and the metric SEES it. A uniform
* global offset (which is all compensation is) cancels out of any row-vs-row
* differential — that is exactly why co-visible and the old residual were
* blind, and exactly why per-notch-motion-vs-run-median is not.
*
* ANTI-CHEAT FLOOR: a frozen or half-applying scroller has near-zero motion
* variance and would false-green. We assert mean rowMove ≈ STEP so a scroller
* that does not actually track the input cannot pass.
*
* RED-AT-TIP IS A HARD GATE, NOT CEREMONY. median-of-run is only valid if the
* corpus produces VARYING realization (dispersion); a degenerate constant-R
* corpus would green a broken writer. The tip run being RED IS the proof that
* `jitter-corpus` produces that dispersion. If a future corpus edit turns the
* tip-run green here, this gate is VOID and must fail loudly — see the assert.
*
* VALIDITY CONTROL: this metric goes to ~0 only when the reading row tracks the
* input every notch — i.e. when a correct owned-compensation fix
* (`overflow-anchor: none` + same-frame `scrollBy(realized − reserved)`) holds
* it. A synthetic per-notch-varying perfect-comp oracle drives 74/77 notches to
* EXACTLY STEP (T1.1 bake-off); the real T2 writer re-pins in the same
* ResizeObserver cycle and closes the remaining synthetic-only outliers.
*
* ENGINE FIDELITY: runs under Playwright headed Chromium, which ships
* `overflow-anchor`. The app ships in WKWebView, which — per Eva's L-C finding
* (Mari) — has NO `overflow-anchor`: it corrects NOTHING, so every
* above-viewport realization delta lands raw on the reading position. We FORCE
* `overflow-anchor: none` on the scroller before measuring so Chromium
* reproduces the shipped engine, and log `CSS.supports(...)` for the record.
*
* Run headed to watch it:
* pnpm build && npx playwright test --config=playwright.perf.config.ts \
* upscroll-jitter --headed
*/
// Peak per-notch motion deviation we tolerate (px). Above this is a lurch the
// eye catches. RED at tip: baseline peaks ~41px on the heterogeneous corpus.
const MAX_PEAK_DEVIATION_PX = 2.0;
// RMS motion deviation across notches (px) — the sustained micro-jitter floor.
const MAX_RMS_DEVIATION_PX = 0.6;
// Fixed synchronous scroll step per notch (px). Constant by construction — no
// wheel-scaling variance for median-of-run to misread as jitter.
const STEP = 220;
const MAX_STEPS = 80; // cap; the walk now survives fetchOlder re-anchors and
// runs to the true top of history (~77 notches), scoring every paged-in window.
// Keep the tracked row this far (px) from both viewport edges so it stays
// realized across the step — no straddling-row un-realization artifact.
const SAFE_MARGIN = 100;
type StepSample = { rowMove: number; appliedTop: number; residual: number };
type Result = {
samples: StepSample[];
reachedTop: boolean;
rowCount: number;
// True once a `fetchOlder` prepend landed and was survived mid-run — the
// gate then keeps scoring notches in the newly paged-in population, so it
// exercises the pagination half of H1, not just in-window CV realization.
prependObserved: boolean;
};
// Drive one upscroll run and collect per-notch samples. `actuate` selects the
// input mode: "sync" (fixed STEP, the gate) or "wheel" (Blink-scaled, printed
// as a non-gating felt-mode diagnostic — Tyler's real input is a wheel).
async function measure(
page: import("@playwright/test").Page,
actuate: "sync" | "wheel",
): Promise<Result> {
const timeline = page.getByTestId("message-timeline");
// Re-pin to the true bottom so everything above is unpainted (at estimate).
await timeline.evaluate((element) => {
const el = element as HTMLDivElement;
el.scrollTop = el.scrollHeight;
el.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await page.waitForTimeout(150);
await timeline.evaluate((element) => {
(element as HTMLElement).style.overflowAnchor = "none";
});
await page.waitForTimeout(50);
await timeline.hover();
const samples: StepSample[] = [];
let reachedTop = false;
let prependObserved = false;
// Mounted-row count before the walk begins — the baseline a prepend grows.
const mountedAtRunStart = await timeline.evaluate(
(el) => (el as HTMLDivElement).querySelectorAll("[data-message-id]").length,
);
for (let step = 0; step < MAX_STEPS; step += 1) {
// Pick a row comfortably inside the viewport (SAFE_MARGIN from both edges)
// so it stays realized across the notch. Capture its top + scrollTop BEFORE.
const before = await timeline.evaluate((element, margin: number) => {
const el = element as HTMLDivElement;
const box = el.getBoundingClientRect();
const rows = el.querySelectorAll<HTMLElement>("[data-message-id]");
for (const row of rows) {
const rect = row.getBoundingClientRect();
if (rect.top > box.top + margin && rect.bottom < box.bottom - margin) {
return {
id: row.dataset.messageId ?? null,
top: rect.top,
scrollTop: el.scrollTop,
};
}
}
return { id: null, top: 0, scrollTop: el.scrollTop };
}, SAFE_MARGIN);
if (before.scrollTop <= 0) {
reachedTop = true;
break;
}
// Actuate one notch upward.
if (actuate === "sync") {
await timeline.evaluate((element, s: number) => {
const el = element as HTMLDivElement;
el.scrollTop = Math.max(0, el.scrollTop - s);
el.dispatchEvent(new Event("scroll", { bubbles: true }));
}, STEP);
} else {
await page.mouse.wheel(0, -STEP);
}
// Settle two frames so realization + any writer compensation land before we
// read positions back.
await timeline.evaluate(
() =>
new Promise<void>((r) =>
requestAnimationFrame(() => requestAnimationFrame(() => r())),
),
);
if (!before.id) {
// No safe-band row this step (rare) — nudged already, try the next.
continue;
}
const after = await timeline.evaluate(
(element, args: { id: string; margin: number }) => {
const el = element as HTMLDivElement;
const box = el.getBoundingClientRect();
const mounted = el.querySelectorAll("[data-message-id]").length;
const row = el.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(args.id)}"]`,
);
if (!row)
return {
top: null,
inSafeBand: false,
scrollTop: el.scrollTop,
mounted,
};
const rect = row.getBoundingClientRect();
return {
top: rect.top,
inSafeBand:
rect.top > box.top + args.margin &&
rect.bottom < box.bottom - args.margin,
scrollTop: el.scrollTop,
mounted,
};
},
{ id: before.id, margin: SAFE_MARGIN },
);
const appliedTop = before.scrollTop - after.scrollTop; // realized px moved up
if (appliedTop <= 0) {
// scrollTop did not decrease. Two causes: (a) we reached the top of
// history (scrollTop ~ 0) — the real terminator; or (b) a `fetchOlder`
// prepend just landed ~CHANNEL_HISTORY_LIMIT older rows ABOVE the
// viewport, so scrollTop jumped UP to preserve the visible content — a
// RE-ANCHOR, not the top. Distinguish by mounted-count: a prepend grows
// it. On a re-anchor we skip scoring this step (its motion is the
// prepend jump, not a fixed STEP notch — scoring it would poison the
// metric with a one-off multi-thousand-px move) and CONTINUE; the next
// iteration re-baselines a fresh safe-band row in the newly paged-in
// window, so scoring resumes ACROSS the prepend. This is what makes the
// gate SCORE at least one prepend per run (T1.2) rather than terminate
// at the sentinel band — closing the pagination-coverage gap Quinn and
// Dawn flagged. Both the writer (GREEN) and the bare estimator (RED)
// survive the re-anchor, so RED still fails on the cold-realization
// jitter of the paged-in rows and GREEN still holds them to STEP.
if (after.mounted > mountedAtRunStart && after.scrollTop > 1) {
prependObserved = true;
continue;
}
reachedTop = after.scrollTop <= 0;
break;
}
if (after.top === null || !after.inSafeBand) {
// Tracked row left the safe band — skip scoring, keep scrolling.
continue;
}
const rowMove = after.top - before.top; // viewport motion — the gated signal
// NON-GATING diagnostic: motion minus REALIZED scrollTop delta = raw
// estimate error R (comp-invariant). This is T3's estimator acceptance
// number, printed for the record; it is NOT the gate.
const residual = rowMove - appliedTop;
samples.push({ rowMove, appliedTop, residual });
}
return {
samples,
reachedTop,
rowCount: await timeline.evaluate(
(el) =>
(el as HTMLDivElement).querySelectorAll("[data-message-id]").length,
),
prependObserved,
};
}
function stats(samples: StepSample[]) {
const moves = samples.map((s) => s.rowMove);
const sorted = moves.slice().sort((a, b) => a - b);
const median = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 0;
const dev = moves.map((m) => Math.abs(m - median));
const peakDev = dev.length ? Math.max(...dev) : 0;
const rmsDev = dev.length
? Math.sqrt(dev.reduce((a, d) => a + d * d, 0) / dev.length)
: 0;
const meanMove = moves.length
? moves.reduce((a, m) => a + m, 0) / moves.length
: 0;
// Old scrollTop-referenced residual (= estimate error R) — non-gating diag.
const resAbs = samples.map((s) => Math.abs(s.residual));
const resPeak = resAbs.length ? Math.max(...resAbs) : 0;
const resRms = resAbs.length
? Math.sqrt(resAbs.reduce((a, r) => a + r * r, 0) / resAbs.length)
: 0;
return { median, peakDev, rmsDev, meanMove, resPeak, resRms };
}
test("GATE: upscroll motion consistency stays below the realization-jitter threshold", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
// The `jitter-corpus` channel is pre-seeded (e2eBridge.ts) with 400
// heterogeneous rows in its mock store — the same reliable cold-load path
// scroll-history.spec.ts uses, no dependence on live-emit timing.
await page.getByTestId("channel-jitter-corpus").click();
await expect(page.getByTestId("chat-title")).toHaveText("jitter-corpus");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForFunction(() => {
const el = document.querySelector(
'[data-testid="message-timeline"]',
) as HTMLDivElement | null;
return !!el && el.scrollHeight > el.clientHeight + 1000;
});
// Log native-anchoring support for this engine (measure() forces it off).
const anchorSupport = await page.evaluate(() =>
typeof CSS !== "undefined" && typeof CSS.supports === "function"
? CSS.supports("overflow-anchor", "auto")
: false,
);
// GATED run: fixed synchronous step (deterministic input, no Blink scaling).
const result = await measure(page, "sync");
const s = stats(result.samples);
// NON-GATING felt-mode diagnostic: same metric under a real wheel. Tyler's
// input is a wheel; we want the felt number on the record every run even
// though Blink's per-notch scaling makes it unsafe to gate on.
const wheelResult = await measure(page, "wheel");
const w = stats(wheelResult.samples);
/* eslint-disable no-console */
console.log(
`overflow-anchor supported by this engine: ${anchorSupport} ` +
`(forced to 'none' on the scroller to mirror shipped WKWebView)`,
);
console.log("\n=== UPSCROLL JITTER GATE (motion consistency, Chromium) ===");
console.log(`rows mounted (live DOM): ${result.rowCount}`);
console.log(`steps measured (sync): ${result.samples.length}`);
console.log(`reached top of history: ${result.reachedTop}`);
console.log(
`scored a fetchOlder prepend: ${result.prependObserved} (T1.2: pagination half exercised)`,
);
console.log(
`median per-notch motion: ${s.median.toFixed(1)}px (STEP=${STEP})`,
);
console.log(
`peak motion deviation: ${s.peakDev.toFixed(2)}px (gate <= ${MAX_PEAK_DEVIATION_PX})`,
);
console.log(
`rms motion deviation: ${s.rmsDev.toFixed(2)}px (gate <= ${MAX_RMS_DEVIATION_PX})`,
);
console.log(
`mean per-notch motion: ${s.meanMove.toFixed(1)}px (anti-cheat: ~= ${STEP})`,
);
console.log("--- non-gating diagnostics ---");
console.log(
`estimate-error R (old resid):peak=${s.resPeak.toFixed(2)}px rms=${s.resRms.toFixed(2)}px (T3 estimator acceptance number)`,
);
console.log(
`wheel-actuated (felt mode): peak-dev=${w.peakDev.toFixed(2)}px rms-dev=${w.rmsDev.toFixed(2)}px over ${wheelResult.samples.length} steps`,
);
console.log("(0 == the reading row tracked the input exactly every notch)");
console.log("===========================================================\n");
/* eslint-enable no-console */
// Sanity: the run actually exercised a meaningful upscroll. Cold-load windows
// the 400-row seed to the newest ~100 rows (CHANNEL_HISTORY_LIMIT), all in the
// DOM (de-virtualized), so ~100 mounted rows is the expected window.
expect(result.rowCount).toBeGreaterThanOrEqual(80);
expect(result.samples.length).toBeGreaterThan(8);
// COVERAGE (T1.2): the scored run must cross at least one `fetchOlder`
// prepend. Upscroll jitter has TWO sources (Eva's H1 + Quinn's seed trace):
// CV-skipped rows in the opening window that realize on entry, AND cold rows
// paged in above the viewport when scrollback fires near the top. The gate
// measures felt motion whatever the cause, but if the run terminated at the
// sentinel band it would only ever certify the first source. We assert it
// survived a re-anchor and kept scoring so BOTH sources are under the gate —
// pass or fail. This is corpus-structural (400-row seed > 300 limit), so it
// holds on the RED baseline and the GREEN fix alike; a run that stops before
// scoring a prepend is a coverage regression, not a jitter verdict.
expect(result.prependObserved).toBe(true);
// ANTI-CHEAT: the reading row must actually track the input — a frozen or
// half-applying scroller (near-zero motion, would false-green on deviation)
// is caught here because its mean motion falls well below STEP.
expect(s.meanMove).toBeGreaterThan(STEP * 0.75);
// THE GATE — per-notch motion consistency. RED at tip (~41px peak / ~23px rms
// on jitter-corpus); a correct owned-compensation fix (T2 writer) drives the
// reading row to STEP every notch and turns it green.
//
// ⚠️ RED-AT-TIP IS LOAD-BEARING: median-of-run is only valid while the corpus
// produces VARYING realization. This gate failing at tip IS the proof of that
// dispersion. If a future corpus edit makes the tip run PASS here WITHOUT a
// compensation fix, this gate is VOID — do not relax the thresholds; restore a
// heterogeneous corpus so the gate reds again, or the whole metric is moot.
expect(s.peakDev).toBeLessThanOrEqual(MAX_PEAK_DEVIATION_PX);
expect(s.rmsDev).toBeLessThanOrEqual(MAX_RMS_DEVIATION_PX);
});