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:
parent
d8e2ecea80
commit
b1d2d2a8e6
@ -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<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`;
|
||||
|
||||
@ -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 ?? [];
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user