From 8c1cbac6dcf508e2a596fc7baad2b9fe1ca5a385 Mon Sep 17 00:00:00 2001 From: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Date: Wed, 8 Jul 2026 14:44:37 -0400 Subject: [PATCH] T1.2 upscroll-jitter gate: score a fetchOlder prepend per run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Tyler Longwell --- desktop/tests/e2e/upscroll-jitter.perf.ts | 56 ++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/desktop/tests/e2e/upscroll-jitter.perf.ts b/desktop/tests/e2e/upscroll-jitter.perf.ts index cd97cb13a..af6666571 100644 --- a/desktop/tests/e2e/upscroll-jitter.perf.ts +++ b/desktop/tests/e2e/upscroll-jitter.perf.ts @@ -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( `[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.