From ff7c1f7985614f576904d9caf47eca6c8538c383 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 16:16:29 +1000 Subject: [PATCH] Lane B S14 gate 2.3: pin the editor's scored wind to the game's played wind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two chains built independently — GAME (loadSite -> createWorld -> dress -> windForSite) and EDITOR (siteClone-shaped object -> buildScoringWorld -> windForSite) — asserted EXACTLY equal at t=30, speed and vector, at every probe on both shipped yards. Exactly, not nearly: a tolerance is where the funnel-off bug hid, and it moved the S13 headline 91.5 -> 39.8. The mutation checks caught my own probe TWICE before this went green, which is the only reason it is worth anything: 1. I proposed backyard_01 in THREADS. It declares venturi: [] — no funnel to lose, so a funnel-off regression could never turn it red. 2. So I moved to site_02 — and its BED is 6.08 m from a radius-5 throat. Starving that yard of its venturi moved the bed probe by 0.000 m/s. Both versions passed their equality assert and both were worthless. Measured sensitivity on site_02/earlybuster at t=30 (funnel on/off, trees present/hidden): bed ( 0, 1) 12.452 dV +0.000 dT +0.000 <- blind, not a pin site throat (-6, 0) 15.910 dV +4.948 dT +0.000 <- funnel tripwire near tr1 ( 7,-1) 8.593 dV +0.000 dT -4.891 <- shelter tripwire Mutation-checked: starving the editor clone of its venturi turns the @throat equality red (plus both funnel asserts), while bed/tree stay green exactly as the table predicts. Also recorded as a pin, because it is load-bearing for anyone reading the score card: the corner block's funnel does NOT reach its garden bed. It is aimed at the rigging zone (the site JSON says so: q1 is 3.5 m inside the radius), so on that yard the funnel is a hardware-load story, not a garden-exposure one. Selftest 374 passed / 0 failed / 0 skipped (362 baseline + 12). --- tools/site_audit/scorecard.selftest.js | 284 +++++++++++++++++++++++++ web/world/js/tests/b.test.js | 7 + 2 files changed, 291 insertions(+) create mode 100644 tools/site_audit/scorecard.selftest.js diff --git a/tools/site_audit/scorecard.selftest.js b/tools/site_audit/scorecard.selftest.js new file mode 100644 index 0000000..febe4ee --- /dev/null +++ b/tools/site_audit/scorecard.selftest.js @@ -0,0 +1,284 @@ +/** + * 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; +} diff --git a/web/world/js/tests/b.test.js b/web/world/js/tests/b.test.js index 4d3a978..a721706 100644 --- a/web/world/js/tests/b.test.js +++ b/web/world/js/tests/b.test.js @@ -28,6 +28,7 @@ import { SAIL_TESTS } from '../sail.selftest.js'; import { RIGGING_TESTS } from '../rigging.selftest.js'; import { SWEEP_TESTS } from '../../../../tools/site_audit/sweep.selftest.js'; import { buildGardenflyTests } from '../../../../tools/site_audit/gardenfly.selftest.js'; +import { buildScorecardTests } from '../../../../tools/site_audit/scorecard.selftest.js'; /** @param {import('../testkit.js').Suite} t */ export default async function run(t) { @@ -42,4 +43,10 @@ export default async function run(t) { // precedent). run() is async now — runAll awaits it. const gardenflyTests = await buildGardenflyTests(); for (const [name, fn] of gardenflyTests) t.test(`site_audit: ${name}`, fn); + // SPRINT14 gate 2.3 (co-owned with C): the editor's scored wind and the + // game's played wind are the same number, exactly, at one probe and one + // second. Same async-prelude shape — the two chains are built here and the + // asserts read the captured measurements. + const scorecardTests = await buildScorecardTests(); + for (const [name, fn] of scorecardTests) t.test(name, fn); }