Lane C S17 gate 1 + 1.2: the honesty gate on the board, and the exposure cross
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
dd69a2cf6c
commit
f029274940
73
THREADS.md
73
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
|
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
|
papers but nothing stops you spending past it. **B — that's your half and the seam is
|
||||||
posted.**
|
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.
|
||||||
|
|||||||
@ -14,10 +14,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as THREE from '../../vendor/three.module.js';
|
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 { 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';
|
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
|
// 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,
|
// 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"
|
// 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 `
|
`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`);
|
+ `(${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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,30 @@ export {
|
|||||||
const kmh = (ms) => ms * 3.6;
|
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)}`);
|
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
|
* 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
|
* 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
|
// 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
|
// 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
|
// 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
|
// player which fabric tonight wants, before a dollar is spent. The word map
|
||||||
// hail.size units (1.0 ≈ a 1.5 cm stone): storm_03's 0.7 reads "pea" (its
|
// itself is module-scope now (S17) — the offer-honesty gate audits it.
|
||||||
// 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');
|
|
||||||
const sz = f.hail.size;
|
const sz = f.hail.size;
|
||||||
const stones = f.hail.chance === 'none' ? '' : (
|
const stones = f.hail.chance === 'none' ? '' : (
|
||||||
stoneWord(sz.lo) === stoneWord(sz.hi)
|
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
|
// 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/.
|
// 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.
|
// import.meta.url is right under both, and under whatever Lane A does next.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user