/** * 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() → **main.js's own * `createWindRouter`**, wired by main.js's own two lines * (loadSiteInto: setVenturi then setSheltersFromTrees). Importing the * router rather than retyping those lines is C's correction to my * first draft, and it matters: my version built BOTH sides with * `windForSite`, so it could only ever have caught a bad INPUT, never * a router that routed wrong. * * 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). This is * the path SCORE IT actually runs, which is what my half adds over * C's: C pins the BUILDER against the game, this pins the thing the * BUTTON runs against the game. Three chains, one number. * * 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: C's knobs, and the three wrong answers before them ── * * **This pin uses Lane C's knobs, not the ones I proposed.** C co-owns gate * 2.3, measured my proposal, and moved all three; converging beats landing a * second pin that disagrees, so the numbers below are theirs. * * site site_02_corner_block the only yard with a shipped venturi * storm storm_02_wildnight (mine, kept) * probe (-6, y, 0) the authored throat centre — site geometry, * not a magic number; speedAt ignores y * second t = 60.0 the alignment plateau, where the funnel is * worth about a third of the answer * match exact `===` * * I proposed backyard_01 / bed corner / t=30. All three were wrong, and it is * worth writing down because they are the SAME failure three times: * * 1. backyard_01 declares `venturi: []`. There, "setVenturi called with an * empty list" and "setVenturi never called" are the same number — the * regression the pin exists to catch is invisible. (Found while measuring; * it is why the pin moved to site_02 at all.) * 2. site_02's BED cannot see the funnel either — the throat's disc doesn't * reach it. Measured Δ 0.0000 m/s, independently by both of us. * 3. t = 30 is a second where the wildnight's direction does not line up with * the gap: C measured the funnel worth 0.4% at the throat there. **This one * I missed.** My throat measurement read a healthy Δ +4.948 m/s and I * trusted it — but that was on `storm_03b_earlybuster`, not on the * wildnight this pin flies. A sensitivity measured on one storm says * nothing about another, and I generalised across exactly that gap. The * mutation check I was proud of would have passed on the wrong storm. * * A pin at 0.4% passes with the funnel wired backwards. Hence the vacuity * guard below: it re-measures what the funnel is WORTH at the pin and fails * under 25% — the check that would have caught my proposal on its own. * * ── The bed is kept, as a SECOND probe, on C's offer ─────────────────────── * * C offered to carry the garden bed as a second probe rather than the one, and * I took it: what the sail SHADES is what the audit is ultimately about, and * equality there is a real claim even though the funnel is not what decides it. * It is labelled honestly and carries no funnel tripwire, because it cannot. A * second probe that measures a different thing is worth having; a first probe * that measures nothing is not. * * A finding worth more than the pin, which C reached independently: the corner * block's funnel does not reach its garden bed at all. It bites the RIGGING * ZONE — 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, and the score card should be read that way. * * ── 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, createWind, windForSite } from '../../web/world/js/weather.js'; // main.js's OWN router, not a retyped copy of its two wiring lines — C's // correction, and the right call: a pin that retypes the thing it pins agrees // with itself by construction. import { createWindRouter } from '../../web/world/js/main.js'; import { buildScoringWorld } from './scorecard.js'; import { auditSweep, auditSweepAsync } from './sweep.js'; const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; /** * GATE 2.3's pinned knobs — Lane C's, adopted. One copy, both lanes, no drift. * If either half moves these, it moves them here and says so in THREADS. */ export const PIN = { site: 'site_02_corner_block', storm: 'storm_02_wildnight', t: 60.0, probeY: 0, /** The funnel must be worth at least this much AT THE PIN or the pin is * decoration. C's threshold, shared rather than re-chosen. */ MIN_FUNNEL_SHARE: 0.25, }; /** Probe sets per yard. Each probe says what it is ALLOWED to claim. */ const PROBES = { site_02_corner_block: { // THE pin: the authored throat centre, where the funnel decides the answer throat: { x: -6, z: 0 }, // C's offered SECOND probe — what the sail shades. Funnel-blind by // geometry (Δ 0.0000), so it carries equality only, and says so. bed: 'gardenBed', tree: { x: 7, z: -1 }, // tr1 — the shelter half }, backyard_01: { bed: 'gardenBed', tree: { x: -9, z: 2 }, // t1, the gum whose sway is dynamic load }, }; 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 OWN router, wired by main.js's own two lines * (loadSiteInto — `setVenturi(siteDef.wind.venturi)` then * `setSheltersFromTrees(anchors.filter(type === 'tree'))`). */ async function gameChain(siteName, stormDef) { const site = await loadSite(siteName); const world = createWorld(new THREE.Scene(), { wind: stubProxy(), site }); await world.dress(); const inner = createWind(stormDef); const router = createWindRouter([inner]); router.use(inner); router.setVenturi(site.wind?.venturi ?? []); router.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree')); return { site, world, wind: router }; } /** 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 ──────────────────────────────────────────────── // The pinned case first (C's knobs). backyard_01 rides along because // equality on a funnel-less yard is still a real claim about the clone and // the dress — it just carries no funnel tripwire, and says so. const cases = [ { site: PIN.site, storm: PIN.storm }, { site: 'backyard_01', storm: 'storm_02_wildnight' }, ]; 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; // ── C's vacuity guard, adopted — threshold and all ─────────────────────── // The strongest of the three checks, and the one that would have caught my // proposal on its own. A mutation check asks "did the number MOVE"; this asks // "is the funnel worth ENOUGH here that a wiring bug couldn't hide in the // rounding". At t=30 on this storm the answer was 0.4%. tests.push([`GATE 2.3 guard: the funnel is worth ≥${(PIN.MIN_FUNNEL_SHARE * 100).toFixed(0)}% at the pinned probe/second`, () => { const probe = fun.probes.throat; const on = baseWind().speedAt(probe, PIN.t); const off = windForSite(fun.stormDef, { ...fun.editor.site, wind: { venturi: [] } }, fun.editor.built.anchors).speedAt(probe, PIN.t); const share = (on - off) / on; assert(Number.isFinite(share) && share >= PIN.MIN_FUNNEL_SHARE, `gate 2.3 GUARD FAILED: at (${probe.x},${probe.z}) t=${PIN.t} on ${PIN.storm} the funnel is ` + `worth ${(share * 100).toFixed(2)}% (${off.toFixed(2)} → ${on.toFixed(2)} m/s), under the ` + `${(PIN.MIN_FUNNEL_SHARE * 100).toFixed(0)}% floor. The equality pin is now decoration — it ` + 'would pass with setVenturi deleted. Do NOT lower this floor: re-measure and move the ' + 'second, the way C moved it off t=30 (0.4%) onto the alignment plateau.'); }]); 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 ' + 'THREE times on this pin (backyard_01 has no funnel; site_02\'s BED is 6.08 m from a ' + 'radius-5 throat; t=30 on the wildnight is worth 0.4%). 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 (2nd probe): 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.'); }]); // ── SPRINT15 gate 2.1: the chunked sweep is the SAME sweep ─────────────── // scoreSite runs auditSweepAsync now — same flights, but the candidate loop // yields so the editor page paints progress instead of freezing for 75 s. // The contract is that yielding is PURE: any yieldEvery produces numbers // byte-identical to the sync sweep. These asserts are the tripwire for // anyone who later threads state across candidates (a reused rig, a shared // accumulator) — the exact class of bug a chunk boundary would expose. // // Synthetic five-anchor yard (multiple candidates, so chunk boundaries land // MID-list), 8 s storm — cheap on purpose; the real-yard reproduction lives // in THREADS (site_02 + site_03 cards pinned before and after the refactor). const YARD5 = [ { id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } }, { id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } }, { id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } }, { id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } }, { id: 'a5', type: 'post', pos: { x: 0, y: 3.9, z: 4 } }, ].map((a) => ({ ...a, sway: () => a.pos })); const YARD5_BED = { x: 0, z: 0, w: 4, d: 4 }; const YARD5_STORM = { id: 'chunk_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14, gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 }, }; // Strip nothing, hide nothing: the whole result must survive comparison. const sweepJSON = (r) => JSON.stringify({ cands: r.cands, rows: r.rows, winners: r.winners, marginalWinners: r.marginalWinners, verdict: r.verdict, }); const syncResult = auditSweep({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [] }); const ticks = []; const chunk1 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [], yieldEvery: 1, onProgress: (p) => ticks.push({ ...p }) }); const chunk3 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [], yieldEvery: 3 }); tests.push(['gate 2.1: the yielding sweep === the sync sweep, byte for byte', () => { // vacuity guard first — a yard whose sweep found nothing would make the // equality below a comparison of two empty objects assert(syncResult.cands.length >= 3, `gate 2.1: the synthetic yard swept only ${syncResult.cands.length} candidate(s) — ` + 'not enough list for a chunk boundary to land mid-way; the purity assert is decoration. ' + 'Fix the fixture, not the assert.'); assert(sweepJSON(chunk1) === sweepJSON(syncResult), 'gate 2.1 FAILED: auditSweepAsync(yieldEvery 1) returned different numbers than auditSweep ' + 'on the same yard and storm. Yielding must be WHEN the page breathes, never WHAT gets ' + 'computed — some state is leaking across candidates.'); }]); tests.push(['gate 2.1: perturbing the chunk boundary moves NOTHING (yieldEvery 3 === yieldEvery 1)', () => { assert(sweepJSON(chunk3) === sweepJSON(chunk1), 'gate 2.1 FAILED: changing yieldEvery (1 → 3) changed the sweep\'s numbers. The chunk ' + 'boundary is a paint schedule, not an input — if moving it moves a number, a candidate ' + 'flight is reading something a previous chunk wrote.'); }]); tests.push(['gate 2.1: progress ticks fire, count monotonically, and reach the total', () => { const sweepTicks = ticks.filter((p) => p.phase === 'sweep'); assert(sweepTicks.length === syncResult.cands.length, `gate 2.1: expected one 'sweep' tick per candidate (${syncResult.cands.length}), got ` + `${sweepTicks.length} — a progress line that undercounts is the looks-wired-isn't ` + 'disease on the UI layer.'); sweepTicks.forEach((p, i) => { assert(p.done === i + 1 && p.total === syncResult.cands.length, `gate 2.1: tick ${i} read done=${p.done}/total=${p.total}, expected ${i + 1}/${syncResult.cands.length}`); }); }]); // ── 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; }