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).
This commit is contained in:
type-two 2026-07-18 16:16:29 +10:00
parent af0d64ffe3
commit ff7c1f7985
2 changed files with 291 additions and 0 deletions

View File

@ -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;
}

View File

@ -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);
}