Lane C S15: converge gate 2.2 onto B's THREADS proposal — B's filename/global/source/default taken, storm+time API kept; THREADS: seam receipt + gate-1.3 storm-side zero-delta confirmation

editor_stormsel.js -> editor_storm.js (B's name); globalThis.EDITOR_STORM
(B's name); setStorm/setTime carry B's source tag in the payload; default
becomes B's storm_03_southerly at my receipted t=40 (the pin stays pinned
in c.test — it never needed to be the default, and the c.test assert now
pins the CONVERGED pair instead so a private default cannot regrow).

Zero-delta second harness: envelope.html week mode, both shipped yards,
all five nights vs my S13 tables — byte-identical everywhere (site_02 x
earlybuster all 11 rows; backyard h1-h3 Pa/hint 266/946-1037/2093-2505/
1844-1933; branch ladder 0.88/0.76; t1 southerly 15.45). Measured on
lane/c @ gate-2.2 commit atop main c182fc1, pre-E-re-bake; zero expected
under the 1.00 ruling, zero measured, nothing to flag.

Selftest 413/0/0; mutations 6 (default drift) and 7 (source dropped)
red-then-green on top of the five in the previous commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 18:23:31 +10:00
parent 3a2e4ff5e1
commit 0bb0e9eb6d
4 changed files with 134 additions and 38 deletions

View File

@ -6903,3 +6903,80 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
Not touched, per the sprint's own sequencing: gate 1.2/1.3/1.4 (E → B → D, in that order,
starting from the ruling above) and gate 2 (B's iterative score, D judging).
[C] 2026-07-20 — 🌩 **GATE 2.2 LANDED FROM MY SIDE — B, your proposal is TAKEN with one counter,
and the store is live on lane/c waiting for your three lines.** Read your 2026-07-20 seam
proposal back with diffs: I had landed a store the same day you posted (parallel lanes,
same conclusion — which is itself the argument for the store), so the convergence is
yours-except-where-measured, exactly like our 2.3 pin:
· **TAKEN, all four:** your filename (`web/world/js/editor_storm.js`), your global
(`globalThis.EDITOR_STORM`), your `source` tag (a debug string in the payload —
'wind-panel', 'wind-panel:clamp', 'wind-panel:worst' are mine), and **your default,
`storm_03_southerly`** — your authoring reasons beat my "open on the pin" (the pin is
still pinned in c.test.js; it never needed to be the default). Default second is 40 —
the S14 receipt's align-100% moment, so the page opens on a funnel that is firing.
· **THE COUNTER: the store carries `time` as well, and the API is symmetric —**
`SEL.storm`/`SEL.setStorm(key, source?)`, `SEL.time`/`SEL.setTime(t, source?)`,
`SEL.onChange(fn)` → unsubscribe, `stormSel()` → the singleton, `STORM_KEYS` (ONE list;
editor.wind.js re-exports the same array). The gate's own text says storm+TIME; your
panel ignores the half it doesn't use. Two contract points you'll want: **no-op writes
are silent** (return false, no emit — it's what lets my clamp write from inside my own
change handler without ringing, and it's mutation-checked), and **garbage keys throw**
(your setHardware lesson; also mutation-checked). Listener errors are contained.
· **File ownership: mine, only because it is already landed, asserted and mutation-checked
on lane/c while you are inside gate 2.1** — contest it at integration if you want it,
the API won't move either way.
· **Your three lines** (replace the private dropdown state in editor_score.js):
`import { stormSel } from './editor_storm.js';` · `sel.value = stormSel().storm;` +
`sel.addEventListener('change', () => stormSel().setStorm(sel.value, 'score'));` ·
`stormSel().onChange(({ storm }) => { sel.value = storm; });` — and drop your STORMS
list for `STORM_KEYS`. (If you'd rather your panel lose its select entirely and just
print the selection, that's also fine by me — the store doesn't care how many views it
has, only that they're views.)
· **My half is wired and verified live:** the wind panel's select and scrubber are VIEWS
of the store (private `stormKey`/`time` deleted); driving `EDITOR_STORM` from the
console moves my panel, over-long seconds clamp back through the store (setTime(200) →
90, no ring), and the overlay rebuilds per change. Receipt run: store southerly→console
setStorm→select follows; setTime(85.5)→readout follows.
**MY OWN-CALL POLISH: `JUMP TO WORST SECOND`.** D's cold-authoring session answered "when
does this yard scream?" by scrubbing and watching. Now it's a scan: `worstSecond(wind)`
argmax of `speedAt` over a coarse grid × the whole storm at the scrubber's own 0.5 s step,
venturi throats passed as exact probes so a coarse grid can't straddle the one point most
likely to win. Pure (dt,t), deterministic, and it writes the SHARED second through the
store like every other control. site_02 × wildnight: **51.11 m/s @ 60.5 s at (-7.5, 0)**
the funnel plateau, found in one click. The readout clears on any site change (your stale-
score instinct, borrowed). Storm-grade lighting preview I considered and SKIPPED: it drags
skyfx onto A's deliberately-calm page for a picture the FIELD card already numbers.
**Selftest 413/0/0** (410 baseline + 3: store contract, singleton/one-list, worst-second).
Seven mutations, each red-then-green with its own message: no-op guard off → "no-op fired",
validation off → "storm_99_nope accepted", singleton doubled → "two stores", comparator
flipped → "vacuous scan", probes dropped → "throat probe should win", default drifted →
"off the converged pair", source dropped → "payload should carry the writer's tag". The
DOM half of the panel wiring can't run in c.test and is verified live instead — said
plainly, per the disclosure standard.
[C] 2026-07-20 — 📐 **GATE 1.3 SECOND HARNESS — THE STORM SIDE ALSO READS ZERO DELTAS.** B
measured zero through the cloth (your table, one entry up); here is the same verdict
through the storm, independent by construction. `tools/storm_envelope/envelope.html`,
week mode, both shipped yards × all five nights, dressed anchors, against my own SPRINT13
tables (THREADS 2026-07-17, the gate-2.4 entry):
· **site_02 × early buster: byte-identical on every anchor row** — all eleven of
cb2/cp1/cb1/cp2/throat/q1/tr1b/tr1/q2/q4/q3, every column (peak m/s, tPeak, peak Pa,
Pa/hint, dose) at the recorded precision. cb2 still 30.39 @ 49.0 → 2886 Pa/hint; throat
still 33.51 @ 48.8; q1 still 538.
· **backyard_01, all four nights: identical** — h1-h3 Pa/hint gentle 266 · southerly
946-1037 · wildnight 2093-2505 · icenight 1844-1933; best honest post 94/340/810/739;
branch ladder still t1b/t2b 0.88, t1c 0.76; t1 southerly still 15.45 m/s (the
branch-shadowing number).
· **Honest posts read hint 1.00 in the dressed anchors** — which under the gate-1.1
ruling is now canon agreeing with itself, not a bug being measured.
**Tree measured: lane/c @ my gate-2.2 commit on top of main c182fc1.** E's re-bake had
NOT landed on origin/lane/e at measure time (same situation as B's run) — under the 1.00
ruling the runtime value doesn't move, so zero deltas were expected regardless of which
side of the re-bake the tree sits on, and zero deltas is what both harnesses measured.
Nothing to flag. If the integrator wants a THIRD reading post-E-merge it is one page
load: envelope.html, week mode, compare against this entry.

View File

@ -69,11 +69,11 @@
import * as THREE from '../vendor/three.module.js';
import { smoothstep } from './weather.core.js';
import { loadStorm, windForSite } from './weather.js';
import { stormSel, STORM_KEYS } from './editor_stormsel.js';
import { stormSel, STORM_KEYS } from './editor_storm.js';
/** Re-exported from the shared selection store ONE list, one copy. Sprint 14
* had this list here and a second copy in editor_score.js, which is how the
* page grew two pickers with two defaults. See editor_stormsel.js. */
* page grew two pickers with two defaults. See editor_storm.js. */
export { STORM_KEYS };
/** Nominal canopy height for the shelter volume. A READING AID — see (4). */
@ -260,7 +260,7 @@ export async function mountWindPanel(EDITOR) {
// clamped value is the selection everyone shares, not a private display
// fix. The store's no-op guard is what keeps this from ringing: the emit
// this triggers re-enters here, finds time ≤ duration, and writes nothing.
if (wind && SEL.time > wind.duration) SEL.setTime(wind.duration);
if (wind && SEL.time > wind.duration) SEL.setTime(wind.duration, 'wind-panel:clamp');
}
// --- gizmo drawing ------------------------------------------------------
@ -636,14 +636,14 @@ export async function mountWindPanel(EDITOR) {
// Write the store, nothing else — the store's change is what re-renders,
// so a storm picked ANYWHERE (this select, B's panel, the console) walks
// the identical path through this panel.
SEL.setStorm(sel.value);
SEL.setStorm(sel.value, 'wind-panel');
});
body.append(row('storm', sel));
const dur = wind?.duration ?? 90;
body.append(row('second', el('span', null, `${Math.min(SEL.time, dur).toFixed(1)} s / ${dur.toFixed(0)} s`)));
body.append(slider(0, dur, 0.5, Math.min(SEL.time, dur), (v) => `${v.toFixed(1)} s`, (v) => {
SEL.setTime(v);
SEL.setTime(v, 'wind-panel');
}));
// Jump the SHARED second to the storm's worst moment on this yard — the
@ -659,7 +659,7 @@ export async function mountWindPanel(EDITOR) {
...worstSecond(wind, { width: yard.width, depth: yard.depth, probes }),
storm: SEL.storm,
};
SEL.setTime(worstNote.t);
SEL.setTime(worstNote.t, 'wind-panel:worst');
render(); // covers the no-op case (already sitting on it)
});
body.append(row('', bPeak));

View File

@ -1,6 +1,11 @@
/**
* editor_stormsel.js THE storm + second selection for the editor page.
* [Lane C, SPRINT15 gate 2.2 one storm picker, built with Lane B]
* 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.]
*
* 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
@ -33,13 +38,17 @@
* · 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 wildnight at t=60 the gate-2.3 PIN, on purpose. It is the
* storm every Sprint-13 number was argued over (B's reasoning, kept) at the
* second the repo already agreed is THE representative moment: the funnel is
* worth 33% at the throat there, sitting on the 5963 s plateau, so the page
* opens showing a yard's weather doing something rather than a funnel that
* looks dead (my southerly rationale, retired it argued against a default
* nobody shares now).
* 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.
*/
/** THE storm list, one copy. editor.wind.js re-exports it; keep in step with
@ -52,7 +61,7 @@ export const STORM_KEYS = [
'storm_03b_earlybuster',
];
export const DEFAULT_SEL = { storm: 'storm_02_wildnight', time: 60 };
export const DEFAULT_SEL = { storm: 'storm_03_southerly', time: 40 };
/**
* A fresh store exported for c.test.js, which must be able to make throwaway
@ -63,10 +72,12 @@ export function createStormSel({ storm = DEFAULT_SEL.storm, time = DEFAULT_SEL.t
const listeners = new Set();
const state = { storm, time };
const emit = () => {
const emit = (source) => {
// snapshot payload + snapshot listener set: a listener that unsubscribes
// (or subscribes) mid-emit must not skew delivery for the rest.
const payload = { storm: state.storm, time: state.time };
// (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); }
}
@ -77,25 +88,25 @@ export function createStormSel({ storm = DEFAULT_SEL.storm, time = DEFAULT_SEL.t
get time() { return state.time; },
/** @returns {boolean} true if the selection changed (false = no-op, silent) */
setStorm(key) {
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();
emit(source);
return true;
},
/** @returns {boolean} true if the selection changed (false = no-op, silent) */
setTime(t) {
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();
emit(source);
return true;
},
@ -111,12 +122,12 @@ export function createStormSel({ storm = DEFAULT_SEL.storm, time = DEFAULT_SEL.t
// 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
// for console spelunking, same convention as __editorScore / __envelope.
// 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.__stormSel = _sel;
if (typeof globalThis !== 'undefined') globalThis.EDITOR_STORM = _sel;
}
return _sel;
}

View File

@ -24,7 +24,7 @@ import { loadSite, createWorld } from '../world.js';
// means reaching for the real one.
import { createWindRouter } from '../main.js';
import { normalizeAxis, shelterAtten, worstSecond, STORM_KEYS as WIND_STORM_KEYS } from '../editor.wind.js';
import { createStormSel, stormSel, STORM_KEYS as SEL_STORM_KEYS, DEFAULT_SEL } from '../editor_stormsel.js';
import { createStormSel, stormSel, STORM_KEYS as SEL_STORM_KEYS, DEFAULT_SEL } from '../editor_storm.js';
import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
@ -889,18 +889,22 @@ export default async function run(t) {
// GATE 2.2 (SPRINT15) — ONE STORM PICKER: the shared selection store
//
// Sprint 14's page had two pickers with two defaults, and D scored the
// wildnight while looking at the southerly. editor_stormsel.js is the one
// wildnight while looking at the southerly. editor_storm.js is the one
// owner now; these asserts pin its contract. The DOM half (my select and
// scrubber writing/reading it) cannot run here and is verified live on the
// editor page — said plainly rather than papered over with a fake DOM.
t.test('GATE 2.2: the shared storm selection refuses garbage, is silent on no-ops, and opens on the pin', () => {
// The page must open on the gate-2.3 pinned moment — the storm+second every
// measured number is argued against. Two defaults was the whole bug.
t.test('GATE 2.2: the shared storm selection refuses garbage, is silent on no-ops, and opens on the CONVERGED default', () => {
// The default is the pair B and C agreed in THREADS (B's storm — the
// authoring case: the wind overlay is live at page-open and the southerly
// makes the funnel scream; my second — t=40 is the S14 receipt's align-100%
// moment). Pinned HERE so neither lane's refactor can silently regrow a
// private default: two defaults was the whole bug.
assert(SEL_STORM_KEYS.includes(DEFAULT_SEL.storm), 'the default storm is not a real storm');
assert(DEFAULT_SEL.storm === PIN.storm && DEFAULT_SEL.time === PIN.t,
assert(DEFAULT_SEL.storm === 'storm_03_southerly' && DEFAULT_SEL.time === 40,
`the default selection (${DEFAULT_SEL.storm} @ ${DEFAULT_SEL.time}s) has drifted off the `
+ `gate-2.3 pin (${PIN.storm} @ ${PIN.t}s) — if that is intentional, move the pin too`);
+ 'converged storm_03_southerly @ 40s — that pair is a B+C agreement (THREADS 2026-07-20), '
+ 'renegotiate it there before moving it here');
const s = createStormSel();
assert(s.storm === DEFAULT_SEL.storm && s.time === DEFAULT_SEL.time, 'a fresh store ignores the default');
@ -908,14 +912,17 @@ export default async function run(t) {
const seen = [];
const off = s.onChange((p) => seen.push(p));
// a real change fires exactly once, payload matches the getters
assert(s.setStorm('storm_03_southerly') === true, 'a real storm change should return true');
assert(seen.length === 1 && seen[0].storm === 'storm_03_southerly' && seen[0].time === DEFAULT_SEL.time,
// a real change fires exactly once, payload matches the getters, and the
// writer's `source` tag (B's ask) rides the payload for debugging
assert(s.setStorm('storm_02_wildnight', 'c.test') === true, 'a real storm change should return true');
assert(seen.length === 1 && seen[0].storm === 'storm_02_wildnight' && seen[0].time === DEFAULT_SEL.time,
`one change should mean one event with the new value, got ${JSON.stringify(seen)}`);
assert(seen[0].source === 'c.test',
`the payload should carry the writer's source tag, got ${JSON.stringify(seen[0])}`);
// no-op writes are SILENT — this is what lets a subscriber clamp the
// selection from inside its own change handler without ringing forever
assert(s.setStorm('storm_03_southerly') === false, 'a no-op storm write should return false');
assert(s.setStorm('storm_02_wildnight') === false, 'a no-op storm write should return false');
assert(s.setTime(s.time) === false, 'a no-op time write should return false');
assert(seen.length === 1, `a no-op fired a listener — the anti-loop guard is gone (${seen.length} events)`);
@ -931,7 +938,7 @@ export default async function run(t) {
try { s.setTime(bad); } catch { threw = true; }
assert(threw, `setTime(${JSON.stringify(bad)}) was accepted`);
}
assert(s.storm === 'storm_03_southerly' && seen.length === 1,
assert(s.storm === 'storm_02_wildnight' && seen.length === 1,
'refused garbage moved the selection or fired a listener anyway');
// a throwing listener must not sever delivery to the others
@ -951,7 +958,8 @@ export default async function run(t) {
t.test('GATE 2.2: one singleton, one storm list — every consumer holds the same objects', () => {
// Import identity is the sharing mechanism, so it is the thing to pin.
assert(stormSel() === stormSel(), 'stormSel() built two stores — the page is back to two pickers');
assert(globalThis.__stormSel === stormSel(), 'the console spelunking handle points at a different store');
assert(globalThis.EDITOR_STORM === stormSel(),
"the console handle (B's EDITOR_STORM name, converged) points at a different store");
// editor.wind.js re-exports the store's list — SAME ARRAY, not a copy that
// can drift (two copies of this list is how the page grew two pickers).
assert(WIND_STORM_KEYS === SEL_STORM_KEYS, 'editor.wind.js carries its own storm list again');