HardYards/tools/site_audit/sweep.selftest.js
type-two e1e14018bf Lane B S13: THE AUDIT REWRITE — the audit predicts the garden, on C's corrected picture
The scoring quantity is the FLOWN garden state now, not static cover%.
New gardenfly.js: windForSite (C's shared builder — venturi + shelters, one
copy in the repo) → real attach (throws on D's skipped-attach trap) → skyfx
exposure → garden.js, per candidate line; cover% demoted to a labelled
geometry diagnostic. audit.html flies every affordable line + bare bed and
judges the site's pinned separation block on the block's own storm; audit.mjs
(node) keeps fast winnability, gains p5 in its verified dump, and points at
the browser for garden truth.

On C's correction, three structural adoptions:
- windForSite everywhere (three harnesses independently mis-built site wind;
  no fourth copy);
- LIVE anchors via the re-pointable wind proxy — the audit's own frozen-sway
  remap was C's landmine 2 wearing my file's name (audit.html:69);
- the MARGIN rule (AUDIT.MARGIN = 0.15, one copy): every row prices twice —
  $hw holds vs $cleanHw with >=15% headroom — and only clean lines are
  winners; verdict code 'marginal-only' when the budget can only buy the
  knife edge (D's 39.8-TATTERED wild night, C's dead 91.9 headline).

Also game-true flight: the phantom 12s calm settle is GONE from the sweep
(commit->attach->storm is one keypress; the settle skewed every storm sample
12 s off the authored curve — SailRig samples wind at its INTERNAL clock).
That skew is not academic: it is a.test's separation-flight harness too, and
gardenfly.selftest now pins BOTH chains (game-true 63.8 tattered vs skewed
68.4 'full' on the pinned p5 line — the disagreement is flagged to A in
THREADS, not overruled here). The pinned recipe also sweeps regardless of the
18-45 band (it is 45.9 m² — the band predates p5; flagged to A).

Selftest 356/0/0 on the scratch merge (b+a+c@45bdc2d). Mutation-checked:
MARGIN=0 reddens the margin test (node); dropping siteDef from gardenfly's
windForSite call reddens the venturi test (browser, exact test named).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:50:15 +10:00

153 lines
8.3 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('the margin rule: a corner inside 15% of its effective rating is not a clean win', () => {
// SPRINT13, C's residual: even a corrected bench under-reads the real UI's
// peaks by ~5-15%, so "any corner within ~15% of rating breaks in the game".
// The sweep must therefore refuse to call a knife-edge line a winner — the
// wild-night 91.9-FULL-with-q4-at-1.24-vs-1.2 headline died of exactly this
// (D's UI: 39.8 TATTERED). Craft a hint that leaves the best steel ~8%
// headroom on a1's measured peak: still priced, still "holds" on paper,
// and the verdict must refuse to bless it. Delete the marginal logic from
// sweep.js and this goes red on the verdict code.
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');
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})`);
assert(row.marginal.some((c) => c.id === 'a1'), 'a1 must land in row.marginal');
assert(row.affordable && !row.clean, 'the line is affordable but must NOT be clean');
assert(!lied.verdict.ok && lied.verdict.code === 'marginal-only',
`the only affordable line is marginal — verdict must say so, got ${lied.verdict.code}/${lied.verdict.ok}`);
});
export const SWEEP_TESTS = TESTS;