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);
}
}
/** 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 calmstorm 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', () => {