The one event was the cascade's empty moment, not the selection
rega, row 43 case 3 at 52eeb0db: the coalescing landed — one event per click,
exactly as intended — and in 3 of 4 runs that one event was EMPTY,
{language:"", languageCode:"", languageCodes:[]}, so the grid stayed at 2,107.
The name fix works whenever it fires; the event just carried nothing to name.
THE CASCADE PASSES THROUGH A CLEARED STATE. mapStore.selectLanguage() sets
selectedDialect = null BEFORE it sets the language, so a dialect click walks the
store through "nothing selected" on its way to the answer. The settle timer fired
inside that window and read exactly what was there. The composite then did the
right thing with what it was told: an all-empty report IS how this element says
"the reader cleared everything".
MY FIRST FIX WAS A TIMEOUT, AND A TIMEOUT WAS THE WRONG KIND OF ANSWER. 60ms was
enough in my seat and not under headless swiftshader. That is the third time I
have tuned a duration on one machine and called it settled, so this one does not
get lengthened — lengthening moves the failure rather than removing it.
The guard is SEMANTIC instead. An all-empty read arriving straight after a filled
one is the one genuinely ambiguous case — it is what a mid-cascade read looks
like AND what a real clear looks like, and nothing in the payload separates them.
So it is re-taken rather than believed: the transient fills in and is reported
correctly, while a real clear stays empty through every re-take and is reported a
few tens of ms later, which no reader can perceive. The re-takes are BOUNDED
(3 x 60ms) so a clear can only ever be delayed, never swallowed, and the whole
budget stays far below the composite's 1.2s push window so the two cannot
collide. The budget resets on every fresh user action.
Note what is NOT assumed: the retries re-read the store on their own timer rather
than waiting for another watcher fire. In the failing runs the watcher fired only
ONCE, so a fix that depended on a second fire would not have worked.
check:language-code-link grows a section G for it, asserting both halves — the
guard exists, and it is bounded. Without the first the transient ships; without
the second a clear can never be reported at all. Both negative-tested.
Source only. STUDIO: needs a rebuild of doxa-research-map.
This commit is contained in:
parent
a94a85cb8c
commit
c53d356d3f
2 changed files with 61 additions and 0 deletions
|
|
@ -2262,6 +2262,34 @@ function emitFilterState() {
|
|||
// only read `languageCode` are unaffected.
|
||||
languageCodes: langCodes,
|
||||
}
|
||||
// ★ AN EMPTY REPORT AFTER A NON-EMPTY ONE IS AMBIGUOUS, SO IT IS NOT SENT IMMEDIATELY.
|
||||
// rega, row 43 case 3 at 52eeb0db: coalescing worked — one event per click — and in 3 of 4 runs
|
||||
// that one event was EMPTY, so the grid stayed at 2,107. The cascade behind a dialect click
|
||||
// passes through a fully-cleared state: mapStore.selectLanguage() sets selectedDialect = null
|
||||
// BEFORE it sets the language, and the clear was the last write my watcher saw. The settle timer
|
||||
// then fired in the gap and read nothing.
|
||||
//
|
||||
// MY FIRST FIX WAS A TIMEOUT, AND A TIMEOUT WAS THE WRONG KIND OF ANSWER. 60ms was enough in my
|
||||
// seat and not under headless swiftshader, which is the same fixed-sleep mistake I have now made
|
||||
// three times: a duration tuned on one machine is a measurement of that machine. Lengthening it
|
||||
// would move the failure, not remove it.
|
||||
//
|
||||
// So the condition is SEMANTIC. "Everything is empty" immediately after a non-empty report is
|
||||
// exactly what a mid-cascade read looks like AND exactly what a genuine clear looks like, and
|
||||
// nothing in the payload tells them apart — but TIME does, cheaply and in the safe direction:
|
||||
// re-read a few times, and take the first answer that is not empty. A real clear stays empty
|
||||
// through every re-read and is reported a few tens of ms later, which no reader can perceive; a
|
||||
// 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
|
||||
}
|
||||
_emptyRetries = 0
|
||||
_lastEmitWasFilled = !isEmpty
|
||||
host.dispatchEvent(new CustomEvent('map-filtered', { detail, bubbles: true, composed: true }))
|
||||
}
|
||||
|
||||
|
|
@ -2283,7 +2311,17 @@ function emitFilterState() {
|
|||
// async work); 60ms is far below the 1.2s guard and below what a reader perceives as a delay.
|
||||
let _emitTimer = null
|
||||
const EMIT_SETTLE_MS = 60
|
||||
// How many times an all-empty read may be re-taken before it is believed. Bounded on purpose: a
|
||||
// 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
|
||||
// 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
|
||||
function emitFilterStateSettled() {
|
||||
_emptyRetries = 0 // a fresh user action restarts the budget
|
||||
clearTimeout(_emitTimer)
|
||||
_emitTimer = setTimeout(emitFilterState, EMIT_SETTLE_MS)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@
|
|||
* it is asserted directly, not inferred.
|
||||
* F. a DIALECT reports a NAME and not a tree node id, and the emitter sends ONE event per
|
||||
* settled selection rather than a burst. Both from rega's row 43 case 3.
|
||||
* G. that ONE event is not the cascade's transient EMPTY state. Same case, second measurement:
|
||||
* the coalescing worked and the single event was EMPTY in 3 of 4 runs.
|
||||
*
|
||||
* NO NETWORK, NO BROWSER (Playwright is rega's alone). The fixture is committed and was taken from
|
||||
* the live source; refresh it with --refresh-fixture when the source's language set changes.
|
||||
|
|
@ -255,6 +257,27 @@ if (matchesLanguage) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── G. an empty report after a filled one is re-taken, and the re-take is BOUNDED ────────────
|
||||
// rega, row 43 case 3 at 52eeb0db: coalescing landed — one event per click — and in 3 of 4 runs
|
||||
// that one event was EMPTY, so the grid stayed at 2,107. The cascade behind a dialect click passes
|
||||
// through a fully-cleared store state (mapStore.selectLanguage sets selectedDialect = null BEFORE
|
||||
// setting the language), and the settle timer fired inside it.
|
||||
// The first fix was a timeout, and a timeout was the wrong KIND of answer: 60ms was enough on my
|
||||
// machine and not under headless swiftshader. Lengthening it moves the failure rather than removing
|
||||
// it. So the guard is SEMANTIC — an all-empty read straight after a filled one is re-taken — and
|
||||
// BOUNDED, because a genuine clear must always get through. Both halves are asserted: without the
|
||||
// guard the transient ships, and without the bound a clear can never be reported at all.
|
||||
{
|
||||
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')
|
||||
}
|
||||
|
||||
for (const n of notes) console.log(' · ' + n)
|
||||
if (fails.length) {
|
||||
console.error(`\n${TAG}: FAIL`)
|
||||
|
|
|
|||
Loading…
Reference in a new issue