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>
This commit is contained in:
type-two 2026-07-20 18:19:44 +10:00
parent d8e2ecea80
commit b1d2d2a8e6
3 changed files with 242 additions and 58 deletions

View File

@ -53,7 +53,7 @@
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld } from '../../web/world/js/world.js';
import { AUDIT, auditSweep } from './sweep.js';
import { AUDIT, auditSweepAsync, yieldToEventLoop } from './sweep.js';
import { flyGarden, flySeparation } from './gardenfly.js';
/** How many affordable lines get flown. Flights are seconds each; the card is
@ -117,9 +117,19 @@ export async function buildScoringWorld(site) {
* the keys match, else reported unjudged
* @param {number} [o.flyCap]
* @param {object} [o.prebuilt] a buildScoringWorld() result to reuse
* @param {function} [o.onProgress] called ({ phase, done, total, label }) as
* work completes phases 'sweep' (per candidate flight),
* 'fly' (per garden flight, bare first), 'separation'.
* SPRINT15 gate 2.1: the 7582 s blocking run made the
* editor batch; the caller renders this so the page tells
* the truth about progress instead of looking crashed.
* @param {number} [o.yieldEvery] work units per event-loop yield (default
* 1). MUST NOT change any number the scorecard selftest
* perturbs it and demands identical results.
* @returns {Promise<object>} pure data no DOM, no strings-as-verdicts
*/
export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null }) {
export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null,
onProgress = null, yieldEvery = 1 }) {
const built = prebuilt ?? await buildScoringWorld(site);
const { world, anchors, bed, use, dressed, dressError } = built;
@ -155,16 +165,29 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
}
const { cands, rows, verdict, winners, marginalWinners } =
auditSweep({ anchors, bed, stormDef, siteDef: site, use });
await auditSweepAsync({ anchors, bed, stormDef, siteDef: site, use, onProgress, yieldEvery });
// Garden flights: the bare-bed control first, then every line the budget can
// buy. The flight list is known up front so the progress tick has an honest
// denominator (bare + capped lines + the separation pair if pinned).
const toFly = [...winners, ...marginalWinners].slice(0, flyCap);
const flyTotal = 1 + toFly.length;
let flyDone = 0;
const tickFly = async (label) => {
flyDone += 1;
onProgress?.({ phase: 'fly', done: flyDone, total: flyTotal, label });
if (flyDone % Math.max(1, yieldEvery) === 0) await yieldToEventLoop();
};
// The bare bed — the control every garden number is read against.
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
await tickFly('bare bed');
// Fly every line the budget can buy. Marginal lines fly too, deliberately:
// they are the trap the margin rule exists to name, and a card that hid them
// would be the 91.9-FULL illusion with better CSS.
const flown = new Map();
for (const r of [...winners, ...marginalWinners].slice(0, flyCap)) {
for (const r of toFly) {
// clean winners fly the CLEAN tiers (what the audit recommends buying);
// marginal winners fly the knife-edge tiers (the trap, priced as sold).
// Aligned by anchorId, never input order — attach reorders picks into ring
@ -174,6 +197,7 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
anchors, bed, stormDef, siteDef: site, use,
ids: r.ids, hw: r.ids.map((id) => tierBy.get(id)),
}));
await tickFly(r.ids.join(','));
}
const skipped = Math.max(0, winners.length + marginalWinners.length - flyCap);
@ -196,7 +220,12 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
sepStormName = site.separation.stormKey;
const sepStorm = sepStormDef ?? (sepStormName && sepStormName === stormName ? stormDef : null);
if (sepStorm) {
// Two flights (held + bare) on the block's own storm — announced before
// they run, because they are the one chunk left with no tick inside it.
onProgress?.({ phase: 'separation', done: 0, total: 1, label: sepStormName });
await yieldToEventLoop();
separation = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use });
onProgress?.({ phase: 'separation', done: 1, total: 1, label: sepStormName });
} else {
// Judging a pinned target on the wrong storm is worse than not judging it.
sepUnjudged = `pinned against ${sepStormName}, which the caller did not supply`;

View File

@ -110,6 +110,7 @@ import { loadStorm, createWind, windForSite } from '../../web/world/js/weather.j
// with itself by construction.
import { createWindRouter } from '../../web/world/js/main.js';
import { buildScoringWorld } from './scorecard.js';
import { auditSweep, auditSweepAsync } from './sweep.js';
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
@ -328,6 +329,74 @@ export async function buildScorecardTests() {
+ 'Every garden number on this yard just moved — re-baseline the audit and tell A/D.');
}]);
// ── SPRINT15 gate 2.1: the chunked sweep is the SAME sweep ───────────────
// scoreSite runs auditSweepAsync now — same flights, but the candidate loop
// yields so the editor page paints progress instead of freezing for 75 s.
// The contract is that yielding is PURE: any yieldEvery produces numbers
// byte-identical to the sync sweep. These asserts are the tripwire for
// anyone who later threads state across candidates (a reused rig, a shared
// accumulator) — the exact class of bug a chunk boundary would expose.
//
// Synthetic five-anchor yard (multiple candidates, so chunk boundaries land
// MID-list), 8 s storm — cheap on purpose; the real-yard reproduction lives
// in THREADS (site_02 + site_03 cards pinned before and after the refactor).
const YARD5 = [
{ 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 } },
{ id: 'a5', type: 'post', pos: { x: 0, y: 3.9, z: 4 } },
].map((a) => ({ ...a, sway: () => a.pos }));
const YARD5_BED = { x: 0, z: 0, w: 4, d: 4 };
const YARD5_STORM = {
id: 'chunk_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 },
};
// Strip nothing, hide nothing: the whole result must survive comparison.
const sweepJSON = (r) => JSON.stringify({
cands: r.cands, rows: r.rows, winners: r.winners,
marginalWinners: r.marginalWinners, verdict: r.verdict,
});
const syncResult = auditSweep({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [] });
const ticks = [];
const chunk1 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [],
yieldEvery: 1, onProgress: (p) => ticks.push({ ...p }) });
const chunk3 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [],
yieldEvery: 3 });
tests.push(['gate 2.1: the yielding sweep === the sync sweep, byte for byte', () => {
// vacuity guard first — a yard whose sweep found nothing would make the
// equality below a comparison of two empty objects
assert(syncResult.cands.length >= 3,
`gate 2.1: the synthetic yard swept only ${syncResult.cands.length} candidate(s) — `
+ 'not enough list for a chunk boundary to land mid-way; the purity assert is decoration. '
+ 'Fix the fixture, not the assert.');
assert(sweepJSON(chunk1) === sweepJSON(syncResult),
'gate 2.1 FAILED: auditSweepAsync(yieldEvery 1) returned different numbers than auditSweep '
+ 'on the same yard and storm. Yielding must be WHEN the page breathes, never WHAT gets '
+ 'computed — some state is leaking across candidates.');
}]);
tests.push(['gate 2.1: perturbing the chunk boundary moves NOTHING (yieldEvery 3 === yieldEvery 1)', () => {
assert(sweepJSON(chunk3) === sweepJSON(chunk1),
'gate 2.1 FAILED: changing yieldEvery (1 → 3) changed the sweep\'s numbers. The chunk '
+ 'boundary is a paint schedule, not an input — if moving it moves a number, a candidate '
+ 'flight is reading something a previous chunk wrote.');
}]);
tests.push(['gate 2.1: progress ticks fire, count monotonically, and reach the total', () => {
const sweepTicks = ticks.filter((p) => p.phase === 'sweep');
assert(sweepTicks.length === syncResult.cands.length,
`gate 2.1: expected one 'sweep' tick per candidate (${syncResult.cands.length}), got `
+ `${sweepTicks.length} — a progress line that undercounts is the looks-wired-isn't `
+ 'disease on the UI layer.');
sweepTicks.forEach((p, i) => {
assert(p.done === i + 1 && p.total === syncResult.cands.length,
`gate 2.1: tick ${i} read done=${p.done}/total=${p.total}, expected ${i + 1}/${syncResult.cands.length}`);
});
}]);
// ── the clone is the same weather as the file ────────────────────────────
tests.push(['gate 2.3: the export clone carries the funnel (site_02 venturi survives the round-trip)', () => {
const v = fun.editor.site.wind?.venturi ?? [];

View File

@ -93,6 +93,38 @@ export const tierFor = (peakN, ratingHint = 1) =>
* @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++)
@ -124,63 +156,59 @@ export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [
} catch { /* a pin naming unriggable anchors will fail loudly in a.test; nothing to add here */ }
}
}
return cands;
}
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
/**
* 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);
// 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);
// 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 };
}
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 });
}
/**
* 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
@ -198,3 +226,61 @@ export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [
: { 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, 1213 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);
});