From e5df63128a3f61868fe69f6818bf42d1bd376a9c Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 20:25:42 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20B=20S16=20pool:=20the=20frame-determinis?= =?UTF-8?q?m=20bound=20is=20pinned=20=E2=80=94=20and=20it=20is=2066.7=20ms?= =?UTF-8?q?,=20not=2083?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROADMAP carried '>83 ms determinism bound (documented, un-tested)'. Measured: the spiral guard fires whenever a step() consumes MAX_SUBSTEPS, dropping even a legal sub-SIM_DT residual — so the unconditionally-lossless bound is (MAX_SUBSTEPS-1)*SIM_DT = 66.7 ms; 67-83 ms may drop up to one SIM_DT by accumulator phase; >=83 ms always drops. Pinned AS MEASURED (no retune): frames <=66 ms converge on the fixed trace at equal sim clocks with zero wall time lost; one 120 ms hitch drops exactly its overflow (36.7 ms). Mutations red-then-green: MAX_SUBSTEPS shrunk (6 s went missing); guard deleted (deficit 3 ms vs expected 37). Test header is the documented waiver. Co-Authored-By: Claude Opus 4.8 --- web/world/js/sail.selftest.js | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js index 91b1422..81838b5 100644 --- a/web/world/js/sail.selftest.js +++ b/web/world/js/sail.selftest.js @@ -467,6 +467,65 @@ test('determinism: variable frame dt matches fixed dt', () => { return 'ragged frame times converge on the fixed-dt trace'; }); +// SPRINT16 pool (B) — ROADMAP's "the >83 ms frame determinism bound +// (documented, un-tested)", now tested — and MEASURED SHARPER than documented. +// step() burns wall time in SIM_DT chunks up to MAX_SUBSTEPS(5) per call, and +// `if (n === MAX_SUBSTEPS) this._acc = 0` fires whenever a call CONSUMES five +// substeps — including when the leftover residual was a perfectly legal +// sub-SIM_DT remainder. So the unconditionally-lossless frame bound is +// (MAX_SUBSTEPS − 1) × SIM_DT ≈ 66.7 ms (a 67–83 ms frame may drop up to one +// SIM_DT of residual depending on accumulator phase; ≥ 83 ms always drops). +// Pinned AS MEASURED, not retuned: every trace ever recorded was made under +// this guard, and no real frame lives in the 67–83 ms gap on purpose. If the +// guard is ever refined to `&& this._acc >= SIM_DT` (making 83 ms the true +// bound), move SAFE_MAX with it in the same commit — this header is the +// documented waiver ROADMAP asked for, THREADS [B] SPRINT16 has the numbers. +test('determinism bound: frames under 66.7 ms hold the trace; a hitch past 83 ms drops time by design', () => { + const SAFE_MAX = 0.066; // just under (MAX_SUBSTEPS − 1) × SIM_DT — see header + const w1 = makeStubWind({ seed: 5, stormLen: 20 }); + const fixed = rig(HEIGHTS_HYPAR); + for (let i = 0; i < Math.round(20 / SIM_DT); i++) fixed.step(SIM_DT, w1, i * SIM_DT); + + // Half 1: ragged frames spanning the WHOLE legal range converge on the + // fixed trace (the existing 4–24 ms test's promise, extended to the bound). + const w2 = makeStubWind({ seed: 5, stormLen: 20 }); + const ragged = rig(HEIGHTS_HYPAR); + const rand = rng(31); + let acc = 0; + while (acc < 19.5) { + const dt = 0.004 + rand() * (SAFE_MAX - 0.004); + ragged.step(dt, w2, acc); + acc += dt; + } + // Compare at equal SIM clocks, not equal wall clocks: a random frame sum + // overshoots the target by up to SAFE_MAX — up to 3 extra substeps of honest + // evolution, an off-by-N comparison rather than a determinism failure. Top + // up with fixed steps until both rigs have burned identical sim time. + while (ragged.t < fixed.t - 1e-9) { ragged.step(SIM_DT, w2, acc); acc += SIM_DT; } + assert(ragged.t === fixed.t, `sim clocks differ after top-up: ${ragged.t} vs ${fixed.t}`); + for (let k = 0; k < 4; k++) { + const d = Math.abs(fixed.corners[k].load - ragged.corners[k].load); + assert(d < 1e-6, `corner ${k} drifted ${d.toFixed(6)} N on frames inside the lossless bound — MAX_SUBSTEPS shrank?`); + } + assert(acc - ragged.t < SIM_DT + 1e-9, + `${((acc - ragged.t) * 1000).toFixed(1)} ms of wall time went missing on legal frames — the guard is dropping time early`); + + // Half 2: one 120 ms hitch loses exactly the time past 5 substeps — the sim + // shrugs, it does not catch up, and the clock says so. This is the bound + // BEING a bound; if this half goes red the spiral guard is gone. + const w3 = makeStubWind({ seed: 5, stormLen: 20 }); + const hitched = rig(HEIGHTS_HYPAR); + const HITCH = 0.120, dropped = HITCH - 5 * SIM_DT; // ≈ 36.7 ms lost + let fed = 0; + hitched.step(HITCH, w3, 0); fed += HITCH; + while (fed < 20) { hitched.step(SIM_DT, w3, fed); fed += SIM_DT; } + const deficit = fed - hitched.t; + assert(deficit > dropped - SIM_DT - 1e-9 && deficit < dropped + SIM_DT + 1e-9, + `fed ${fed.toFixed(3)} s of wall time, sim clock reads ${hitched.t.toFixed(3)} — deficit ${deficit.toFixed(4)} s, ` + + `expected ≈${dropped.toFixed(4)} s (one hitch's overflow). The spiral guard moved`); + return `frames ≤${(SAFE_MAX * 1000).toFixed(0)} ms: byte-faithful, no time lost; one 120 ms hitch drops ${(deficit * 1000).toFixed(1)} ms as designed`; +}); + test('re-attach resets the clock: night 2 flies the same storm as night 1', () => { // SPRINT13. main.js builds ONE SailRig at boot and re-attach()es it every // night, and step() runs in EVERY phase once rigged — so the clock ran on