HardYards/tools/site_audit/scorecard.selftest.js
type-two ff7c1f7985 Lane B S14 gate 2.3: pin the editor's scored wind to the game's played wind
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).
2026-07-18 16:16:29 +10:00

285 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}