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>
This commit is contained in:
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
2026-07-08 14:52:43 -04:00
co-authored by Tyler Longwell
parent 963133b6a9
commit 8c1cbac6dc
+54 -2
View File
@@ -84,7 +84,8 @@ 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; stop early once we near the top of the window
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;
@@ -95,6 +96,10 @@ 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
@@ -120,6 +125,11 @@ async function measure(
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)
@@ -174,11 +184,17 @@ async function measure(
(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 };
return {
top: null,
inSafeBand: false,
scrollTop: el.scrollTop,
mounted,
};
const rect = row.getBoundingClientRect();
return {
top: rect.top,
@@ -186,6 +202,7 @@ async function measure(
rect.top > box.top + args.margin &&
rect.bottom < box.bottom - args.margin,
scrollTop: el.scrollTop,
mounted,
};
},
{ id: before.id, margin: SAFE_MARGIN },
@@ -193,6 +210,25 @@ async function measure(
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;
}
@@ -215,6 +251,7 @@ async function measure(
(el) =>
(el as HTMLDivElement).querySelectorAll("[data-message-id]").length,
),
prependObserved,
};
}
@@ -288,6 +325,9 @@ test("GATE: upscroll motion consistency stays below the realization-jitter thres
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})`,
);
@@ -317,6 +357,18 @@ test("GATE: upscroll motion consistency stays below the realization-jitter thres
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.