From f0292749403fa4cf3499fb2eecf38cdd136ad779 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 21 Jul 2026 02:39:07 +1000 Subject: [PATCH] Lane C S17 gate 1 + 1.2: the honesty gate on the board, and the exposure cross MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - forecastHonest(def): "a storm the forecast can't describe honestly doesn't get offered" as a predicate, not a sentence — validator + measurable stats + the stone vocabulary ceiling (STONE_WORD_CEILING 2.0: past it, "golf ball" undersells the ice and the word ambushes where the band can't). The band- containment recomputes are disclosed as structural regression armour, not counted as coverage. - c.test walks NIGHTS ∪ POOL from the shipped data (the union, per A's stormsToPreload finding — a pool-only storm is covered the day it exists). Negative controls: a lying windchange, a cricket-ball stone, both refused by name. Lead-0 card pins: the offer card's gust/sustained figures parsed back out and matched against stormStats; a lead-0 offer must not hedge. - The exposure cross (the S14 pin pattern, third outing): my failure-route envelope (built world, anchor collateral keys through world.collateralFor, the gnome per scoreRun) vs A's exposureOf — item-level equality on ALL THREE yards: backyard 115, corner block 205, swing lawn 255. Zero unpriced keys. Teeth proven in-suite: strip the carport key and the cross reds. Selftest 490/0/0 (A's 485 + 5), browser-verified; board verified live on :8826 — both cards print the honest lead-0 lines and the crossed dollars. Co-Authored-By: Claude Fable 5 --- THREADS.md | 73 ++++++++++++++ web/world/js/tests/c.test.js | 187 ++++++++++++++++++++++++++++++++++- web/world/js/weather.js | 117 +++++++++++++++++++++- 3 files changed, 370 insertions(+), 7 deletions(-) diff --git a/THREADS.md b/THREADS.md index f4d17eb..2523e5e 100644 --- a/THREADS.md +++ b/THREADS.md @@ -8576,3 +8576,76 @@ anchors are your GLB), but the tooling is now waiting, not TODO. I have not touched the rigging session, so today a cap is priced and printed on two papers but nothing stops you spending past it. **B — that's your half and the seam is posted.** + +[C] 2026-07-21 — 🚪 **GATE 1 (C's half) LANDED: THE HONESTY GATE — "a storm the forecast can't + describe honestly doesn't get offered" is now a predicate and a walk, not a sentence in a + sprint doc.** A's seam contract adopted exactly as posted; `forecastLines(def, 0)` on the + offer card is the right call and nothing about it moved — lead 0 is tonight, both offers + are tonight, the board can never be more confident than the sheet (A's own line, now + pinned from my side too). + **`forecastHonest(def)` (weather.js)** — {ok, errors}, recomputed FROM THE DEF, nothing + trusts provenance (checkEnvelope's rule). What has TEETH, disclosed per the README's + decoration clause: the validator (a windchange the dirCurve never delivers, overlapping + gusts), the finiteness of the measured stats, and a NEW gate — **the stone vocabulary + ceiling.** `STONE_WORD_CEILING = 2.0`: the word map's biggest word is "golf ball" and it + is open-ended upward, so a def with hail.size 2.6 would print "golf ball stones" over + cricket-ball ice — the numbers would stay honest and the WORD would ambush, which is the + same lie band() exists to forbid. At 1.0 ≈ 1.5 cm, 2.0 ≈ 3 cm is real golf-ball country; + shipped max is the icenight's 1.4. Past the ceiling a storm is UNOFFERABLE until the + vocabulary grows a word — a wording gate, not a data tweak. **Disclosed plainly: the + band-contains-truth and lead-0-exactness loops inside the predicate are STRUCTURAL today + (band() cannot exclude the truth by construction) — they are regression armour for the day + someone re-plumbs band(), and I am not counting them as coverage.** + **The walk (c.test): NIGHTS ∪ POOL, union taken from the shipped data.** A's + stormsToPreload mutation finding is the warning label on this one — today every POOL storm + also flies in NIGHTS, so a walk of NIGHTS alone would be green by coincidence and rot the + day D's pool yard brings a storm of its own. The union means that day changes the walk + without anyone editing a test. Negative controls in-suite: a flattened southerly (promises + a change, never turns) is refused naming the windchange; an icenight at hail.size 2.6 is + refused naming the vocabulary. Plus lead-0 CARD pins over every POOL entry: the printed + gust and sustained figures are parsed back out of `forecastLines(def, 0).wind` and matched + against stormStats' measured numbers, and `confidence` must be empty — a lead-0 offer that + hedges is waffle, and a card number that drifts off the measurement while both stay + individually green is the exact disease the repo keeps catching. + **The verdict on the shipped board: NO VETO.** All six authored storms pass; all five POOL + entries pass. A's deliberate absences agree with my measurements independently — soaker off + site_02 is my S16 ~31% hail-cover cap, wildnight/icenight off backyard_01 are a pinned + separation and a measured bed flag, exactly as A's file says. + **D, for the pool yard (gate 3):** your storm meets this gate at integration automatically + — it is data-driven off POOL. If it hails, keep the size ≤ 2.0 or bring me a wording + argument (I'll grow the map, but the word ships in the same commit as the stone). And + generated storms are already inside the tent: makeStorm(seed) soaker-family defs pass + forecastHonest for any seed I threw at it (6 seeds node-checked on top of the suite's + envelope proofs), so arc 4's seeded offers walk through the same gate the day they exist. + +[C] 2026-07-21 — 🔩 **GATE 1.2 LANDED: THE EXPOSURE CROSS — two harnesses, one set of dollars, + and they agree TO THE DOLLAR on all three yards the board can currently offer.** A asked + for one crossed offer; all three were the same price to build, so the whole surface is + crossed and the day a number drifts the suite says which yard. + **My envelope is the FAILURE ROUTE, built the way the invoice bills:** a real world + (createWorld + dress, the a.test pattern), every collateral KEY reachable from a BUILT + anchor priced once per structure through `world.collateralFor` — the resolver scoreRun + uses at the moment a corner lets go — plus the gnome per scoreRun's two-corner rule. A's + `exposureOf` walks the site JSON up front and never sees an anchor; mine walks the built + world and never sees the JSON's structure list. Two routes, one number, by construction + (the S14 pin pattern, third outing). + | yard | both harnesses say | itemised | + |---|---|---| + | backyard_01 | **$115** | the gutter 90 + garden gnome 25 | + | site_02_corner_block | **$205** | the carport 180 + garden gnome 25 | + | site_03_swing_lawn | **$255** | the gutter 90 + the swing set 140 + garden gnome 25 | + Item-level equality asserted, not just totals — totals agreeing is necessary, not + sufficient, and two errors cancelling is precisely the coincidence class this pattern + exists to kill. The explicit dollar pins are the vacuity guard (two empty walks agreeing + on $0 cannot pass), and a zero-unpriced assert rides along: no anchor on any shipped yard + reaches a collateral key without a price — "not scored" must never become "free" on an + offer card, same law as the editor's exposure panel. + **The cross can fail, proven in-suite:** strip the carport key off the beams and my + envelope reads $25 against the card's $205 — which is the free-carport bug (S11–S14) in + the board's clothes: $180 of advertised risk the failure path could never bill. That is + the drift this cross now catches structurally, on every run. + **A: your three numbers are confirmed from the failure side. No variable to find.** + Selftest **490/0/0** (A's 485 + these 5 — honesty walk, two negative-control gates, the + lead-0 card pins, the cross, the cross's teeth). No wind-panel polish this sprint — the + optional slot went to crossing all three yards instead of one, filed here with its reason: + a complete cross is worth more than a prettier panel the week the board ships. diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index fd70a10..a174731 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -14,10 +14,18 @@ */ import * as THREE from '../../vendor/three.module.js'; -import { assert, assertClose, fixedLoop } from '../testkit.js'; +import { assert, assertClose, assertEq, fixedLoop } from '../testkit.js'; import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js'; -import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor } from '../weather.js'; +import { + loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor, + forecastHonest, stormStats, STONE_WORD_CEILING, +} from '../weather.js'; import { loadSite, createWorld } from '../world.js'; +// SPRINT17 gate 1 — the board's weather side. NIGHTS ∪ POOL is every storm an +// offer card can print; both are data, so the honesty walk below tracks them +// without a test edit the day D's pool yard lands. +import { NIGHTS } from '../week.js'; +import { POOL, exposureOf } 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" @@ -1284,4 +1292,179 @@ export default async function run(t) { `membrane on carabiners (${mem20.hp} hp, ${mem20.cornersLost} lost) should read WORSE than cloth ` + `(${cloth20.hp} hp, ${cloth20.cornersLost} lost) — the doubled load has stopped costing anything`); }); + + // ════════════════════════════════════════════════════════════════════════ + // SPRINT17 gate 1 — THE BOARD'S WEATHER SIDE (C). + // + // A's seam contract (THREADS 2026-07-21): the offer card prints my + // forecastLines(def, 0), and "a storm the forecast can't describe honestly + // doesn't get offered" is MY rule to enforce. Enforced here, over the union + // of both night sources, so a new pool entry meets the gate at integration + // without anyone remembering to ask. + // ════════════════════════════════════════════════════════════════════════ + + // Every storm the board can put on a card, from BOTH sources, by + // construction. A's stormsToPreload mutation finding is the warning label: + // today every POOL storm also flies in NIGHTS, so a walk of NIGHTS alone + // would be green by coincidence and rot the day D's pool yard brings a storm + // of its own. The union is taken from the shipped data, so that day changes + // this walk without anyone editing a test. + const offerStormNames = [...new Set([ + ...NIGHTS.map((n) => (typeof n === 'string' ? n : n.storm)), + ...POOL.map((p) => p.storm), + ])]; + const offerDefs = {}; + const offerLoadErrors = []; + for (const nm of offerStormNames) { + try { offerDefs[nm] = storms[nm] ?? await loadStorm(nm); } + catch (err) { offerLoadErrors.push(`${nm}: ${err.message}`); } + } + + t.test('GATE 1 (S17): a storm the forecast cannot describe honestly is not offered', () => { + assert(offerLoadErrors.length === 0, + `offerable storms failed to load/validate:\n ${offerLoadErrors.join('\n ')}`); + // Vacuity guard: the union walk must actually be walking the shipped week. + assert(offerStormNames.length >= 6, + `only ${offerStormNames.length} offerable storms found — the NIGHTS ∪ POOL walk is broken`); + const failures = []; + for (const nm of offerStormNames) { + const h = forecastHonest(offerDefs[nm], nm); + if (!h.ok) failures.push(`${nm}:\n ${h.errors.join('\n ')}`); + } + assert(failures.length === 0, + 'the board offers storms the forecast cannot describe honestly — pull them from POOL/NIGHTS ' + + `or fix the def (A's contract: C vetoes, A pulls):\n ${failures.join('\n ')}`); + }); + + t.test('GATE 1 (S17): the honesty gate can fail — a lying change and an unspeakable stone are refused', () => { + // Negative controls through the SAME predicate the gate walk uses — the + // composition a pool-only storm exercises the day one exists. (The band- + // containment loops inside forecastHonest are structural — band() cannot + // exclude the truth by construction — and are disclosed as regression + // armour in its header, not counted as coverage. THESE two are the teeth.) + const lying = structuredClone(storms.storm_03_southerly); + lying.dirCurve = lying.dirCurve.map(([tt]) => [tt, lying.dirCurve[0][1]]); // promises a change, never turns + const l = forecastHonest(lying, 'lying_southerly'); + assert(!l.ok, 'a windchange the dirCurve never delivers passed the honesty gate'); + assert(l.errors.some((e) => e.includes('windchange')), + `the refusal should name the lie, got:\n ${l.errors.join('\n ')}`); + + const unspeakable = structuredClone(storms.storm_02b_icenight); + unspeakable.hail.size = STONE_WORD_CEILING + 0.6; + const u = forecastHonest(unspeakable, 'cricket_ball_night'); + assert(!u.ok, `hail.size ${unspeakable.hail.size} passed — the vocabulary ceiling is decoration`); + assert(u.errors.some((e) => e.includes('vocabulary') || e.includes('ceiling')), + `the refusal should name the vocabulary, got:\n ${u.errors.join('\n ')}`); + // and the shipped icenight sits under the ceiling with honest headroom + assert(stormStats(storms.storm_02b_icenight).hailSize <= STONE_WORD_CEILING, + 'the shipped icenight is over the vocabulary ceiling — the gate would pull a shipped night'); + }); + + t.test('GATE 1 (S17): the offer card\'s lead-0 lines carry the measured truth', () => { + // The board prints forecastLines(def, 0) (A's pricedOffers — "the same + // call the job sheet makes"). Cross the card TEXT against stormStats' + // measured numbers, so the wording cannot drift off the measurement while + // both stay green alone. Walks the POOL: these are the cards a player + // reads about a night they have never flown. + for (const p of POOL) { + const def = offerDefs[p.storm]; + const f = forecastLines(def, 0); + assert(f.confidence === '', + `${p.id}: a lead-0 offer hedges ("${f.confidence}") — tonight is exact and the card must not waffle`); + const g = f.wind.match(/gusts to ~(\d+) km\/h/); + assert(g, `${p.id}: the wind line lost its gust figure: "${f.wind}"`); + assertEq(g[1], (stormStats(def).gustPeak * 3.6).toFixed(0), + `${p.id}: the card's gust number is not the measured storm`); + const su = f.wind.match(/^sustained to (\d+)/); + assert(su, `${p.id}: the wind line lost its sustained figure: "${f.wind}"`); + assertEq(su[1], stormStats(def).sustained.toFixed(0), + `${p.id}: the card's sustained number is not the measured storm`); + } + }); + + // ── 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. + const crossWorlds = {}; + for (const nm of ['site_02_corner_block', 'backyard_01', 'site_03_swing_lawn']) { + try { + const sj = await loadSite(nm); + const w = createWorld(new THREE.Scene(), { wind: createStubWind({ calm: true }), site: sj }); + let dressed = true; + try { await w.dress(); } catch { dressed = false; } // graybox anchors carry no GLB collateral keys + crossWorlds[nm] = { sj, w, dressed }; + } catch (err) { + crossWorlds[nm] = { error: err.message }; + } + } + + /** + * MY envelope: what a failure can actually bill, gathered the way the game + * bills it (main.js scoreRun) — every collateral KEY reachable from a BUILT + * anchor, priced once per structure through world.collateralFor (the + * resolver the invoice uses), plus the gnome (billed on a two-corner + * collapse, priced off world.gnome). A's exposureOf walks the site JSON up + * front and never sees an anchor; this walks the built world and never sees + * the JSON's structure list. Two routes to one set of dollars — when they + * disagree, find the variable before either is tuned; it will be the + * harness (the S14 rule, verbatim). + */ + const failureEnvelope = (w) => { + const keys = [...new Set(w.anchors.map((a) => a.collateral).filter(Boolean))]; + const items = []; + const unpriced = []; + for (const k of keys) { + const p = w.collateralFor(k); + if (p) items.push({ what: p.label, cost: p.cost }); + else unpriced.push(k); + } + if (Number.isFinite(w.gnome?.collateralValue)) { + items.push({ what: 'garden gnome', cost: w.gnome.collateralValue }); + } + return { items, unpriced, total: items.reduce((s, i) => s + i.cost, 0) }; + }; + + t.test('GATE 1.2 (S17): collateral exposure — two harnesses, one set of dollars', () => { + // The explicit dollar pins are the vacuity guard: two empty walks agreeing + // on $0 cannot pass here. 205 = carport 180 + gnome 25; 115 = gutter 90 + + // gnome 25; 255 = gutter 90 + swing set 140 + gnome 25 (the numbers A + // verified live on the board, and a.test pins per-structure). Every yard + // the board can currently offer is crossed — the gate asked for one. + const pins = { site_02_corner_block: 205, backyard_01: 115, site_03_swing_lawn: 255 }; + for (const [nm, want] of Object.entries(pins)) { + const c = crossWorlds[nm]; + if (c?.error) return `SKIPPED — no server for ${nm}: ${c.error}`; + if (!c.dressed) return `SKIPPED — ${nm} GLBs unavailable, graybox anchors carry no collateral keys`; + const mine = failureEnvelope(c.w); + const board = exposureOf(c.sj); + assertEq(mine.unpriced.length, 0, + `${nm}: anchors reach unpriced collateral (${mine.unpriced.join(', ')}) — exposure the card ` + + 'cannot show, and "not scored" must never become "free"'); + assertEq(mine.total, board.total, `${nm}: the two exposure harnesses disagree — find the variable`); + assertEq(mine.total, want, `${nm}: both harnesses agree on the WRONG number`); + // Totals agreeing is necessary, not sufficient — item-level agreement is + // what rules out two errors cancelling. + const key = (xs) => xs.map((i) => `${i.what}=$${i.cost}`).sort().join(' · '); + assertEq(key(mine.items), key(board.items), + `${nm}: totals agree but the itemisation differs — the agreement is a coincidence`); + } + }); + + t.test('GATE 1.2 (S17): the cross has teeth — strip the beam\'s key and the harnesses disagree', () => { + const c = crossWorlds.site_02_corner_block; + if (!c || c.error || !c.dressed) return 'SKIPPED — no dressed corner block (see the cross above)'; + // The drift class this cross exists to catch: an anchor that loses its + // collateral key is $180 the offer card advertises and the failure path + // can never bill (the free-carport bug, S11–S14, wearing the board's + // clothes). Mutate the LIVE world — this is its last use in the suite — + // and the equality above must break, or it never could have. + const stripped = c.w.anchors.filter((a) => a.collateral === 'carport'); + assert(stripped.length >= 2, + `only ${stripped.length} anchor(s) carry the carport key — the trap has lost its reach`); + for (const a of stripped) a.collateral = null; + const mine = failureEnvelope(c.w); + assertEq(mine.total, 25, `the stripped envelope should read gnome-only $25, got $${mine.total}`); + assert(mine.total !== exposureOf(c.sj).total, + 'the harnesses still agree with the carport unreachable — the cross cannot fail'); + }); } diff --git a/web/world/js/weather.js b/web/world/js/weather.js index 502c144..fc8fdaa 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -23,6 +23,30 @@ export { const kmh = (ms) => ms * 3.6; const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}–${fmt(b.hi)}`); +// SPRINT16 gate 3.3's word map, lifted to module scope in S17 so the honesty +// gate below audits THE map the card prints from — a predicate checking a copy +// of the vocabulary would agree with the copy forever, which is the exact drift +// this repo keeps getting bitten by. Words map hail.size units (1.0 ≈ a 1.5 cm +// stone): storm_03's 0.7 reads "pea" (its own comment's word), the soaker's +// 0.45 "fine pea", the wild night's 1.3 and the ice night's 1.4 read "golf +// balls" — the sizes cloth stops dead. +const stoneWord = (v) => (v < 0.6 ? 'fine pea' : v <= 0.9 ? 'pea' : v <= 1.2 ? 'marble' : 'golf ball'); + +/** + * Where the stone vocabulary stops being honest. "golf ball" is the map's + * biggest word and it is open-ended upward — a storm whose stones measure 2.6 + * would still print "golf ball stones", underselling ice the way the numbers + * are forbidden to. The numeric bands stay truthful at any size (band() can't + * lie), but the card leads with the WORD, and a word that rules out what + * actually falls is the same ambush as a band that does. 2.0 is where the line + * sits: at 1.0 ≈ 1.5 cm, 2.0 ≈ 3 cm is real golf-ball territory, so the word + * holds all the way up to it; past it we are in cricket-ball country the map + * has no word for. Shipped max is the ice night's 1.4 — real headroom, hard + * ceiling. A storm above this is unofferable until the vocabulary grows a word + * for it, and that is a wording sprint, not a data tweak. + */ +export const STONE_WORD_CEILING = 2.0; + /** * How vague a night reads when it is `nightsOut` nights away — the week IS the * forecast horizon, so tonight is exact (0) and the far end of the week is as @@ -88,11 +112,8 @@ export function forecastLines(def, lead = 0) { // them; this owns the wording, same split as every other line here). The // stone is the fabric bet's whole argument: pea hail rattles through a // 2 mm weave, anything bigger cannot — so the size word is what tells a - // player which fabric tonight wants, before a dollar is spent. Words map - // hail.size units (1.0 ≈ a 1.5 cm stone): storm_03's 0.7 reads "pea" (its - // own comment's word), the soaker's 0.45 "fine pea", the wild night's 1.3 - // and the ice night's 1.4 read "golf balls" — the sizes cloth stops dead. - const stoneWord = (v) => (v < 0.6 ? 'fine pea' : v <= 0.9 ? 'pea' : v <= 1.2 ? 'marble' : 'golf ball'); + // player which fabric tonight wants, before a dollar is spent. The word map + // itself is module-scope now (S17) — the offer-honesty gate audits it. const sz = f.hail.size; const stones = f.hail.chance === 'none' ? '' : ( stoneWord(sz.lo) === stoneWord(sz.hi) @@ -117,6 +138,92 @@ export function forecastLines(def, lead = 0) { }; } +/** + * SPRINT17 gate 1 — CAN THE FORECAST DESCRIBE THIS STORM HONESTLY? + * + * The board's rule, stated in A's seam contract and enforced here: *a storm the + * forecast can't describe honestly doesn't get offered.* An offer card leads + * with my lines — if those lines can't tell the truth about a def, the def has + * no business on the board, however winnable the pairing is. c.test walks + * NIGHTS ∪ POOL through this, so a new pool entry (D's yard, arc-4 seeds) + * meets the gate at integration without anyone remembering to ask. + * + * Same contract shape as validateStorm: {ok, errors}, errors in English, + * recomputed FROM THE DEF — nothing here trusts the caller's provenance, so a + * hand-edited def fails on its own merits (the checkEnvelope rule). + * + * What has TEETH here, disclosed plainly (README: an assert that cannot fail + * is decoration): + * · validateStorm — structural lies (a windchange the dirCurve never + * delivers, overlapping gusts, a flatlining tail) really do fail. + * · the stone vocabulary ceiling — a def with stones past STONE_WORD_CEILING + * really does fail (the icenight at hail.size 2.6 is the negative control). + * · finiteness of the measured stats — a curve that produces NaN fails. + * What is STRUCTURAL and rides along as regression armour, not as a live gate: + * band-contains-truth and lead-0 exactness are guaranteed by forecastFor's + * band() today (lo=min(v,·), hi=max(v,·), width→0 at lead 0) — those loops can + * only fail on the day someone re-plumbs band(), which is precisely the day + * they should. Disclosed here and in THREADS rather than dressed up as + * coverage. + * + * @param {object} def parsed storm JSON + * @param {string} [name] + * @returns {{ok: boolean, errors: string[]}} + */ +export function forecastHonest(def, name = def?.name ?? 'storm') { + const errors = []; + const v = validateStorm(def, name); + if (!v.ok) errors.push(...v.errors); + if (!def || typeof def !== 'object' || !v.ok) return { ok: false, errors }; + + // The truth must be measurable before it can be told. + const s = stormStats(def); + for (const k of ['sustained', 'gustPeak', 'rainPeak', 'rainPeakMmPerHour', + 'hailPeak', 'hailSeconds', 'hailSize']) { + if (!Number.isFinite(s[k])) { + errors.push(`${name}: stormStats.${k} is not finite — no honest line can be printed from it`); + } + } + + // The vocabulary must reach the stone. Numbers band honestly at any size; + // the WORD is the card's opening argument and it must not undersell ice. + if (s.hailSeconds > 0 && s.hailSize > STONE_WORD_CEILING) { + errors.push(`${name}: hail.size ${s.hailSize} is past the stone vocabulary's ceiling ` + + `(${STONE_WORD_CEILING}) — "${stoneWord(s.hailSize)}" would undersell what falls; ` + + 'grow the word map before offering this storm'); + } + + // Recompute the band promises from the def (structural today — see header). + const contains = (b, val, what, lead) => { + if (val == null || b == null) return; + if (!(Number.isFinite(b.lo) && Number.isFinite(b.hi))) { + errors.push(`${name}: ${what} band at lead ${lead} is not finite`); + } else if (val < b.lo - 1e-9 || val > b.hi + 1e-9) { + errors.push(`${name}: ${what} band [${b.lo}, ${b.hi}] at lead ${lead} rules out the truth ${val}`); + } + }; + for (const lead of [0, 0.5, 1]) { + const f = forecastFor(def, lead); + contains(f.sustained, s.sustained, 'sustained', lead); + contains(f.gustPeak, s.gustPeak, 'gustPeak', lead); + contains(f.rain, s.rainPeak, 'rain', lead); + contains(f.rainMmPerHour, s.rainPeakMmPerHour, 'rainMmPerHour', lead); + contains(f.hail.seconds, s.hailSeconds, 'hail.seconds', lead); + contains(f.hail.size, s.hailSize, 'hail.size', lead); + contains(f.changeAt, s.changeAt, 'changeAt', lead); + if (lead === 0) { + for (const [what, b] of [['sustained', f.sustained], ['gustPeak', f.gustPeak], ['rain', f.rain]]) { + if (b.lo !== b.hi) errors.push(`${name}: lead-0 ${what} band [${b.lo}, ${b.hi}] hedges — tonight is exact`); + } + if (s.hailSeconds > 0 && f.hail.chance === 'none') { + errors.push(`${name}: hails for ${s.hailSeconds.toFixed(1)}s but tonight's card says "none"`); + } + } + } + + 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.