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:
parent
af0d64ffe3
commit
ff7c1f7985
284
tools/site_audit/scorecard.selftest.js
Normal file
284
tools/site_audit/scorecard.selftest.js
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user