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.