W4 felt gate: jerk metric rewrite + whole-run cumulative drift instrument

Replaces the original deviation-vs-appliedScroll W3 metric with the
ratified W4 three-leg jerk contract (RESEARCH/FELT_WHEEL_GATE_METRIC_W4.md,
Quinn) and adds the whole-run cumulative anti-scroll drift instrument
Eva/Quinn asked for as the W4a A/B reference scale.

Metric (all on the reading row's per-frame rect.top, no scrollTop / no
dispatched-delta reference — coordinate-free by second-differencing):
- LEG 1 peak jerk = max |second diff| — the felt-lurch spike. Gate <= 8px.
- LEG 2 signed anti-scroll drift over a trailing 100ms window — the
  skip-forever guard (catches a sustained reversal by sign). Gate <= 12px.
- LEG 3 rms jerk (chatter) — LOG-ONLY behind BUZZ_PERF_GATE_RMS_JERK=1;
  no A/B separation reachable under discrete actuation (structural, thread).
- LEG 2 cumulative drift (NEW) — same anti-scroll component summed over the
  whole run, no window. LOG-ONLY, never gated. The windowed gate is the
  felt-relevant burst; this is the W4a reference scale that the 12
  non-recovering under-corrections accumulate into. The pre-realization
  band (W2) should collapse THIS number.

Harness fixes for small-STEP runs (both were false-fails, not scorer bugs):
- velocity anti-cheat floor scales with STEP (0.008*STEP_PX) — was a fixed
  0.1 pinned at STEP=12, false-failed STEP=2.
- test timeout 300s -> 900s (408 events/swipe at STEP=2 is slow).

Verified both engines against Dawn's shipped writer e7297849 (SWIPES=12,
STEP=2): Chromium 2.00 / 0.00 / cumulative 0.00 (clean PASS, control);
WebKit peak 37.55 / windowed 27.55 / cumulative 1042.81 (exposed W4a
frame-late layer). Chromium cumulative = 0.00 validates the instrument:
the quantization staircase is pure forward scroll, so cumulative is
entirely the WebKit reversal signal — clean control(0)/signal(1042).

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
2026-07-08 20:33:50 -04:00
co-authored by Tyler Longwell
parent 7c2587472c
commit 45500ed11c
+280 -160
View File
@@ -3,106 +3,138 @@ import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
/**
* TRACKPAD-MOMENTUM UPSCROLL GATE (W3) — the merge-blocking, offline sibling of
* `upscroll-trackpad-live.perf.ts`.
* TRACKPAD-MOMENTUM UPSCROLL GATE (W3/W4) — the merge-blocking, offline sibling
* of T1.2's sync gate. T1.2 guards the writer's MATH (fixed synchronous step,
* per-notch median-of-run); this gate guards the INPUT PATH Tyler actually
* feels (a real wheel under momentum, whose per-notch Blink scaling of
* 218/220/222 poisons median-of-run). The two JOIN — sync + felt — neither
* replaces the other.
*
* Eva's kickoff (thread) localised the WebKit-compensation defect: the T2
* writer is near-perfect on Chromium and near-ineffective on WKWebView, both
* settled (68.3% vs 98.9%) and — far worse — under continuous trackpad
* momentum (39 felt lurches up to 204px per 30-swipe pass). The live probe
* proved it against real #buzz-bugs but needs staging + a port-forward + a
* member nsec, so it can never gate a PR merge. This gate reproduces the SAME
* felt-lurch metric under the SAME momentum actuation on the deterministic
* mock `jitter-corpus` bridge — no network, greppable in CI, on BOTH engines.
* WHY FELT, NOT SYNC-ONLY (Quinn's sharpening, Eva-ratified). The sync path
* UNDER-REPORTS the WebKit defect ~4-5x (sync reds ~41px, felt reds ~200px):
* the wheel path exercises realization-under-momentum that the settled sync
* path never does. Sync catching *a* red is not sync catching the *whole* red —
* a sync-only gate can false-green a "fix" that still lurches 200px on the
* trackpad. Gate the path the user's input takes.
*
* WHY THE MOCK COVERS BOTH LURCH SOURCES (the W3 trace, thread):
* 1. CV-realization lurch — jitter-corpus is the heterogeneous 400-row seed
* whose `estimateRowHeight` mismatch is the realization error T1.1/T1.2
* already gate; momentum crossing those rows realises them under the
* reading row.
* 2. Prepend-commit-under-momentum — jitter-corpus is 400 rows > the 300
* CHANNEL_HISTORY_LIMIT, so `fetchOlder` pages behind an until-cursor.
* `useLoadOlderOnScroll` fires the fetch off an IntersectionObserver on a
* 600px-margin top sentinel — pure geometry, input-cadence-independent —
* so momentum triggers it exactly like live. We set
* `channelWindowDelayMs` so the page commits several frames LATER, under
* continuous momentum, reproducing the commit race rather than an instant
* same-frame commit.
* THE METRIC — two legs, both on the reading row's per-frame `rect.top`, NEITHER
* referencing scrollTop nor the dispatched wheel delta. Full derivation +
* invariance proofs: RESEARCH/FELT_WHEEL_GATE_METRIC_W4.md (Quinn, W4).
*
* ENGINE FIDELITY — same mirror as T1.1/T1.2: the shipped WKWebView has no
* `overflow-anchor`, so we force `overflow-anchor: none` on the scroller and
* Chromium reproduces the shipped engine. Under Playwright `perf-webkit` this
* is the real WebKit family. We log `CSS.supports(...)` for the record.
* p_i = tracked row's viewport top, sampled PER FRAME i
* rowMove_i = p_i − p_{i-1} (first diff)
*
* WHAT IT MEASURES — identical to the live probe. A per-RAF in-page sampler,
* independent of input timing, records scrollTop + the tracked centre row's
* rect.top + mounted count + fetch count every frame. For a solid page,
* rowMove(frame) = rect.top delta = scrollTop_before - scrollTop_after
* so per-frame deviation = rowMove - appliedScroll. Nonzero = content shifted
* under the viewport that the input did NOT ask for — the felt jump — whether
* from CV realization, a losing compensation race, or a prepend anchor miss.
* Prepend-commit frames (mounted grew) are scored separately: the page legitly
* gains height there, so a one-frame jump is expected and NOT a lurch.
* LEG 1 — peak lurch = peak |second difference| (jerk):
* lurch_i = |p_i − 2·p_{i-1} + p_{i-2}| = |rowMove_i − rowMove_{i-1}|
* gate = max_i lurch_i ≤ MAX_PEAK_JERK_PX
*
* TWO GATE SHAPES (Quinn's W4): peak-per-frame catches a single big lurch (a
* wheel-fight spike); RMS-across-the-run catches SKIP-FOREVER — a too-tight
* staleness skip-bound that never fires the correction under momentum, showing
* as a run of small deviations that dodge the peak threshold but sum to drift.
* The gate asserts BOTH so Dawn's skip-bound is a two-sided constraint.
* WHY JERK SURVIVES WHEEL-SCALING WITHOUT A MEDIAN OR INPUT CLOCK. Blink's
* scaling makes rowMove a SMOOTH ramp (218,220,222…); the second difference of
* any locally-linear sequence is ≈ 0 ((222−220)−(220−218)=0), so scaling
* flattens itself with NO reference. It is the coordinate-free version of Eva's
* "subtract the per-notch expected value" — differencing twice removes any
* smooth trend without naming it, so no dispatched-delta reference (Quinn's
* "Trap A" / timing-skew). Comp-invariant: a working writer produces smooth row
* motion (holding at ~0 OR tracking smoothly, both second-diff 0); a failing
* writer produces a one-frame realization spike → second-diff spikes.
*
* RED-AT-TIP IS LOAD-BEARING (same discipline as T1.2): this gate MUST fail on
* the contract SHA (`6b9203ca`) under the WebKit mirror. That red is the proof
* the metric has teeth. A correct engine-order-independent fix (Dawn's W1)
* turns it green. If a future change greens the tip WITHOUT the fix, this gate
* is VOID — do not relax thresholds; restore the mirror/corpus so it reds.
* WHY PER-FRAME IS CORRECT HERE (and why it does NOT repeat the mistake that
* killed my median-of-run). median-of-run needed per-NOTCH settled sampling: a
* zero-motion per-RAF frame read as a false stall against the nonzero run
* median. Jerk is IMMUNE to that exact ghost — a paused frame is rowMove_i=0 AND
* rowMove_{i-1}=0 → second-diff 0. The sampling grain follows the metric: jerk
* is a frame-to-frame second difference and REQUIRES per-frame p_i (Quinn, W4).
*
* LEG 2 — skip-forever = signed cumulative drift over a trailing horizon:
* drift_H = Σ_{i in H} max(0, −sign(scrollDir)·rowMove_i) (anti-scroll)
* Jerk MISSES a *sustained* reversal (constant reversal → second-diff 0 too).
* Signed drift catches it: scaling changes forward MAGNITUDE, never SIGN, so
* scaled notches contribute 0 to the anti-scroll accumulator while a sustained
* reversal accumulates. sign(scrollDir) is the coarse run direction, a constant
* — not a per-frame input delta, so Leg 2 also touches no input clock.
*
* EXCLUSION SET (doc §Contract summary). Prepend-commit frames (page legitly
* gains height) and writer skip-catchup frames are EXCLUDED from Leg 1 peak — a
* one-frame reanchor is a real single event, not a lurch. Because jerk is a
* second difference, excluding a frame means BREAKING the diff sequence at it:
* we reset the diff run and only score jerk within contiguous CLEAN spans (span
* (a) in the thread — cleaner than dropping straddling windows, and a real lurch
* that coincides with a commit is not lost, it re-appears on the next clean
* span's first scored frame). Excluded frames are KEPT in Leg 2 drift.
*
* ENGINE FIDELITY — same mirror as T1.1/T1.2: shipped WKWebView has no
* `overflow-anchor`, so we force `overflow-anchor: none` and Chromium reproduces
* the shipped engine. Under `perf-webkit` this is the real WebKit family.
*
* ASYMMETRIC TWO-ENGINE CONTRACT (Eva-ratified; matches T1.2). The engines DO
* NOT share the defect (Dawn's white-box RO logs: WebKit's correction never
* fires, Chromium's fires and holds). So:
* - WebKit @ contract (`6b9203ca`): MUST be RED — the load-bearing validity
* proof. Dawn's fix turns it green.
* - Chromium: GREEN @ contract AND @ fix — a no-regression guard on the engine
* that already works. There is NO Chromium contract defect; a red there
* could only be a metric artifact. DO NOT "fix" Chromium's green red.
* If a future change greens WebKit at contract WITHOUT the fix, this gate is
* VOID — do not relax thresholds; restore the mirror/corpus so WebKit reds.
*
* GUARDRAIL / FALSIFIER (doc §Acceptance): a correct writer (Chromium
* T1.2-green) MUST score ~0 on BOTH legs under wheel actuation. If Leg 1 can't
* hold Chromium ~0 under scaling, the second-diff invariance claim is FALSE and
* we fall back to sync-only (option 2). The ceilings below are pinned only after
* the Chromium-green baseline number is on the record.
*
* Run (Chromium):
* pnpm build && npx playwright test --config=playwright.perf.config.ts \
* upscroll-trackpad-gate
* Run (WebKit — the engine that actually reds at tip):
* Run (WebKit — the engine that reds at tip):
* npx playwright test --config=playwright.perf-webkit.config.ts \
* upscroll-trackpad-gate --project=perf-webkit
*/
// A felt lurch: the tracked reading row jumped in one frame by more than this
// beyond what the input asked, OUTSIDE a prepend-commit frame. Eva's target
// (thread) is the felt threshold from the live probe: |dev|>10px is a jump the
// eye catches. Gate = zero such frames.
const MAX_FELT_LURCHES = 0;
const LURCH_PX = 10;
// Sustained smoothness: fraction of scored frames whose row tracked the input
// within 1px. Eva's target: >=99% on both engines.
const MIN_SMOOTH_FRACTION = 0.99;
// RMS of per-frame deviation across the whole run (px). Quinn's W4 caveat: a
// too-tight staleness skip-bound causes SKIP-FOREVER — the correction never
// fires under momentum, so we're back to main's raw uncompensated shift, but as
// a run of small-to-medium per-notch deviations that each DODGE the peak
// LURCH_PX threshold while summing to large drift. Peak-per-frame is blind to
// that shape; RMS is not. This is the second assertion Quinn asked the mock
// GATE to carry. Placeholder ceiling; PINNED against Dawn's first green
// candidate, same as the lurch count.
const MAX_RMS_DEVIATION_PX = 1.0;
// LEG 1 ceiling: peak |second difference of rect.top| across per-frame samples
// within a clean span (px). Sits in the gap between the Chromium quantization
// floor (~2-3px at STEP_PX=2) and the WebKit realization spike (~27px). RED at
// tip: WebKit felt lurches spike peak jerk to ~27px (peak ≫ rms — a clean
// outlier). Chromium stays at the floor. GUARDRAIL: pinned only after the
// Chromium-green baseline (~2px) is on the record (WORK_LOG 2026-07-08).
const MAX_PEAK_JERK_PX = 8.0;
// LEG 2 ceiling: signed anti-scroll cumulative drift over a trailing horizon
// (px). Chromium scores 0.00 (huge headroom); WebKit ~25px. Sits between.
const MAX_DRIFT_PX = 12.0;
// LEG 3 ceiling: rms of |second diff| (jerk) over the same non-commit clean
// spans as Leg 1 — the CHATTER catcher (sign-balanced sub-peak lurches that
// dodge Leg 1's peak AND Leg 2's sign; RESEARCH/FELT_WHEEL_GATE_METRIC_W4.md
// §Leg 3). Under discrete integer actuation rms-jerk has an irreducible
// staircase floor ≈ f(STEP) (STEP=2 → ~2px), so this GATES only once a chattery
// R_bad is measured to clear the floor with margin (≥~4-5px). Until that A/B
// separation is on the record the leg is LOG-ONLY: set BUZZ_PERF_GATE_RMS_JERK=1
// to assert it, pinning the ceiling here between the floor and the R_bad number.
const MAX_RMS_JERK_PX = Number(process.env.BUZZ_PERF_MAX_RMS_JERK_PX ?? 4.0);
const GATE_RMS_JERK = process.env.BUZZ_PERF_GATE_RMS_JERK === "1";
// Trailing horizon for Leg 2 drift (ms). Long enough to contain a real
// sustained reversal, short enough not to dilute one; sits above a phase pair.
const DRIFT_HORIZON_MS = Number(process.env.BUZZ_PERF_DRIFT_HORIZON_MS ?? 100);
// Keep the tracked row this far (px) from both viewport edges so it stays
// realized across a frame — no straddling-row un-realization artifact.
const SAFE_MARGIN = 60;
// Swipes per run. Enough to cross several fetchOlder pages on the 400-row seed.
// Swipes per run — enough to cross several fetchOlder pages on the 400-row seed.
const SWIPES = Number(process.env.BUZZ_PERF_SWIPES ?? 30);
// Deferred-commit latency for the mock fetchOlder page, so the prepend commits
// under continuous momentum (mirrors the live ~1s network fetch).
const FETCH_DELAY_MS = Number(process.env.BUZZ_PERF_FETCH_DELAY_MS ?? 1000);
// One macOS-ish swipe: finger ramp (accelerating) + momentum tail (exp decay).
// Byte-for-byte the profile the live probe uses so the two agree.
function swipeDeltas(): number[] {
const deltas: number[] = [];
for (let i = 0; i < 12; i++) deltas.push(4 + Math.round((32 * i) / 11));
let v = 36;
while (v >= 1) {
deltas.push(Math.round(v));
v *= 0.94;
}
return deltas; // ~68 events, ~1500px total
}
// Constant per-event wheel delta (px). Continuous, no settle between events —
// realization happens WHILE scroll is in flight and more input keeps arriving
// (the momentum property). SMALL by design: Blink/WebKit deliver DISCRETE
// integer-px wheel events and rect.top is integer-quantized, so per-frame
// rowMove is a 0/STEP staircase whose second difference is STEP everywhere — a
// quantization FLOOR that scales with STEP. The WebKit realization shove is a
// FIXED physical displacement, independent of step size, so a SMALL step pulls
// the Chromium floor down (STEP=12 → 12px floor; STEP=2 → 2px floor) while the
// WebKit red holds (~27px) — signal-to-floor 4.6× → 13× (WORK_LOG 2026-07-08).
// This is what makes the raw per-frame 2nd-diff scorer survive without
// windowing: peak≫rms on WebKit is a clean outlier above a ~2px Chromium floor.
const STEP_PX = Number(process.env.BUZZ_PERF_STEP_PX ?? 2);
type Frame = {
t: number;
@@ -113,10 +145,10 @@ type Frame = {
fetch: number;
};
test("GATE: trackpad-momentum upscroll produces zero felt lurches on both engines", async ({
test("GATE: trackpad-momentum upscroll — peak jerk in rect.top stays below the felt-lurch threshold", async ({
page,
}) => {
test.setTimeout(300_000);
test.setTimeout(900_000);
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
@@ -135,7 +167,6 @@ test("GATE: trackpad-momentum upscroll produces zero felt lurches on both engine
});
// Defer the next fetchOlder page so it commits under momentum, not instantly.
// channelWindowDelayMs is read live by the bridge per fetch.
await page.evaluate((delayMs: number) => {
window.__BUZZ_E2E__ = {
...window.__BUZZ_E2E__,
@@ -159,7 +190,10 @@ test("GATE: trackpad-momentum upscroll produces zero felt lurches on both engine
});
await page.waitForTimeout(500);
// ---- Per-RAF sampler, in page, independent of input cadence ----
// ---- Per-RAF sampler, in page, independent of input cadence. Records the
// tracked centre row's rect.top (viewport-relative) every frame. This is the
// per-frame grain jerk requires; scrollTop is recorded ONLY as an activity
// gate for the analysis, never fed into the scorer.
await timeline.evaluate((element, margin: number) => {
const el = element as HTMLDivElement;
const store = window as unknown as {
@@ -193,6 +227,8 @@ test("GATE: trackpad-momentum upscroll produces zero felt lurches on both engine
const loop = () => {
if (store.__SAMPLER_STOP__) return;
const box = el.getBoundingClientRect();
// Report the row's top RELATIVE to the container so a container reflow
// does not read as row motion.
let rowTop: number | null = null;
if (trackedId) {
const row = el.querySelector<HTMLElement>(
@@ -201,7 +237,7 @@ test("GATE: trackpad-momentum upscroll produces zero felt lurches on both engine
if (row) {
const r = row.getBoundingClientRect();
if (r.top > box.top + margin && r.bottom < box.bottom - margin)
rowTop = r.top;
rowTop = r.top - box.top;
}
}
if (rowTop === null) {
@@ -212,7 +248,7 @@ test("GATE: trackpad-momentum upscroll produces zero felt lurches on both engine
`[data-message-id="${CSS.escape(trackedId)}"]`,
)
?.getBoundingClientRect();
rowTop = r ? r.top : null;
rowTop = r ? r.top - box.top : null;
}
}
store.__FRAMES__.push({
@@ -228,7 +264,9 @@ test("GATE: trackpad-momentum upscroll produces zero felt lurches on both engine
requestAnimationFrame(loop);
}, SAFE_MARGIN);
// ---- Trusted trackpad-like wheel input via CDP (Chromium) / mouse.wheel ----
// ---- Continuous constant-delta wheel input. No settle between events: input
// keeps arriving through realization and prepend commits (the momentum
// property). Blink scales the delta per notch; jerk reads through it.
const box = await timeline.boundingBox();
if (!box) throw new Error("no timeline box");
const cx = box.x + box.width / 2;
@@ -238,20 +276,25 @@ test("GATE: trackpad-momentum upscroll produces zero felt lurches on both engine
const cdp = isChromium ? await page.context().newCDPSession(page) : null;
await page.mouse.move(cx, cy);
// Each swipe covers a fixed SCROLL DISTANCE, not a fixed event count, so a
// smaller STEP_PX just means more (finer) events per swipe — the swipe still
// crosses the same amount of history and pages a fetchOlder prepend. At the
// live profile's ~816px/swipe (68 × 12px), STEP=2 → ~408 events/swipe.
const SWIPE_DISTANCE_PX = 816;
const EVENTS_PER_SWIPE = Math.max(1, Math.round(SWIPE_DISTANCE_PX / STEP_PX));
for (let s = 0; s < SWIPES; s++) {
const deltas = swipeDeltas();
for (const d of deltas) {
for (let e = 0; e < EVENTS_PER_SWIPE; e++) {
if (cdp) {
await cdp.send("Input.dispatchMouseEvent", {
type: "mouseWheel",
x: cx,
y: cy,
deltaX: 0,
deltaY: -d,
deltaY: -STEP_PX,
pointerType: "mouse",
});
} else {
await page.mouse.wheel(0, -d);
await page.mouse.wheel(0, -STEP_PX);
}
await new Promise((r) => setTimeout(r, 8));
}
@@ -273,113 +316,190 @@ test("GATE: trackpad-momentum upscroll produces zero felt lurches on both engine
() => (window as unknown as { __FRAMES__: Frame[] }).__FRAMES__,
)) as Frame[];
// ---- Analysis: per-frame deviation of row motion vs applied scroll ----
type Dev = {
// ---- Analysis: two legs on per-frame rect.top derivatives (Quinn's W4).
//
// Build the first-difference series rowMove_i = p_i − p_{i-1}, flagging frames
// where the diff sequence must BREAK (an excluded frame): a re-pick boundary
// (rowId changed), a prepend commit (mounted grew), or a missing sample. A
// "clean span" is a maximal run of consecutive frames with a valid rowMove and
// no break inside it. Leg 1 (jerk = second diff) is scored WITHIN clean spans
// only; Leg 2 (drift) accumulates across everything (excluded frames kept).
type Step = {
i: number;
dev: number;
applied: number;
rowMove: number;
t: number;
dt: number;
mountedGrew: boolean;
fetch: number;
scrollTop: number;
rowMove: number;
scrollDelta: number;
breakBefore: boolean; // this step cannot chain to the previous (excluded)
};
const devs: Dev[] = [];
const steps: Step[] = [];
for (let i = 1; i < frames.length; i++) {
const a = frames[i - 1];
const b = frames[i];
if (!a.rowId || a.rowId !== b.rowId) continue; // re-pick boundary
if (a.rowTop === null || b.rowTop === null) continue;
const applied = a.scrollTop - b.scrollTop;
const rowMove = b.rowTop - a.rowTop;
devs.push({
const sameRow = a.rowId != null && a.rowId === b.rowId;
const haveTops = a.rowTop !== null && b.rowTop !== null;
const commit = b.mounted > a.mounted; // prepend re-anchor: excluded frame
if (!sameRow || !haveTops) {
// No valid rowMove across this boundary — emit no step. The resulting gap
// in frame indices is detected below and breaks the jerk chain.
continue;
}
steps.push({
i,
dev: rowMove - applied,
applied,
rowMove,
t: b.t,
dt: b.t - a.t,
mountedGrew: b.mounted > a.mounted,
fetch: b.fetch,
scrollTop: b.scrollTop,
rowMove: (b.rowTop as number) - (a.rowTop as number),
scrollDelta: a.scrollTop - b.scrollTop,
breakBefore: commit, // a commit frame breaks the jerk chain before it
});
}
// A gap in frame indices (a skipped invalid pair — re-pick / missing sample)
// also breaks the chain: the step after the gap cannot second-difference
// against a rowMove computed across the excluded region.
for (let k = 1; k < steps.length; k++) {
if (steps[k].i !== steps[k - 1].i + 1) steps[k].breakBefore = true;
}
// Prepend-commit frames legitimately gain height; scored separately.
const scored = devs.filter((d) => !d.mountedGrew);
const commits = devs.filter((d) => d.mountedGrew);
const feltLurches = scored
.filter((d) => Math.abs(d.dev) > LURCH_PX)
.sort((x, y) => Math.abs(y.dev) - Math.abs(x.dev));
const smoothCount = scored.filter((d) => Math.abs(d.dev) <= 1).length;
const smoothFraction = smoothCount / Math.max(1, scored.length);
const worstLurch = feltLurches.length ? Math.abs(feltLurches[0].dev) : 0;
// RMS deviation across ALL scored frames (Quinn's skip-forever signature):
// a run of sub-LURCH_PX deviations that dodge the peak gate still lifts RMS.
const rmsDeviation = Math.sqrt(
scored.reduce((a, d) => a + d.dev * d.dev, 0) / Math.max(1, scored.length),
// Empirical run velocity (px/ms) from row motion + wall clock only — used as
// an activity gate and anti-cheat, never in the scorer.
const totalMove = steps.reduce((acc, s) => acc + s.rowMove, 0);
const totalDt = steps.reduce((acc, s) => acc + s.dt, 0);
const velocity = totalDt > 0 ? totalMove / totalDt : 0; // px/ms
const scrollDir = Math.sign(steps.reduce((acc, s) => acc + s.scrollDelta, 0)); // coarse run direction (up), constant — NOT a per-frame input delta
// ---- LEG 1: peak |second difference| within clean spans. A span breaks at
// any step whose breakBefore is set; jerk_k = |rowMove_k − rowMove_{k-1}| is
// scored only when step k and k-1 are in the same span.
let peakJerk = 0;
let jerkCount = 0;
const jerks: number[] = [];
let peakAt = -1;
for (let k = 1; k < steps.length; k++) {
if (steps[k].breakBefore || steps[k - 1].breakBefore) continue;
// Activity gate: both frames must have actually scrolled — a paused pair
// has rowMove≈0 both sides (jerk 0 anyway), but skip to avoid inflating the
// sample count with dead frames.
if (
Math.abs(steps[k].scrollDelta) < 0.5 &&
Math.abs(steps[k - 1].scrollDelta) < 0.5
)
continue;
const jerk = Math.abs(steps[k].rowMove - steps[k - 1].rowMove);
jerks.push(jerk);
jerkCount += 1;
if (jerk > peakJerk) {
peakJerk = jerk;
peakAt = steps[k].i;
}
}
const rmsJerk = jerks.length
? Math.sqrt(jerks.reduce((acc, j) => acc + j * j, 0) / jerks.length)
: 0;
// ---- LEG 2: peak signed anti-scroll cumulative drift over a trailing
// horizon. For each end step, walk back DRIFT_HORIZON_MS accumulating only the
// component of rowMove OPPOSITE the scroll direction (a sustained reversal);
// scaled forward notches are same-sign and contribute 0.
let peakDrift = 0;
for (let end = 0; end < steps.length; end++) {
let acc = 0;
let dt = 0;
for (let start = end; start >= 0 && dt < DRIFT_HORIZON_MS; start--) {
const anti = -scrollDir * steps[start].rowMove; // >0 == against scroll
if (anti > 0) acc += anti;
dt += steps[start].dt;
}
if (dt < DRIFT_HORIZON_MS) continue; // not enough history (run start)
if (acc > peakDrift) peakDrift = acc;
}
// Whole-run cumulative anti-scroll drift: the same anti-scroll component as
// Leg 2 but summed over EVERY step, no trailing window. LOG-ONLY, never gated
// — the windowed peakDrift is the felt-relevant burst (what you perceive); this
// is the W4a reference scale, the monotone accumulation of non-recovering
// under-corrections (12 × ~18.5 ≈ 220px). Windowed can't reach it by
// construction. The sharp W2 signal: the band should collapse THIS number even
// if the windowed burst barely moves (RESEARCH/FELT_WHEEL_GATE_METRIC_W4.md).
let totalDrift = 0;
for (const step of steps) {
const anti = -scrollDir * step.rowMove; // >0 == against scroll
if (anti > 0) totalDrift += anti;
}
const commits = frames.filter(
(f, i) => i > 0 && f.mounted > frames[i - 1].mounted,
);
// Cumulative unasked drift = signed sum of deviation; a one-directional
// skip-forever accumulates here even if each frame is small. Diagnostic.
const cumulativeDrift = scored.reduce((a, d) => a + d.dev, 0);
const prependObserved = commits.length > 0;
// Anti-cheat: the reading row must actually track the input. Total input
// applied over the run must be a meaningful upscroll (a frozen/half-applying
// scroller has near-zero motion and would false-green on the lurch count).
const totalApplied = scored.reduce((a, d) => a + d.applied, 0);
const finalMounted = frames[frames.length - 1]?.mounted ?? 0;
const engine = page.context().browser()?.browserType().name();
/* eslint-disable no-console */
console.log(
`\n=== TRACKPAD-MOMENTUM UPSCROLL GATE (mock jitter-corpus) engine=${engine} ===`,
`\n=== TRACKPAD-MOMENTUM UPSCROLL GATE (jerk + signed drift, mock jitter-corpus) engine=${engine} ===`,
);
console.log(
`overflow-anchor supported by this engine: ${anchorSupport} (forced 'none' to mirror WKWebView)`,
);
console.log(
`frames=${frames.length} scored=${scored.length} commits=${commits.length} swipes=${SWIPES} fetchPages=${frames[frames.length - 1]?.fetch ?? 0} finalMounted=${finalMounted}`,
);
console.log(
`felt lurches (|dev|>${LURCH_PX}px, non-commit): ${feltLurches.length} (gate <= ${MAX_FELT_LURCHES})`,
);
console.log(`worst single-frame lurch: ${worstLurch.toFixed(1)}px`);
console.log(
`smooth frames (|dev|<=1px): ${smoothCount}/${scored.length} = ${(100 * smoothFraction).toFixed(2)}% (gate >= ${(100 * MIN_SMOOTH_FRACTION).toFixed(0)}%)`,
);
console.log(
`rms deviation (skip-forever guard): ${rmsDeviation.toFixed(2)}px (gate <= ${MAX_RMS_DEVIATION_PX}) cumulativeDrift=${cumulativeDrift.toFixed(0)}px`,
`frames=${frames.length} steps=${steps.length} jerk-samples=${jerkCount} commits=${commits.length} swipes=${SWIPES} finalMounted=${finalMounted} velocity=${velocity.toFixed(3)}px/ms`,
);
console.log(
`scored a fetchOlder prepend: ${prependObserved} (prepend-commit half exercised)`,
);
console.log(
`total upscroll applied (anti-cheat): ${totalApplied.toFixed(0)}px`,
`LEG 1 peak jerk |d2 rect.top|: ${peakJerk.toFixed(2)}px (gate <= ${MAX_PEAK_JERK_PX}) @frame ${peakAt} rms=${rmsJerk.toFixed(2)}px`,
);
console.log(
`LEG 2 peak signed drift: ${peakDrift.toFixed(2)}px over ${DRIFT_HORIZON_MS}ms horizon (gate <= ${MAX_DRIFT_PX})`,
);
console.log(
`LEG 2 cumulative drift: ${totalDrift.toFixed(2)}px whole-run (LOG-ONLY — W4a reference scale, W2 should collapse this)`,
);
console.log(
`LEG 3 rms jerk (chatter): ${rmsJerk.toFixed(2)}px (${GATE_RMS_JERK ? `gate <= ${MAX_RMS_JERK_PX}` : "LOG-ONLY — no A/B separation pinned yet"})`,
);
console.log(
"(peak jerk ~0 == smooth row motion; a one-frame spike == felt lurch)",
);
for (const d of feltLurches.slice(0, 20))
console.log(
` lurch frame=${d.i} dev=${d.dev.toFixed(1)}px applied=${d.applied.toFixed(1)} rowMove=${d.rowMove.toFixed(1)} dt=${d.dt.toFixed(1)}ms fetch=${d.fetch} scrollTop=${d.scrollTop.toFixed(0)}`,
);
console.log("===========================================================\n");
/* eslint-enable no-console */
// Sanity: the run actually exercised a meaningful momentum upscroll.
expect(frames.length).toBeGreaterThan(500);
expect(finalMounted).toBeGreaterThanOrEqual(80);
expect(jerkCount).toBeGreaterThan(20);
// COVERAGE: the run must cross at least one fetchOlder prepend, so BOTH lurch
// COVERAGE: the run must cross at least one fetchOlder prepend so BOTH lurch
// sources (CV realization + prepend-commit-under-momentum) are under the gate.
// Corpus-structural (400-row seed > 300 limit) — holds on RED tip and GREEN
// fix alike; a run that never paged is a coverage regression, not a verdict.
expect(prependObserved).toBe(true);
// ANTI-CHEAT: the reading row must track the input. A frozen or half-applying
// scroller (near-zero motion) is caught here well before the lurch count.
expect(totalApplied).toBeGreaterThan(SWIPES * 200);
// ANTI-CHEAT: the reading row must actually track the input. A frozen or
// half-applying scroller has near-zero velocity; assert a real scroll rate
// (frame-rate invariant — px/ms, not per-frame motion). The floor scales with
// STEP_PX: a smaller step delivers the same total distance over more events
// (more per-event pacing overhead), so wall-clock px/ms drops proportionally —
// a fixed 0.1 floor was pinned at STEP=12 and false-fails a correctly-tracking
// STEP=2 run. 0.008·STEP_PX ≈ 0.016 at STEP=2 / 0.096 at STEP=12: well above a
// frozen scroller (~0), well below a real tracking run (STEP=2 measured ~0.07).
const MIN_VELOCITY = 0.008 * STEP_PX;
expect(Math.abs(velocity)).toBeGreaterThanOrEqual(MIN_VELOCITY);
// THE GATE. RED at tip under the WebKit mirror (dozens of felt lurches up to
// ~200px); Dawn's engine-order-independent fix turns it green on both engines.
expect(feltLurches.length).toBeLessThanOrEqual(MAX_FELT_LURCHES);
expect(smoothFraction).toBeGreaterThanOrEqual(MIN_SMOOTH_FRACTION);
// Quinn's skip-forever guard: sustained sub-lurch under-compensation.
expect(rmsDeviation).toBeLessThanOrEqual(MAX_RMS_DEVIATION_PX);
// THE GATE (LEG 1). RED at tip under the WebKit mirror (felt lurches spike
// jerk well past the threshold); Dawn's engine-order-independent fix produces
// smooth row motion (jerk ~0) and turns it green on both engines.
//
// GUARDRAIL: trustworthy only once a correct writer (Chromium T1.2-green) is
// confirmed ~0 here under wheel. If jerk can't hold Chromium ~0, invariance is
// falsified — do NOT relax; fall back to sync-only (option 2). See the header.
expect(peakJerk).toBeLessThanOrEqual(MAX_PEAK_JERK_PX);
// Leg 2 (signed drift) — the skip-forever guard. Pinned once the guardrail
// number lands; asserted here as the second leg of the ratified contract.
expect(peakDrift).toBeLessThanOrEqual(MAX_DRIFT_PX);
// Leg 3 (rms-jerk) — the chatter guard. Gated only when the A/B separation is
// on the record (BUZZ_PERF_GATE_RMS_JERK=1); log-only until then so a run
// against a correct writer that merely sits at the ~2px discretization floor
// does not false-red. See §Leg 3 pin criterion in the metric doc.
if (GATE_RMS_JERK) {
expect(rmsJerk).toBeLessThanOrEqual(MAX_RMS_JERK_PX);
}
});