From 8e23bf470286201ae8051e8dacd457a10feeed75 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:14:53 +1000 Subject: [PATCH] Land leadFor(): the job sheet's per-night forecast lead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forecastLines(def, lead) always took the param; what was missing was the map from "this night is N nights away" to a lead. leadFor(nightsOut, weekNights) is that map, linear onto forecastFor's documented 0..1 domain so tonight is exact and the week's far end is lead 1. Tomorrow at a five-night week reads 0.25 — 75% confidence, sustained band ~±8%. hud.js is Lane A's; this only exports the argument. The new assert pins the property that makes a band safe to print: it RESOLVES. lo rises and hi falls monotonically onto the truth as the night approaches, because the centre-wander is the same seeded draw at every lead — so tomorrow's band always contains tonight's and the card never jumps. Verified against a mutation that reseeds per lead (caught). Its containment half is honestly decoration today and is labelled as such in the test: 0.6-of-half-width wander means c±w never crosses v, so deleting the clamps does not make it fail. Kept as a contract guard for a future rewrite of band(), not counted as coverage of today's code. Selftest 298/0/0. --- web/world/js/tests/c.test.js | 60 +++++++++++++++++++++++++++++++++++- web/world/js/weather.js | 24 +++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index f717292..03e7140 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -16,7 +16,7 @@ import * as THREE from '../../vendor/three.module.js'; import { assert, fixedLoop } from '../testkit.js'; import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js'; -import { loadStorm, createWind, forecastLines } from '../weather.js'; +import { loadStorm, createWind, forecastLines, forecastFor, leadFor } from '../weather.js'; import { createDebris } from '../debris.js'; import { createSkyFx, RainShadow } from '../skyfx.js'; import { SailRig, HARDWARE } from '../sail.js'; @@ -414,6 +414,64 @@ export default async function run(t) { 'the gentle storm advertised hail it does not have'); }); + // SPRINT11 gate 2: the job sheet shows TOMORROW, and tomorrow becomes tonight. + // A band that widens or jumps as the night approaches is a card that lied + // once — so the RESOLVING is the contract, not just the width. + // + // What each half is worth, established by mutation (SPRINT11, see THREADS): + // · NESTING is the live one. It rests on the wander being the SAME seeded + // draw at every lead — reseed `r` per lead and this goes red immediately. + // · CONTAINMENT is structural under today's band(): the wander is 0.6 of the + // half-width, so c±w never crosses v, and the min/max clamps are belt-and- + // braces. Removing the clamps alone does NOT make it fail. It is kept as a + // contract guard for a future rewrite of band() (a percentile model, say), + // where it stops being free — not as proof of today's code. + t.test('the forecast band resolves as the night approaches: nests, and never sheds the truth', () => { + const keys = [ + ['sustained', (s) => s.sustained], + ['gustPeak', (s) => s.gustPeak], + ['rain', (s) => s.rainPeak], + ['rainMmPerHour', (s) => s.rainPeakMmPerHour], + ]; + for (const [name, def] of Object.entries(storms)) { + for (const [k, truthOf] of keys) { + let prev = null; + // walk the week backwards: the far end -> tonight + for (let i = 40; i >= 0; i--) { + const f = forecastFor(def, i / 40); + const b = f[k]; + const truth = truthOf(f.truth); + assert(b.lo <= truth + 1e-9 && b.hi >= truth - 1e-9, + `${name}.${k} @lead ${i / 40}: band ${b.lo}–${b.hi} rules out the truth ${truth}`); + if (prev) { + assert(b.lo >= prev.lo - 1e-9 && b.hi <= prev.hi + 1e-9, + `${name}.${k} @lead ${i / 40}: band ${b.lo.toFixed(3)}–${b.hi.toFixed(3)} is not inside ` + + `the vaguer ${prev.lo.toFixed(3)}–${prev.hi.toFixed(3)} — it jumped instead of resolving`); + } + prev = b; + } + // and it must ARRIVE: tonight is the truth, not merely a narrow band + const exact = forecastFor(def, 0); + assert(exact[k].lo === exact[k].hi, + `${name}.${k}: tonight should be exact, got ${exact[k].lo}–${exact[k].hi}`); + } + } + }); + + t.test('leadFor: tonight is exact, the far end of the week is vague, past it clamps', () => { + assert(leadFor(0, 5) === 0, 'tonight must be lead 0 — the job sheet is not a guess'); + assert(leadFor(4, 5) === 1, "the week's far end must be lead 1"); + assert(leadFor(1, 5) === 0.25, `tomorrow @5 nights should be 0.25, got ${leadFor(1, 5)}`); + assert(leadFor(9, 5) === 1, 'beyond the week must clamp, not exceed 1'); + assert(leadFor(-1, 5) === 0, 'a night already survived must not go negative'); + assert(leadFor(1, 1) === 1, 'a one-night week has no far end: anything but tonight is a guess'); + // the point of the param: tomorrow reads hedged, tonight does not + const def = storms.storm_02_wildnight; + assert(!/–/.test(forecastLines(def, leadFor(0, 5)).wind), 'tonight should not hedge'); + assert(/–/.test(forecastLines(def, leadFor(1, 5)).wind), + `tomorrow should hedge, got "${forecastLines(def, leadFor(1, 5)).wind}"`); + }); + t.test('every storm in data/storms/ loads and validates', () => { // loadStorm throws on invalid, so reaching here with all of them is the pass assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load'); diff --git a/web/world/js/weather.js b/web/world/js/weather.js index 5b70807..ed94749 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)}`); +/** + * 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 + * vague as this game forecasts (1). Linear, because that maps the week onto + * forecastFor's documented 0..1 domain with nothing invented in between. + * + * For a five-night week: tomorrow reads at lead 0.25 — 75% confidence, a + * sustained band about ±8% wide. Visibly hedged, still worth rigging to. + * + * Lane A: this is the job sheet's missing argument. `forecastLines(def, 0)` is + * tonight; `forecastLines(tomorrowDef, leadFor(1, wk.nights))` is tomorrow's + * band. Nights beyond the week (or a one-night week) clamp to 1 and 0. + * + * @param {number} nightsOut 0 = tonight, 1 = tomorrow + * @param {number} [weekNights] nights in the week (5) + * @returns {number} lead, 0..1 + */ +export function leadFor(nightsOut, weekNights = 5) { + if (!(nightsOut > 0)) return 0; + const horizon = weekNights - 1; + if (!(horizon > 0)) return 1; // a one-night week: anything but tonight is a guess + return Math.min(1, nightsOut / horizon); +} + /** * The forecast card's two stat lines, already worded — DESIGN.md's partial * information made visible. Lane A owns the card; this owns the numbers, so the