HardYards/tools/site_audit/sweep.selftest.js
type-two 4718548c38 Lane B S18 gates 2+3+1: ⚠️ THE VARIABLE IS POROSITY; the clean boundary is DWELL; the knife
One landing, three gates and Lane C's clock finding. (Not split: sail.js,
rigging.js and balance.test.js each carry work for more than one gate and a
per-hunk split would have been fiction.)

GATE 2 — THE VARIABLE IS `porosity`, AND THE LIAR WAS THE GAME.
`rig.setFabric()` has exactly one call site, `RiggingSession.commit(rig)`, and
the shipped commit path never reaches it: the rigging UI's commit() calls
onCommit and main.js's rigSail goes straight to rig.attach(). So
session.commit(rig) runs in TESTS AND BENCHES ONLY, and the sim's rig (built
once at boot as `new SailRig({anchors})`, porosity defaulting to 0) has flown
the waterproof membrane every night of every playtest while the panel, the
SCORE IT card, the HUD row, the invoice and every audit number said shade
cloth. The F key moved the paperwork and never the physics.

    shade cloth 0.30 (what every card promised)  0/4 lost  p2 3.07  p3 1.78
    membrane 0      (what the sim actually flew)  2/4 lost  p2 3.48  p3 3.90

Row two is D's cold play AND the pin's own _playedReference (hp 55.7, p2 then
p3) to the decimal, reproduced in the shipped game. It also retires C's
"benches under-read the real UI by 5-15%, cause unfound": with the fabric held
equal, the bench and the FULL game chain agree to three decimals, two sites,
two storms. Debris hits on the sail: 0 — D's candidate ruled out by
measurement. Fixed on my side: commit() carries the fabric as onCommit's 4th
argument; dev_rigging.html picks it up, which is the shape of the one-line
pickup filed for A's rigSail.

GATE 3 — THE CLEAN BOUNDARY IS THE FUSE, NOT A PERCENTAGE. Same flight, two
corners, identical $15 shackles, same 3.2 kN rating:
    p1  3.528 kN = 10.3% OVER -> HELD  (over for 0.150 s)
    p2  3.493 kN =  9.2% OVER -> BROKE (over for 0.400 s)
p1 peaks HIGHER and survives, so "peak within X% of rating" is wrong at every
X. sail.js never used peak — it burns a 0.4 s overload fuse. OVERLOAD_SECS /
OVERLOAD_RECOVER are exported and priceCandidate replays them per corner per
tier, so the audit and the sim now answer "will it hold" with one arithmetic.
AUDIT.MARGIN stops deciding anything and says why in its own comment.

GATE 1 — THE RIG YOU DIDN'T BUILD, AND THE KNIFE. `_releaseCorner` is now the
one definition of a corner letting go (it was open-coded twice); on top of it:
`failCorner` for a corner that arrived down, `cutAway` for DESIGN.md line 165
emitting A's requested `{t, corners, reason}`, and `session.inherit()` which
adopts somebody else's rig for $0 and keeps it out of the refund — because
week.js pays back half of every intact corner, so counting a stranger's steel
as yours would have the ledger PUNISHING the player for every corner they cut.
Neither a cut nor an inherited corner emits `break`: warranty must not chase a
corner you chose to let go or one you never hung.

LANE C's CLOCK FINDING, fixed. `_substep` samples the wind at `this.t`, which
attach() zeroes — safe only while the caller's clock starts with it, exactly as
attach()'s own comment says. An emergency opens at t=30, so the cloth would fly
the storm's first 60 s under a sky flying its last 60. attach() now takes
{at, wind} (seeded, or genuinely pre-flown so the drape and the pond are real),
seedClock() is the late door, and `clockSkew` makes any future drift readable
instead of silent — measured 90 N vs 655 N for the same rig entering at t=0 vs
t=40, which is the size of what was hiding.

+15 asserts, all node-verified; the two gate-2 pins mutation-checked both ways.
2026-07-25 19:53:06 +10:00

193 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* sweep.selftest.js — the audit tool audits itself. [Lane B, SPRINT11]
*
* Same shape as sail.selftest.js / rigging.selftest.js: [name, fn] pairs, so one
* set of asserts runs under both Lane A's selftest.html (via js/tests/b.test.js)
* and plain node.
*
* WHY THIS FILE EXISTS, in one sentence: site_audit shipped SPRINT9 and SPRINT10
* with no test of its own, and in SPRINT11 it turned out to have been flying the
* corner block with the funnel switched OFF.
*
* The venturi lives in the SITE json; the sweep built its wind from the STORM def
* alone and never called setVenturi, which main.js:424 does at every site load.
* So the tool whose entire job is "catch a site that lies about its difficulty"
* was itself lying about the difficulty of the only site that has a venturi —
* under-reporting every corner load on the yard whose whole personality is the
* gap. It reported PASS with $20 lines. That is the SPRINT6 p1=7.4 kN failure
* inverted: there the tool called a fine site unriggable, here it called a mean
* site cheap. A tool built to catch drift must not be the thing that drifts.
*
* The assert below is written to FAIL if the setVenturi calls are removed from
* sweep.js: with the funnel dropped, the funnelled and unfunnelled sweeps
* collapse to the same peak loads and the strict inequality goes red.
*/
import { AUDIT, auditSweep } from './sweep.js';
import { HARDWARE } from '../../web/world/js/contracts.js';
const TESTS = [];
const test = (name, fn) => TESTS.push([name, fn]);
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
/**
* A deliberately synthetic yard, not site_02: four anchors around a bed, sized to
* sit inside AUDIT.BAND so exactly one quad sweeps. Synthetic because this test
* pins the SWEEP's plumbing, not the corner block's balance — site_02's numbers
* are C's to tune and would make this assert fail every time they moved.
*/
const ANCHORS = [
{ 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 } },
].map((a) => ({ ...a, sway: () => a.pos }));
const BED = { x: 0, z: 0, w: 4, d: 4 };
/** A storm blowing dead along +Z, and a funnel whose axis matches it. */
const STORM = {
id: 'sweep_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 },
};
/** Centred on the bed, wide enough to swallow it, aligned with the storm. */
const FUNNEL = [{ x: 0, z: 0, axis: Math.PI / 2, gain: 2.0, radius: 12, sharp: 1 }];
const peaksOf = (venturi) => {
const { rows } = auditSweep({ anchors: ANCHORS, bed: BED, stormDef: STORM, venturi });
assert(rows.length > 0, 'sweep selftest yard produced no candidate quad — fix the fixture, not the test');
return rows[0].tiers.map((c) => c.peak);
};
test('sweep honours the SITE venturi, not just the storm', () => {
const bare = peaksOf([]);
const funnelled = peaksOf(FUNNEL);
assert(bare.length === funnelled.length, 'the two sweeps disagree about corner count');
// Every corner must pull HARDER through the funnel. Strictly — an equal read
// is the exact bug: it means setVenturi never reached the wind.
for (let i = 0; i < bare.length; i++) {
assert(funnelled[i] > bare[i],
`corner ${i}: funnelled peak ${(funnelled[i] / 1000).toFixed(2)} kN is not above bare ` +
`${(bare[i] / 1000).toFixed(2)} kN — the site's venturi is not reaching the sweep's wind ` +
`(sweep.js must call wind.setVenturi, the way main.js does at every site load)`);
}
});
test('no venturi is a no-op — a site without a funnel is untouched', () => {
// The other half of the contract: backyard_01 ships "venturi": [], and this
// fix must not have moved a single number on it. Default arg == explicit [].
const omitted = peaksOf(undefined);
const empty = peaksOf([]);
for (let i = 0; i < empty.length; i++) {
assert(Math.abs(omitted[i] - empty[i]) < 1e-9,
`corner ${i}: omitting venturi read ${omitted[i]} but [] read ${empty[i]} — a funnel-less site must be byte-identical`);
}
});
test('pricing reads the ANCHOR, not just the steel — ratingHint reaches tierFor', () => {
// SPRINT12: sail.js fails a corner on rating × ratingHint, so the sweep must
// price hardware against the same product or it audits a nicer yard than
// ships — a fascia (0.35) or carport beam (0.22) corner would be sold a $5
// carabiner that the sim now snaps. Same synthetic yard, one anchor's hint
// dropped to 0.01: its corner's demand (peak / hint) leaves the shop's 6.5 kN
// ceiling entirely, so its tier must go to NONE while every OTHER corner and
// every PEAK stays byte-identical (the hint is a pricing fact, not physics —
// the sweep flies unbreakable audit hardware). Written to fail if the hint
// is dropped from sweep.js's tierFor call: the two runs collapse into one.
const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, venturi: [] }).rows[0];
const honest = sweep(ANCHORS);
const lied = sweep(ANCHORS.map((a) => (a.id === 'a1' ? { ...a, ratingHint: 0.01, sway: a.sway } : a)));
for (let i = 0; i < honest.tiers.length; i++) {
assert(Math.abs(honest.tiers[i].peak - lied.tiers[i].peak) < 1e-9,
`corner ${honest.tiers[i].id}: ratingHint moved a PEAK (${honest.tiers[i].peak} -> ${lied.tiers[i].peak}) — ` +
'the hint is a failure threshold, it must never touch the flown loads');
}
const a1 = lied.tiers.find((c) => c.id === 'a1');
const a1honest = honest.tiers.find((c) => c.id === 'a1');
assert(a1honest.tier, 'fixture drift: a1 must be holdable at hint 1 or this test asserts nothing');
assert(!a1.tier,
`a1 at hint 0.01 (demand ${(a1.peak / 0.01 / 1000).toFixed(1)} kN vs the 6.5 kN ceiling) still got ` +
`hardware ($${a1.tier?.cost}) — sweep.js is pricing bare hw.rating, not rating × ratingHint`);
const others = lied.tiers.filter((c) => c.id !== 'a1');
assert(others.every((c) => c.tier && c.tier.cost === honest.tiers.find((h) => h.id === c.id).tier.cost),
'a hint on a1 repriced a DIFFERENT corner — hints must be per-anchor');
assert(lied.unholdable.some((c) => c.id === 'a1'),
'an over-ceiling corner must land in unholdable, or the verdict lies');
});
test('SPRINT18 gate 3: the boundary is DWELL against the fuse, not a percentage of peak', () => {
// WHAT THIS REPLACES. SPRINT13 priced every corner twice — `tier` (peak <=
// rating) and `cleanTier` (peak <= rating × 0.85) — because C had measured the
// bench under-reading the real UI by 515% with "cause unfound", so 15% of
// taste stood in for it. Gate 2 found the cause (the shipped game never called
// `rig.setFabric`, so it flew the membrane while every card said shade cloth);
// with the fabric held equal the bench and the full game chain agree to three
// decimals. There is no residual left, so the pad is not covering anything.
//
// But the real reason a percentage had to go is that NO percentage can work.
// Measured on the wildnight pin, one flight, two corners on identical $15
// shackles (THREADS [B] SPRINT18 gate 3):
//
// p1 peak 3.528 kN / 3.20 = 10.3% OVER its rating -> HELD (dwell 0.150 s)
// p2 peak 3.493 kN / 3.20 = 9.2% OVER its rating -> BROKE (dwell 0.400 s)
//
// p1 goes HIGHER than p2 and survives. Ordering by peak is INVERTED against
// the outcome, so a rule of the form "peak within X% of rating" is wrong at
// every X: 15% condemns p1's perfectly good steel, 9% blesses p2's break.
// sail.js has never used peak — it fills an `overload` timer while a corner is
// over its rating, bleeds it at OVERLOAD_RECOVER, and lets go past
// OVERLOAD_SECS. `priceCandidate` replays exactly that, so the audit and the
// sim now answer "will it hold" with the same arithmetic.
//
// What this fixture can prove: a corner with 8% headroom is now CLEAN (its load
// never reaches the rating at all), where the old rule called it marginal —
// that IS the re-ruling. Take the dwell replay out of sweep.js and `cleanTier`
// falls back to a pad, `cleanHw` stops equalling `hw`, and this goes red.
const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, venturi: [] });
const honest = sweep(ANCHORS);
const a1h = honest.rows[0].tiers.find((c) => c.id === 'a1');
assert(a1h.tier, 'fixture drift: a1 must be holdable at hint 1');
assert(honest.verdict.ok && honest.verdict.code === 'pass',
'fixture drift: the honest yard must be a clean pass or this test asserts nothing');
assert(honest.rows[0].tiers.every((c) => c.dwell === 0),
`the honest yard burned fuse on ${honest.rows[0].tiers.filter((c) => c.dwell > 0).map((c) => c.id)}` +
'a yard nothing is over-loaded on must replay a dwell of exactly 0');
assert(honest.rows[0].cleanHw === honest.rows[0].hw,
`cleanHw $${honest.rows[0].cleanHw} != hw $${honest.rows[0].hw} on a yard where no corner ever reaches ` +
'its rating. That gap is the retired 15% pad — the audit is charging for steel the night cannot need');
const RATED = HARDWARE.at(-1);
const hint = a1h.peak / (RATED.rating * 0.92); // → best steel holds a1 with 8% headroom
const lied = sweep(ANCHORS.map((a) => (a.id === 'a1' ? { ...a, ratingHint: hint, sway: a.sway } : a)));
const row = lied.rows[0];
const a1 = row.tiers.find((c) => c.id === 'a1');
assert(a1.tier === RATED, `a1 at the crafted hint must price to the rated shackle, got ${a1.tier?.name}`);
assert(a1.headroom > 0 && a1.headroom < AUDIT.MARGIN,
`fixture: a1's headroom ${a1.headroom} must sit inside (0, ${AUDIT.MARGIN}) — the band the OLD rule condemned`);
assert(a1.dwell === 0,
`a1 burned ${a1.dwell}s of fuse at 8% headroom. Headroom means the load never reached the rating, so the ` +
'fuse must never start — if this fires, the replay is reading the wrong quantity');
assert(a1.cleanTier === RATED && row.clean,
`8% headroom must now read CLEAN (it did not, got clean=${row.clean}): the steel is simply stronger than ` +
'the night, and calling it a knife edge was the pad talking');
assert(lied.verdict.ok && lied.verdict.code === 'pass',
`the verdict must bless a line no corner is ever over, got ${lied.verdict.code}/${lied.verdict.ok}`);
// and the other side of the boundary: drop the ceiling UNDER the peak and the
// tier is refused outright — this fixture's gusts are seconds long, so any
// excursion outlasts the 0.4 s fuse and there is no timing to survive on.
const tooWeak = sweep(ANCHORS.map((a) => (a.id === 'a1'
? { ...a, ratingHint: a1h.peak / (RATED.rating * 1.05), sway: a.sway } : a)));
const weak = tooWeak.rows[0].tiers.find((c) => c.id === 'a1');
assert(!weak.tier && tooWeak.rows[0].unholdable.some((c) => c.id === 'a1'),
`a1 priced to ${weak.tier?.name} with its ceiling 5% UNDER the peak — on a gust that lasts seconds the ` +
'fuse burns through and the corner is simply unholdable');
return 'clean = the fuse never starts (8% headroom passes); over the ceiling on a long gust = unholdable';
});
export const SWEEP_TESTS = TESTS;