Merge remote-tracking branch 'origin/lane/b'
# Conflicts: # THREADS.md
This commit is contained in:
commit
a79f714c86
122
THREADS.md
122
THREADS.md
@ -7114,3 +7114,125 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
side of the re-bake the tree sits on, and zero deltas is what both harnesses measured.
|
||||
Nothing to flag. If the integrator wants a THIRD reading post-E-merge it is one page
|
||||
load: envelope.html, week mode, compare against this entry.
|
||||
[B] 2026-07-20 — ✅ **GATE 1.3 — THE ZERO-DELTA VERIFICATION: ZERO DELTAS, measured, not assumed.**
|
||||
Tree measured: **main @ c182fc1** (lane/b fast-forwarded to it; E's re-bake/manifest half had
|
||||
NOT landed on lane/e at measure time — under the 1.00 ruling the runtime value doesn't move,
|
||||
so this re-measure doesn't block on E; if their landing somehow moves a number, their own
|
||||
gate-1.2 assert is the tripwire). audit.html on all three yards, plus the editor SCORE IT
|
||||
card on site_02 and site_03, against the S14 pinned numbers:
|
||||
|
||||
| yard / storm | S14 pinned | re-measured @ c182fc1 | Δ |
|
||||
|---|---|---|---|
|
||||
| backyard_01 / wildnight | 16 cands · 3 clean · 2 marginal · bare 35.7 TATTERED · best p1,p2,p3,p4 60.5 · sep MEETS held 63.8 | identical, every number | **0** |
|
||||
| site_02 / earlybuster | 66 / 20 / 1 · bare 82.6 FULL · best tr1,tr1b,q1,q4 91.3 · cheapest honest tr1,tr1b,q1,q3 $40 (+$15=$55) · 31 m² · 86.4 FULL · +3.8 HP · worst flag q4 0% · collateral $180 | identical, every number | **0** |
|
||||
| site_03_swing_lawn / southerly | 81 / 11 / 16 · cheapest honest h3,t2,t2b,p1 $65 (holds $55) · 22 m² · 85.3 FULL · bare 83.7 → best clean h3,t1c,t2,p1 90.0 (+6.3) · best-reading marginal t1c,t2,t2b,s1_f2 91.8 · flags t2b 0%, t1/t1b/p1/s1_f1 1% · collateral $230 | identical, every number (D's card, reproduced on my machine) | **0** |
|
||||
|
||||
Selftest on the same tree: **410/0/0** (the integrator-verified baseline). Two things this
|
||||
table quietly proves beyond the ruling: (1) the fresh-eyes review's sim fixes (divergence
|
||||
break on the shipped path, pond cap post-flow) are empirically no-ops on every shipped score
|
||||
path — the review argued they only bite on non-finite corners / post-rain stacking, and the
|
||||
numbers agree; (2) D's site_03 card numbers reproduce exactly cross-machine — only the WALL
|
||||
TIME differs (their 82.5 s was 139.9 s on my measuring browser), which is gate 2.1's whole
|
||||
argument. Editor cards for site_02 (71.9 s) and site_03 (139.9 s) are captured in full as
|
||||
the gate-2.1 pins: a faster score that moves ANY of these numbers is a regression.
|
||||
|
||||
[B] 2026-07-20 — 🔌 **GATE 2.2 PROPOSAL FOR C — one storm picker, posted early so you can veto
|
||||
while I'm inside gate 2.1.** D scored the wildnight while your panel showed the southerly:
|
||||
two private `stormKey`s, independently defaulted (my STORMS leads wildnight, your
|
||||
DEFAULT_STORM is southerly). Proposal, shaped like our 2.3 convergence — counter and I match:
|
||||
|
||||
· **One tiny shared module, `web/world/js/editor_storm.js`, Lane B owns the file**; it owns
|
||||
the state and the ONE storm list. API: `stormSel.key` (getter), `stormSel.set(key, source)`
|
||||
(no-op on same key, throws on a storm not in the list — setHardware's lesson),
|
||||
`stormSel.onChange(fn)` → unsubscribe, `stormSel.STORM_KEYS`. Also exposed as
|
||||
`globalThis.EDITOR_STORM` for consoles/selftests. It deliberately does NOT ride A's
|
||||
`EDITOR.on` — emit is private to editor.js, and asking A to widen the seam for this is
|
||||
more contract than one shared dropdown needs.
|
||||
· **The shared default: `storm_03_southerly` — YOUR default, not mine.** Reasons, not
|
||||
courtesy: your panel is live the moment the page opens and needs a storm that shows
|
||||
something (your own comment — a funnel authored under the gentle storm looks like it does
|
||||
nothing); SCORE IT is an explicit click behind a visible dropdown; site_03 — the yard most
|
||||
likely to be iterated next — was authored and played on the southerly; and my separation
|
||||
judging never depended on the dropdown anyway (the block names its own storm).
|
||||
· **My half lands on lane/b now**: editor_score.js's dropdown reads/writes `stormSel`, so
|
||||
the moment your panel subscribes, changing the storm in either place changes it in both.
|
||||
Until your half lands, behaviour is unchanged for you (my module holds the state; your
|
||||
panel keeps its private key). **Your half**: drop `STORM_KEYS`/`DEFAULT_STORM`/`stormKey`
|
||||
from editor.wind.js, import `stormSel`, subscribe → `ensureStorm + refreshWind + render`,
|
||||
and write `stormSel.set` from your select. Two selects, one state, zero A edits.
|
||||
[B] 2026-07-20 — ✅ **GATE 2.1 + 2.2 LANDED — THE SCORE PAINTS, YIELDS, AND MOVED NOTHING; ONE
|
||||
STORM PICKER, CONVERGED ONTO C'S STORE.** Selftest **413/0/0** (410 baseline + 3, all three
|
||||
mutation-checked red-then-green in one combined run: an impure chunk boundary + a swallowed
|
||||
tick lit all three with their intended diagnostics). Pushed on lane/b.
|
||||
|
||||
**2.1 — WHAT CHANGED AND WHAT PROVABLY DIDN'T.** The sweep's per-candidate flight is
|
||||
extracted (`findCandidates` / `priceCandidate` / `judgeSweep`, one copy of the math — sync
|
||||
`auditSweep` and the new `auditSweepAsync` are the same functions in a different loop), and
|
||||
`scoreSite` now yields to the event loop between flights with `onProgress` ticks (phases
|
||||
sweep/fly/separation). The editor card and audit.html both render the tick:
|
||||
`sweeping — flight 43/81 quads · 61.2 s` / `flying the garden — line 4/13 · h3,t1c,p1,s1_f1`.
|
||||
D's priority order taken literally: (a) paint, (b) responsiveness; on (c) speed I looked at
|
||||
the offered cull and DECLINED it — the coarse-rank-then-cull idea changes card numbers by
|
||||
construction (clean/marginal COUNTS and the margin-flag table are computed over every
|
||||
priced row, and those counts are on the card), so a cull is a design change needing its own
|
||||
ruling, not a speedup. `FLY_CAP` and `yieldEvery` are the knobs that exist; D, say the word.
|
||||
|
||||
**THE PIN, REPRODUCED TO THE DIGIT.** Captured both editor cards in full BEFORE the
|
||||
refactor, reproduced after — every number identical on site_02/earlybuster (66/20/1,
|
||||
tr1,tr1b,q1,q3 $40, 86.4 FULL +3.8, bare 82.6, best 91.3 +8.7, hail/rain 5.1/3.5, all ten
|
||||
margin flags to the kN, carport $180) and site_03/southerly (81/11/16, h3,t2,t2b,p1 $65
|
||||
holds $55, 85.3 +1.6, bare 83.7, best 90.0 +6.3, marginal-best 91.8, all eleven flags,
|
||||
$230). audit.html backyard_01/wildnight re-run through the new driver: 16/3/2, bare 35.7,
|
||||
best 60.5, sep MEETS held 63.8 — identical, separation path included. The chunk-boundary
|
||||
mutation is also an ASSERT now: sync vs yieldEvery 1 vs yieldEvery 3 must be byte-identical
|
||||
JSON (and went red when I deliberately leaked the boundary into a row).
|
||||
|
||||
**TIMINGS, honestly, because the wall clock on this laptop is lying to everyone today.**
|
||||
First-run wall clocks drifted 71.9 → 238.8 s on the SAME yard across two hours — five lane
|
||||
agents are hammering this machine in parallel, so cross-run comparisons are mush. So the
|
||||
before/after is a back-to-back controlled pair, same page, same prebuilt world,
|
||||
site_02/earlybuster: **blocking (yieldEvery=∞) 139.2 s, page frozen** (my mid-run probes
|
||||
timed out — the old experience, reproduced on demand) vs **yielding (yieldEvery=1) 187.0 s,
|
||||
page live the whole way** (probed it repeatedly mid-run; the progress line answered every
|
||||
time). The +34% is NOT the yield — an idle yield measures 0.03–0.2 ms (bench in-page) — it
|
||||
is the editor's rAF frame getting to run between chunks on a contended CPU, i.e. the page
|
||||
doing its job. On D's machine (which renders this editor at interactive rates while
|
||||
authoring) the per-yield cost is one frame, single-digit ms. **D: gate 2.3 is yours — judge
|
||||
the loop in anger, and if the tick cadence or the tradeoff is wrong for authoring, both
|
||||
knobs are one line.**
|
||||
|
||||
⚠️ Two field notes with teeth, for whoever touches this next:
|
||||
· **`yieldToEventLoop` is a MessageChannel task, NOT setTimeout(0), and that is
|
||||
load-bearing.** Chrome's intensive timer throttling clamps chained timers in
|
||||
hidden/occluded tabs to ONE WAKE PER MINUTE — measured: the first yielding run crawled
|
||||
12→13 of 66 flights across 60 s in an occluded pane. The selftest header's rule ("stays
|
||||
honest in a background tab") applies to the score too; message tasks aren't throttled.
|
||||
· The editor page's score now defaults to the SHARED storm (southerly), not wildnight —
|
||||
that is gate 2.2 working as specced, saying it here so nobody reads a southerly card
|
||||
and remembers a wildnight default.
|
||||
|
||||
**2.2 — CLOSED, C's store, my three lines.** C converged onto my proposal harder than I
|
||||
proposed it (their entry, one up) and countered with storm+TIME symmetric API — counter
|
||||
TAKEN, same as 2.3: I deleted my draft `editor_storm.js` and adopted C's file BYTE-IDENTICAL
|
||||
onto lane/b (`git checkout origin/lane/c -- web/world/js/editor_storm.js`, so integration
|
||||
merges clean; the file is C's, uncontested). editor_score.js is a view of the store now:
|
||||
`sel.value = stormSel().storm`, writes via `setStorm(key,'score')`, subscribes via
|
||||
`onChange`. Verified live on my tree: default southerly@40 ✓ · console `setStorm` moves my
|
||||
select ✓ · my select moves the store ✓ · no-op returns false ✓ · garbage throws ✓. (C's
|
||||
wind-panel half lives on lane/c; both halves meet at integration, where D's
|
||||
scored-one-viewed-another becomes unrepresentable.)
|
||||
|
||||
**GATE 1.3 CODA:** C's storm-side envelope run (their entry, one up) reads the same zero my
|
||||
cloth-side table read — two independent harnesses, both zero, both measured pre-E-landing
|
||||
under a ruling where the runtime value cannot move. **D: gate 1.4 can be recorded as
|
||||
nothing-to-feel** unless E's landing surprises everyone, and E's own assert is the tripwire
|
||||
for that.
|
||||
|
||||
**NOT TAKEN (declared, not dropped):** A's gate-3.4 offer (a "copy measured separation
|
||||
block" affordance on the card). It is NOT a mechanical copy — the block's thresholds
|
||||
(heldMustExceed / bareMustLoseBelow) are design margins around measured numbers, and
|
||||
picking them is a ruling, not a clipboard. Still worth building; it needs its own slot and
|
||||
A's thresholds-policy sentence first. **SPRINT16 note:** the chunking keeps
|
||||
`priceCandidate`/`flyGarden` as the atomic units — the node-position heal after a
|
||||
divergence break lives inside sail.js, BELOW the chunk seam, so TEETH's sanitation gate
|
||||
does not fight this refactor; membrane-balance flights ride the same driver unchanged.
|
||||
|
||||
@ -91,7 +91,17 @@ async function run() {
|
||||
? (sepKey === stormName ? stormDef : await loadJSON(`../../web/world/data/storms/${sepKey}.json`))
|
||||
: null;
|
||||
|
||||
const score = await scoreSite({ site, stormDef, stormName, sepStormDef });
|
||||
// scoreSite yields between flights now (SPRINT15 gate 2.1), so this page can
|
||||
// say where it is instead of sitting on "loading…" for a minute.
|
||||
const t0 = performance.now();
|
||||
const score = await scoreSite({ site, stormDef, stormName, sepStormDef, onProgress: (p) => {
|
||||
const secs = ((performance.now() - t0) / 1000).toFixed(1);
|
||||
el('sub').textContent = p.phase === 'sweep'
|
||||
? `${siteName} / ${stormName} — sweeping: flight ${p.done}/${p.total} quads · ${secs} s`
|
||||
: p.phase === 'fly'
|
||||
? `${siteName} / ${stormName} — flying the garden: line ${p.done}/${p.total}${p.label ? ` (${p.label})` : ''} · ${secs} s`
|
||||
: `${siteName} / ${stormName} — judging the separation target (${p.label}) · ${secs} s`;
|
||||
} });
|
||||
const {
|
||||
dressed, cands, rows, verdict, winners, marginalWinners,
|
||||
flown, skipped, bare, separation: sep, sepStormName,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -40,17 +40,12 @@
|
||||
import { loadStorm } from './weather.js';
|
||||
import { START_BUDGET } from './contracts.js';
|
||||
import { scoreSite, buildScoringWorld, cheapestHonest, marginFlags, collateralExposure } from '../../../tools/site_audit/scorecard.js';
|
||||
|
||||
/** The storms a yard gets judged against. `storm_02_wildnight` leads because
|
||||
* it is the storm every Sprint-13 number was argued over — score against the
|
||||
* one people remember. */
|
||||
const STORMS = [
|
||||
'storm_02_wildnight',
|
||||
'storm_01_gentle',
|
||||
'storm_02b_icenight',
|
||||
'storm_03_southerly',
|
||||
'storm_03b_earlybuster',
|
||||
];
|
||||
// SPRINT15 gate 2.2: the storm list and the SELECTION live in editor_storm.js
|
||||
// now — C's converged store (storm + second; this panel ignores the second on
|
||||
// purpose — a score flies the WHOLE storm). D scored the wildnight while
|
||||
// viewing the southerly because this file and editor.wind.js each defaulted a
|
||||
// private stormKey; neither carries one any more.
|
||||
import { stormSel, STORM_KEYS } from './editor_storm.js';
|
||||
|
||||
const el = (tag, cls, text) => {
|
||||
const n = document.createElement(tag);
|
||||
@ -93,7 +88,14 @@ function boot() {
|
||||
const rowStorm = el('div', 'ed-row');
|
||||
rowStorm.append(el('span', 'ed-label', 'storm'));
|
||||
const sel = el('select', 'ed-sel');
|
||||
for (const s of STORMS) sel.append(new Option(s.replace(/^storm_/, ''), s));
|
||||
for (const s of STORM_KEYS) sel.append(new Option(s.replace(/^storm_/, ''), s));
|
||||
// One storm selection for the whole page (gate 2.2): this select is a VIEW of
|
||||
// C's store, not an owner — change it here and the wind panel follows, change
|
||||
// it there and this follows. Scoring one storm while reading another's wind
|
||||
// field is how D got quietly wrong numbers.
|
||||
sel.value = stormSel().storm;
|
||||
sel.addEventListener('change', () => stormSel().setStorm(sel.value, 'score'));
|
||||
stormSel().onChange(({ storm }) => { sel.value = storm; });
|
||||
rowStorm.append(sel);
|
||||
body.append(rowStorm);
|
||||
|
||||
@ -108,7 +110,8 @@ function boot() {
|
||||
const note = el('div', 'ed-note',
|
||||
'Scores a fresh dressed world built from the EXPORT clone — not the editor\'s '
|
||||
+ 'view, whose wind is a calm stub. Every number flies windForSite() and the real '
|
||||
+ 'commit→attach chain. Takes a few seconds; that is the point.');
|
||||
+ 'commit→attach chain. Slow is honest — every candidate flies the full storm — '
|
||||
+ 'but the page stays live and the progress line says where it is.');
|
||||
body.append(note);
|
||||
|
||||
// A score describes the yard as it was WHEN SCORED. The moment anything
|
||||
@ -129,14 +132,30 @@ function boot() {
|
||||
stale = false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'scoring…';
|
||||
out.replaceChildren(el('div', 'ed-note', 'building a dressed world, sweeping every quad, flying the winners…'));
|
||||
// yield so the button repaints before we block the thread for seconds
|
||||
// The progress line (gate 2.1). D's verdict on the old blocking run: the
|
||||
// worst part "is not the wait, it's not knowing whether it died". scoreSite
|
||||
// yields between flights now, so every update here actually PAINTS.
|
||||
const prog = el('div', 'ed-note', 'building a dressed world…');
|
||||
out.replaceChildren(prog);
|
||||
// yield so the button repaints before the first heavy chunk
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const t0 = performance.now();
|
||||
const secs = () => `${((performance.now() - t0) / 1000).toFixed(1)} s`;
|
||||
const onProgress = (p) => {
|
||||
if (p.phase === 'sweep') {
|
||||
prog.textContent = `sweeping — flight ${p.done}/${p.total} quads · ${secs()}`;
|
||||
} else if (p.phase === 'fly') {
|
||||
prog.textContent = `flying the garden — line ${p.done}/${p.total}${p.label ? ` · ${p.label}` : ''} · ${secs()}`;
|
||||
} else if (p.phase === 'separation') {
|
||||
prog.textContent = p.done
|
||||
? `separation target judged · ${secs()}`
|
||||
: `judging the pinned separation target (${p.label}) · ${secs()}`;
|
||||
}
|
||||
};
|
||||
try {
|
||||
const site = EDITOR.siteClone();
|
||||
const stormName = sel.value;
|
||||
const stormName = stormSel().storm;
|
||||
const stormDef = await loadStorm(stormName);
|
||||
|
||||
// The separation block names its OWN storm — judging A's pinned target on
|
||||
@ -146,7 +165,7 @@ function boot() {
|
||||
const sepStormDef = sepKey ? (sepKey === stormName ? stormDef : await loadStorm(sepKey)) : null;
|
||||
|
||||
const built = await buildScoringWorld(site);
|
||||
const score = await scoreSite({ site, stormDef, stormName, sepStormDef, prebuilt: built });
|
||||
const score = await scoreSite({ site, stormDef, stormName, sepStormDef, prebuilt: built, onProgress });
|
||||
render(out, score, performance.now() - t0);
|
||||
globalThis.__editorScore = score; // for the selftest + console spelunking
|
||||
} catch (err) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user