Gate 0' guard: redesign it to measure SHAPE, and prove it fires

The integrator demoted my settled-at-entry guard to a warning at the Sprint-8
merge and handed B+D the redesign. Investigating it refuted the stated cause and
found a better observable.

The ponding diagnosis does not hold: 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. The real
confound was that the guard measured in the STORM's wind while the rig settled in
CALM — and that calm->storm step is one the REAL GAME ALSO HAS (wind.use() swaps
on the phase change), so it was never a harness artifact. 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%.

But fixing the clock isn't enough, because LOAD cannot answer this question at
all. In calm there is no transient to see — the unsettled rig is LIGHTER (0.30 kN
vs 0.47 settled) and the trend test passes at every settle length from 0 to 20 s.
A guard that cannot fail is decoration, which is precisely what StumbleBack and
the fake skips were.

So ask the physical question: has the cloth stopped MOVING. Mean node drift
separates ~6x either side, and cannot be confused by wind or water:
    unsettled (0 s)  -> 212 mm/s  (cover quad) · 208 (miss quad)
    settled (8-20 s) ->  35 mm/s worst breath, typically 4-6
Limit 100 mm/s. Proved it FAILS before trusting it to pass: sabotaged the settle
to 0 s and it fired — "cloth still drifting 212 mm/s (limit 100)".

The integrator's 600 N floor was a sound amendment to the LOAD design; it isn't
carried over because drift has no units of force and 212-vs-35 is not a judgement
call. 274/0/0, all five balance asserts green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 14:25:08 +10:00
parent 62005c8fd1
commit 601adedf45

View File

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