From b1d2d2a8e6cf5ae2caf3153167c8cba3413a0b8b Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 18:19:44 +1000 Subject: [PATCH] =?UTF-8?q?score:=20the=20sweep=20gets=20a=20heartbeat=20?= =?UTF-8?q?=E2=80=94=20chunked=20driver=20yields=20between=20flights,=20sa?= =?UTF-8?q?me=20numbers=20to=20the=20byte=20(S15=20gate=202.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tools/site_audit/scorecard.js | 37 ++++- tools/site_audit/scorecard.selftest.js | 69 +++++++++ tools/site_audit/sweep.js | 194 ++++++++++++++++++------- 3 files changed, 242 insertions(+), 58 deletions(-) diff --git a/tools/site_audit/scorecard.js b/tools/site_audit/scorecard.js index 11b8e70..b1b80b0 100644 --- a/tools/site_audit/scorecard.js +++ b/tools/site_audit/scorecard.js @@ -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 75–82 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} 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`; diff --git a/tools/site_audit/scorecard.selftest.js b/tools/site_audit/scorecard.selftest.js index e29e36c..f6045e9 100644 --- a/tools/site_audit/scorecard.selftest.js +++ b/tools/site_audit/scorecard.selftest.js @@ -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 ?? []; diff --git a/tools/site_audit/sweep.js b/tools/site_audit/sweep.js index 2501913..f5c3421 100644 --- a/tools/site_audit/sweep.js +++ b/tools/site_audit/sweep.js @@ -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 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 }) { + 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); + });