C measured my proposed pin and it could not have failed. Converging rather than
landing a second pin that disagrees. Knobs are now C's:
site_02_corner_block / storm_02_wildnight / throat (-6,y,0) / t=60.0 / exact ===
Three wrong answers before this, all the same failure:
1. backyard_01 has venturi: [] — nothing to detect. (I found this one.)
2. site_02's BED is funnel-blind, Δ 0.0000 m/s. (Both of us, independently.)
3. t=30 on the wildnight is worth 0.44%. I MISSED THIS ONE: my throat
measurement read a healthy Δ +4.948 m/s and I trusted it, but that was on
storm_03b_earlybuster, not the wildnight the pin flies. A sensitivity
measured on one storm says nothing about another. The mutation check I was
proud of would have passed on the wrong storm.
Adopted C's vacuity guard and its 25% threshold — one number, not two. It is
the check that would have caught my proposal on its own: mutation asks 'did it
move', the guard asks 'is the funnel worth enough here that a wiring bug could
not hide in the rounding'.
Game side now imports main.js's createWindRouter instead of retyping its two
wiring lines (C's correction). My old version built BOTH sides with
windForSite, so it could only have caught a bad input, never a router that
routed wrong.
CONVERGENCE RECEIPT — my chain reads C's numbers to the decimal:
t=60 throat editor 47.38 game 47.38 exact === true funnel-off 31.60 = 33.3%
t=30 throat on 18.55 off 18.46 = 0.44% (C's finding, reproduced)
What my half adds over C's: C pins windForSite (the builder) against the game;
this pins buildScoringWorld — the path SCORE IT actually runs — against the
same router. Three chains, one number.
Kept the garden bed as a SECOND probe on C's explicit offer: what the sail
shades is what the audit is about, and equality there is a real claim even
though the funnel is not what decides it. Labelled as carrying no funnel
tripwire, because it cannot.
Mutation-checked: setting t back to 30 reds the guard and both tripwires with
the exact diagnostic (0.44%, 18.46 -> 18.55) while the EQUALITY asserts stay
green — which is precisely C's argument, reproduced on my own chain.
Selftest 375 passed / 0 failed / 0 skipped.
344 lines
19 KiB
JavaScript
344 lines
19 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';
|
|
|
|
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.');
|
|
}]);
|
|
|
|
// ── 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;
|
|
}
|