HardYards/tools/site_audit/sweep.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

201 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.js — the winnability sweep, shared by BOTH front-ends. [Lane B, SPRINT10]
*
* There is exactly one copy of "is this site winnable" and this is it. audit.mjs
* (node, fast, but blind to GLB-dressed anchors) and audit.html (browser, reads
* the fully dressed createWorld(site).anchors) both import auditSweep and only
* differ in how they GET the anchors and how they PRINT the result. A tool built
* to catch reimplemented-formula drift must not carry two copies of its own math.
*
* Pure given its inputs: hand it anchors (world.anchors themselves, ideally —
* live sway matters, see below), the bed rect, the storm def and the SITE def,
* and it flies the same attach→storm chain the game flies and returns ranked
* rows + a verdict. No I/O, no process, no DOM.
*
* SPRINT13: winnability rows carry the margin rule now (AUDIT.MARGIN) — a
* corner priced inside 15% of its effective rating holds on this bench and
* "breaks in the game" (C's residual, cause unfound), so every row prices
* twice: `hw` (holds) and `cleanHw` (holds with margin), and only clean lines
* are winners. The garden side of the audit lives in gardenfly.js (browser).
*/
import { SailRig, orderRing } from '../../web/world/js/sail.js';
import { windForSite } from '../../web/world/js/weather.js';
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
/** Audit knobs, in one place so both front-ends and any future site agree. */
export const AUDIT = {
BAND: { lo: 18, hi: 45 }, // A's a.test rigging band, m²
MIN_COVER: 0.25, // A's "shades the bed" bar
SPARE_COST: 15, // a $15 spare is the difference between a repair and a prayer
/**
* C's margin rule (SPRINT13 correction): even a corrected bench under-reads
* the real UI's peaks by ~515% on site_02 (cause unfound, in the pool).
* Until it's found, a corner priced within this fraction of its effective
* rating "breaks in the game" — the bench held q4 at 1.24-vs-1.2 and read
* 91.9 FULL; the real game pushed it over and shipped D's 39.8 TATTERED.
* A line with a marginal corner is flagged, never sold as a clean PASS.
*/
MARGIN: 0.15,
};
// SPRINT13: SETTLE_S / PRE_GUST_S / CALM_STORM are GONE, with the phantom
// settle they parameterised. In the real game the rig does not exist before
// commit — commit→attach→storm is one keypress (the rig.t fix's own words) —
// so the attach transient flies under STORM wind and its loads count. The old
// 12 s calm settle + resetPeaks measured a chain the game never flies, and
// also skewed every storm sample 12 s off the authored curve (C measured both
// on the bench and removed its copy the same day).
/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */
export function areaOf(q) {
const r = orderRing(q);
let a = 0;
for (let i = 0, j = r.length - 1; i < r.length; j = i++) a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z);
return Math.abs(a / 2);
}
/**
* Cheapest hardware that holds a corner at `peakN` newtons off an anchor with
* `ratingHint` hint, or null if the shop can't at any price.
*
* SPRINT12: sail.js fails a corner on `load > hw.rating * anchor.ratingHint`
* (A's ruling — the anchor is a failure point, not just the steel), so the
* hardware that HOLDS is the cheapest h with `h.rating * hint >= peak`, i.e.
* `h.rating >= peak / hint`. An audit that keeps pricing on bare hw.rating
* flies a different game than ships: on the corner block it would sell you a
* $5 carabiner for a beam that now lets go at 0.22 of it.
*/
export const tierFor = (peakN, ratingHint = 1) =>
HARDWARE.find((h) => h.rating * ratingHint >= peakN) || null;
/**
* Sweep every bed-covering quad and price the cheapest holding line.
* @param {object} o
* @param {Array} o.anchors world.anchors THEMSELVES where possible (their
* sway closures carry the tree's real movement — a
* static remap froze the gum tree and under-read
* every tr1 corner, C's landmine 2), or resolved
* { id, type, pos, sway, ratingHint } for synthetic
* yards. Omit ratingHint and the anchor is priced
* at full hardware strength (hint 1), which is a
* LIE for any GLB-dressed anchor (fascia 0.35,
* carport beam 0.22). Hand this the dressed truth.
* @param {object} o.bed garden bed rect { x, z, w, d }
* @param {object} o.stormDef the storm JSON to fly
* @param {object} [o.siteDef] the SITE json — its wind.venturi is half the
* yard's weather (three harnesses learned this the
* hard way); preferred over `venturi`
* @param {Array} [o.venturi] bare funnel list, for synthetic yards with no
* site JSON (sweep.selftest). Ignored if siteDef given.
* @param {function} [o.use] the yard's wind-proxy re-pointer (C's bench
* pattern) — called with the sweep's wind so live
* tree-sway closures sample the storm being flown
* @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best } }}
*/
export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null }) {
// 1. every quad, in the rigging band, that shades the bed
const cands = [];
for (let a = 0; a < anchors.length; a++) for (let b = a + 1; b < anchors.length; b++)
for (let c = b + 1; c < anchors.length; c++) for (let d = c + 1; d < anchors.length; d++) {
const q = [anchors[a], anchors[b], anchors[c], anchors[d]];
const area = areaOf(q);
if (area < AUDIT.BAND.lo || area > AUDIT.BAND.hi) continue;
let rig;
try {
rig = new SailRig({ anchors, gridN: 10 }).attach(q.map((x) => x.id), Array(4).fill(HARDWARE[2]), 1.0);
} catch { continue; } // degenerate ring
const cover = rig.coverageOver(bed, { x: 0, y: 1, z: 0 });
if (cover >= AUDIT.MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover });
}
// The site's PINNED separation line sweeps regardless of the band: A's
// gate-1.4 recipe (p1+p2+p3+p5) is 45.9 m² against the band's 45 — the band
// is an audit heuristic calibrated before p5 existed, and an audit that
// cannot see the site's own ruled-best line is auditing a different yard.
// Flagged `pinned` so front-ends can say why it's there; band-vs-pin
// reconciliation is A's (posted in THREADS).
const pinIds = siteDef?.separation?.line?.map((l) => l.anchor);
if (pinIds && !cands.some((c) => pinIds.every((id) => c.ids.includes(id)))) {
const q = pinIds.map((id) => anchors.find((a) => a.id === id)).filter(Boolean);
if (q.length === 4) {
try {
const rig = new SailRig({ anchors, gridN: 10 }).attach(pinIds, Array(4).fill(HARDWARE[2]), 1.0);
cands.push({ ids: pinIds, area: areaOf(q), cover: rig.coverageOver(bed, { x: 0, y: 1, z: 0 }), pinned: true });
} catch { /* a pin naming unriggable anchors will fail loudly in a.test; nothing to add here */ }
}
}
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
// 2. peak corner loads, flown the way the GAME flies them. Every clause here
// is a bug some harness shipped:
// · windForSite — the one shared wind builder (C's helper): venturi from
// the SITE def + tree shelters, byte-for-byte main.js's site-load
// wiring. THREE harnesses independently mis-built site wind before it
// existed (this tool's Sprint-11 funnel-off audit, C's bench reading
// def.wind.venturi off the STORM def, D's first garden harness) — a
// fourth copy of the wiring is how there's a fifth bug.
// · `use` re-points the caller's world-wind proxy so LIVE tree-sway
// closures sample this sweep's storm (frozen sway under-read q4 by
// 0.22 kN on the $80 line — C's landmine 2).
// · NO calm settle, NO resetPeaks: commit→attach→storm is one keypress
// in the real game, so the attach transient flies under storm wind
// and its loads count (cheap steel genuinely dies "at the settle" —
// that's the storm's opening seconds, not a separate phase).
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
use?.(wind);
const rows = [];
for (const cnd of cands) {
// shade cloth (porosity 0.30): the fabric a competent player takes into a windy night
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
// Price each corner against its anchor's EFFECTIVE strength. c.anchor is the
// resolved anchor handed in above; `?? 1` mirrors sail.js for bare fixtures.
//
// TWO prices per corner (C's margin rule):
// `tier` cheapest hardware that HOLDS the measured peak — what the
// old audit sold, and what a player gambling the knife edge
// actually buys;
// `cleanTier` cheapest hardware that holds it WITH ≥ MARGIN headroom —
// the price the margin rule trusts (demand ÷ (1 MARGIN)).
// A corner whose `tier` sits inside the margin band is `marginal`: it
// holds on this bench and "breaks in the game" (the residual's working
// rule). The row's clean price is what closing that gap costs.
const tiers = rig.corners.map((c) => {
const hint = c.anchor.ratingHint ?? 1;
const tier = tierFor(c.peakLoad, hint);
return { id: c.anchorId, peak: c.peakLoad, hint, tier,
cleanTier: tierFor(c.peakLoad / (1 - AUDIT.MARGIN), hint),
headroom: tier ? +(1 - c.peakLoad / (tier.rating * hint)).toFixed(3) : null };
});
const unholdable = tiers.filter((c) => !c.tier);
const marginal = tiers.filter((c) => c.tier && c.headroom < AUDIT.MARGIN);
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
const cleanHw = tiers.every((c) => c.cleanTier)
? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null;
rows.push({ ...cnd, tiers, unholdable, marginal, hw, cleanHw,
total: hw + AUDIT.SPARE_COST,
affordable: !unholdable.length && hw <= START_BUDGET,
clean: cleanHw != null && cleanHw <= START_BUDGET });
}
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
// winners are lines the budget can buy at the CLEAN price (≥15% headroom on
// every corner); a line only affordable on knife-edge steel is the wild-night
// 91.9-FULL illusion and gets its own bucket + verdict code so no front-end
// can print PASS over it by accident.
const winners = rows.filter((r) => r.clean).sort((a, b) => a.cleanHw - b.cleanHw);
const marginalWinners = rows.filter((r) => r.affordable && !r.clean);
return {
cands, rows, winners, marginalWinners,
verdict: winners.length
? { ok: true, code: 'pass', best: winners[0] }
: marginalWinners.length
? { ok: false, code: 'marginal-only', best: marginalWinners[0] }
: { ok: false, code: 'unaffordable', best: rows[0] },
};
}