diff --git a/tools/site_audit/scorecard.selftest.js b/tools/site_audit/scorecard.selftest.js index febe4ee..e29e36c 100644 --- a/tools/site_audit/scorecard.selftest.js +++ b/tools/site_audit/scorecard.selftest.js @@ -19,17 +19,22 @@ * * ── 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. + * 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). + * 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 @@ -38,40 +43,55 @@ * 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 ───────────── + * ── WHERE the probe goes: C's knobs, and the three wrong answers before them ── * - * I proposed `backyard_01`, bed corner, t=30 in THREADS. Then I measured it, - * and the measurement killed the proposal twice over: + * **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. * - * 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. + * 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 `===` * - * 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. + * 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: * - * Measured sensitivity on site_02 / earlybuster at t = 30 (funnel on vs off, - * trees present vs hidden): + * 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. * - * 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 + * 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. * - * 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. + * ── The bed is kept, as a SECOND probe, on C's offer ─────────────────────── * - * 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. + * 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 ───────────────────────────────────────────────────── * @@ -84,36 +104,43 @@ 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 { 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'; 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). + * 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 = { t: 30.0, probeY: 0 }; +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. `sensitive` names what each probe is allowed to claim. */ +/** 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 }, - 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); @@ -121,12 +148,21 @@ 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. */ +/** + * 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(); - return { site, world, wind: windForSite(stormDef, site, world.anchors) }; + 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. */ @@ -153,9 +189,12 @@ 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: 'backyard_01', storm: 'storm_02_wildnight', funnel: false }, - { site: 'site_02_corner_block', storm: 'storm_03b_earlybuster', funnel: true }, + { site: PIN.site, storm: PIN.storm }, + { site: 'backyard_01', storm: 'storm_02_wildnight' }, ]; const measured = {}; @@ -223,6 +262,25 @@ export async function buildScorecardTests() { * 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); @@ -233,8 +291,9 @@ export async function buildScorecardTests() { + `${(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.'); + + '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)', () => { @@ -252,7 +311,7 @@ export async function buildScorecardTests() { + 'pin green.'); }]); - tests.push(['gate 2.3: the corner block\'s funnel does NOT reach its garden bed (recorded, not a bug)', () => { + 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.