diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index cf725f8..97b7409 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -187,52 +187,48 @@ async function fly(yard, session, stormName, { repair = false, broom = false } = rig.step(FIXED_DT, settleWind, (i * FIXED_DT) % period); } } - /** Peak corner load the instant the storm starts — the settled-at-entry guard reads this. */ - const entryPeak = Math.max(...rig.corners.map((c) => c.load || 0)); - - // D's settled-at-entry guard (SPRINT8 gate 0'). - // - // The settle above is only load-bearing if it actually settled — if a future - // change makes the cloth ring longer than 12 s, every number below silently - // becomes an attach-transient measurement again, which is the bug that cost - // two sprints. So prove it. - // - // It has to be a TREND test, not an instant one. Measured: with the wind held - // constant the worst corner still oscillates 0.9 -> 1.9 -> 1.3 -> 1.7 kN, - // because damping is deliberately light (VEL_DAMP 0.995 — the relative-wind - // drag is meant to do the damping). There is no single settled value; the - // cloth breathes. An instantaneous "has it stopped moving" check can never - // pass, and would just be a flaky assert that gets deleted. Same lesson as the - // statics assert in sail.selftest: compare time-AVERAGED windows. - const meanLoad = (secs) => { - let sum = 0, n = Math.round(secs / FIXED_DT); - for (let i = 0; i < n; i++) { rig.step(FIXED_DT, wind, 0); sum += rig.maxLoad(); } - return sum / n; - }; - const w1 = meanLoad(2); - const w2 = meanLoad(2); - const trend = Math.abs(w2 - w1) / Math.max(1, w1); - // Integrator amendment (Sprint-8 merge, D to bless): the trend only matters - // at a scale that can move a verdict. The transient that cost two sprints was - // kN-scale; a dry settled rig still shows a decaying ~0.2 kN tail that reads - // as 40%+ RELATIVE while being noise against a 1.2 kN carabiner rating. - // ⚠️ INTEGRATOR DEMOTION (Sprint-8 merge) — B+D, this guard needs a redesign, - // not a threshold. It was authored before ponding merged, and at a held clock - // storm_02's compressed rain (~35 kg/s on a 40 m² sail) ponds the cloth DURING - // the guard's own windows — the "trend" it reads is water arriving, which no - // settle length fixes (measured tonight: 105% with full-curve settle, 46% - // dried, 73% dried-every-step; entryPeak also read a 3.04 kN water belly). - // The guard's idea is right; its clock is wrong. Redesign: measure the trend - // over the storm's own advancing first seconds (rain then follows the real - // curve, mild at t=0), or dry-and-hold in a rainless probe. Until then it - // WARNS instead of failing so the merged suite reports the balance truthfully. - if (trend > 0.35 && Math.max(w1, w2) > 600) { - console.warn(`yard is NOT settled at storm entry: worst-corner mean is still trending ` + - `${(trend * 100).toFixed(0)}% between consecutive 2 s windows ` + - `(${(w1 / 1000).toFixed(2)} -> ${(w2 / 1000).toFixed(2)} kN) after a ${12} s settle. ` + - `Every balance number below is measuring the attach transient — lengthen the settle ` + - `before trusting them. (Oscillation is expected and fine; a TREND is not.)`); - } + /** + * D's settled-at-entry guard, REDESIGNED (SPRINT9, B+D). It now measures SHAPE, not load. + * + * The guard exists for one reason: a harness that rigs and storms in the same tick measures a + * cloth still falling out of its flat _build() shape, and pretends that is a rig. That bug cost + * two sprints, so the settle above has to prove it worked. + * + * The Sprint-8 version asked the question with LOAD and could not answer it. Measured tonight, + * why every load-based form fails: + * · in CALM (what the rig settles in) there is no transient to see at all — worst-corner mean + * reads 0.30 kN unsettled vs 0.47 kN settled, i.e. the unsettled rig is LIGHTER, and the + * trend test passes at every settle length from 0 s to 20 s. A guard that cannot fail is + * decoration. + * · in the STORM at a held t=0 it read a 105% "trend" — but that is the calm→storm step change, + * which the REAL GAME ALSO HAS (`wind.use(to === 'storm' ? winds[stormKey] : calmWind)` on the + * phase change). It was never a harness artifact; the suite was being faithful and the guard + * was calling it a bug. + * · the integrator's ponding diagnosis at the Sprint-8 merge does not hold either: every storm's + * rain curve starts at [0, 0], so `storm_02.rainMmPerHour(0)` is 0.0 mm/h and a held clock + * rains nothing. (Their 2 s windows were also shorter than the cloth's breath — 2 s reads a + * 32% trend on a settled rig where 3 s reads 6%.) + * + * So ask the physical question instead: HAS THE CLOTH STOPPED MOVING. Mean node drift separates + * cleanly, ~6x either side of the line, and unlike load it cannot be confused by wind or water: + * unsettled (0 s) → 212 mm/s (cover quad) · 208 mm/s (miss quad) + * settled (8-20 s) → 35 mm/s worst case, typically 4-6 + * The integrator's 600 N floor was a sound amendment to the LOAD design and is not carried over — + * drift has no units of force to need a floor, and it needs no threshold on "does it matter" + * because 212-vs-35 is not a judgement call. + */ + const settleDriftMmPerSec = (() => { + const before = Float64Array.from(rig.pos); + const secs = 2; + for (let i = 0, n = Math.round(secs / FIXED_DT); i < n; i++) { + rig.step(FIXED_DT, calmWind, ((12 + i * FIXED_DT) % Math.max(1, calmDef.duration))); + } + let sum = 0; + for (let i = 0; i < before.length; i += 3) { + sum += Math.hypot(rig.pos[i] - before[i], rig.pos[i + 1] - before[i + 1], rig.pos[i + 2] - before[i + 2]); + } + return (sum / (before.length / 3)) * 1000 / secs; + })(); let hp = 100, pond = 0, used = 0; const steps = Math.round(def.duration / FIXED_DT); @@ -264,8 +260,8 @@ async function fly(yard, session, stormName, { repair = false, broom = false } = lost: rig.corners.filter((c) => c.broken).length, spent: START_BUDGET - session.budget, pond: Math.round(pond), - /** kN on the worst corner at storm entry — the settled-at-entry guard's number. */ - entryPeak: entryPeak / 1000, + /** mm/s the cloth was still drifting when the storm started — the settled-at-entry guard. */ + settleDrift: settleDriftMmPerSec, }; } @@ -347,22 +343,26 @@ export default async function run(t) { }); /** - * D's settled-at-entry guard (SPRINT8 §Lane D). The suite must enter the storm with the yard in - * the state a player hands it: rigged, breathing on the calm day, corners loaded by nothing worse - * than their own tension. It does not decide storm_02's verdict — measured, it doesn't — but a - * harness silently measuring the attach transient is how three of them disagreed for two sprints, - * and the transient IS worth 2.7× on tree corners in the moment it exists. + * D's settled-at-entry guard, redesigned to measure SHAPE (SPRINT9, B+D). Full reasoning at the + * measurement site in fly(); the short version is that load could not answer this question and + * node drift can, ~6x clear either side of the line. + * + * It does NOT decide storm_02's verdict — measured twice now, the settle never moves the corner + * count. It exists because a harness silently flying a cloth that is still falling is how three + * of them disagreed for two sprints, and the next one should trip a wire instead of an argument. */ - t.test('harness: the yard is SETTLED when the storm starts', () => { + t.test('harness: the cloth has STOPPED MOVING when the storm starts', () => { + const LIMIT = 100; // mm/s. Measured: 212 unsettled, 35 worst settled breath. const runs = [['line', line], ['cheap', cheap], ['gentle', gentle]].filter(([, r]) => r); for (const [name, r] of runs) { - if (!(r.entryPeak < 1.0)) { + if (!(r.settleDrift < LIMIT)) { throw new Error( - `${name} entered the storm carrying ${r.entryPeak.toFixed(2)} kN — that is an attach ` + - 'transient, not a rig. Settle on the calm day with a running clock, as main.js does.'); + `${name} entered the storm with the cloth still drifting ${r.settleDrift.toFixed(0)} mm/s ` + + `(limit ${LIMIT}). That is a sail falling into shape, not a rig — every number below is ` + + 'measuring the attach transient. Lengthen the settle in fly().'); } } - return `entry loads: ${runs.map(([n, r]) => `${n} ${r.entryPeak.toFixed(2)} kN`).join(' · ')}`; + return `settle drift: ${runs.map(([n, r]) => `${n} ${r.settleDrift.toFixed(1)} mm/s`).join(' · ')}`; }); t.test('balance: storm_02 punishes a cheap rig on the same quad', () => {