From afb61b2926a48baeb5ea8050d62795c76e3da83b Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 19:21:29 +1000 Subject: [PATCH 1/6] =?UTF-8?q?Lane=20C=20S18:=20the=20mid-storm=20entry?= =?UTF-8?q?=20seam=20=E2=80=94=20a=20crate=20is=20a=20pure=20function=20of?= =?UTF-8?q?=20its=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emergency callout arrives at t≈30, which makes a NON-ZERO START a first-class case for the storm clock. Measured before writing anything, and the debris module failed two ways that had nothing to do with the callout: · the same storm flown twice through one module spawned DIFFERENT crates (wildnight height 1.582 m then 1.165 m). main.js builds one debris module at boot and clear()s it per phase change; clear() rewound the leaf accumulators and not the piece PRNG. Live on main today — night 2 of the seven-night week never flew night 1's crates, and no test looked. · an entry at t=45, past the wildnight's t=38 event, gave every LATER crate the skipped crate's height, spin axis and phase — the stream sat 5 draws behind. Skipping history corrupted the future, not just the present. One cause: a crate's jitter came off a shared stream whose POSITION encoded how much history had happened. Keyed on the event itself now, so a crate is a pure function of its event — the move weather.core.js's header already argues for. debris.enterAt(t0, dt) for the one part that is a genuine path integral: the ambient leaf layer. A t=0 run has 6 leaves crossing the grass at t=30 and a cold entry has ZERO, staying becalmed ~7 s while the 2.5 s EMA climbs off zero — DESIGN.md line 149 makes that moving grass the gust front's tell, and gate 1's whole premise is that the grass is the only briefing a callout gets. It runs the ladder rather than skipping it, and the ladder ACCUMULATES: i*dt is the more accurate ladder and therefore the wrong one (1770 of 1800 steps disagree, first at step six), because main.js, fixedLoop and sail.js all accumulate. Pins: +5 (522 -> 527). Wind order-independence broadened to every storm and five scalar observables plus the exact callout ladder, and a BACKWARDS peak scan cross-checking what the dispatch will quote. All four mutations went red; the first draft of the stormStats case could not fail (it memoises per def, so it was comparing an object to itself) and was rewritten rather than shipped. Co-Authored-By: Claude Fable 5 --- web/world/js/debris.js | 86 +++++++++++++++- web/world/js/tests/c.test.js | 132 +++++++++++++++++++++++++ web/world/js/tests/weather.selftest.js | 89 +++++++++++++++++ 3 files changed, 306 insertions(+), 1 deletion(-) diff --git a/web/world/js/debris.js b/web/world/js/debris.js index 3c24fbd..d2c6fa4 100644 --- a/web/world/js/debris.js +++ b/web/world/js/debris.js @@ -46,7 +46,39 @@ export function createDebris(o = {}) { // contracts.js documents world.heightAt as the thing Lane C bounces debris off. // Flat fallback so this still runs against a graybox yard. const groundAt = o.heightAt || (() => o.groundY ?? 0); - const rand = rng(((wind && wind.seed) || 1) ^ 0x5eed1e); + + /** + * A crate's jitter is keyed on THE EVENT, not on a shared stream. [SPRINT18] + * + * This was `const rand = rng(seed ^ 0x5eed1e)` — one stream, drawn from inside + * spawn(). A stream's POSITION encodes how many pieces have already spawned, + * so history leaked into every later crate, and it cost us two determinism + * breaks measured on the wildnight (crate heights, m): + * + * the same storm twice through one module night 1 y 1.582 night 2 y 1.165 + * (main.js builds ONE debris module at boot and clear()s it per phase + * change; clear() rewound leafDist/leafEma and NOT this stream, so night 2 + * of the seven-night week flew different crates than night 1 — live on + * main today, no emergency callout required.) + * an entry at t=45, past the t=38 event t=0 run y 0.937 sx 0.394 ph 4.413 + * t=45 y 1.623 sx 0.581 ph 0.839 + * (every crate after a skipped event wore the SKIPPED crate's clothes: the + * stream sat 5 draws behind. Skipping history corrupted the future, not + * just the present — the thing that makes a mid-storm arrival unpinnable.) + * + * Keyed on `ev.t` (stable authored data, unique per event in every shipped + * storm) so the crate at t=38 is the same crate whether you watched the first + * 38 seconds or arrived at 30. Manual spawns — A's debug keys, tuning — have no + * `ev.t` and fall back to the spawn time, which differs per keypress. + * + * This is the same move weather.core.js's header argues for: determinism + * STRUCTURAL, not a promise. A crate is now a pure function of its event. + */ + const seedBase = (((wind && wind.seed) || 1) ^ 0x5eed1e) | 0; + const eventRng = (ev, t) => { + const key = Math.round((Number.isFinite(ev.t) ? ev.t : t) * 1000); + return rng((seedBase ^ Math.imul(key, 374761393)) | 0); + }; const pieces = []; const w = new THREE.Vector3(); @@ -122,6 +154,7 @@ export function createDebris(o = {}) { function spawn(ev, t) { const spec = { ...(MODEL_SPEC[ev.model] || DEFAULT_SPEC) }; if (Number.isFinite(ev.mass)) spec.mass = ev.mass; + const rand = eventRng(ev, t); // this crate's own stream — see eventRng above // upwind of the yard, offset sideways, so it crosses the whole thing const d = wind.dirAt(t); @@ -282,6 +315,57 @@ export function createDebris(o = {}) { } }, + /** + * ARRIVE MID-STORM at t0. [SPRINT18 — the emergency callout's entry] + * + * The storm ran; you just weren't there. Three classes of state, and only one + * of them can be teleported: + * + * 1. closed-form in t — the wind field, rain rate, hail rate. Measured + * order-independent (sample t=30 cold, or after walking 0→30, or after + * scrambling 90/0/45/12/88/30: byte-identical). Needs nothing. + * 2. a pure function of its own event — crate jitter, as of eventRng above. + * Needs nothing, now. + * 3. a genuine path integral — `leafDist` (metres travelled downwind) and + * `leafEma` (2.5 s smoothed speed). These CANNOT be teleported, and + * pretending otherwise is what made the arrival read wrong: measured on + * the wildnight, a t=0 run has 6 leaves crossing the grass at t=30 and a + * cold entry has 0 — and stays becalmed for ~7 s while the EMA climbs + * off zero. DESIGN.md line 149 makes the moving grass the gust front's + * tell, and gate 1's whole premise is that the sky and the grass are the + * only briefing a callout gets. Losing the tell for the first seven + * seconds robs exactly the read the dispatch is asking the player to make. + * + * So class 3 is RUN, not skipped: this is `fixedLoop(t0, dt, step)` and + * nothing more — the same ladder, the same code path, no shortcut. It is + * cheap because the ambient layer is two scalars and a wind sample (crates + * spawn, fly and despawn honestly on the way past). Do NOT "optimise" this + * into a teleport; the pin in c.test.js exists to catch that, and the + * negative control beside it records what the teleport actually costs. + * + * ⚠️ The ladder ACCUMULATES — `t += dt`, not `i * dt`. I wrote it as `i * dt` + * first (exact multiplication, no drift) and the pin went red: 6 leaves in + * both yards, in different places. `i * dt` is the MORE ACCURATE ladder and + * that is precisely why it is wrong — main.js's `phaseT += dt`, testkit's + * `fixedLoop` and sail.js's `this.t += SIM_DT` all accumulate, so a warm-up + * that multiplies reproduces a storm the game never flies. Measured: over + * 1800 steps the two ladders drift 4.2e-13 s apart in total, they pass a + * different `t` on 1770 of those 1800 steps, and they first disagree at step + * SIX (0.09999999999999999 against 0.1). A number gathered from a harness + * that is a hair better than the game's is still a number from the wrong + * harness — the class of bug this repo hunts, in its smallest possible form. + * + * @param {number} t0 storm time to arrive at, seconds + * @param {number} dt fixed step — pass contracts.FIXED_DT, the same ladder + * the caller will keep stepping on + */ + enterAt(t0, dt) { + const steps = Math.round(t0 / dt); + let t = 0; + for (let i = 0; i < steps; i++) { debris.step(dt, t, {}); t += dt; } + return t; + }, + /** Drop everything (phase change, restart). */ clear() { for (const p of pieces) despawn(p); diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index da6cc57..3a7ffda 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -208,6 +208,138 @@ export default async function run(t) { assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card'); }); + // ------------------------------------------------------------------------- + // SPRINT18 gate 1 — THE MID-STORM ENTRY, debris half. + // + // The emergency callout arrives at t≈30. weather.selftest.js pins that the WIND + // is the same storm however you got to t0 (it is closed-form, measured + // order-independent). Debris was not, and it cost two determinism breaks that + // I measured before writing a line — both recorded in debris.js beside the fix: + // + // · the same storm flown twice through one module spawned DIFFERENT crates + // (wildnight crate height 1.582 m then 1.165 m). main.js builds one debris + // module at boot and clear()s it per phase change; clear() rewound the leaf + // accumulators and not the piece PRNG. Live on main today — night 2 of the + // seven-night week never flew night 1's crates, and no test looked. + // · an entry at t=45 (past the wildnight's t=38 event) gave every LATER crate + // the skipped crate's height, spin axis and phase — the stream sat 5 draws + // behind. Skipping history corrupted the FUTURE, not just the present. + // + // Both had one cause: a crate's jitter came off a shared stream whose POSITION + // encoded how much history had happened. It is now keyed on the event itself. + // These three cases are what stop it coming back. + // ------------------------------------------------------------------------- + + /** Every piece the storm spawns, snapshotted AT BIRTH. */ + const crateLog = (dbg, from, to, dt = FIXED_DT) => { + const born = []; + const seen = new Set(); + for (let i = Math.round(from / dt); i < Math.round(to / dt); i++) { + const time = i * dt; + dbg.step(dt, time, {}); + for (const p of dbg.pieces) { + if (seen.has(p)) continue; + seen.add(p); + // y/sx/sy/sz/phase are the seeded jitter; x/z/vx come off the wind and + // were never in doubt. Round only for the message — compare exactly. + born.push({ model: p.model, y: p.y, sx: p.sx, sy: p.sy, sz: p.sz, phase: p.phase }); + } + } + return born; + }; + const sameCrates = (a, b) => a.length === b.length && a.every((p, i) => + p.model === b[i].model && p.y === b[i].y && p.sx === b[i].sx + && p.sy === b[i].sy && p.sz === b[i].sz && p.phase === b[i].phase); + const showCrates = (a) => a.map((p) => `${p.model}@y${p.y.toFixed(3)}/ph${p.phase.toFixed(3)}`).join(', ') || '(none)'; + + t.test('a crate is a pure function of its event, not of what came before it', () => { + for (const name of STORMS) { + const def = storms[name]; + const dur = def.duration ?? 90; + + // two independently built modules on the same seed + const one = crateLog(createDebris({ wind: createWind(def) }), 0, dur); + const two = crateLog(createDebris({ wind: createWind(def) }), 0, dur); + assert(sameCrates(one, two), + `${name}: two freshly built debris modules spawned different crates — ${showCrates(one)} vs ${showCrates(two)}`); + + // and the same module flown twice, clear()ed between: night 1 vs night 2 + const reused = createDebris({ wind: createWind(def) }); + const night1 = crateLog(reused, 0, dur); + reused.clear(); + const night2 = crateLog(reused, 0, dur); + assert(sameCrates(night1, night2), + `${name}: the same storm flown twice through one module spawned different crates — night 1 ${showCrates(night1)}, night 2 ${showCrates(night2)}. clear() has stopped rewinding something.`); + } + }); + + t.test('an entry at t0 spawns the crates a t=0 run spawns after t0 (every storm, every t0)', () => { + for (const name of STORMS) { + const def = storms[name]; + const dur = def.duration ?? 90; + const mk = () => createDebris({ wind: createWind(def) }); + + // the reference: watch the whole storm, and count how many crates are born + // at or after each candidate entry time + const whole = []; + const seen = new Set(); + const ref = mk(); + for (let i = 0; i < Math.round(dur / FIXED_DT); i++) { + const time = i * FIXED_DT; + ref.step(FIXED_DT, time, {}); + for (const p of ref.pieces) { + if (seen.has(p)) continue; + seen.add(p); + whole.push({ at: time, model: p.model, y: p.y, sx: p.sx, sy: p.sy, sz: p.sz, phase: p.phase }); + } + } + + for (const t0 of [10, 30, 45, 60]) { + const want = whole.filter((p) => p.at >= t0); + const got = crateLog(mk(), t0, dur); + assert(sameCrates(want, got), + `${name}: entering at t=${t0} spawned ${showCrates(got)} where a t=0 run spawns ${showCrates(want)} — the crates a callout flies depend on whether anybody watched the first ${t0} s`); + } + } + }); + + t.test('arriving mid-storm, the grass is already moving (and a cold entry proves it can fail)', () => { + // DESIGN.md line 149: a gust front is readable because a wave of motion + // crosses the grass a beat before it hits. The callout has NO forecast paper + // (gate 1), so that motion is not decoration — it is the briefing. `leafDist` + // and `leafEma` are genuine path integrals (a 2.5 s EMA off zero), so they are + // the one part of my modules a mid-storm entry cannot teleport: measured, a + // t=0 run has 6 leaves crossing at t=30 and a cold entry has ZERO, staying + // becalmed for ~7 s while the EMA climbs. debris.enterAt() runs them. + const def = storms.storm_02_wildnight; + const T0 = 30; + const mk = () => createDebris({ wind: createWind(def) }); + const look = (d) => ({ n: d.leafCount, at: d.leaves.map((p) => [p.x, p.y, p.z]) }); + const same = (a, b) => a.n === b.n && a.at.every((v, i) => + v[0] === b.at[i][0] && v[1] === b.at[i][1] && v[2] === b.at[i][2]); + + const watched = mk(); + fixedLoop(T0, FIXED_DT, (dt, time) => watched.step(dt, time, {})); + const want = look(watched); + assert(want.n > 0, `the reference t=0 run has ${want.n} leaves flying at t=${T0} — pick a windier moment or this case proves nothing`); + + const arrived = mk(); + arrived.enterAt(T0, FIXED_DT); + const got = look(arrived); + assert(same(want, got), + `enterAt(${T0}) put ${got.n} leaves in the yard where a t=0 run has ${want.n}, or put them elsewhere — the arrival is not the storm the player would have been standing in`); + + // NEGATIVE CONTROL. Without this the case above could be satisfied by a + // do-nothing enterAt on a layer that happened to warm instantly. The naive + // path — fresh module, first step lands at t0 — must be measurably wrong, and + // is: 0 leaves against 6. This is also the guard against somebody later + // "optimising" enterAt into a teleport. + const cold = mk(); + cold.step(FIXED_DT, T0, {}); + assert(!same(want, look(cold)), + `a cold module stepped straight to t=${T0} already matches the watched run — the ambient layer carries no history, so the case above cannot fail and must be rewritten`); + }); + // SPRINT13, the third time this repo learned it: the venturi lives in the // SITE json, not the storm def. B's site_audit flew site_02 funnel-OFF in // Sprint 11 (their sweep.selftest tells it); C's garden_bench did it again diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index 15c3c4c..5242bbd 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -901,6 +901,95 @@ export function weatherCases(storms) { } }); + // ------------------------------------------------------------------------- + // SPRINT18 gate 1 — THE MID-STORM ENTRY SEAM. + // + // The emergency callout spawns the player at t≈30 in a storm nobody briefed + // them on. That makes a NON-ZERO START a first-class case for the storm clock, + // and "a number gathered from the wrong harness" is the exact class of bug this + // repo hunts. The whole callout stands on one property: an entry at t0 must see + // the storm a t=0 run sees at t0. + // + // For the WIND that property is structural — weather.core.js's header promises + // everything is a closed-form function of (pos, t) — but promised is not pinned, + // and the field is not as stateless as it looks: buildGustTimeline and the + // advected-drift table are both built at construction, uniformSpeed early-exits + // on a sorted scan, and stormStats memoises into a WeakMap. Any one of those + // growing a cursor would make the storm depend on the order it was watched in. + // Below: cold vs walked vs scrambled, and two independently built fields. + // ------------------------------------------------------------------------- + + test('the wind at t=30 does not care how you got there (the callout seam)', () => { + for (const [name, def] of Object.entries(storms)) { + const read = (f, t) => [f.uniformSpeed(t), f.gustOnly(t), f.dirAt(t), f.rainAt(t), f.hailAt(t)]; + const T0 = 30; + + const cold = createWindField(def); + const want = read(cold, T0); + + // walked: every step a t=0 run would have taken, in order, first + const walked = createWindField(def); + for (let i = 0; i < Math.round(T0 / DT); i++) read(walked, i * DT); + const got = read(walked, T0); + for (let i = 0; i < want.length; i++) { + assert(want[i] === got[i], + `${name}: field #${i} at t=${T0} reads ${got[i]} after being walked 0→30 but ${want[i]} cold — the wind remembers being watched, so a mid-storm entry cannot reproduce a t=0 run`); + } + + // scrambled: backwards, past the end, and repeated + const scrambled = createWindField(def); + for (const t of [90, 0, 45.5, 12, 88, T0, 1, T0, -5]) read(scrambled, t); + const sc = read(scrambled, T0); + for (let i = 0; i < want.length; i++) { + assert(want[i] === sc[i], + `${name}: field #${i} at t=${T0} moved after out-of-order sampling — ${sc[i]} vs ${want[i]}`); + } + + // two independently constructed fields must agree: the gust timeline and + // the drift table are drawn from a PRNG at build, so this is the pin that + // says "built twice" is the same storm as "built once". + const twin = read(createWindField(def), T0); + for (let i = 0; i < want.length; i++) { + assert(want[i] === twin[i], + `${name}: field #${i} at t=${T0} differs between two freshly built fields — ${twin[i]} vs ${want[i]}`); + } + } + }); + + test('the storm the dispatch quotes is the storm a BACKWARDS scan finds', () => { + // Two harnesses, one number — the repo's oldest rule, aimed at the entry seam. + // stormStats scans FORWARD from 0 and everything worded (the dispatch, the + // offer band, the honesty gate) is built on its peaks. A mid-storm arrival is + // the first consumer that reads the field at a t nobody walked up to, so scan + // it in the one order no production code uses — downwards from the end — and + // insist on the same peak. A field that remembers being watched fails here and + // nowhere else: caught the resume-cursor mutation on uniformSpeed (14.6 m/s + // against 18.58 on the wildnight, a 21% understatement of the gust peak). + // + // Deliberately NOT `stormStats(def)` twice: it memoises per def in a WeakMap, + // so the second call hands back the SAME OBJECT and comparing them is + // comparing an object to itself. That version of this case could not fail. + const STEP = 0.05; + for (const [name, def] of Object.entries(storms)) { + const s = stormStats(def); + const f = createWindField(def); + const dur = def.duration ?? 90; + let gustPeak = 0, rainPeak = 0, hailPeak = 0; + for (let t = Math.floor(dur / STEP) * STEP; t >= 0; t -= STEP) { + gustPeak = Math.max(gustPeak, f.uniformSpeed(t)); + rainPeak = Math.max(rainPeak, f.rainAt(t)); + hailPeak = Math.max(hailPeak, f.hailAt(t)); + } + // Same grid, opposite direction: this is an equality, not a tolerance. + assert(Math.abs(gustPeak - s.gustPeak) < 1e-9, + `${name}: scanned backwards the gust peak is ${gustPeak.toFixed(3)} m/s, but the dispatch quotes ${s.gustPeak.toFixed(3)} — the field depends on the order it was read`); + assert(Math.abs(rainPeak - s.rainPeak) < 1e-9, + `${name}: backwards rain peak ${rainPeak.toFixed(4)} vs quoted ${s.rainPeak.toFixed(4)}`); + assert(Math.abs(hailPeak - s.hailPeak) < 1e-9, + `${name}: backwards hail peak ${hailPeak.toFixed(4)} vs quoted ${s.hailPeak.toFixed(4)}`); + } + }); + return { cases, metrics }; } From ffe5ca2fbff392fbebf7ce5dcea92b92354ce5db Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 19:22:27 +1000 Subject: [PATCH 2/6] =?UTF-8?q?Lane=20C=20S18:=20[C]=20THREADS=20=E2=80=94?= =?UTF-8?q?=20the=20non-zero-start=20pin,=20two=20live=20debris=20breaks,?= =?UTF-8?q?=20and=20the=20sail-clock=20filing=20for=20A=20and=20B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- THREADS.md | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/THREADS.md b/THREADS.md index 4f0fff1..b583870 100644 --- a/THREADS.md +++ b/THREADS.md @@ -9505,3 +9505,120 @@ anchors are your GLB), but the tooling is now waiting, not TODO. **Deploy still owes one hand:** tools/deploy_hardyards.sh is permission-gated in the integrator's session (ssh + docker to the games VPS). John runs it or green-lights it; S18 must not ship before S17 is live, per the standing one-sprint rail. + +[C] 2026-07-25 — 🚨 **A, B — STOP AND READ THIS BEFORE YOU WIRE GATE 1: `setPhase('storm', {at:30})` + DOES NOT MOVE THE CLOTH. Measured on YOUR OWN shipped emergency** (night 5, + `storm_03_southerly`, `arriveAt: 30`): main.js passes `windT=30` on every frame and the sail + samples **t = 0.000 .. 0.983**. `rig.t` reads 1.0 after one second of an emergency night. + + | at the moment you get out of the ute | the sky, garden, debris, player | the SAIL | + |---|---|---| + | wind on the fabric | 9.074 m/s | **4.468 m/s** | + | → load, going with v² | 1.0× | **0.24× — a 4.12× understatement** | + | rain driving the pond | 5.000 mm/hr | **0.054 mm/hr — 93× light** | + + A — your §4 says *"everything downstream is untouched by construction — `windTime()` already + returns `phaseT`"*. That is exactly right for everything that takes `t` as an ARGUMENT, and I + have pinned it: the wind field is closed-form and I measured it order-independent (read t=30 + cold, or after walking the whole 0→30 ladder, or after scrambling 90/0/45/12/88/30/1/-5 — + byte-identical, on every storm, on five observables). Your phase entry is sound and the whole + callout stands on that property. **But `SailRig.step(dt, wind, t, debris)` accepts `t` and + never reads it** — sail.js:426 passes `this.t`, its own fixed-step clock, and `attach()` zeroes + it. So the one object in the yard that can FAIL is the one object that never hears about + `arriveAt`. + + **This is SPRINT13's bug arriving through the new door, and sail.js's own comment names the + precondition gate 1 breaks.** That comment (sail.js:189-218) closes with: *"commit → attach → + game.advance() → storm all land in a single keypress, so t=0 at attach IS t=0 at the first + storm frame."* True for every night this repo has ever shipped. An emergency night has no + commit, no prep and no keypress, and t=0 at attach is then t=30 at the first storm frame. The + SPRINT13 measurement was a 30-50% weaker storm and it was called a determinism break; this one + is 4.12× on load and 93× on rain. + + **B — THE FIX IS ONE LINE AND IT IS YOURS.** Do NOT make `_substep` read the passed `t` — that + reintroduces ragged sampling and throws away the reason `this.t` exists. Seed the clock + instead: `attach(anchorIds, hwChoices, tension, {at = 0})` → `this.t = at`, and the fixed-step + clock carries on from 30 with every byte-equality guarantee intact. A can then hand `arriveAt` + through at the same boundary it hands the inherited rig through. + + ⚠️ **AND THE HARDER HALF, B, which the one line does NOT fix: a rig with `rig.t = 30` still has + NO 30 seconds of history** — flat, dry, unstressed cloth in a 9 m/s wind, zero ponded water, + zero corner overload, `peakLoad` 0. Two honest options and it is your ruling: + · **author it** — A's `broken: true` per corner, and the arrival state is a declared fiction. + Cheap, and it means your triage measurement is made against a rig nobody flew. + · **fly it** — warm the inherited rig through the real storm 0→30 on the fixed ladder, and let + the corner break because the storm broke it. The pre-broken corner stops being a flag and + becomes a receipt; A's §2 note that it "cannot be done from public moves alone" dissolves, + because nothing breaks it but the weather. `debris.enterAt()` is the precedent and my pin is + the shape of the assert. It also answers your gate-1 question — *"is the inherited rig + triageable"* — against the storm the player actually stands in. + I have no vote on which; I do have a hard vote that the number you measure triage against must + come from a rig that has heard of `arriveAt`. **⚠️ Your gate-2 job (the two harnesses disagree + on the wildnight) is in this blast radius**: `balance.test.js`'s `fly()` already carries the + same skew — `prep()` advances `rig.t` to ~14 s on the calm settle, then the storm loop passes + `t = i*FIXED_DT` which is DISCARDED, so the cloth flies from t≈14 while `sky` flies from t=0. + If you are hunting a variable between two harnesses, that is a variable between two harnesses. + +[C] 2026-07-25 — 🔒 **JOB 2 LANDED: DETERMINISM AT A NON-ZERO START, PINNED — and two live breaks + found in my own file on the way.** Selftest **527/0/0** (+5 on 522: 2 in weather.selftest.js so + the node runner carries them too, 3 in c.test.js). + + **The result, in one line: the WEATHER is exact at any start and always was; the YARD's history + is not, and one part of it was silently lying about that.** Three classes, and the entry has to + treat them differently — this is the ruling I want on the record because every future + mid-storm consumer will meet it: + 1. **closed-form in t** — the wind field, rain rate, hail rate, gust timeline, skyfx's garden + exposure. Teleportable, verified. Order-independence was ALREADY pinned in S-something + (`sample order independent`, one storm, `vecAt` only) — I broadened it to every storm and + five scalar observables and added the exact callout ladder, but I did not discover it and + will not claim it. + 2. **should be a pure function of its own event, and wasn't** — debris crate jitter. Fixed. + 3. **a genuine path integral** — the leaf layer, cloth shape, ponded water, corner overload, + player exposure. Cannot be teleported by anybody, ever. Run it or author it, and say which. + + **⚠️ TWO LIVE BREAKS ON MAIN, neither needing the callout to fire.** Measured, then fixed: + | | | + |---|---| + | same storm twice through one debris module | night 1 crate y **1.582 m**, night 2 y **1.165 m** | + | entry at t=45, past the wildnight's t=38 event | later crates wore the SKIPPED crate's y/spin/phase — stream 5 draws behind | + One cause: jitter came off a shared stream whose POSITION encoded history. `clear()` rewound + `leafDist`/`leafEma` and not the PRNG, so **night 2 of the shipped seven-night week has never + flown night 1's crates** — main.js builds one debris module at boot. Nothing looked, because no + test had ever asserted a crate's jitter at all. Keyed on `ev.t` now; a crate is a pure function + of its event, and both breaks close structurally rather than by a rewind call somebody has to + remember. **Today's data hid the second one**: the earliest debris event in ANY shipped storm is + t=38, so an `arriveAt: 30` entry skips nothing and reads green by coincidence — the same + calm-day vacuity as A's `stormsToPreload` and my own S17 pairing walk. It fires at + `arriveAt: 45`, and A's §3 says the entry time is authored. + + **`debris.enterAt(t0, dt)` — the arrival runs the ladder, it does not skip it.** For class 3. + Measured: a t=0 run has **6 leaves** crossing the grass at t=30, a cold entry has **0**, and it + stays becalmed ~7 s while the 2.5 s EMA climbs off zero. DESIGN.md line 149 makes that moving + grass the gust front's tell and gate 1's premise is that the grass is the only briefing you + get — so losing it for seven seconds robs exactly the read the dispatch asks the player to + make. **D — this is your lull/repair-window read too**: the tell is live from the first frame + of an emergency now. + + ⚠️ **THE SMALLEST LESSON AND MY FAVOURITE.** I wrote `enterAt` as `i * dt`. Pin went red: 6 + leaves in both yards, in different places. **`i * dt` is the more ACCURATE ladder and that is + precisely why it is wrong** — main.js's `phaseT += dt`, testkit's `fixedLoop` and sail.js's + `this.t += SIM_DT` all accumulate. Over 1800 steps the two ladders drift 4.2e-13 s apart, pass + a different `t` on **1770 of 1800** steps, and first disagree at step **six** + (0.09999999999999999 against 0.1). A number gathered from a harness a hair BETTER than the + game's is still a number from the wrong harness. `enterAt` accumulates. + + **MUTATIONS — four, all red, all restored.** (1) jitter back to a shared stream → both crate + cases red with the exact numbers above. (2) `enterAt` teleports → 0 leaves against 6. (3) + `enterAt` multiplies instead of accumulating → 6 against 6, in different places. (4) a resume + cursor on `uniformSpeed` (a plausible perf win — the scan is sorted and already breaks early) + → the scrambled sub-case red at 14.6 m/s against 18.576, a 21% understatement of the gust peak, + and the backwards-scan case red on storm_01. + + **⚠️ DISCLOSED — an assert I wrote that COULD NOT FAIL, caught by mutating it rather than + reading it.** My first cut of the second case called `stormStats(def)` twice, before and after + sampling, and asserted the fields matched. `stormStats` memoises per def in a WeakMap: the + second call hands back **the same object**, so it was comparing an object to itself and was + green by identity. Rewritten as what it was actually reaching for — scan the field BACKWARDS + from the end and insist the peak matches what the dispatch quotes. Two harnesses, one number, + in the one scan order no production code uses. That version catches mutation 4; the first + version caught nothing. From 8554de78245a8caddf5e7bb299615e933d144d35 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 19:37:45 +1000 Subject: [PATCH 3/6] =?UTF-8?q?Lane=20C=20S18=20gate=201:=20the=20dispatch?= =?UTF-8?q?=20=E2=80=94=20midStormRead,=20and=20the=20honesty=20gate=20ove?= =?UTF-8?q?r=20storms=20nobody=20forecast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A's §8 seam, built to their shape: an ordered {key, text} list the card maps, the offerBand construction, reachable through weather.midStormRead?.() under a namespace import. THE INVERSION IS THE DESIGN. offerBand's sin is OMISSION — the board lied about night 7 by printing two of the fields it held. A dispatch's sin is INVENTION, and the consequence is that the read is SHARPER than a forecast, not vaguer: standing in the yard you know the wind on your back to the km/h, so the present tense is exact where the offer card was banded. What you do not have is the bureau's numbers — how long it runs, the peak, whether a southerly lands at t=48. So the read is ruled by TENSE. Permitted: NOW (exact, unhedged); THE VISIBLE IMMINENT (a gust front crossing the grass — DESIGN.md line 149, a 1.5 s telegraph horizon earned by looking); and THE PAST AS WRITTEN ON THE YARD — at t=30 you have no memory of the storm because you were driving, but hail lying in the grass and water standing on the flat are a record you did not have to be present for. Integrals over [0, t] are honest; over (t, ∞) they are not. The future is forbidden outright. And the admission is mandatory: on an offer card a missing hail line means the storm does not hail, on a dispatch it means nobody knows, and silence cannot tell those apart. The read says so first and last, in constant words — the ignorance line is unconditional because a line that appears only when there is something to not-know becomes the very fact it claims not to have. dispatchHonest(def, t) extends the gate: forecastHonest unchanged (no forecast card is not a licence for an indescribable storm — it raises the bar, since the read is the only description there is), plus the three things only an arrival can get wrong. My own S17 filing was wrong and testing it said so: hail.size 0 was already refused by validateStorm a layer up. The reachable hole is size ABSENT — it passes every gate and defaults to the MIDDLE of the vocabulary, so the soaker minus one field prints "marble stones" for its real 0.45 fine pea. Pins +5 (527 -> 532). The strongest states the rule rather than policing a vocabulary: THE READ AT t MAY NOT MOVE WHEN THE STORM'S FUTURE BEYOND t MOVES, with a vacuity guard proving the mutations really do change the storm. Four mutations red and restored. Co-Authored-By: Claude Fable 5 --- web/world/js/tests/c.test.js | 191 +++++++++++++++++++++++++++ web/world/js/weather.js | 242 +++++++++++++++++++++++++++++++++++ 2 files changed, 433 insertions(+) diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 3a7ffda..585b14c 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -22,6 +22,9 @@ import { // SPRINT17, the second half of gate 1 (see weather.js's divider): what the // card PRINTS, and which yard a storm may be printed over. offerBand, PAIRING_LAW, pairingRefusal, pairingRefusals, + // SPRINT18 gate 1 — the dispatch: the read with no paper behind it, and the + // honesty gate extended to the storms nobody forecast. + midStormRead, dispatchHonest, } from '../weather.js'; import { loadSite, createWorld } from '../world.js'; // SPRINT17 gate 1 — the board's weather side. NIGHTS ∪ POOL is every storm an @@ -1644,6 +1647,194 @@ export default async function run(t) { + 'nobody flew is the same offence in the other direction'); }); + // ═══ SPRINT18 GATE 1 — THE DISPATCH: what an honest read is when no paper comes ═══ + // + // `offerBand`'s sin was OMISSION. The dispatch's sin is INVENTION, and these + // cases are aimed at that inversion. The sharpest of them states the rule as an + // invariant rather than as a vocabulary check: A READ AT t MAY NOT MOVE WHEN THE + // STORM'S FUTURE BEYOND t MOVES. If it does, the card is printing something + // nobody standing in that yard could know. + // + // ⚠️ NOT pinned by diffing storm_03_southerly against storm_03b_earlybuster, + // which was my first instinct and is wrong: THREADS has them "identical in every + // number the band prints", but they carry DIFFERENT SEEDS (30717/30818), so their + // gust timelines differ and their instantaneous wind differs at every t. They are + // twins on the FORECAST's numbers, not on the sim's. Isolating the variable means + // constructing it, so these cases mutate a def's future and hold everything else. + const AT = 30; // A's authored arriveAt (§1), the number ROADMAP names + + /** A copy of `def` whose FUTURE past `t` is different and whose present is not. */ + const futureChanged = (def, t) => { + const d = JSON.parse(JSON.stringify(def)); + // Curve points strictly after the first point at/after t cannot affect the + // value AT t (piecewise-linear interpolation there brackets t). Move those. + for (const key of ['dirCurve', 'baseCurve']) { + const c = d[key]; + if (!Array.isArray(c)) continue; + let first = c.findIndex((p) => p[0] >= t); + if (first < 0) continue; + for (let i = first + 1; i < c.length; i++) c[i][1] = -(c[i][1] ?? 0) * 1.7 - 0.3; + } + // A hail burst that has not started by t cannot be falling at t. + if (d.hail && Array.isArray(d.hail.bursts)) { + d.hail.bursts = d.hail.bursts.filter((b) => b.t <= t); + d.hail.bursts.push({ t: t + 5, ramp: 0.5, hold: 9, fade: 1, intensity: 1 }); + } + // Events are announcements; the read never reads them. Move them anyway. + if (Array.isArray(d.events)) d.events = d.events.filter((e) => e.t <= t); + return d; + }; + + t.test('GATE 1: the mid-storm read cannot know the future (change the future, the read holds)', () => { + const same = (a, b) => a.length === b.length + && a.every((l, i) => l.key === b[i].key && l.text === b[i].text); + let futuresActuallyChanged = 0; + for (const name of STORMS) { + const def = storms[name]; + for (const t of [8, 20, AT]) { + const now = midStormRead(def, t); + const altered = midStormRead(futureChanged(def, t), t); + assert(same(now, altered), + `${name}: the read at t=${t} MOVED when only the storm's future moved.\n was: ` + + `${now.map((l) => l.key + '=' + l.text).join(' | ')}\n now: ` + + `${altered.map((l) => l.key + '=' + l.text).join(' | ')}\n ` + + 'a dispatch that reads past t is inventing the paper it says it has not got'); + // VACUITY GUARD: the mutation must actually alter the storm, or the case + // above is comparing a def to itself. Read them both LATER than t. + const later = Math.min((def.duration ?? 90) - 1, t + 12); + if (!same(midStormRead(def, later), midStormRead(futureChanged(def, t), later))) { + futuresActuallyChanged++; + } + } + } + assert(futuresActuallyChanged >= STORMS.length, + `only ${futuresActuallyChanged} of ${STORMS.length * 3} mutations changed the storm at all — ` + + 'the invariant above is being satisfied by a no-op mutation, not by an honest read'); + }); + + t.test('GATE 1: the read never carries a forecast-only fact, by key or by number', () => { + for (const name of STORMS) { + const def = storms[name]; + const s = stormStats(def); + for (const t of [8, AT, 60]) { + const read = midStormRead(def, t); + for (const l of read) { + // the offer band's forward-looking keys have no business on a dispatch + assert(l.key !== 'change' && l.key !== 'confidence', + `${name} @${t}: the read carries a '${l.key}' line — that is forecast paper`); + } + const all = read.map((l) => l.text).join(' '); + // the two numbers only a bureau could hand you + if (s.changeAt != null && s.changeAt > t) { + assert(!all.includes(String(Math.round(s.changeAt))), + `${name} @${t}: the read prints ${Math.round(s.changeAt)} and the change lands at ` + + `${s.changeAt} — nobody told you when it swings`); + } + if (s.hailSeconds > 0) { + assert(!all.includes(`${s.hailSeconds.toFixed(0)}s of it`), + `${name} @${t}: the read prints the storm's TOTAL hail duration — that is the forecast's number`); + } + } + } + }); + + t.test('GATE 1: the admission is unconditional — every storm, every t, same words', () => { + // The subtle half of the design. On an offer card a missing hail line means + // THE STORM DOES NOT HAIL. On a dispatch it means NOBODY KNOWS. Silence cannot + // tell those apart, so the read says so out loud — and it must say it in the + // SAME words every time, because a line that appears only when there is + // something to not-know becomes the very fact it claims not to have. + const texts = { nopaper: new Set(), unknown: new Set() }; + let reads = 0; + for (const name of STORMS) { + for (let t = 1; t < (storms[name].duration ?? 90); t += 7) { + const read = midStormRead(storms[name], t); + reads++; + assert(read[0].key === 'nopaper', + `${name} @${t}: the read does not OPEN with the no-forecast line — it frames every line under it`); + assert(read[read.length - 1].key === 'unknown', + `${name} @${t}: the read does not CLOSE with what you cannot know`); + for (const l of read) if (texts[l.key]) texts[l.key].add(l.text); + } + } + assert(reads > 60, `only ${reads} reads sampled — widen the grid`); + for (const k of ['nopaper', 'unknown']) { + assert(texts[k].size === 1, + `the '${k}' line has ${texts[k].size} different wordings across the shipped storms — ` + + `it is conditional on something, and its presence is then information the observer ` + + `cannot have:\n ${[...texts[k]].join('\n ')}`); + } + }); + + t.test('GATE 1: the honesty gate covers emergency storms — a storm nobody can describe cannot be dispatched', () => { + // "A storm that cannot be described honestly cannot be DISPATCHED either." + // The storm-side gate applies unchanged; the arrival adds its own three. + for (const name of STORMS) { + const r = dispatchHonest(storms[name], AT, name); + assert(r.ok, `${name} cannot be honestly dispatched at t=${AT}:\n ${r.errors.join('\n ')}`); + } + + // NEGATIVE CONTROLS — four, because a gate that only ever says yes is decoration. + const base = storms.storm_03_southerly; + assert(!dispatchHonest(base, 0, 'x').ok, + 'arriveAt 0 was accepted — that is not a mid-storm arrival, it is an ordinary night'); + assert(!dispatchHonest(base, base.duration ?? 90, 'x').ok, + 'arriveAt at the storm\'s end was accepted — you would be dispatched to finished weather'); + assert(!dispatchHonest(base, 500, 'x').ok, 'arriveAt past the end was accepted'); + + // THE S17 FILING, CORRECTED BY MEASUREMENT. I filed "stoneWord has no floor, + // size 0 words itself fine pea" — and that hole was already shut one layer up: + // validateStorm refuses size <= 0, so this is the case proving the gate above + // does the work, not a floor check in forecastHonest. + const zero = JSON.parse(JSON.stringify(storms.storm_06_soaker)); + zero.hail.size = 0; + assert(!dispatchHonest(zero, AT, 'zero').ok, 'hail.size 0 was dispatched'); + + // ⚠️ THE REAL HOLE, found by probing the layer that DOES pass: the validator + // only checks a size that is PRESENT (`hd.size != null &&`), and hailSize reads + // `size ?? 1`. So a hailing storm with NO authored size prints "marble stones" + // — the middle of the vocabulary, stated as fact, authored by nobody. On the + // soaker that turns a real 0.45 "fine pea" into a stone 2.2x bigger, on the one + // axis the fabric bet is decided on. + const sizeless = JSON.parse(JSON.stringify(storms.storm_06_soaker)); + delete sizeless.hail.size; + assert(stormStats(sizeless).hailSeconds > 1, + 'the sizeless fixture does not actually hail — this case would prove nothing'); + const sr = dispatchHonest(sizeless, AT, 'sizeless'); + assert(!sr.ok, 'a storm that hails with no authored hail.size was dispatched — the card ' + + 'would state "marble stones" as fact for a stone nobody wrote'); + assert(sr.errors.some((e) => /hail\.size is not authored/.test(e)), + `the refusal does not say WHY: ${sr.errors.join('; ')}`); + // and the ceiling still bites, so the new check did not displace it + const boulders = JSON.parse(JSON.stringify(storms.storm_06_soaker)); + boulders.hail.size = 2.6; + assert(!dispatchHonest(boulders, AT, 'boulders').ok, + 'a 2.6 stone was dispatched — the vocabulary ceiling stopped biting'); + }); + + t.test('GATE 1: every line of the read is reachable, and each one is earned', () => { + // A vocabulary with an unreachable word is a vocabulary that has never been + // read. Walk the shipped storms and prove each key fires somewhere — and that + // the two conditional-on-weather keys do NOT fire where the weather is absent. + const where = {}; + for (const name of STORMS) { + for (let t = 0.5; t < (storms[name].duration ?? 90); t += 0.25) { + for (const l of midStormRead(storms[name], t)) (where[l.key] ??= []).push(name); + } + } + for (const k of ['nopaper', 'wind', 'unknown', 'lull', 'front', 'rain', 'veer', 'hail', 'ground']) { + assert(where[k]?.length, `no shipped storm ever produces a '${k}' line — it is unreachable vocabulary`); + } + // storm_01_gentle is the calm-day preload: no hail, no change. It must not + // grow a hail line or a swing, and this is the case that would catch a read + // that manufactures weather to fill a card. + const gentle = new Set(); + for (let t = 0.5; t < 90; t += 0.25) for (const l of midStormRead(storms.storm_01_gentle, t)) gentle.add(l.key); + assert(!gentle.has('hail') && !gentle.has('ground'), + 'the gentle night reads hail or stones on the ground, and it has neither'); + assert(!gentle.has('veer'), 'the gentle night reads a wind swing, and it has no change'); + }); + // ── GATE 1.2 (S17): THE EXPOSURE CROSS — A's exposureOf vs the failure // route, the S14 pin pattern (two harnesses, one number, by construction). // Prep up front (Suite.test can't await); the tests skip honestly offline. diff --git a/web/world/js/weather.js b/web/world/js/weather.js index 6979778..f023550 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -192,6 +192,33 @@ export function forecastHonest(def, name = def?.name ?? 'storm') { + `(${STONE_WORD_CEILING}) — "${stoneWord(s.hailSize)}" would undersell what falls; ` + 'grow the word map before offering this storm'); } + // ⚠️ AND THE OTHER END. [SPRINT18 — the S17 filing, corrected by measuring it] + // + // I filed this forward in S17 as "stoneWord has no FLOOR: a def with + // hail.size 0 words itself 'fine pea stones'". **That hole was already shut and + // I had not checked one layer up**: validateStorm refuses `size <= 0` outright, + // forecastHonest runs the validator FIRST and returns early, so a floor check + // here would have been dead code. Recorded rather than quietly dropped — + // filing a hole without testing the gate above it is the same mistake as + // trusting a comment. + // + // Probing the layer that DOES pass turned up the real one, and it is worse: + // `hd.size != null &&` — the validator only checks a size that is PRESENT, and + // `hailSize` reads `hailDef.size ?? 1`. So a storm that hails with no authored + // size passes every gate and the card prints **"marble stones"**: the middle of + // the vocabulary, stated as fact, authored by nobody. Measured on the soaker — + // delete one field and its real 0.45 "fine pea" becomes a confident "marble", + // a stone 2.2× bigger, on the single axis the fabric bet is decided on. The + // validator makes this exact argument two lines above about a hail block with + // nothing to fire it: "that's a typo, not a design, so say so". + // + // It matters more now than in S17: the dispatch's stone line is the ONLY + // description an emergency night gets, with no band beside it to hedge in. + if (s.hailSeconds > 0 && def.hail?.size == null) { + errors.push(`${name}: hails for ${s.hailSeconds.toFixed(1)}s but hail.size is not authored — ` + + `it defaults to ${s.hailSize} and the card would state "${stoneWord(s.hailSize)} stones" ` + + 'as fact on the axis the fabric bet turns on; a hailing storm must declare its stone'); + } // Recompute the band promises from the def (structural today — see header). const contains = (b, val, what, lead) => { @@ -404,6 +431,221 @@ export function pairingRefusals(pairings = []) { return out; } +// ───────────────────────────────────────────────────────────────────────────── +// THE DISPATCH — SPRINT18 gate 1, C's seam (A's §8). +// +// ⚖️ **THE CALLOUT HAS NO FORECAST, AND THAT IS A WORDING PROBLEM BEFORE IT IS +// A FEATURE.** Every other night in this game is sold off paper: `offerBand` +// prints a forecast, and its honesty rule is the one S17 settled — the band may +// be vague, it may not rule out what happens, and it may not omit a fact it +// holds. An emergency callout has no paper at all. You are standing in the +// weather at t=30 with a phone call and a set of eyes. +// +// ⚠️ **THE INVERSION, which is the whole design.** The forecast's sin is +// OMISSION — the board lied about night 7 by printing two of the fields it had. +// The dispatch's sin is the opposite: INVENTION. Standing in a yard you know the +// wind on your back to the km/h, better than any forecast ever told you — so the +// present tense is EXACT here where the card was banded. What you do not know is +// everything the bureau would have told you: how long this has to run, what the +// peak will be, whether a southerly is coming at t=48. Printing any of that +// would be inventing paper that does not exist. +// +// So the read is ruled by TENSE, and the permitted set is not "less than a +// forecast" — it is a different, sharper set: +// · **NOW** — exact, unhedged. You are standing in it. +// · **THE VISIBLE IMMINENT** — a gust front crossing the grass (DESIGN.md line +// 149: "readable — a wave of motion crosses the grass and trees a beat +// before it hits"). The telegraph window is 1.5 s and that is the whole +// horizon you have earned by looking. +// · **THE PAST, AS WRITTEN ON THE YARD** — and this is the one that makes the +// design work. You were not here for the first 30 seconds, so you have no +// memory of them; but hail lying in the grass and water standing on the flat +// are a RECORD of them, and reading a yard for what already went wrong is +// literally this game's other job type (DESIGN.md's cowboy-job line: "half +// the puzzle is finding what's about to fail"). Integrals over [0, t] are +// honest. Anything over (t, ∞) is not. +// · **THE FUTURE** — forbidden. Not hedged, not banded. Absent. +// +// ⚠️ **AND THE ADMISSION IS MANDATORY, which is the subtle half.** On a forecast +// card a missing hail line means THE STORM DOES NOT HAIL — `offerBand` rules it +// so, deliberately, and seven nights have taught the player to read it that way. +// On a dispatch a missing hail line means NOBODY KNOWS. Those two cannot look +// identical on a document, and silence cannot distinguish them: a player fresh +// off six forecast cards will read an absent line as a reassurance nobody gave. +// So the read SAYS it has no paper, first, and says what it cannot know, last. +// That is the honest answer to "what does honest mean when the honest answer is +// nobody told you" — you say the words "nobody told you", on the card, in the +// order a person reads. +// +// ⚠️ **THE IGNORANCE LINE IS UNCONDITIONAL, and it has to be.** A tempting +// version says "a change may still be coming" only when `def` HAS a change event +// — which would make the line's PRESENCE the very fact it claims not to have. +// The same trick in reverse to `offerBand`'s "hail line iff it hails": there, +// presence is information the forecast legitimately holds; here, presence would +// be information the observer cannot possibly hold. Constant text, every storm, +// every t. It leaks nothing because it says nothing about the weather — it is a +// statement about YOUR INFORMATION, and those you may always make. +// ───────────────────────────────────────────────────────────────────────────── + +/** Below this, no gust is "on it" — the base wind is all you are feeling. */ +const LULL_GUST = 0.4; // m/s of live gust envelope +/** Above this the wind is visibly swinging, rad/s, measured over ±0.5 s. */ +const VEER_RATE = 0.06; +/** Hail-seconds already delivered before stones are lying where you can see them. */ +const HAIL_ON_GROUND = 1.5; +/** Real mm of rain already delivered before water is standing on the flat. */ +const WATER_STANDING = 6; + +/** + * ⚖️ **WHAT THE SKY AND THE GRASS TELL YOU WHEN NO PAPER DOES.** + * + * The dispatch's weather half — A owns `emergency.dispatch` (the phone call) and + * this is everything under it. Same construction as `offerBand`: an ORDERED list + * of `{key, text}`, so the card MAPS it and a fact added here cannot be dropped + * by a card that predates the fact. Reading order is the order a tradie takes the + * yard in — what you haven't got, what's hitting you, what's coming, what has + * already happened, and what you still don't know. + * + * Reads ONLY the permitted tenses (see the divider above). It never touches + * `stormStats` — that is the whole-storm truth, the thing the bureau would have + * measured, and `gustPeak`/`hailSeconds`/`changeAt` are precisely the facts a + * callout is not entitled to. Pinned two ways: `storm_03_southerly` and + * `storm_03b_earlybuster` are identical in every number except when the change + * lands (30 s against 18 s), so a read taken BEFORE both changes must be + * character-identical — and one taken between them must DIFFER, because by then + * the swing is something you can feel. + * + * @param {object} def parsed storm JSON + * @param {number} t storm seconds at which you got out of the ute (`arriveAt`) + * @returns {{key:string, text:string}[]} nopaper, wind, veer?, front?|lull?, + * hail?, rain?, ground?, unknown + */ +export function midStormRead(def, t = 0) { + const f = createWindField(def); + const lines = [{ key: 'nopaper', text: 'no forecast on this one — nobody briefed it' }]; + const i = (v) => v.toFixed(0); + + // NOW, exact. Speed is the one number a body measures better than a bureau: it + // is on your back. Unhedged on purpose — the contrast with a forecast card is + // the point, not an oversight. + lines.push({ key: 'wind', text: `${i(kmh(f.uniformSpeed(t)))} km/h on it right now` }); + + // NOW: is it swinging? A wind backing round is felt and seen (the trees go + // first), so it is fair game — and it is the fact that matters most on an + // inherited rig, because a rig built for a westerly is loaded backwards by a + // southerly. Central difference over ±0.5 s: short enough to be "right now", + // wide enough not to read curve noise as a swing. It reports only that it IS + // swinging, never that one is COMING, and never a compass word — the def alone + // carries no reference frame the player shares, and inventing one would be the + // same offence as inventing the forecast. + const veer = f.dirAt(t + 0.5) - f.dirAt(t - 0.5); + if (Math.abs(veer) > VEER_RATE) { + lines.push({ key: 'veer', text: 'the wind is backing round on you as you stand there' }); + } + + // THE VISIBLE IMMINENT — the only future you have earned, and you earned it by + // looking at grass. + const tele = f.telegraph ? f.telegraph(t) : null; + if (tele && Number.isFinite(tele.eta)) { + lines.push({ + key: 'front', + text: `a gust front crossing the grass — on you in about ${tele.eta.toFixed(1)}s`, + }); + } else if (f.gustOnly(t) < LULL_GUST) { + // NOW: nothing on it this second. DESIGN.md line 149 — "lulls are repair + // windows" — and on a triage job that is the most actionable line on the + // card. Honest at an instant: it says nothing about how long the lull holds, + // because nothing tells you that. + lines.push({ key: 'lull', text: 'nothing on it this second — if you are moving, move now' }); + } + + // NOW: what is falling. Present tense only — no duration and no total, because + // those are the bureau's numbers. + if (f.hailAt(t) > 0) { + lines.push({ key: 'hail', text: `${stoneWord(f.hailSize)} stones coming through` }); + } + const rain = f.rainMmPerHour(t); + if (rain > 0.5) lines.push({ key: 'rain', text: `rain at ${i(rain)} mm/hr` }); + + // THE PAST, AS WRITTEN ON THE YARD. You did not see the first t seconds; the + // yard did, and it kept the receipt. Integrals over [0, t] — never past t. + const ground = []; + let hailSoFar = 0; + for (let s = 0; s <= t; s += 0.05) hailSoFar += f.hailAt(s) * 0.05; + if (hailSoFar > HAIL_ON_GROUND) ground.push('stones lying in the grass'); + if (f.rainDepthMm(0, t) > WATER_STANDING) ground.push('water standing on the flat'); + if (ground.length) { + lines.push({ key: 'ground', text: `${ground.join(' and ')} — it has been through here a while` }); + } + + // THE ADMISSION. Unconditional, constant, and last, because it is what the + // player carries into the yard. It says nothing about the weather — only about + // what you were given, which is nothing. + lines.push({ + key: 'unknown', + text: 'how long it runs, what it does next, whether it swings again — no paper, no quote. You find that out standing in it.', + }); + return lines; +} + +/** + * ⚖️ **THE HONESTY GATE, EXTENDED TO EMERGENCY STORMS.** A storm that cannot be + * described honestly cannot be DISPATCHED either — an emergency night still gets + * worded, priced, invoiced, warrantied and rep'd through the surfaces it already + * had (A's §10 acceptance criterion), so `forecastHonest`'s storm-side gate + * applies unchanged. Having no forecast card is not a licence for a storm nobody + * can describe; it RAISES the bar, because the read is the only description there + * is and there is no band to hide vagueness in. + * + * On top of the storm gate, three things only an ARRIVAL can get wrong: + * 1. **the arrival must be inside a storm that is still running.** `arriveAt` + * past the end dispatches you to weather that has already finished. + * 2. **the read must not come out empty or mute.** A dispatch whose weather half + * renders nothing is the offer card picking two fields all over again. + * 3. **the stone must be real AT THE INSTANT you arrive** — the S17 filing, + * corrected by measuring it. `size: 0` turned out to be refused by the + * validator a layer up, so the floor I filed for would have been dead code. + * The REACHABLE hole is `size` ABSENT: it passes every gate and defaults to + * the MIDDLE of the vocabulary, so the card states "marble stones" as fact for + * a stone nobody authored. See the note beside the check in `forecastHonest`. + * + * @param {object} def parsed storm JSON + * @param {number} t the authored `arriveAt` + * @param {string} [name] + * @returns {{ok:boolean, errors:string[]}} + */ +export function dispatchHonest(def, t, name = def?.name ?? 'storm') { + const base = forecastHonest(def, name); + const errors = [...base.errors]; + if (!base.ok) return { ok: false, errors }; + + const dur = def.duration ?? 90; + if (!(Number.isFinite(t) && t > 0)) { + errors.push(`${name}: arriveAt ${t} is not a time inside a storm — a callout arrives MID-storm`); + } else if (t >= dur) { + errors.push(`${name}: arriveAt ${t} is at or past the storm's end (${dur}s) — ` + + 'you would be dispatched to weather that has already finished'); + } else { + const read = midStormRead(def, t); + const keys = new Set(read.map((l) => l.key)); + for (const must of ['nopaper', 'wind', 'unknown']) { + if (!keys.has(must)) { + errors.push(`${name}: the read at t=${t} has no '${must}' line — a dispatch that does not ` + + 'say it has no paper teaches that a missing line is a reassurance'); + } + } + if (read.some((l) => !l.text || !l.text.trim())) { + errors.push(`${name}: the read at t=${t} contains an empty line`); + } + const f = createWindField(def); + if (f.hailAt(t) > 0 && !(f.hailSize > 0)) { + errors.push(`${name}: hail is falling at t=${t} with size ${f.hailSize} — ` + + `the read would print "${stoneWord(f.hailSize)} stones" for stones that do not exist`); + } + } + return { ok: errors.length === 0, errors }; +} + // Resolved against this module, not the server root: server.py serves the repo // root (so the 2D prototype stays reachable), but the demo bench serves web/. // import.meta.url is right under both, and under whatever Lane A does next. From 9506f6e79f7d58dd2bff3cf356c464b54169c661 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 19:37:46 +1000 Subject: [PATCH 4/6] =?UTF-8?q?Lane=20C=20S18:=20[C]=20THREADS=20=E2=80=94?= =?UTF-8?q?=20gate=201's=20dispatch,=20the=20tense=20ruling,=20and=20my=20?= =?UTF-8?q?S17=20filing=20corrected=20by=20measuring=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- THREADS.md | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/THREADS.md b/THREADS.md index b583870..4b0bde2 100644 --- a/THREADS.md +++ b/THREADS.md @@ -9622,3 +9622,125 @@ anchors are your GLB), but the tooling is now waiting, not TODO. from the end and insist the peak matches what the dispatch quotes. Two harnesses, one number, in the one scan order no production code uses. That version catches mutation 4; the first version caught nothing. + +[C] 2026-07-25 — 🚨⚖️ **GATE 1 (C's half) LANDED: THE DISPATCH — `midStormRead(def, t)`, and the + ruling on what HONEST means when the honest answer is "nobody told you".** Built against A's + §8 exactly (ordered `{key, text}`, the `offerBand` construction, reachable through + `weather.midStormRead?.()` under a namespace import — verified on that exact call path). + Selftest **532/0/0** (+5 on 527, all in c.test.js). + + **THE INVERSION IS THE WHOLE DESIGN, and it took measuring the other card to see it.** + `offerBand`'s sin is OMISSION — the board lied about night 7 by printing two of the fields it + held. **A dispatch's sin is the opposite: INVENTION.** And the consequence is not that the + read is *vaguer* than a forecast. It is SHARPER. Standing in the yard you know the wind on + your back to the km/h, better than any bureau ever told you — so the present tense is EXACT + here, where the offer card was banded. What you do not have is everything the paper would + have said: how long this runs, what the peak will be, whether a southerly lands at t=48. + + So the read is ruled by **TENSE**, and I'll defend these four lines as the gate: + · **NOW** — exact, unhedged. You are standing in it. + · **THE VISIBLE IMMINENT** — a gust front crossing the grass. DESIGN.md line 149 makes it + readable and the telegraph window is 1.5 s; that is the entire horizon, and you earned it + by looking at grass rather than at paper. + · **THE PAST, AS WRITTEN ON THE YARD** — the one that makes the design work, and the answer + to the problem I got stuck on for a while: at t=30 you have NO MEMORY of the storm, so + "it's easing" is not available to you — you were driving. But **hail lying in the grass and + water standing on the flat are a record of the last 30 seconds that you did not have to be + present for.** Reading a yard for what has already gone wrong is literally this game's other + job type (DESIGN.md's cowboy line: "half the puzzle is finding what's about to fail"). + Integrals over [0, t] are honest; over (t, ∞) they are not. That is the whole rule. + · **THE FUTURE** — forbidden. Not hedged, not banded. Absent. + + ⚠️ **AND THE ADMISSION IS MANDATORY — the subtle half, and the bit I'd argue hardest for.** + On an offer card a missing hail line means THE STORM DOES NOT HAIL; `offerBand` rules it so + deliberately and seven nights have taught the player to read it that way. On a dispatch a + missing hail line means NOBODY KNOWS. **Those two cannot look identical on a document, and + silence cannot tell them apart** — a player fresh off six forecast cards reads an absent line + as a reassurance nobody gave. So the read SAYS it, first (`nopaper`) and last (`unknown`). + The honest answer to "what does honest look like with no paper" is that you print the words + "nobody briefed it", on the card, in the order a person reads. + + ⚠️ **THE IGNORANCE LINE IS UNCONDITIONAL, and it has to be.** The tempting version says "and + it may still swing" only when the def HAS a change event — which makes the line's PRESENCE + the very fact it claims not to have. Exactly `offerBand`'s "hail line iff it hails" run + backwards: there, presence is information the forecast legitimately holds; here it would be + information the observer cannot possibly hold. Constant text, every storm, every t. It leaks + nothing because it says nothing about the weather — it is a statement about YOUR INFORMATION, + and those you may always make. + + **THE LINES:** `nopaper` · `wind` · `veer?` · `front?`|`lull?` · `hail?` · `rain?` · `ground?` + · `unknown`. Every one reachable on shipped data (pinned; 5–6 storms each), and the gentle + night — no hail, no change — must NOT grow a hail, ground or veer line, which is the case that + catches a read manufacturing weather to fill a card. + + **A — YOUR NIGHT 5, RENDERED. And `arriveAt: 30` is a better number than you may know:** + `storm_03_southerly`'s windchange event is AT t=30, and its dirCurve is swinging −0.21 rad/s + right there. So the dispatch's most dramatic line is free, honest and observable — + · no forecast on this one — nobody briefed it + · 32 km/h on it right now + · **the wind is backing round on you as you stand there** + · nothing on it this second — if you are moving, move now + · rain at 5 mm/hr + · how long it runs, what it does next, whether it swings again — no paper, no quote. + You find that out standing in it. + The veer and the lull firing together is not a contradiction, it is a southerly change: it + goes quiet and comes back from the other quarter. A lull that is also a trap, and the card + tells the truth about both and lets the player decide. The soaker at t=30 reads + `stones lying in the grass — it has been through here a while` instead: past tense, off the + yard, on the storm whose whole trap is hail. + + ⚠️ **WHAT I DELIBERATELY DID NOT PRINT: a compass word.** `midStormRead(def, t)` has a def and + no site, and direction only means something against a reference frame the player shares. The + fiction calls the change a "southerly" but there is no dir→compass map in this repo, and + inventing one to make a card read nicer is the same offence as inventing the forecast. The + read reports THAT it is swinging, never which way and never that one is coming. **B/D — the + yard-relative version ("it is loading the north-west corner") is the useful one and it needs + the rig; it is yours if you want it, and I will not guess at it from a storm def.** + + **THE HONESTY GATE, EXTENDED — `dispatchHonest(def, t)`.** My brief: a storm that cannot be + described honestly cannot be dispatched either. `forecastHonest`'s storm-side gate applies + UNCHANGED — an emergency night is still worded, priced, invoiced, warrantied and rep'd through + A's §10 surfaces, and having no forecast card is not a licence for an indescribable storm; it + RAISES the bar, because the read is the only description there is and there is no band to hide + vagueness in. On top, three things only an ARRIVAL can get wrong: the arrival must be inside a + storm still running (`arriveAt` 0, `= duration` and past it are all refused — three negative + controls); the read must not come out mute (the `nopaper`/`wind`/`unknown` lines are required, + so a dispatch cannot be silent about its own silence); and the stone must be real at the + instant you arrive. + + ⚠️ **MY OWN S17 FILING WAS WRONG, AND I FOUND OUT BY TESTING IT INSTEAD OF IMPLEMENTING IT.** + I filed forward: *"`stoneWord` has no FLOOR — a def with `hail.size: 0` words itself 'fine pea + stones'"*, with a suggested one-liner. **That hole was already shut one layer up:** + `validateStorm` refuses `size <= 0`, and `forecastHonest` runs the validator FIRST and returns + early — so the check I filed for would have been dead code, and I never checked the gate above + the gate. Recorded rather than quietly dropped: filing a hole without probing the layer above + it is the same mistake as trusting a comment. + + **Probing the layer that DOES pass turned up the real one, and it is worse.** The validator + reads `hd.size != null &&` — it only checks a size that is PRESENT — and `hailSize` reads + `hailDef.size ?? 1`. So **a storm that hails with no authored `hail.size` passes every gate and + the card prints "marble stones": the middle of the vocabulary, stated as fact, authored by + nobody.** Measured on the soaker — delete one field and its real 0.45 "fine pea" becomes a + confident "marble", a stone 2.2× bigger, on the single axis the fabric bet is decided on. Now + refused, with the validator's own words for the neighbouring case as the precedent ("that's a + typo, not a design, so say so"). The ceiling still bites at 2.6 — pinned, so the new check did + not displace the old one. + + **THE PIN I'M PROUDEST OF, because it states the rule instead of policing a vocabulary: THE + READ AT t MAY NOT MOVE WHEN THE STORM'S FUTURE BEYOND t MOVES.** Mutate a def's future — flip + the dirCurve tail past the bracketing point, drop the hail bursts that have not started, move + the events — and the read at t must be character-identical, on every storm at t = 8, 20 and 30. + Carries its own vacuity guard: the same mutated defs are read again LATER than t and must + differ there, or the invariant is being satisfied by a no-op mutation rather than by an honest + read. ⚠️ **NOT pinned by diffing `storm_03_southerly` against `storm_03b_earlybuster`, which + was my first instinct and is wrong:** THREADS has them identical in every number the band + prints, but they carry different SEEDS (30717/30818), so their gust timelines and their + instantaneous wind differ at every t. They are twins on the FORECAST's numbers, not on the + sim's — a trap I set for myself in S17 and nearly walked into in S18. + + **MUTATIONS — four, all red, all restored.** (1) the read leaks `changeAt` → the future- + invariant case AND the forecast-fact case, independently. (2) the ignorance line made + conditional on a pending change → the unconditional case, plus the future-invariant case + caught it too, which is two pins disagreeing with the same lie for different reasons. (3) the + absent-size check removed → the gate case, naming "marble stones" in the message. (4) the hail + line made unconditional → the gentle night grows hail it does not have. From 062fd0075831c5e57ffa2a1f1201298208326d00 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 19:44:11 +1000 Subject: [PATCH 5/6] =?UTF-8?q?Lane=20C=20S18=20gate=201:=20the=20honesty?= =?UTF-8?q?=20gate=20walks=20A's=20EMERGENCIES=20=E2=80=94=20storm=20AND?= =?UTF-8?q?=20pairing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIGHTS ∪ POOL grew a third member this sprint, and an emergency is the member with the most to hide: no forecast card, no quote, and a storm entered at a t nobody walked up to. Both arms checked per entry — dispatchHonest(def, arriveAt) for the storm-and-instant, pairingRefusal(storm, site) for the yard, because a read can be perfectly honest about a storm and still be an invitation to a night nobody can work, which is worse on a callout the player cannot decline once they are in it. Read through a NAMESPACE import (board.EMERGENCIES), not a named one, so this file survives the window where A's spine is on lane/a and not in my tree — a named import of a not-yet-exported symbol is a hard module error, not a graceful no-op. A's own reasoning for weather.midStormRead?.(), applied back at them. Walks a FIXTURE mirroring A's §6 as well as the real table, so the gate is exercised on known data TODAY rather than reading green off an absent import — the calm-day vacuity that has now bitten this repo in three separate gates. Verified against A's actual lane/a tree out of band: their one shipped emergency (corner_block_emergency, night 5, storm_03_southerly x site_02_corner_block, arriveAt 30) passes both arms, so the walk binds clean at merge. Three vacuity guards, one per arm plus the late arrival. 533/0/0 (+1). Co-Authored-By: Claude Fable 5 --- web/world/js/tests/c.test.js | 56 ++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 585b14c..854a85a 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -32,6 +32,11 @@ import { loadSite, createWorld } from '../world.js'; // without a test edit the day D's pool yard lands. import { NIGHTS, nightAt } from '../week.js'; import { POOL, exposureOf } from '../board.js'; +// SPRINT18 gate 1: A's EMERGENCIES table read through a NAMESPACE import, so this +// file survives the window where A's spine is on lane/a and not yet in my tree — +// a named import of a not-yet-exported symbol is a hard module error, not a +// graceful no-op. A's own reasoning for `weather.midStormRead?.()`, applied back. +import * as board from '../board.js'; // GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin // that copied main.js's two lines would agree with a copy of the game forever, // including on the day the game itself changed. "Two harnesses, one number" @@ -1812,6 +1817,57 @@ export default async function run(t) { 'a 2.6 stone was dispatched — the vocabulary ceiling stopped biting'); }); + t.test('GATE 1: every EMERGENCY is a storm that can be honestly dispatched over that yard', () => { + // The honesty gate walking A's own table — NIGHTS ∪ POOL grew a third member + // this sprint, and an emergency is the member with the most to hide: no + // forecast card, no quote, and a storm entered at a t nobody walked up to. + // + // ⚠️ Read through a NAMESPACE import (`board.EMERGENCIES`), not a named one, so + // this file does not hard-error in the sprint-window where A's spine is on + // lane/a and not yet in my tree — the same reasoning A gave for calling + // `weather.midStormRead?.()` that way, applied back at them. And it walks a + // FIXTURE mirroring A's §6 as well as the real table, so the gate is exercised + // on known data TODAY rather than reading green off an absent import: the + // calm-day vacuity that has now bitten this repo in three separate gates. + const fixture = [{ + id: 'fixture:corner_block_emergency', night: 5, + storm: 'storm_03_southerly', site: 'site_02_corner_block', + emergency: { arriveAt: 30 }, + }]; + const real = Array.isArray(board.EMERGENCIES) ? board.EMERGENCIES : []; + const walk = [...fixture, ...real]; + assert(walk.length >= 1, 'the emergency walk found nothing at all'); + + for (const e of walk) { + const at = e.emergency?.arriveAt; + const def = storms[e.storm]; + assert(def, `${e.id}: emergency storm '${e.storm}' is not in the loaded storm set — ` + + 'an emergency whose weather nobody can load cannot be dispatched'); + assert(Number.isFinite(at), `${e.id}: no numeric emergency.arriveAt`); + + // (1) the storm-and-instant gate + const r = dispatchHonest(def, at, e.id); + assert(r.ok, `${e.id} cannot be honestly dispatched at arriveAt=${at}:\n ${r.errors.join('\n ')}`); + + // (2) THE PAIRING LAW, extended to the new job type. A read can be perfectly + // honest about a storm and still be an invitation to a night nobody can + // work — and that is worse on a callout, which the player cannot decline + // once they are standing in it. + const why = pairingRefusal(e.storm, e.site); + assert(why === null, + `${e.id}: dispatching ${e.storm} over ${e.site} is refused by measurement — ${why}`); + } + + // VACUITY GUARDS — three, because a walk that only ever says yes is decoration. + assert(pairingRefusal('storm_06_soaker', 'backyard_01') !== null, + 'the pairing arm of this walk cannot say no — an emergency on a refused yard would pass'); + assert(!dispatchHonest(storms[fixture[0].storm], 95, 'late').ok, + 'an emergency arriving after its storm ended was accepted'); + const sizeless = JSON.parse(JSON.stringify(storms.storm_06_soaker)); + delete sizeless.hail.size; + assert(!dispatchHonest(sizeless, 30, 'sizeless').ok, 'the storm arm of this walk cannot say no'); + }); + t.test('GATE 1: every line of the read is reachable, and each one is earned', () => { // A vocabulary with an unreachable word is a vocabulary that has never been // read. Walk the shipped storms and prove each key fires somewhere — and that From 00b068957f93e149f73ccf5f8c53ef920ed1ca67 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 19:44:11 +1000 Subject: [PATCH 6/6] =?UTF-8?q?Lane=20C=20S18:=20[C]=20THREADS=20=E2=80=94?= =?UTF-8?q?=20closing=20entry,=20the=20emergency=20walk=20verified=20again?= =?UTF-8?q?st=20A's=20tree,=20three=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- THREADS.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/THREADS.md b/THREADS.md index 4b0bde2..48dd1b7 100644 --- a/THREADS.md +++ b/THREADS.md @@ -9744,3 +9744,41 @@ anchors are your GLB), but the tooling is now waiting, not TODO. caught it too, which is two pins disagreeing with the same lie for different reasons. (3) the absent-size check removed → the gate case, naming "marble stones" in the message. (4) the hail line made unconditional → the gentle night grows hail it does not have. + +[C] 2026-07-25 — ✅ **CLOSING: THE HONESTY GATE NOW WALKS A's `EMERGENCIES` — both arms — and my + count is final at 533/0/0** (+11 on the 522 baseline: +5 the non-zero-start pin, +5 the + dispatch, +1 the emergency walk). + + NIGHTS ∪ POOL grew a third member this sprint and it is the member with the most to hide: no + forecast card, no quote, a storm entered at a `t` nobody walked up to. Each entry is checked + on BOTH arms — `dispatchHonest(def, arriveAt)` for the storm-and-instant, and + `pairingRefusal(storm, site)` for the yard, because a read can be flawless about a storm and + still be an invitation to a night nobody can work. **That is worse on a callout than on an + offer**: the board's bad pairing you can decline, and a dispatch you are already standing in. + + **A — verified against your real tree, out of band, before you merge:** I ran my gate over + `origin/lane/a`'s `board.js`. `corner_block_emergency` (night 5, `storm_03_southerly` × + `site_02_corner_block`, `arriveAt: 30`) passes both arms — `dispatchHonest` OK, pairing legal. + The walk reads `board.EMERGENCIES` through a **namespace import** so c.test.js does not + hard-error in the window where your spine is on lane/a and not in my tree — your own reasoning + for `weather.midStormRead?.()`, handed back. It also always walks a FIXTURE mirroring your §6, + so it is exercised on known data today instead of reading green off an absent import. Three + vacuity guards: the pairing arm (soaker × backyard_01 must refuse), the storm arm (a hailing + storm with no authored size must refuse), and a late arrival (`arriveAt` 95 into a 90 s storm). + + **WHAT I OWE NOBODY AND FLAG ANYWAY — three, in priority order:** + 1. ⚠️ **B/A: the sail clock. Nothing has changed about it and it is still the sprint's biggest + number** — 4.12× on load, 93× on rain, measured on A's own shipped emergency. See my filing + above. Gate 1 can be wired, demoed and merged without noticing, because the sky, the garden + and the debris all move correctly and only the cloth is in the wrong storm. It will read as + "triage is easy" rather than as a bug. + 2. **A: `stormsToPreload` is still green by coincidence** and it is now MY coincidence too — + the emergency's storm is `storm_03_southerly`, which is already in NIGHTS, so a pool-only + or emergency-only storm still never bites. Carried from S17's [I], unchanged. + 3. **D: the lull is a real read now and it is yours to spend.** `midStormRead`'s `lull` line + ("nothing on it this second — if you are moving, move now") is the repair-window read off + DESIGN.md line 149, and `debris.enterAt` means the moving grass is live from the first frame + of an emergency instead of seven seconds in. On `storm_03_southerly` at `arriveAt: 30` the + lull and the veer fire TOGETHER, which is not a contradiction — it is a southerly change, + going quiet before it comes back from the other quarter. A repair window that is also a + trap, printed honestly, and the knife decision sits right on top of it.