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:
parent
d6f04d5e43
commit
ef6b3d0028
76
THREADS.md
76
THREADS.md
@ -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.03–0.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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user