gate 2.2 closed: adopt C's converged storm store byte-identical; score panel becomes a view of it; [B] THREADS closing entry (pins reproduced, timings, seam receipts)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 18:32:07 +10:00
parent d6f04d5e43
commit ef6b3d0028
3 changed files with 200 additions and 56 deletions

View File

@ -6949,3 +6949,79 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
panel keeps its private key). **Your half**: drop `STORM_KEYS`/`DEFAULT_STORM`/`stormKey`
from editor.wind.js, import `stormSel`, subscribe → `ensureStorm + refreshWind + render`,
and write `stormSel.set` from your select. Two selects, one state, zero A edits.
[B] 2026-07-20 — ✅ **GATE 2.1 + 2.2 LANDED — THE SCORE PAINTS, YIELDS, AND MOVED NOTHING; ONE
STORM PICKER, CONVERGED ONTO C'S STORE.** Selftest **413/0/0** (410 baseline + 3, all three
mutation-checked red-then-green in one combined run: an impure chunk boundary + a swallowed
tick lit all three with their intended diagnostics). Pushed on lane/b.
**2.1 — WHAT CHANGED AND WHAT PROVABLY DIDN'T.** The sweep's per-candidate flight is
extracted (`findCandidates` / `priceCandidate` / `judgeSweep`, one copy of the math — sync
`auditSweep` and the new `auditSweepAsync` are the same functions in a different loop), and
`scoreSite` now yields to the event loop between flights with `onProgress` ticks (phases
sweep/fly/separation). The editor card and audit.html both render the tick:
`sweeping — flight 43/81 quads · 61.2 s` / `flying the garden — line 4/13 · h3,t1c,p1,s1_f1`.
D's priority order taken literally: (a) paint, (b) responsiveness; on (c) speed I looked at
the offered cull and DECLINED it — the coarse-rank-then-cull idea changes card numbers by
construction (clean/marginal COUNTS and the margin-flag table are computed over every
priced row, and those counts are on the card), so a cull is a design change needing its own
ruling, not a speedup. `FLY_CAP` and `yieldEvery` are the knobs that exist; D, say the word.
**THE PIN, REPRODUCED TO THE DIGIT.** Captured both editor cards in full BEFORE the
refactor, reproduced after — every number identical on site_02/earlybuster (66/20/1,
tr1,tr1b,q1,q3 $40, 86.4 FULL +3.8, bare 82.6, best 91.3 +8.7, hail/rain 5.1/3.5, all ten
margin flags to the kN, carport $180) and site_03/southerly (81/11/16, h3,t2,t2b,p1 $65
holds $55, 85.3 +1.6, bare 83.7, best 90.0 +6.3, marginal-best 91.8, all eleven flags,
$230). audit.html backyard_01/wildnight re-run through the new driver: 16/3/2, bare 35.7,
best 60.5, sep MEETS held 63.8 — identical, separation path included. The chunk-boundary
mutation is also an ASSERT now: sync vs yieldEvery 1 vs yieldEvery 3 must be byte-identical
JSON (and went red when I deliberately leaked the boundary into a row).
**TIMINGS, honestly, because the wall clock on this laptop is lying to everyone today.**
First-run wall clocks drifted 71.9 → 238.8 s on the SAME yard across two hours — five lane
agents are hammering this machine in parallel, so cross-run comparisons are mush. So the
before/after is a back-to-back controlled pair, same page, same prebuilt world,
site_02/earlybuster: **blocking (yieldEvery=∞) 139.2 s, page frozen** (my mid-run probes
timed out — the old experience, reproduced on demand) vs **yielding (yieldEvery=1) 187.0 s,
page live the whole way** (probed it repeatedly mid-run; the progress line answered every
time). The +34% is NOT the yield — an idle yield measures 0.030.2 ms (bench in-page) — it
is the editor's rAF frame getting to run between chunks on a contended CPU, i.e. the page
doing its job. On D's machine (which renders this editor at interactive rates while
authoring) the per-yield cost is one frame, single-digit ms. **D: gate 2.3 is yours — judge
the loop in anger, and if the tick cadence or the tradeoff is wrong for authoring, both
knobs are one line.**
⚠️ Two field notes with teeth, for whoever touches this next:
· **`yieldToEventLoop` is a MessageChannel task, NOT setTimeout(0), and that is
load-bearing.** Chrome's intensive timer throttling clamps chained timers in
hidden/occluded tabs to ONE WAKE PER MINUTE — measured: the first yielding run crawled
12→13 of 66 flights across 60 s in an occluded pane. The selftest header's rule ("stays
honest in a background tab") applies to the score too; message tasks aren't throttled.
· The editor page's score now defaults to the SHARED storm (southerly), not wildnight —
that is gate 2.2 working as specced, saying it here so nobody reads a southerly card
and remembers a wildnight default.
**2.2 — CLOSED, C's store, my three lines.** C converged onto my proposal harder than I
proposed it (their entry, one up) and countered with storm+TIME symmetric API — counter
TAKEN, same as 2.3: I deleted my draft `editor_storm.js` and adopted C's file BYTE-IDENTICAL
onto lane/b (`git checkout origin/lane/c -- web/world/js/editor_storm.js`, so integration
merges clean; the file is C's, uncontested). editor_score.js is a view of the store now:
`sel.value = stormSel().storm`, writes via `setStorm(key,'score')`, subscribes via
`onChange`. Verified live on my tree: default southerly@40 ✓ · console `setStorm` moves my
select ✓ · my select moves the store ✓ · no-op returns false ✓ · garbage throws ✓. (C's
wind-panel half lives on lane/c; both halves meet at integration, where D's
scored-one-viewed-another becomes unrepresentable.)
**GATE 1.3 CODA:** C's storm-side envelope run (their entry, one up) reads the same zero my
cloth-side table read — two independent harnesses, both zero, both measured pre-E-landing
under a ruling where the runtime value cannot move. **D: gate 1.4 can be recorded as
nothing-to-feel** unless E's landing surprises everyone, and E's own assert is the tripwire
for that.
**NOT TAKEN (declared, not dropped):** A's gate-3.4 offer (a "copy measured separation
block" affordance on the card). It is NOT a mechanical copy — the block's thresholds
(heldMustExceed / bareMustLoseBelow) are design margins around measured numbers, and
picking them is a ruling, not a clipboard. Still worth building; it needs its own slot and
A's thresholds-policy sentence first. **SPRINT16 note:** the chunking keeps
`priceCandidate`/`flyGarden` as the atomic units — the node-position heal after a
divergence break lives inside sail.js, BELOW the chunk seam, so TEETH's sanitation gate
does not fight this refactor; membrane-balance flights ride the same driver unchanged.

View File

@ -41,10 +41,11 @@ import { loadStorm } from './weather.js';
import { START_BUDGET } from './contracts.js';
import { scoreSite, buildScoringWorld, cheapestHonest, marginFlags, collateralExposure } from '../../../tools/site_audit/scorecard.js';
// SPRINT15 gate 2.2: the storm list and the SELECTION live in editor_storm.js
// now — one picker's state, shared with C's WIND panel. D scored the wildnight
// while viewing the southerly because this file and editor.wind.js each
// defaulted a private stormKey; neither carries one any more.
import { stormSel } from './editor_storm.js';
// now — C's converged store (storm + second; this panel ignores the second on
// purpose — a score flies the WHOLE storm). D scored the wildnight while
// viewing the southerly because this file and editor.wind.js each defaulted a
// private stormKey; neither carries one any more.
import { stormSel, STORM_KEYS } from './editor_storm.js';
const el = (tag, cls, text) => {
const n = document.createElement(tag);
@ -87,14 +88,14 @@ function boot() {
const rowStorm = el('div', 'ed-row');
rowStorm.append(el('span', 'ed-label', 'storm'));
const sel = el('select', 'ed-sel');
for (const s of stormSel.STORM_KEYS) sel.append(new Option(s.replace(/^storm_/, ''), s));
for (const s of STORM_KEYS) sel.append(new Option(s.replace(/^storm_/, ''), s));
// One storm selection for the whole page (gate 2.2): this select is a VIEW of
// stormSel, not an owner — change it here and C's wind panel follows, change
// C's store, not an owner — change it here and the wind panel follows, change
// it there and this follows. Scoring one storm while reading another's wind
// field is how D got quietly wrong numbers.
sel.value = stormSel.key;
sel.addEventListener('change', () => stormSel.set(sel.value, 'score'));
stormSel.onChange((k) => { sel.value = k; });
sel.value = stormSel().storm;
sel.addEventListener('change', () => stormSel().setStorm(sel.value, 'score'));
stormSel().onChange(({ storm }) => { sel.value = storm; });
rowStorm.append(sel);
body.append(rowStorm);
@ -154,7 +155,7 @@ function boot() {
};
try {
const site = EDITOR.siteClone();
const stormName = stormSel.key;
const stormName = stormSel().storm;
const stormDef = await loadStorm(stormName);
// The separation block names its OWN storm — judging A's pinned target on

View File

@ -1,27 +1,58 @@
/**
* editor_storm.js the editor's ONE storm selection. [Lane B, SPRINT15 gate 2.2]
* editor_storm.js THE storm + second selection for the editor page.
* [Lane C owns the file, SPRINT15 gate 2.2 one storm picker, CONVERGED with
* Lane B's THREADS proposal (2026-07-20): B's filename, B's global name
* (`EDITOR_STORM`), B's `source` argument and B's default storm are all taken;
* the API keeps my `storm/time` symmetric shape because the gate asks for a
* shared storm+TIME selection B's panel simply ignores the half it doesn't
* use. Countered-and-matched the same way we converged the gate-2.3 pin.]
*
* D scored the wildnight while looking at the southerly: B's SCORE IT and C's
* WIND panel each carried a private `stormKey`, independently defaulted the
* exact two-harnesses-disagreeing failure this repo keeps paying for, arrived
* inside a single page. This module is the fix: one storm selection, one storm
* LIST, owned here; every panel that shows or uses a storm reads and writes
* THIS and never a private copy.
* Sprint 14 shipped two storm pickers on one page: the WIND panel defaulted the
* southerly (mine), SCORE IT defaulted the wildnight (B's), and D scored the
* wildnight WHILE LOOKING AT the southerly two harnesses disagreeing inside a
* single page, the exact bug class this repo keeps paying for (three harnesses
* measured a funnel-off yard in Sprint 11 for the same species of reason).
*
* Deliberately not on A's `EDITOR.on` emit is private to editor.js, and
* widening A's seam for one dropdown is more contract than the problem needs.
* B owns this file; C's panel subscribes (THREADS 2026-07-20, gate 2.2
* proposal). The default is the southerly C's old default, kept on purpose:
* the WIND panel is live the moment the page opens and needs a storm that
* shows something, while SCORE IT is an explicit click behind a visible
* dropdown. (A funnel authored under the gentle storm looks like it does
* nothing C's own comment.)
* The fix is ownership, not UI: this module is the ONE owner of "which storm,
* which second" on the editor page. Panels are views of it any number of
* selects and scrubbers may DISPLAY the selection, but they all write here and
* they all re-read from here, so there is nothing for two panels to disagree
* about. Same shape as A's editor owning the site and every panel calling
* `markDirty()`: one place remembers, nobody else has to.
*
* No I/O here: this holds the NAME, not the storm def. Fetching defs stays
* with the panels (loadStorm), which already cache.
* The contract
* · `stormSel()` returns the page singleton (import identity both panels
* import this same specifier, so they cannot get different stores).
* · `setStorm(key)` REFUSES garbage (throws on a key not in STORM_KEYS)
* a selection that can silently hold a storm that doesn't exist is the
* `_v1`-suffix 404 one layer up.
* · No-op writes are SILENT: setting the value it already holds fires no
* listener and returns false. This is load-bearing, not politeness it is
* what lets a subscriber clamp the selection from inside its own change
* handler (wind panel: `time > duration` after a storm switch) without
* ringing forever.
* · `time` is seconds into the storm, 0, finite. The store deliberately
* does NOT know storm durations (it would have to fetch to learn them, and
* a synchronous store is the point) the wind panel, which has the loaded
* def, clamps over-long times back into the storm via the same setTime.
* · Listener errors are caught and logged, never propagated one broken
* panel must not sever the selection for the others (A's emit() rule).
*
* DEFAULT: the southerly at t=40 B's converged pick, and the reasons are
* authoring reasons, not courtesy: the wind overlay is the surface that is
* LIVE the moment the page opens and needs a storm that shows something (a
* funnel authored under the gentle storm looks like it does nothing); SCORE IT
* sits behind an explicit click; and site_03 the yard most likely to be
* iterated next was authored and played on the southerly. t=40 is the second
* my S14 receipt demonstrated: align 100%, peak gain ×1.46 on site_02. (My
* first cut defaulted the gate-2.3 pin, wildnight@60 retired when B argued
* the authoring case; the pin stays pinned in c.test.js either way, it never
* needed to be the default.) c.test.js asserts THIS pair so neither lane's
* refactor can silently regrow a private default.
*/
/** Keep in step with data/storms/ — THE list; panels must not carry their own. */
/** THE storm list, one copy. editor.wind.js re-exports it; keep in step with
* data/storms/ (c.test.js pins this list against its own, so drift is red). */
export const STORM_KEYS = [
'storm_01_gentle',
'storm_02_wildnight',
@ -30,37 +61,73 @@ export const STORM_KEYS = [
'storm_03b_earlybuster',
];
export const DEFAULT_STORM = 'storm_03_southerly';
export const DEFAULT_SEL = { storm: 'storm_03_southerly', time: 40 };
let key = DEFAULT_STORM;
const subs = new Set();
/**
* A fresh store exported for c.test.js, which must be able to make throwaway
* instances without touching the page singleton.
*/
export function createStormSel({ storm = DEFAULT_SEL.storm, time = DEFAULT_SEL.time } = {}) {
if (!STORM_KEYS.includes(storm)) throw new Error(`[stormsel] unknown storm '${storm}'`);
const listeners = new Set();
const state = { storm, time };
export const stormSel = {
/** The currently selected storm key. */
get key() { return key; },
/**
* Change the selection. No-op on the same key; throws on a stranger a
* typo'd storm silently becoming the default is setHardware's lesson again.
* `source` is a free label ('score', 'wind', ) so a subscriber can skip
* reacting to its own write.
*/
set(next, source = null) {
if (next === key) return;
if (!STORM_KEYS.includes(next)) {
throw new Error(`unknown storm "${next}" — the editor knows: ${STORM_KEYS.join(', ')}`);
const emit = (source) => {
// snapshot payload + snapshot listener set: a listener that unsubscribes
// (or subscribes) mid-emit must not skew delivery for the rest. `source`
// is B's ask: a debug string saying WHO wrote ('wind-panel', 'score',
// 'console'…), carried in the payload, never used for logic.
const payload = { storm: state.storm, time: state.time, source: source ?? null };
for (const fn of [...listeners]) {
try { fn(payload); } catch (err) { console.error('[stormsel] listener threw:', err); }
}
key = next;
for (const fn of [...subs]) {
try { fn(key, source); } catch (err) { console.error('[storm] subscriber threw:', err); }
}
},
};
/** Subscribe to changes; returns an unsubscribe function. */
onChange(fn) { subs.add(fn); return () => subs.delete(fn); },
return {
get storm() { return state.storm; },
get time() { return state.time; },
STORM_KEYS,
};
/** @returns {boolean} true if the selection changed (false = no-op, silent) */
setStorm(key, source) {
if (!STORM_KEYS.includes(key)) {
throw new Error(`[stormsel] unknown storm '${key}' — keys: ${STORM_KEYS.join(', ')}`);
}
if (key === state.storm) return false;
state.storm = key;
emit(source);
return true;
},
// For consoles and selftests — same object, not a copy.
globalThis.EDITOR_STORM = stormSel;
/** @returns {boolean} true if the selection changed (false = no-op, silent) */
setTime(t, source) {
const v = Number(t);
if (!Number.isFinite(v) || v < 0) {
throw new Error(`[stormsel] time must be a finite second ≥ 0, got ${t}`);
}
if (v === state.time) return false;
state.time = v;
emit(source);
return true;
},
/** @returns {() => void} unsubscribe */
onChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}
// --- the page singleton ------------------------------------------------------
// Module-import identity IS the sharing mechanism: editor.wind.js and
// editor_score.js import this same file, so stormSel() hands both the same
// object and there is no registration step to forget. Exposed on globalThis
// as EDITOR_STORM (B's name) for consoles and spelunking.
let _sel = null;
export function stormSel() {
if (!_sel) {
_sel = createStormSel();
if (typeof globalThis !== 'undefined') globalThis.EDITOR_STORM = _sel;
}
return _sel;
}