HardYards/tools/site_audit/sweep.js
type-two b1d2d2a8e6 score: the sweep gets a heartbeat — chunked driver yields between flights, same numbers to the byte (S15 gate 2.1)
findCandidates/priceCandidate/judgeSweep extracted so the sync auditSweep and
the new auditSweepAsync are ONE copy of the math; scoreSite drives the async
path with onProgress ticks (sweep/fly/separation phases). Yield is a
MessageChannel task, not setTimeout — Chrome's intensive timer throttling
turned an occluded run into one flight per minute, measured. Three new
asserts, mutation-checked red-then-green in one sitting (impure chunk + a
swallowed tick = all three red with the intended diagnostics).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:19:44 +10:00

287 lines
15 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 }) {
const cands = findCandidates({ anchors, bed, siteDef });
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 = cands.map((cnd) => priceCandidate(cnd, { anchors, stormDef, wind }));
return judgeSweep(cands, rows);
}
/**
* Step 1 of the sweep, alone: every quad in the rigging band that shades the
* bed, plus the site's pinned separation line. Extracted (SPRINT15 gate 2.1)
* so an ASYNC driver can enumerate the work before doing it — a progress tick
* needs a denominator. Same code auditSweep always ran, one copy.
*/
export function findCandidates({ anchors, bed, siteDef = 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 */ }
}
}
return cands;
}
/**
* Fly ONE candidate and price its corners. Extracted (SPRINT15 gate 2.1) as
* the unit of chunked work: the async driver yields between calls to this so
* the page can paint, and because each call builds its own rig and flies the
* same wind at the same seconds, chunking cannot change a number — the
* scorecard selftest asserts exactly that (chunked === sync, to the byte).
*/
export function priceCandidate(cnd, { anchors, stormDef, wind }) {
// 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;
return { ...cnd, tiers, unholdable, marginal, hw, cleanHw,
total: hw + AUDIT.SPARE_COST,
affordable: !unholdable.length && hw <= START_BUDGET,
clean: cleanHw != null && cleanHw <= START_BUDGET };
}
/**
* Sort the priced rows and hand down the verdict — the tail of auditSweep,
* shared with the async driver. Sorts `rows` in place, exactly as auditSweep
* always has (Array.prototype.sort is stable, so chunked and sync drivers
* order ties identically).
*/
export function judgeSweep(cands, rows) {
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] },
};
}
/**
* auditSweep with a heartbeat — same candidates, same flights, same verdict,
* but the candidate loop yields to the event loop every `yieldEvery` flights
* so a page driving it repaints instead of freezing. [SPRINT15 gate 2.1]
*
* D's verdict on the 7582 s blocking score: "made the editor batch, not
* iterative — I scored once and shipped that run rather than tuning", and the
* worst part "is not the wait, it's not knowing whether it died". The fix is
* NOT a faster sim (the numbers must not move) — it is telling the truth about
* progress while the same work happens.
*
* PURITY IS THE CONTRACT: every number this returns must equal auditSweep's
* to the byte, for any yieldEvery ≥ 1. That holds by construction — each
* priceCandidate builds its own rig and flies a wind that is a pure function
* of (p, t) — and by assert (scorecard.selftest.js runs sync vs yieldEvery 1
* vs yieldEvery 3 on the same yard and demands identical JSON). If you add
* state that survives across candidates, that assert is the tripwire.
*
* @param {function} [o.onProgress] called ({ phase:'sweep', done, total })
* after every flight — cheap, DOM-free, caller renders it
* @param {number} [o.yieldEvery] flights per yield (macrotask); 1 = every flight
*/
export async function auditSweepAsync({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null,
onProgress = null, yieldEvery = 1 }) {
const cands = findCandidates({ anchors, bed, siteDef });
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
use?.(wind);
const rows = [];
for (let i = 0; i < cands.length; i++) {
rows.push(priceCandidate(cands[i], { anchors, stormDef, wind }));
onProgress?.({ phase: 'sweep', done: i + 1, total: cands.length });
if ((i + 1) % Math.max(1, yieldEvery) === 0) await yieldToEventLoop();
}
return judgeSweep(cands, rows);
}
/**
* One macrotask boundary — long enough for the browser to paint, nothing else.
*
* MessageChannel, NOT setTimeout(0), and it is load-bearing: Chrome clamps
* chained timers in hidden/occluded tabs (1 s, then 1/minute under intensive
* throttling), which turned a 72 s score into one flight per MINUTE the first
* time this ran in an occluded pane — measured, 12→13 of 66 across 60 s.
* Message tasks are not timer-throttled, and the selftest header's own rule
* ("stays honest in a background tab") applies to the score as much as the
* suite. Falls back to setTimeout where MessageChannel is missing (node).
*/
export const yieldToEventLoop = typeof MessageChannel === 'undefined'
? () => new Promise((r) => setTimeout(r, 0))
: () => new Promise((r) => {
const ch = new MessageChannel();
ch.port1.onmessage = () => { ch.port1.close(); r(); };
ch.port2.postMessage(0);
});