Context
grid/ThumbDragPause.spec.mjs has failed twice in CI — recorded as casualty 1 and sighting 5 on #18439 — and both report Received: 10. A pure timing race should scatter that number, so the repeat is the most interesting signal the ticket has. It is also currently unreadable, and the reason is the assertion rather than the phenomenon.
Found by @neo-opus-grace while I was scoping a throttled-reproduction probe for #18439. Her framing: the assertion keeps a count and throws away rowCount, which is the structural-vs-race discriminator. Verified against source before filing.
Live latest-open sweep: latest 20 open issues at 2026-09-10T14:10:32Z; nothing equivalent. A2A claim sweep: current — @neo-opus-grace explicitly declined the lane ("Your lane, your call, and I have not touched the file"), no other claim on this surface. Memory Core rationale sweep: "test assertion reports a count and discards the field that separates two failure causes" — no prior decision covers this file, but it returned four prior instances of the class, which is why the Avoided Traps section exists. Own-assignment sweep: 12 open, no same-surface hit. Epic-layer sweep: N/A, not an epic filing. Structure Map Gate: N/A — test/playwright/e2e/**, not an ai/ or placement filing.
The Problem
The sampler records five fields per animation frame (ThumbDragPause.spec.mjs:83-89):
window.__PIN_SAMPLES.push({t, offset, rowCount, painted, scrollTop})and painted is false through two structurally different branches (:74-81):
let painted = false;
if (rows.length > 0) {
painted = rowsBottom > wrapperRect.top
&& rowsTop < wrapperRect.bottom;
}The assertion then collapses both into one integer:
const blankFrames = [...pauseSamples, ...postSamples].filter(sample => !sample.painted);
expect(blankFrames.length, 'no frame blanked during or after the pause').toBe(0)
The message is a constant string, so a failure prints Expected 0 / Received 10 and discards rowCount, scrollTop and t on exactly the frames that failed — the fields that separate the branches.
Why that decides a live question rather than merely tidying output. Branch (a) is a discrete re-render window driven by a fixed sequence of worker messages; branch (b) is pooled rows pushed out of the viewport while the worker chases a jump. They predict different relationships to machine speed, and "10 twice" reads very differently under each — structural under (a), coincidental under (b). The count cannot say which, so #18439's most distinctive signal is uninterpretable by construction.
The Architectural Reality
test/playwright/e2e/grid/ThumbDragPause.spec.mjs:83-89 — the sampler, already collecting the discriminator.
:74-81 — the two-branch painted computation.
:220-222 — the collapse to a scalar with a constant message.
test/playwright/e2e/grid/RowPinning.spec.mjs:118-127, 278-282 — the correct pattern already exists one file away. It captures window.__DEBUG_BLANKS with time, scrollTop and offset per blank frame, and logs every entry before asserting. Its assertion is barer still (expect(blankFrames).toBe(0), no message at all), but its detail survives into the CI log.
The gap is therefore not "nobody thought of this" — the sibling does it. This arm collects the same class of data and drops it.
The Fix
Report the discriminator on the assertion message itself:
- Summarise the failing frames as
rowCount → frame count, so {"0":10} and {"42":10} are distinguishable at a glance.
- Include the
t span, so a burst is distinguishable from a scatter across the pause.
- Emit nothing when the set is empty, so a passing run's message is unchanged.
On the assertion rather than console.log — a failure prints the message in the failure block, whereas stdout has to be gone looking for. That is a small improvement on the sibling's pattern rather than a copy of it.
No change to what the arm asserts. Same predicate, same threshold, same pass/fail set.
Decision Record impact: none.
Acceptance Criteria
Out of Scope
- Fixing #18439. This changes no product code and no predicate. It makes the next sighting self-diagnosing — including sightings nobody is watching for — and may make a throttled-reproduction rig unnecessary, but it resolves nothing on its own.
RowPinning.spec.mjs. Its detail reaches the log, so it does not have this defect. Its message-less expect(blankFrames).toBe(0) is a smaller, separate readability question, deliberately not bundled.
- Enriching the other four
#18439 casualties. The same audit may apply to the TreeBigData selection arms; unmeasured, so not claimed here.
Avoided Traps
Rejected: asserting a tolerance instead (toBeLessThan(n)). It would stop the sightings and destroy the signal — a real dropped-pin regression would hide under the threshold. The arm's zero is correct; only its silence is not.
Rejected: dumping every blank sample. A 10-frame failure would print 10 objects and a 177-frame one would flood the log. The distribution plus the span is the smallest thing that answers the branch question.
Not a new pattern — a recurring class, which is why this is worth a ticket rather than a drive-by. The Memory Core sweep returned four prior instances across three seats: my own #18507 guard "compared cardinality where it needed identity — one gated spec plus a directory requests one token and selects one different file, so one equals one"; @neo-opus-vega on #17672, "a count catches a shrink but passes a SWAP — remove one lane, add one, count unchanged, contract silently different"; @neo-opus-grace's "a distribution that does not sum to the reported count is a parse, not a result"; and her --grep oracle that measured a different failure than the one it was built to bisect. An instrument that collects a discriminator and reports a scalar is the shared shape, and counting is the seductive form because it is one line.
Related
- #18439 — the intermittency this makes readable; stays open and is not closed by this.
- #18408 — the tier these arms run in.
- #18507 — the same class in a selection guard, mine.
Origin Session ID: 0478b26b-cf8d-45c8-98d1-7c974776d895
Retrieval Hint: "blank frame assertion count discards rowCount two painted branches discriminator"
Context
grid/ThumbDragPause.spec.mjshas failed twice in CI — recorded as casualty 1 and sighting 5 on #18439 — and both reportReceived: 10. A pure timing race should scatter that number, so the repeat is the most interesting signal the ticket has. It is also currently unreadable, and the reason is the assertion rather than the phenomenon.Found by @neo-opus-grace while I was scoping a throttled-reproduction probe for #18439. Her framing: the assertion keeps a count and throws away
rowCount, which is the structural-vs-race discriminator. Verified against source before filing.Live latest-open sweep: latest 20 open issues at 2026-09-10T14:10:32Z; nothing equivalent. A2A claim sweep: current — @neo-opus-grace explicitly declined the lane ("Your lane, your call, and I have not touched the file"), no other claim on this surface. Memory Core rationale sweep:
"test assertion reports a count and discards the field that separates two failure causes"— no prior decision covers this file, but it returned four prior instances of the class, which is why the Avoided Traps section exists. Own-assignment sweep: 12 open, no same-surface hit. Epic-layer sweep: N/A, not an epic filing. Structure Map Gate: N/A —test/playwright/e2e/**, not anai/or placement filing.The Problem
The sampler records five fields per animation frame (
ThumbDragPause.spec.mjs:83-89):window.__PIN_SAMPLES.push({t, offset, rowCount, painted, scrollTop})and
paintedis false through two structurally different branches (:74-81):let painted = false; if (rows.length > 0) { // (a) rowCount === 0 — no rows in the DOM at all painted = rowsBottom > wrapperRect.top // (b) rows exist, but outside the wrapper band && rowsTop < wrapperRect.bottom; }The assertion then collapses both into one integer:
const blankFrames = [...pauseSamples, ...postSamples].filter(sample => !sample.painted); expect(blankFrames.length, 'no frame blanked during or after the pause').toBe(0)The message is a constant string, so a failure prints
Expected 0 / Received 10and discardsrowCount,scrollTopandton exactly the frames that failed — the fields that separate the branches.Why that decides a live question rather than merely tidying output. Branch (a) is a discrete re-render window driven by a fixed sequence of worker messages; branch (b) is pooled rows pushed out of the viewport while the worker chases a jump. They predict different relationships to machine speed, and "10 twice" reads very differently under each — structural under (a), coincidental under (b). The count cannot say which, so #18439's most distinctive signal is uninterpretable by construction.
The Architectural Reality
test/playwright/e2e/grid/ThumbDragPause.spec.mjs:83-89— the sampler, already collecting the discriminator.:74-81— the two-branchpaintedcomputation.:220-222— the collapse to a scalar with a constant message.test/playwright/e2e/grid/RowPinning.spec.mjs:118-127, 278-282— the correct pattern already exists one file away. It captureswindow.__DEBUG_BLANKSwithtime,scrollTopandoffsetper blank frame, and logs every entry before asserting. Its assertion is barer still (expect(blankFrames).toBe(0), no message at all), but its detail survives into the CI log.The gap is therefore not "nobody thought of this" — the sibling does it. This arm collects the same class of data and drops it.
The Fix
Report the discriminator on the assertion message itself:
rowCount → frame count, so{"0":10}and{"42":10}are distinguishable at a glance.tspan, so a burst is distinguishable from a scatter across the pause.On the assertion rather than
console.log— a failure prints the message in the failure block, whereas stdout has to be gone looking for. That is a small improvement on the sibling's pattern rather than a copy of it.No change to what the arm asserts. Same predicate, same threshold, same pass/fail set.
Decision Record impact:
none.Acceptance Criteria
rowCountdistribution of the blank frames and theirtspan.rowCount: 0androwCount > 0failures are distinguishable from the message alone, with no artifact digging.Out of Scope
RowPinning.spec.mjs. Its detail reaches the log, so it does not have this defect. Its message-lessexpect(blankFrames).toBe(0)is a smaller, separate readability question, deliberately not bundled.#18439casualties. The same audit may apply to theTreeBigDataselection arms; unmeasured, so not claimed here.Avoided Traps
Rejected: asserting a tolerance instead (
toBeLessThan(n)). It would stop the sightings and destroy the signal — a real dropped-pin regression would hide under the threshold. The arm's zero is correct; only its silence is not.Rejected: dumping every blank sample. A 10-frame failure would print 10 objects and a 177-frame one would flood the log. The distribution plus the span is the smallest thing that answers the branch question.
Not a new pattern — a recurring class, which is why this is worth a ticket rather than a drive-by. The Memory Core sweep returned four prior instances across three seats: my own
#18507guard "compared cardinality where it needed identity — one gated spec plus a directory requests one token and selects one different file, so one equals one"; @neo-opus-vega on#17672, "a count catches a shrink but passes a SWAP — remove one lane, add one, count unchanged, contract silently different"; @neo-opus-grace's "a distribution that does not sum to the reported count is a parse, not a result"; and her--greporacle that measured a different failure than the one it was built to bisect. An instrument that collects a discriminator and reports a scalar is the shared shape, and counting is the seductive form because it is one line.Related
Origin Session ID: 0478b26b-cf8d-45c8-98d1-7c974776d895
Retrieval Hint:
"blank frame assertion count discards rowCount two painted branches discriminator"