The number's job was wrong, not its value

rega, row 43 case 3 at 835ec1ac: the all-empty guard catches the cascade on
MapLibre and NOT reliably on Mapbox — in ar/mapbox the single event is still
EMPTY and the grid stays at 2,107. Three of four.

THIS IS THE THIRD TIME I HAVE TUNED A NUMBER FOR THIS ONE DEFECT, so the number
does not get another value. 60ms was "long enough for the cascade" and was not.
3 x 60ms was "long enough for the cascade" and is not either. I cannot measure
the cascade from inside the element, and every value I pick is therefore a
measurement of whichever machine I picked it on — which is the lesson already in
COMMON.md, arriving a third time because I applied it to the duration and not to
the DESIGN.

So the number's JOB changes. It was a guess at how long the cascade takes, which
is unknowable from here. It is now a CEILING on how long an empty report waits
before it is believed, which is a choice — and the two fail in opposite
directions. Too short reintroduced the bug three times; too long merely delays a
genuine clear by an amount no reader perceives. The loop now re-reads until the
state is NON-EMPTY, which is the condition that actually matters, and gives up
only at the ceiling.

900ms, chosen to sit under the composite's 1.2s push window so the two cannot
interact, and to be generous rather than tight — the failure mode of generous is
invisible.

Driven through the rule outside the browser: a cascade that fills at 400ms reports
FILLED at 420, one that fills at 800ms reports FILLED at 840, and a genuine clear
reports empty at 960. The old budget gave up at 180.

check:language-code-link's section G already asserted BOUNDEDNESS, and it failed
when I changed the shape — correctly, because that is the invariant it guards. It
now asserts the ceiling instead of the count, and says in the file why the shape
changed while the invariant did not: without SOME bound a genuine clear is
deferred for ever and the grid can never be un-narrowed. Both arms
negative-tested.

Source only. STUDIO: rebuild doxa-research-map.
NEEDS EYES: rega's ar/mapbox run is the one that matters — the other three already
pass, so a green ar/mapbox is the whole result.
This commit is contained in:
Doxa Maps 2026-09-27 01:46:06 +00:00
parent 273785b2e6
commit 9b3eb99550
2 changed files with 38 additions and 14 deletions

View file

@ -2282,13 +2282,20 @@ function emitFilterState() {
// transient fills in and is reported correctly. The retries are bounded so a clear can never be
// swallowed, only delayed.
const isEmpty = !detail.region && !detail.religion && !detail.language && !detail.languageCode
if (isEmpty && _lastEmitWasFilled && _emptyRetries < EMPTY_RETRIES) {
_emptyRetries++
clearTimeout(_emitTimer)
_emitTimer = setTimeout(emitFilterState, EMIT_SETTLE_MS)
return
if (isEmpty && _lastEmitWasFilled) {
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now())
if (!_emptyWaitStartedAt) _emptyWaitStartedAt = now
if (now - _emptyWaitStartedAt < EMPTY_SETTLE_CEILING_MS) {
// Still inside the window in which an empty read is more likely to be the cascade than the
// reader. Re-read rather than believe it; the condition that ends this is the state becoming
// non-empty, not a count running out.
clearTimeout(_emitTimer)
_emitTimer = setTimeout(emitFilterState, EMIT_SETTLE_MS)
return
}
// The ceiling passed and it is still empty: the reader cleared it. Report that.
}
_emptyRetries = 0
_emptyWaitStartedAt = 0
_lastEmitWasFilled = !isEmpty
host.dispatchEvent(new CustomEvent('map-filtered', { detail, bubbles: true, composed: true }))
}
@ -2315,8 +2322,19 @@ const EMIT_SETTLE_MS = 60
// genuine clear must always get through, and the worst case here is EMPTY_RETRIES * EMIT_SETTLE_MS
// of delay on a clear — about a fifth of a second, well under what a reader notices, and far below
// the composite's own 1.2s push window so it cannot collide with that.
const EMPTY_RETRIES = 3
let _emptyRetries = 0
// ★ A CEILING ON BELIEVING A CLEAR — NOT A GUESS AT HOW LONG THE CASCADE TAKES.
// rega, row 43 case 3 at 835ec1ac: the retry budget catches the transient on MapLibre and NOT
// reliably on Mapbox (and not at all in ar/mapbox), because 3 x 60ms is still a duration I picked
// without being able to measure the thing it has to outlast. That is the THIRD time I have tuned a
// number on one machine for this one defect, so the number's JOB changes rather than its value.
//
// Before: "wait long enough for the cascade to finish" — unknowable from here, and wrong on any
// machine slower than mine. Now: "keep re-reading until the state is non-empty, and if it is STILL
// empty after this long, the reader really did clear it." Too long merely delays a clear by an
// amount no one perceives; too short reintroduces the bug. So it errs generous, and it stays under
// the composite's 1.2s push window so the two cannot interact.
const EMPTY_SETTLE_CEILING_MS = 900
let _emptyWaitStartedAt = 0
// Whether the LAST report actually carried a selection. Only then is an empty read suspicious: on
// first load, or after a clear that has already been reported, empty is simply the truth.
let _lastEmitWasFilled = false
@ -2338,7 +2356,7 @@ function emitFilterStateSettled() {
// So the decision is taken here, at schedule time, where the flag still means what it says: a
// change that arrived from the host is not a change the reader made, and is not reported back.
if (applyingExternal) return
_emptyRetries = 0 // a fresh user action restarts the budget
_emptyWaitStartedAt = 0 // a fresh user action restarts the window
clearTimeout(_emitTimer)
_emitTimer = setTimeout(emitFilterState, EMIT_SETTLE_MS)
}

View file

@ -271,11 +271,17 @@ if (matchesLanguage) {
const src = emitterSrc
if (!/_lastEmitWasFilled/.test(src) || !/const isEmpty/.test(src)) {
fails.push('G research-map.vue no longer guards an all-empty report. A dialect click passes through a fully-cleared store state, so the settled read can be taken mid-cascade and ship {language:"",languageCode:""} — which the composite correctly reads as "the reader cleared everything".')
} else if (!/_emptyRetries\s*<\s*EMPTY_RETRIES/.test(src)) {
fails.push('G the empty guard is not BOUNDED — without a retry ceiling a genuine clear is deferred for ever and the grid can never be un-narrowed.')
} else if (!/_emptyRetries = 0/.test(src)) {
fails.push('G the retry budget is never reset, so the guard protects only the first cascade of a session.')
} else notes.push('empty guard: a mid-cascade read is re-taken, a real clear still gets through')
} else if (!/EMPTY_SETTLE_CEILING_MS/.test(src)) {
// ★ THE BOUND'S SHAPE CHANGED AND THE INVARIANT DID NOT. It used to be a retry COUNT; rega
// measured that 3 x 60ms catches the cascade on MapLibre and not on Mapbox, so the count became
// a TIME CEILING — the difference being what the number is FOR. A count is a guess at how long
// the cascade takes, which cannot be known from here; a ceiling is how long before a clear is
// believed, which is a choice. What must never change is that SOME bound exists: without one a
// genuine clear is deferred for ever and the grid can never be un-narrowed.
fails.push('G the empty guard is not BOUNDED — without a ceiling a genuine clear is deferred for ever and the grid can never be un-narrowed.')
} else if (!/_emptyWaitStartedAt = 0/.test(src)) {
fails.push('G the wait window is never reset, so the guard protects only the first cascade of a session.')
} else notes.push('empty guard: re-read until the state fills, with a ceiling before a clear is believed')
}
// ── H. the echo guard survives the coalescing ────────────────────────────────────────────────