/** * scorecard.selftest.js — GATE 2.3: the editor scores the wind the game plays. * [Lane B, SPRINT14; co-owned with Lane C] * * SPRINT13's whole lesson, made into an assert. Three harnesses independently * rebuilt site wind by hand and each got it wrong in a different way (funnel * off twice, frozen tree sway once); the fix was `windForSite()`, one door, and * the rule that no tool builds site wind again. Sprint 14 adds a fourth place * that scores a yard — A's editor — so the rule needs a tripwire rather than a * promise. This is it: * * the wind the EDITOR scores == the wind the GAME plays, * at one probe point, at one storm second, EXACTLY. * * Exactly, not nearly. A tolerance is precisely where the funnel-off bug hid: * it moved the Sprint-13 headline from 91.5 FULL to 39.8 TATTERED, so any * epsilon loose enough to feel "safe" is loose enough to hide the bug this * assert exists to catch. Two chains that agree agree to the last bit. * * ── The two chains, built independently on purpose ───────────────────────── * * GAME loadSite(name) → createWorld → dress() → windForSite(storm, site, * world.anchors). This is main.js's site-load wiring, verified * line-for-line: main.js:511-512 calls setVenturi(siteDef.wind.venturi) * then setSheltersFromTrees(anchors.filter(type==='tree')), which is * what windForSite does and in that order. * * EDITOR siteClone-shaped object → buildScoringWorld() → dress() → * windForSite(storm, clone, scoringWorld.anchors). The editor's yard * is an object that was never a file, and its scoring world is built * fresh (A's page renders on a calm stub whose sway closures must * never reach a score — C's landmine 2 wearing a new hat). * * The clone goes through a JSON round-trip because that is what `siteClone()` * and the export actually produce: if canonicalisation ever dropped or * reshaped something wind-relevant, the editor would score a yard whose * weather nobody could play. This file does not import `editor.js` — the pin * is about the two WIND CHAINS, and importing A's page would make lane/b's * selftest unable to run on lane/b. * * ── WHERE the probe goes, and how two wrong answers got caught ───────────── * * I proposed `backyard_01`, bed corner, t=30 in THREADS. Then I measured it, * and the measurement killed the proposal twice over: * * 1. backyard_01 declares `venturi: []`. A funnel-off regression cannot turn * that pin red, because there is no funnel to lose. * 2. So I moved to site_02 — and its BED cannot see the funnel either. The * throat sits at (-6, 0) with radius 5; the bed's NW corner is (0, 1), * which is 6.08 m away. Outside. Starving that yard of its venturi moved * the bed probe by exactly 0.000 m/s. * * Both versions PASSED their equality assert and both were worthless. That is * the entire failure mode of this repo's four harness bugs, reproduced in an * assert whose job was to catch it — and the only reason it got caught here is * that the mutation checks were written at the same time as the pin, not after. * * Measured sensitivity on site_02 / earlybuster at t = 30 (funnel on vs off, * trees present vs hidden): * * probe speed Δ funnel Δ trees * bed ( 0, 1) 12.452 +0.000 +0.000 ← blind, do not pin here * throat (-6, 0) 15.910 +4.948 +0.000 ← the funnel pin * near tr1 ( 7,-1) 8.593 +0.000 −4.891 ← the shelter pin * * So the gate asserts EQUALITY at every probe (the bed included — it is what * the score is about, and equality there is still a real claim), and puts each * MUTATION at the probe that can actually feel it. * * A finding worth more than the pin, filed to THREADS: the corner block's * funnel does not reach its garden bed at all. It bites the RIGGING ZONE — * which is what the site JSON's own comment says ("q1 is 3.5 m away, inside * the radius"). The funnel on that yard is a HARDWARE-LOAD story, not a * garden-exposure story. * * ── Vacuous-pass guard ───────────────────────────────────────────────────── * * `a === b` also passes when both are 0, or both are garbage from a chain that * silently did nothing. Every equality assert here is paired with a liveness * assert (finite, non-trivial) and every claim of sensitivity is paired with a * mutation assert. An assert that cannot fail is documentation, and * documentation cannot fail. */ import * as THREE from '../../web/world/vendor/three.module.js'; import { createWorld, loadSite } from '../../web/world/js/world.js'; import { loadStorm, windForSite } from '../../web/world/js/weather.js'; import { buildScoringWorld } from './scorecard.js'; const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; /** * GATE 2.3's pinned knobs. Proposed to C in THREADS 2026-07-18 and open to * being moved — but moved in one place, by both lanes, not drifted. * * second t = 30.0 — mid-storm, clear of the attach transient at the front * (where S13's 12 s settle skew lived) and of any duration edge. * probes named points per yard. `bed` is derived from `world.gardenBed` * rather than typed as a literal — it is bed geometry, and the wind * over the bed is what the score is about. `throat` and `tree` are * the site's own weather furniture, and they are where the mutation * checks bite (see the header table). */ export const PIN = { t: 30.0, probeY: 0 }; /** Probe sets per yard. `sensitive` names what each probe is allowed to claim. */ const PROBES = { backyard_01: { bed: 'gardenBed', tree: { x: -9, z: 2 }, // t1, the gum whose sway is dynamic load }, site_02_corner_block: { bed: 'gardenBed', throat: { x: -6, z: 0 }, // the venturi's own centre — Δ +4.948 at t=30 tree: { x: 7, z: -1 }, // tr1 — Δ −4.891 at t=30 }, }; const vec = (p) => new THREE.Vector3(p.x, PIN.probeY, p.z); const resolveProbes = (name, world) => Object.fromEntries( Object.entries(PROBES[name]).map(([k, v]) => [k, vec(v === 'gardenBed' ? { x: world.gardenBed.x, z: world.gardenBed.z } : v)])); /** The GAME chain: main.js's site load, byte for byte. */ async function gameChain(siteName, stormDef) { const site = await loadSite(siteName); const world = createWorld(new THREE.Scene(), { wind: stubProxy(), site }); await world.dress(); return { site, world, wind: windForSite(stormDef, site, world.anchors) }; } /** The EDITOR chain: a siteClone-shaped object through the scoring world. */ async function editorChain(siteName, stormDef) { // What EDITOR.siteClone() hands over: a deep clone that has been through the // canonical export shape. JSON round-trip stands in for the key ordering — // the pin is that this survives as the SAME WEATHER. const clone = JSON.parse(JSON.stringify(await loadSite(siteName))); const built = await buildScoringWorld(clone); assert(built.dressed, `gate 2.3: scoring world for ${siteName} did not dress (${built.dressError}) — ` + 'an undressed yard has no fascia hints and is a different game'); return { site: clone, built, wind: windForSite(stormDef, clone, built.anchors) }; } function stubProxy() { return { sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4), speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0, gustTelegraph: () => null, eventsBetween: () => [], setSheltersFromTrees() {}, setVenturi() {}, }; } export async function buildScorecardTests() { const tests = []; // ── the two shipped yards ──────────────────────────────────────────────── const cases = [ { site: 'backyard_01', storm: 'storm_02_wildnight', funnel: false }, { site: 'site_02_corner_block', storm: 'storm_03b_earlybuster', funnel: true }, ]; const measured = {}; for (const c of cases) { const stormDef = await loadStorm(c.storm); const game = await gameChain(c.site, stormDef); const editor = await editorChain(c.site, stormDef); const gProbes = resolveProbes(c.site, game.world); const eProbes = resolveProbes(c.site, editor.built.world); measured[c.site] = { stormDef, editor, probes: eProbes }; tests.push([`gate 2.3: ${c.site} — both chains agree where the yard's probes ARE`, () => { for (const k of Object.keys(gProbes)) { assert(gProbes[k].equals(eProbes[k]), `gate 2.3: the chains disagree about the '${k}' probe on ${c.site} — ` + `game (${gProbes[k].x},${gProbes[k].z}) vs editor (${eProbes[k].x},${eProbes[k].z}). ` + 'A pin on two different points is not a pin.'); } }]); for (const k of Object.keys(gProbes)) { const gSpeed = game.wind.speedAt(gProbes[k], PIN.t); const eSpeed = editor.wind.speedAt(eProbes[k], PIN.t); const gVec = game.wind.sample(gProbes[k], PIN.t, new THREE.Vector3()); const eVec = editor.wind.sample(eProbes[k], PIN.t, new THREE.Vector3()); tests.push([`gate 2.3: ${c.site} @${k} — editor-scored wind === game-played wind (t=${PIN.t})`, () => { // liveness first: `a === b` on two zeroes is not agreement, it is silence assert(Number.isFinite(gSpeed) && gSpeed > 1, `gate 2.3: the GAME chain read ${gSpeed} m/s at '${k}' — that is not a storm, it is a ` + 'broken chain, and an equality assert against it would pass vacuously'); assert(gSpeed === eSpeed, `gate 2.3 FAILED on ${c.site} @${k}: the editor would score a wind the game never plays.\n` + ` game ${gSpeed}\n editor ${eSpeed}\n delta ${eSpeed - gSpeed}\n` + 'Both sides go through windForSite(); if they differ, the INPUTS differ — check the ' + 'venturi list survived the clone, and that the scoring world dressed.'); assert(gVec.x === eVec.x && gVec.y === eVec.y && gVec.z === eVec.z, `gate 2.3: speeds matched but DIRECTIONS did not on ${c.site} @${k} — ` + `game (${gVec.x},${gVec.y},${gVec.z}) vs editor (${eVec.x},${eVec.y},${eVec.z}). ` + 'A sail cares which way the wind blows, so scalar agreement alone is not the pin.'); }]); } } // backyard_01 carries no funnel, and the pin must SAY so rather than let a // future reader assume the funnel-off tripwire covers both yards. tests.push(['gate 2.3: backyard_01 declares no venturi (so site_02 carries the funnel tripwire)', () => { const v = measured['backyard_01'].editor.site.wind?.venturi ?? []; assert(v.length === 0, `backyard_01 now declares ${v.length} venturi. That is not a failure — but the funnel ` + 'mutation check below lives on site_02 BECAUSE this yard had none, and if this yard grew ' + 'weather the pin should cover it too. Update the probe table.'); }]); // ── the mutation checks, on the yard that HAS weather ──────────────────── // These are what stop the pin above from being decoration. Each one breaks an // input that a real harness has actually broken, and demands the number move. const fun = measured['site_02_corner_block']; const baseWind = () => windForSite(fun.stormDef, fun.editor.site, fun.editor.built.anchors); /** Minimum move a mutation must produce to count as "this probe can feel it". * Not `!== 0`: a floating-point hair would satisfy that while the probe sat * effectively blind. Measured deltas here are ~4.9 m/s, so 1.0 is a floor * with two orders of headroom, not a threshold anyone tuned to pass. */ const MIN_MOVE = 1.0; tests.push(['gate 2.3 mutation: dropping the venturi MOVES the throat (funnel-off tripwire)', () => { const probe = fun.probes.throat; const before = baseWind().speedAt(probe, PIN.t); const starved = { ...fun.editor.site, wind: { venturi: [] } }; const after = windForSite(fun.stormDef, starved, fun.editor.built.anchors).speedAt(probe, PIN.t); assert(Math.abs(before - after) >= MIN_MOVE, `gate 2.3 mutation FAILED: starving site_02 of its venturi moved the THROAT probe by ` + `${(before - after).toFixed(4)} m/s (${before} → ${after}). This probe cannot SEE the ` + 'funnel, so the equality pin above would stay green through exactly the funnel-off bug it ' + 'exists to catch — the one that moved the S13 headline 91.5 → 39.8. This already happened ' + 'twice while writing this file (backyard_01 has no funnel; site_02\'s BED is 6.08 m from a ' + 'radius-5 throat). Re-measure and move the probe; do not relax the assert.'); }]); tests.push(['gate 2.3 mutation: hiding the trees MOVES the tree probe (shelter tripwire)', () => { // setSheltersFromTrees filters on type; anchors that no longer say "tree" // register no shelter. The frozen-tree landmine's cousin — the wind arrives // unsheltered and the yard is easier than the one that ships. const probe = fun.probes.tree; const before = baseWind().speedAt(probe, PIN.t); const noTrees = fun.editor.built.anchors.map((a) => ({ ...a, type: a.type === 'tree' ? 'post' : a.type })); const after = windForSite(fun.stormDef, fun.editor.site, noTrees).speedAt(probe, PIN.t); assert(Math.abs(before - after) >= MIN_MOVE, `gate 2.3 mutation FAILED: hiding every tree moved the tree probe by ` + `${(before - after).toFixed(4)} m/s (${before} → ${after}) — the ANCHOR half of ` + 'windForSite is not reaching this probe, so a harness that forgot tree shelters would ' + 'pin green.'); }]); tests.push(['gate 2.3: the corner block\'s funnel does NOT reach its garden bed (recorded, not a bug)', () => { // Not a defect — it is what the site JSON's own comment describes ("q1 is // 3.5 m away, inside the radius"): the funnel is aimed at the RIGGING // ZONE, so it is a hardware-load story rather than a garden-exposure one. // Pinned because it is load-bearing for anyone reading the score card: if // this ever changes, the bed probe silently becomes funnel-sensitive and // every garden number on that yard moves with it. const probe = fun.probes.bed; const before = baseWind().speedAt(probe, PIN.t); const starved = { ...fun.editor.site, wind: { venturi: [] } }; const after = windForSite(fun.stormDef, starved, fun.editor.built.anchors).speedAt(probe, PIN.t); assert(before === after, `The funnel now reaches site_02's garden bed: ${before} → ${after} with the venturi removed. ` + 'That is a real design change (the bed sat 6.08 m from a radius-5 throat and felt nothing). ' + 'Every garden number on this yard just moved — re-baseline the audit and tell A/D.'); }]); // ── the clone is the same weather as the file ──────────────────────────── tests.push(['gate 2.3: the export clone carries the funnel (site_02 venturi survives the round-trip)', () => { const v = fun.editor.site.wind?.venturi ?? []; assert(v.length === 1, `gate 2.3: the cloned site_02 has ${v.length} venturi, not 1 — the editor would score, and ` + 'export, a corner block with no weather. The funnel lives in the SITE def; if the clone ' + 'loses it the whole yard silently becomes an easier one.'); assert(v[0].gain === 1.5 && v[0].axis === 2.1, `gate 2.3: the cloned venturi reads gain ${v[0].gain} axis ${v[0].axis}, not gain 1.5 axis 2.1.`); }]); return tests; }