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.
416 lines
23 KiB
JavaScript
416 lines
23 KiB
JavaScript
/**
|
||
* 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).
|
||
*/
|
||
|
||
// OVERLOAD_SECS / OVERLOAD_RECOVER are the failure law itself, imported rather
|
||
// than re-typed: gate 3 replaces a percentage of taste with sail.js's own rule,
|
||
// and a second copy of the rule would be the same drift the shared sweep exists
|
||
// to prevent.
|
||
import { SailRig, orderRing, OVERLOAD_SECS, OVERLOAD_RECOVER } 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';
|
||
// gate 2 (SPRINT17): one validator for the constraint shapes, shared with the
|
||
// session that enforces them — audit and enforcement must not disagree.
|
||
import { validateNightConstraint } from '../../web/world/js/rigging.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 — ITS CAUSE IS FOUND, AND IT NO LONGER DECIDES ANYTHING
|
||
* (SPRINT18 gate 3).
|
||
*
|
||
* It was invented because "even a corrected bench under-reads the real UI's
|
||
* peaks by ~5–15% on site_02, cause unfound". The cause was `porosity`: the
|
||
* shipped game never called `rig.setFabric`, so the sim flew the waterproof
|
||
* membrane (full wind load) every night while this sweep — correctly — flew the
|
||
* shade cloth every card promised. Gate 2's measurement, on two sites and two
|
||
* storms: with the fabric held equal the bench and the FULL game chain agree to
|
||
* three decimals on all four corners. There is no residual left to pad.
|
||
*
|
||
* So the 15% is not a physical fact and never was; it was a fudge factor
|
||
* cancelling a missing function call, and keeping it would now make the audit
|
||
* lie in the other direction (selling $15 of steel nobody needs). The
|
||
* clean/marginal boundary is ruled by sail.js's OWN failure law instead — see
|
||
* `priceCandidate`. This constant survives only as the DISPLAY band the
|
||
* front-ends have always printed, so their prose keeps a number to name.
|
||
*/
|
||
MARGIN: 0.15,
|
||
/**
|
||
* SPRINT17 gate 0.3 — the sweep's FABRIC CHARTER, in the knobs instead of
|
||
* hardcoded twice (here and gardenfly.js). Every sweep and every garden
|
||
* flight is knitted shade cloth (0.30), the fabric a competent player takes
|
||
* into a windy night; the F key never enters an audit. That was always true
|
||
* and never SAID — D's soaker finding: the card read WINNABLE over an
|
||
* all-DEAD cloth garden and the silence read as "the night is broken".
|
||
* scorecard.js names the charter on every score now (fabricCharter);
|
||
* this constant is what it names.
|
||
*/
|
||
POROSITY: 0.30,
|
||
};
|
||
// 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
|
||
* @param {Array} [o.constraints] the night's client constraints (A's shapes;
|
||
* SPRINT17 gate 2) — bans shrink the candidate
|
||
* list, a cap shrinks the budget, and the result
|
||
* says how much of each. A card that recommends a
|
||
* forbidden line is the card lying.
|
||
* @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best },
|
||
* budget, constrainedOut }}
|
||
*/
|
||
export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null, constraints = [] }) {
|
||
const con = applyNightConstraints({ cands: findCandidates({ anchors, bed, siteDef }), anchors, constraints });
|
||
const cands = con.cands;
|
||
if (!cands.length) {
|
||
return { cands, rows: [], winners: [], marginalWinners: [],
|
||
verdict: { ok: false, code: 'no-cover', best: null },
|
||
budget: con.budget, constrainedOut: con.dropped };
|
||
}
|
||
|
||
// 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, budget: con.budget }));
|
||
return { ...judgeSweep(cands, rows), budget: con.budget, constrainedOut: con.dropped };
|
||
}
|
||
|
||
/**
|
||
* SPRINT17 gate 2 [B] — the night's client constraints, applied to the
|
||
* sweep's inputs. ONE function shared by the sync and async drivers, because
|
||
* their purity contract ("every number equal, to the byte") extends to which
|
||
* candidates exist and what budget prices them.
|
||
*
|
||
* noAnchorFamily drops every candidate that touches a banned-family anchor
|
||
* — including the site's pinned separation line, if it does:
|
||
* the pin is a fact about the YARD, but tonight's card
|
||
* recommends lines for tonight's CLIENT, and a card that
|
||
* recommends a forbidden line is the card lying.
|
||
* budgetCap caps the budget that decides `affordable`/`clean` — the
|
||
* client caps the invoice, not your wallet, so the numbers
|
||
* below the cap don't move; lines above it stop being
|
||
* answers.
|
||
*
|
||
* Shapes validated at the door (rigging.js's validator — the same teeth the
|
||
* session bites with, so the audit and the enforcement can never disagree
|
||
* about what a constraint means).
|
||
*
|
||
* @returns {{ cands, budget, dropped }}
|
||
*/
|
||
export function applyNightConstraints({ cands, anchors, constraints = [] }) {
|
||
for (const c of constraints) validateNightConstraint(c);
|
||
let budget = START_BUDGET, out = cands, dropped = 0;
|
||
const fams = new Set(constraints.filter((c) => c.kind === 'noAnchorFamily').map((c) => c.family));
|
||
if (fams.size) {
|
||
const banned = new Set(anchors.filter((a) => fams.has(a.type)).map((a) => a.id));
|
||
out = cands.filter((c) => !c.ids.some((id) => banned.has(id)));
|
||
dropped = cands.length - out.length;
|
||
}
|
||
for (const c of constraints) if (c.kind === 'budgetCap') budget = Math.min(budget, c.cap);
|
||
return { cands: out, budget, dropped };
|
||
}
|
||
|
||
/**
|
||
* 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, budget = START_BUDGET }) {
|
||
// shade cloth (AUDIT.POROSITY): the fabric a competent player takes into a
|
||
// windy night — the sweep charter, named on the card since SPRINT17 gate 0.3
|
||
const rig = new SailRig({ anchors, gridN: 10, porosity: AUDIT.POROSITY })
|
||
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
|
||
|
||
// SPRINT18 gate 3 — REPLAY THE FAILURE LAW, don't pad the peak.
|
||
//
|
||
// sail.js breaks a corner on DWELL, not on peak: `overload` fills at +dt above
|
||
// the effective rating, bleeds at −OVERLOAD_RECOVER·dt below it, and the corner
|
||
// lets go past OVERLOAD_SECS. A peak-only price cannot express that, which is
|
||
// why the audit needed a 15% pad to stand in for it. So run the real timer, for
|
||
// every corner against every tier in the shop, as the flight happens: 12
|
||
// accumulators, no trace buffers, and no second copy of the law (the constants
|
||
// are imported from sail.js).
|
||
//
|
||
// `worst` is the closest that corner/tier pair ever came to letting go, in
|
||
// seconds of the 0.4 s it takes. worst === 0 means the load never once reached
|
||
// the rating; 0 < worst < OVERLOAD_SECS means it went over and survived on
|
||
// TIMING; worst >= OVERLOAD_SECS means the game breaks it.
|
||
const dwell = rig.corners.map(() => HARDWARE.map(() => ({ o: 0, worst: 0 })));
|
||
for (let i = 0; i < stormDef.duration * 60; i++) {
|
||
rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||
for (let k = 0; k < rig.corners.length; k++) {
|
||
const c = rig.corners[k], hint = c.anchor.ratingHint ?? 1, load = c.load;
|
||
for (let h = 0; h < HARDWARE.length; h++) {
|
||
const d = dwell[k][h];
|
||
if (load > HARDWARE[h].rating * hint) d.o += FIXED_DT;
|
||
else d.o = Math.max(0, d.o - FIXED_DT * OVERLOAD_RECOVER);
|
||
if (d.o > d.worst) d.worst = d.o;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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, k) => {
|
||
const hint = c.anchor.ratingHint ?? 1;
|
||
const d = dwell[k];
|
||
// The cheapest tier the LAW lets live — it may go over its rating, so long as
|
||
// no excursion lasts the 0.4 s that breaks it. This is the gamble price.
|
||
const tier = HARDWARE.find((h, h_i) => d[h_i].worst < OVERLOAD_SECS) ?? null;
|
||
// The cheapest tier that never reaches its rating at all. THIS is "clean",
|
||
// measured: the steel is simply stronger than the night, with no reliance on
|
||
// a gust being brief. It replaces `tierFor(peak / (1 - MARGIN))`.
|
||
const cleanTier = HARDWARE.find((h, h_i) => d[h_i].worst === 0) ?? null;
|
||
return { id: c.anchorId, peak: c.peakLoad, hint, tier, cleanTier,
|
||
// seconds of the 0.4 s fuse the CHOSEN tier burned — 0 is untouched steel
|
||
dwell: tier ? +d[HARDWARE.indexOf(tier)].worst.toFixed(3) : null,
|
||
// effective rating in newtons, so a reader never has to do hint arithmetic:
|
||
// a $5 carabiner is 1.2 kN on the price list and 1.056 on a 0.88 branch, and
|
||
// that haircut is the whole of the cheap tier's danger (gate 3).
|
||
effN: tier ? Math.round(tier.rating * hint) : null,
|
||
cleanEffN: cleanTier ? Math.round(cleanTier.rating * hint) : null,
|
||
headroom: tier ? +(1 - c.peakLoad / (tier.rating * hint)).toFixed(3) : null };
|
||
});
|
||
const unholdable = tiers.filter((c) => !c.tier);
|
||
// MARGINAL, re-ruled by measurement: the tier survives the night only because
|
||
// every excursion above its rating was shorter than the fuse. That is a real
|
||
// knife edge with a real mechanism — "it held on timing, not on strength" — and
|
||
// it is the thing the 15% pad was groping for.
|
||
const marginal = tiers.filter((c) => c.tier && c.dwell > 0);
|
||
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;
|
||
// `budget` is START_BUDGET on an unconstrained night and the client's cap on
|
||
// a capped one (gate 2): the loads and tiers above never move — the client
|
||
// caps the invoice, not the physics — but a line the cap can't buy stops
|
||
// being an answer.
|
||
return { ...cnd, tiers, unholdable, marginal, hw, cleanHw,
|
||
total: hw + AUDIT.SPARE_COST,
|
||
affordable: !unholdable.length && hw <= budget,
|
||
clean: cleanHw != null && cleanHw <= 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 75–82 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, constraints = [] }) {
|
||
const con = applyNightConstraints({ cands: findCandidates({ anchors, bed, siteDef }), anchors, constraints });
|
||
const cands = con.cands;
|
||
if (!cands.length) {
|
||
return { cands, rows: [], winners: [], marginalWinners: [],
|
||
verdict: { ok: false, code: 'no-cover', best: null },
|
||
budget: con.budget, constrainedOut: con.dropped };
|
||
}
|
||
|
||
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, budget: con.budget }));
|
||
onProgress?.({ phase: 'sweep', done: i + 1, total: cands.length });
|
||
if ((i + 1) % Math.max(1, yieldEvery) === 0) await yieldToEventLoop();
|
||
}
|
||
return { ...judgeSweep(cands, rows), budget: con.budget, constrainedOut: con.dropped };
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
});
|