Skip to content

DRAFT (do not merge): coherence F2 — invalidate facet counts at input, before the await - #347

Draft
rdhyee wants to merge 2 commits into
isamplesorg:mainfrom
rdhyee:fix/coherence-preawait-invalidation
Draft

DRAFT (do not merge): coherence F2 — invalidate facet counts at input, before the await#347
rdhyee wants to merge 2 commits into
isamplesorg:mainfrom
rdhyee:fix/coherence-preawait-invalidation

Conversation

@rdhyee

@rdhyee rdhyee commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

⚠️ DRAFT — do not merge. Codex has blocked this twice and I agree with it.

Opened as a draft so the work is visible and reviewable, not because it's ready.
Built overnight; two substantive blockers remain, detailed at the bottom.

What this is

The coherence audit's highest-value open item (F2), and the approach Codex argued for over a watchdog: "the invariant belongs at input invalidation, not at a timer attached to selected query paths."

The bug it targets

Both filter handlers had this shape:

writeQueryState(); refreshHeatmap();
await <globe reload>;          // long
refreshFacetCounts();          // only NOW are counts invalidated

That await is not short. Measured overnight on production: 168 s on 4G, 423 s on 3G — because the Explorer currently downloads ~74 MB on cold load (separate finding, #345). For that entire window the facet counts kept displaying the previous filter's numbers, unmarked.

#340 was this same shape with a different trigger. #341 and #342 fixed instances; this targets the class.

The change

invalidateFacetCountsNow() — synchronous, called before any await: bumps the request id, clears the pending debounce, and marks counts recomputing (which swaps text to (Loading…) after 400 ms via #342). It deliberately does not schedule a recompute. An inner try/finally guarantees a successor on success and on throw.

Demonstrated effect

400 kbps, toggle one source filter, sample the DOM every 0.4 s:

production (no fix) this branch
invalidated within 0.4 s False True
stale unmarked windows 27 0
what the user sees (4,389,231) for 10+ s after deselecting SESAR (Loading…)

Why it is NOT ready

1. Overlapping handlers can still fire a premature query. Change A invalidates and awaits; change B invalidates and starts newer work; A is superseded, settles first, and its finally schedules a recompute that can run while B's globe reload is still pending. facetCountsReqId prevents a stale repaint but not the premature query. Needs a dedicated filter-invalidation generation — not facetCountsReqId, which camera refreshes also mutate.

2. The verifier cannot prove what I claimed. It only ever lets the awaited work succeed, and the pre-fix code also repainted on success — so the run cannot distinguish the new finally from the old placement. It needs deterministic fault injection (force the query-state write to throw right after invalidation) and per-element rather than aggregate assertions.

3. Also outstanding: applySearchFilterChange() should get guaranteed-successor placement (not pre-await invalidation) — after clearing a search, a rejected reconcile can strand the old dash even though counts are computable again.

4. Untested risk: facet-tree.spec.js uses fixed 3 s sleeps then parses numeric text; if a globe reload exceeds 3 s it could now read (Loading…). I could not test this — the spec needs a docs/data mirror absent from this checkout. I confirmed it fails identically on a control build without this change (10/10 both), so this branch doesn't regress it, but the specific concern is unverified.

Verification that did pass

quarto render clean · tests/test_smoke.py PASS · tests/test_frontend_derived.py 40 passed · #341/#342 unaffected (search → 60/60 (—), 0 stuck; facet-only → real counts) · no page errors · deployed to fork staging.

Refs #340, #304, #305

🤖 Generated with Claude Code

https://claude.ai/code/session_01QCCDurpcLzMe7L72y2HDAa

rdhyee and others added 2 commits August 6, 2026 00:03
The audit's highest-value open item, and the one that turns the honesty rule
from call-site discipline into an invariant.

THE BUG. Both filter handlers had this shape:

    writeQueryState(); refreshHeatmap();
    await <globe reload>;          // long
    refreshFacetCounts();          // only now are the counts invalidated

The await is not short. Measured on production: the globe reload takes 168s on
4G and 423s on 3G (see PERF_BANDWIDTH_FINDINGS_2026-08-06.md — it is currently
downloading ~74 MB). For that entire window the facet counts kept displaying the
PREVIOUS filter's numbers, unmarked and looking settled. isamplesorg#340 was this same
shape with a different trigger; isamplesorg#341/isamplesorg#342 fixed instances, not the class.

THE FIX. invalidateFacetCountsNow() — synchronous, cheap, called at the top of
the source and facet handlers BEFORE any await:
  1. ++facetCountsReqId so an in-flight recompute cannot repaint over us
  2. clearTimeout on the debounce armed for the old inputs
  3. markFacetCountsRecomputing() — dims now, and via its 400ms timer (isamplesorg#342)
     swaps text to "(Loading…)" when the wait is long enough to matter

It deliberately does NOT schedule a recompute: the caller still owns that, after
its await, once the new inputs have settled. Scheduling here would query
half-applied state and add contention to the very load being waited on.

Under an active search it returns early — those counts are already the honest
"(—)" dash (isamplesorg#340), and re-dimming would downgrade "we cannot know this" to
"we're about to know it", a worse claim rather than a better one.

PROVEN by A/B, not asserted. Same script, same 400 kbps throttle, toggle one
source filter, sample the DOM every 0.4s:

  production (no fix)   invalidated within 0.4s: False
                        stale UNMARKED windows : 27
                        user sees "(4,389,231)" for 10+s after deselecting SESAR
  this branch           invalidated within 0.4s: True
                        stale UNMARKED windows : 0
                        user sees "(Loading…)" throughout

Adds tests/playwright/verify_preawait_invariant.py, which is that experiment.

Verified: quarto render clean; test_smoke.py passes; test_frontend_derived 40
passed; isamplesorg#341/isamplesorg#342 behaviour unaffected (search -> 60/60 dashes 0 stuck;
facet-only -> real counts); no pageerrors.

SCOPE. Facet counts only. The samples table has the same shape
(applySearchFilterChange awaits reconcileGlobeForFilters before
refreshSamplesTable) — audit F8, deliberately left for a separate change.

Refs isamplesorg#340, isamplesorg#304, isamplesorg#305

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCCDurpcLzMe7L72y2HDAa
…unable to false-pass

Codex blocked round 1 on two findings, both valid:

1. A TERMINAL EXCEPTION COULD STRAND (Loading...). invalidateFacetCountsNow()
   bumps facetCountsReqId and cancels the pending debounce, but
   refreshFacetCounts() sat only on the success path. If anything between the
   invalidate and the end of the globe work threw — updateSourceLegendState,
   writeQueryState, refreshHeatmap, reconcileGlobeForFilters — nothing would ever
   schedule a recompute and the counts would sit at (Loading...) until some
   unrelated later event. A HUNG promise staying Loading is honest (isamplesorg#342); a
   REJECTED one is terminal, so Loading becomes a different kind of lie.

   Fixed with an inner try/finally in both handlers, so refreshFacetCounts() runs
   on success AND on throw. No watchdog, no query issued while work is pending —
   it only guarantees a successor once the work settles or rejects. The outer
   finally still owns busyRelease(), and selection revalidation still runs after.

   Deliberately NOT applied to applySearchFilterChange(): Codex analysed it and
   it is not a stale-number hole (activation paints the (—) dash synchronously,
   and clearing leaves that honest dash visible during reconciliation).

2. THE VERIFIER COULD FALSE-PASS. It printed ever_loading without asserting it,
   checked stale-unmarked only before t=12s while sampling past 18s, would accept
   a permanently dimmed numeric value, and never proved a successor repaint.
   All four clauses are now required, the stale check covers every observation,
   and it un-throttles at the end and requires real numbers to come back — which
   is precisely the anti-stranding assertion for finding 1.

Also took Codex's idempotence hardening: under an active search
invalidateFacetCountsNow() now repaints the (—) dash rather than merely
returning, so it does not depend on an earlier isamplesorg#340 caller having painted it.

Verified on the rebuilt page: verifier passes all four clauses (invalidated
within 0.4s / swapped to Loading / 0 stale-unmarked windows / successor
repainted); test_smoke.py passes; test_frontend_derived 40 passed; isamplesorg#341/isamplesorg#342
behaviour unaffected.

STILL OPEN from the review, deliberately not done here: converting the verifier
to a discovered .spec.js that deterministically holds a globe request, and
hardening facet-tree.spec.js's fixed 3s sleeps (they parse numeric text and
could now read (Loading...) if a globe reload exceeds 3s).

Refs isamplesorg#340, isamplesorg#304, isamplesorg#305

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCCDurpcLzMe7L72y2HDAa
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant