HardYards/tools/site_audit/scorecard.selftest.js
type-two b1d2d2a8e6 score: the sweep gets a heartbeat — chunked driver yields between flights, same numbers to the byte (S15 gate 2.1)
findCandidates/priceCandidate/judgeSweep extracted so the sync auditSweep and
the new auditSweepAsync are ONE copy of the math; scoreSite drives the async
path with onProgress ticks (sweep/fly/separation phases). Yield is a
MessageChannel task, not setTimeout — Chrome's intensive timer throttling
turned an occluded run into one flight per minute, measured. Three new
asserts, mutation-checked red-then-green in one sitting (impure chunk + a
swallowed tick = all three red with the intended diagnostics).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:19:44 +10:00

413 lines
23 KiB
JavaScript

/**
* 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() → **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). 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
* 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: C's knobs, and the three wrong answers before them ──
*
* **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.
*
* 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 `===`
*
* 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:
*
* 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.
*
* 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.
*
* ── The bed is kept, as a SECOND probe, on C's offer ───────────────────────
*
* 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 ─────────────────────────────────────────────────────
*
* `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, 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';
import { auditSweep, auditSweepAsync } from './sweep.js';
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
/**
* 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 = {
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. 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
},
};
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 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();
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. */
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 ────────────────────────────────────────────────
// 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: PIN.site, storm: PIN.storm },
{ site: 'backyard_01', storm: 'storm_02_wildnight' },
];
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;
// ── 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);
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 '
+ '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)', () => {
// 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 (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.
// 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.');
}]);
// ── SPRINT15 gate 2.1: the chunked sweep is the SAME sweep ───────────────
// scoreSite runs auditSweepAsync now — same flights, but the candidate loop
// yields so the editor page paints progress instead of freezing for 75 s.
// The contract is that yielding is PURE: any yieldEvery produces numbers
// byte-identical to the sync sweep. These asserts are the tripwire for
// anyone who later threads state across candidates (a reused rig, a shared
// accumulator) — the exact class of bug a chunk boundary would expose.
//
// Synthetic five-anchor yard (multiple candidates, so chunk boundaries land
// MID-list), 8 s storm — cheap on purpose; the real-yard reproduction lives
// in THREADS (site_02 + site_03 cards pinned before and after the refactor).
const YARD5 = [
{ id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } },
{ id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } },
{ id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } },
{ id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } },
{ id: 'a5', type: 'post', pos: { x: 0, y: 3.9, z: 4 } },
].map((a) => ({ ...a, sway: () => a.pos }));
const YARD5_BED = { x: 0, z: 0, w: 4, d: 4 };
const YARD5_STORM = {
id: 'chunk_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 },
};
// Strip nothing, hide nothing: the whole result must survive comparison.
const sweepJSON = (r) => JSON.stringify({
cands: r.cands, rows: r.rows, winners: r.winners,
marginalWinners: r.marginalWinners, verdict: r.verdict,
});
const syncResult = auditSweep({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [] });
const ticks = [];
const chunk1 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [],
yieldEvery: 1, onProgress: (p) => ticks.push({ ...p }) });
const chunk3 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [],
yieldEvery: 3 });
tests.push(['gate 2.1: the yielding sweep === the sync sweep, byte for byte', () => {
// vacuity guard first — a yard whose sweep found nothing would make the
// equality below a comparison of two empty objects
assert(syncResult.cands.length >= 3,
`gate 2.1: the synthetic yard swept only ${syncResult.cands.length} candidate(s) — `
+ 'not enough list for a chunk boundary to land mid-way; the purity assert is decoration. '
+ 'Fix the fixture, not the assert.');
assert(sweepJSON(chunk1) === sweepJSON(syncResult),
'gate 2.1 FAILED: auditSweepAsync(yieldEvery 1) returned different numbers than auditSweep '
+ 'on the same yard and storm. Yielding must be WHEN the page breathes, never WHAT gets '
+ 'computed — some state is leaking across candidates.');
}]);
tests.push(['gate 2.1: perturbing the chunk boundary moves NOTHING (yieldEvery 3 === yieldEvery 1)', () => {
assert(sweepJSON(chunk3) === sweepJSON(chunk1),
'gate 2.1 FAILED: changing yieldEvery (1 → 3) changed the sweep\'s numbers. The chunk '
+ 'boundary is a paint schedule, not an input — if moving it moves a number, a candidate '
+ 'flight is reading something a previous chunk wrote.');
}]);
tests.push(['gate 2.1: progress ticks fire, count monotonically, and reach the total', () => {
const sweepTicks = ticks.filter((p) => p.phase === 'sweep');
assert(sweepTicks.length === syncResult.cands.length,
`gate 2.1: expected one 'sweep' tick per candidate (${syncResult.cands.length}), got `
+ `${sweepTicks.length} — a progress line that undercounts is the looks-wired-isn't `
+ 'disease on the UI layer.');
sweepTicks.forEach((p, i) => {
assert(p.done === i + 1 && p.total === syncResult.cands.length,
`gate 2.1: tick ${i} read done=${p.done}/total=${p.total}, expected ${i + 1}/${syncResult.cands.length}`);
});
}]);
// ── 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;
}