HardYards/tools/site_audit/scorecard.selftest.js
type-two 1bcfcdad67 Lane B S17 gates 0.3 + 2: the card names its cloth, the session speaks for the client
GATE 0.3 — THE CHARTER, SAID OUT LOUD (D's soaker finding, A consulted on
wording and their every-card rule adopted). The audit always flew shade cloth
and never said so; on the soaker that silence read 'WINNABLE over an all-DEAD
garden — the night is broken'. Now: AUDIT.POROSITY is the one charter knob
(sweep.js + gardenfly.js both read it), scorecard.fabricCharter() names the
fabric and whether tonight's stones pass its weave (same hailBlockFor the sim
charges), and the SCORE IT header prints 'fabric · shade cloth (sweep
charter)' on EVERY card — plus, on leaky-storm nights, A's wording: the
fabric is the bet, and the audit does not place it.

GATE 2 — CLIENT CONSTRAINTS: A's shapes (seam contract, built against as
posted), this lane's teeth.
· rigging.js: validateNightConstraint (checked enum, both ends of the seam),
  setConstraints (survives reset like _startBudget; [] is the only clear).
  The house ban refuses the pick with the CLIENT'S WORDS as the ticker
  reason; the cap refuses at SPEND time on every path (rig / cycle / set /
  spares / fabric), exact at the boundary, refunds always free — and commit
  is belt-and-braces: a caller who walked around the doors gets a THROW.
  The cap covers the whole invoice, spares included — the client queries the
  invoice, not the hardware line.
· sweep.js: applyNightConstraints, ONE function both drivers share — bans
  shrink the candidate list (a card that recommends a forbidden line is the
  card lying), the cap re-prices affordable/clean without touching a load.
· scoreSite carries constraints/budget/constrainedOut; the card prints the
  client's terms next to funnel and fabric, and tells 'no geometry' from
  'client forbids all of it'.
· spent getter now measures against _startBudget (the re-banked session was
  measuring spend against the default bank).

Selftest 485/0/0 in the browser (474 + exactly the 11 added: 1 sail, 4
session, 2 charter, 4 audit-constraints). Wiring line for A filed in THREADS:
session.setConstraints(week.job?.constraints ?? []) at the night boundary.
2026-07-21 02:45:49 +10:00

547 lines
32 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, fabricCharter } from './scorecard.js';
import { auditSweep, auditSweepAsync, AUDIT } from './sweep.js';
import { hailBlockFor } from '../../web/world/js/weather.core.js';
import { START_BUDGET } from '../../web/world/js/contracts.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.`);
}]);
// ── SPRINT17 gate 0.3: the fabric charter is named, and named RIGHT ──────
// D's soaker finding: the card said WINNABLE over an all-DEAD cloth garden
// and nothing named the charter. These pin the disclosure's DATA against
// real storm defs — the soaker (fine stones, the one night the charter is
// the wrong bet) must read leaks:true, the icenight (1.4 fists of ice that
// no weave passes) must read leaks:false, and a dry storm must never warn.
// The wording is the card's; the truth of the flag is pinned here.
{
const soaker = await loadStorm('storm_06_soaker');
const icenight = await loadStorm('storm_02b_icenight');
tests.push(['gate 0.3: the charter names a real fabric, and it is the cloth the sweep flies', () => {
const c = fabricCharter(soaker);
assert(c.fabric.id === 'cloth' && c.fabric.porosity === AUDIT.POROSITY,
`gate 0.3: the charter resolved to '${c.fabric.id}' porosity ${c.fabric.porosity} — the card `
+ `would name a fabric the sweep does not fly (AUDIT.POROSITY ${AUDIT.POROSITY}). If the `
+ 'charter knob moved, rigging.js FABRIC must carry an entry at the new porosity or the '
+ 'card degrades to a raw number.');
assert(hailBlockFor(soaker.hail.size, AUDIT.POROSITY) < 1,
'gate 0.3 vacuity guard: the soaker\'s stones no longer pass the charter weave — the '
+ 'leaks pin below would be testing a night that cannot leak. Re-pick the fixture storm.');
}]);
tests.push(['gate 0.3: soaker leaks (fine stones pass the weave), icenight does not, dry night silent', () => {
assert(fabricCharter(soaker).leaks === true,
`gate 0.3: the soaker (hail.size ${soaker.hail.size}) reads leaks:false — the one card `
+ 'the disclosure exists for would not carry it. hailBlockFor and the charter porosity '
+ 'no longer agree with C\'s weave ruling.');
assert(fabricCharter(icenight).leaks === false,
`gate 0.3: the icenight (hail.size ${icenight.hail.size}) reads leaks:true — stones an `
+ 'order of magnitude over the weave aperture do not pass it, and a warning on every '
+ 'card teaches the player to ignore the one that matters.');
assert(fabricCharter({ hail: null }).leaks === false && fabricCharter(null).leaks === false,
'gate 0.3: a storm with no hail (or no storm at all) warns about a leak — the flag must '
+ 'mean "tonight\'s stones pass the weave", never "the weave has holes in principle".');
}]);
}
// ── SPRINT17 gate 2: the audit honours the night's constraints ───────────
// A card that recommends a forbidden line is the card lying — so the sweep
// itself refuses banned steel and prices against the client's cap, and these
// pin it on a fixture where both bites are provably non-vacuous. Shapes are
// A's seam contract verbatim; enforcement asserts for the SESSION live in
// rigging.selftest.js — this is the SCORE IT half of the same gate.
{
// YARD5 with the fifth anchor as HOUSE steel — same geometry, one family
// change, so every delta below is the constraint and nothing else.
const YARD5H = [
{ 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: 'house', pos: { x: 0, y: 3.9, z: 4 } },
].map((a) => ({ ...a, sway: () => a.pos }));
const HOUSE_BAN = { kind: 'noAnchorFamily', family: 'house',
label: 'nothing attached to the house',
says: 'Nothing goes on the house. Not a bracket, not a screw — we\'ve done that once.' };
const sweepArgs = { anchors: YARD5H, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [] };
const baseH = auditSweep({ ...sweepArgs });
const emptyCon = auditSweep({ ...sweepArgs, constraints: [] });
const bannedH = auditSweep({ ...sweepArgs, constraints: [HOUSE_BAN] });
tests.push(['gate 2: constraints:[] is byte-identical to no constraints at all', () => {
assert(sweepJSON(emptyCon) === sweepJSON(baseH),
'gate 2: an empty constraint list changed the sweep — the unconstrained path is not '
+ 'the default path, and every historical number is quietly a different claim.');
assert(emptyCon.budget === START_BUDGET && emptyCon.constrainedOut === 0,
'gate 2: an unconstrained sweep reports a cap or a drop that did not happen');
}]);
tests.push(['gate 2: the house ban removes every line touching house steel — and provably removed something', () => {
assert(baseH.cands.some((c) => c.ids.includes('a5')),
'gate 2 vacuity guard: no unconstrained candidate touches a5 — the ban below would be '
+ 'measuring nothing. Fix the fixture, not the assert.');
assert(bannedH.constrainedOut > 0, 'gate 2: the ban dropped nothing on a yard with house candidates');
assert(bannedH.cands.length + bannedH.constrainedOut === baseH.cands.length,
'gate 2: dropped + kept != total — the filter is inventing or eating candidates');
for (const c of bannedH.cands) {
assert(!c.ids.includes('a5'),
`gate 2: candidate ${c.ids.join(',')} survived the house ban with a5 in it — `
+ 'the card would recommend a line the session refuses to rig, i.e. the card lying');
}
for (const r of [...bannedH.winners, ...bannedH.marginalWinners]) {
assert(!r.ids.includes('a5'), `gate 2: recommended line ${r.ids.join(',')} touches banned steel`);
}
}]);
tests.push(['gate 2: the cap re-prices affordability without touching a single load', () => {
// The cap is derived from the fixture's own price list so the test
// tracks the physics instead of hardcoding tonight's dollars — and so
// it can never go vacuous: when the yard's clean lines differ in price
// the cap sits at the cheapest (keeping it, excluding the rest); when
// they all cost the same (this symmetric fixture's actual shape, the
// first run of this test proved it) the cap sits $1 under, and must
// exclude every clean line.
assert(baseH.winners.length >= 1, 'gate 2 vacuity guard: no clean line to cap — re-fixture');
const prices = [...new Set(baseH.rows.filter((r) => r.cleanHw != null).map((r) => r.cleanHw))]
.sort((a, b) => a - b);
const capVal = prices.length >= 2 ? prices[0] : prices[0] - 1;
const over = baseH.rows.filter((r) => r.cleanHw != null && r.cleanHw > capVal);
assert(over.length >= 1, 'gate 2 vacuity guard: the derived cap excludes nothing — unreachable by construction, so the derivation regressed');
const capped = auditSweep({ ...sweepArgs, constraints: [{ kind: 'budgetCap', cap: capVal,
label: `client caps the rig at $${capVal}`, says: 'Cheapest option that does the job.' }] });
assert(capped.budget === capVal, `gate 2: cap $${capVal} not echoed (got ${capped.budget})`);
assert(capped.winners.length === baseH.winners.length - over.filter((o) => baseH.winners.some((w) => w.ids.join(',') === o.ids.join(','))).length,
`gate 2: ${capped.winners.length} winner(s) under the cap — the cap must remove exactly the clean lines it cannot buy`);
for (const r of capped.winners) {
assert(r.cleanHw <= capVal, `gate 2: winner ${r.ids.join(',')} at $${r.cleanHw} sold over a $${capVal} cap`);
}
for (const o of over) {
const twin = capped.rows.find((r) => r.ids.join(',') === o.ids.join(','));
assert(twin && !twin.clean,
`gate 2: ${o.ids.join(',')} ($${o.cleanHw} clean) still reads clean under a $${capVal} cap`);
assert(twin.cleanHw === o.cleanHw && twin.hw === o.hw,
`gate 2: the cap moved a PRICE on ${o.ids.join(',')} — the client caps the invoice, `
+ 'never the physics; loads and tiers must be byte-identical');
}
return `cap $${capVal} (${prices.length >= 2 ? 'cheapest of ' + prices.length + ' prices' : 'all clean lines priced $' + prices[0] + ', capped under'}) — ${over.length} line(s) priced out, loads untouched`;
}]);
tests.push(['gate 2: a ban that empties the yard says no-cover honestly, with the drop counted', () => {
const allBanned = auditSweep({ ...sweepArgs, constraints: [{ kind: 'noAnchorFamily', family: 'post',
label: 'nothing on the posts', says: 'Those posts are heritage-listed, believe it or not.' }] });
assert(allBanned.cands.length === 0 && allBanned.verdict.code === 'no-cover',
'gate 2: banning every rideable family did not empty the sweep');
assert(allBanned.constrainedOut === baseH.cands.length,
`gate 2: emptied yard reports ${allBanned.constrainedOut} dropped, expected ${baseH.cands.length}`
+ 'the card cannot tell "no geometry" from "client forbids all of it" without this number');
}]);
}
return tests;
}