Redesign the settled-at-entry guard: ask the cloth, not the corners

SPRINT9 task for B+D. The demoted guard compared the worst corner's mean LOAD
across two windows and called the difference a "trend". The integrator's
diagnosis was that its clock was wrong — held at t=0, storm_02's compressed
rain ponds the cloth during the guard's own windows. That mechanism is real,
but it is not the bug, and neither of the two suggested fixes works. Measured
on the dressed yard, 0 s settle vs 12 s:

    probe under CALM, held in prep        13%  vs   17%
    probe under STORM, held clock         24%  vs  104%
    probe under STORM, advancing clock    24%  vs  104%
    probe under STORM, RAINLESS           17%  vs   96%

Every variant is either flat — calm cannot excite the cloth, so the guard is
vacuous and passes forever — or INVERTED, firing hardest on the properly
settled rig. Rain is not the culprit: the rainless probe ponds 1 kg and still
inverts. Advancing the clock isn't a fix either, because RAIN_TIME_COMPRESSION
is 40× and 4 s of storm is 160 s of rain regardless.

What a load-trend measures is the rig LOADING UP when the wind changes, and a
taut settled cloth ramps harder than a limp unsettled one (0.63 -> 1.23 kN vs
0.44 -> 0.52). The observable was pointed at the storm's arrival, not at the
cloth. No threshold, clock or rain switch fixes that.

"Settled" is a statement about the cloth, so ask the cloth. sail.js gains
nodeSpeed(): RMS node speed out of verlet. Sampled over 2 s of the same calm
prep the rig already stands in — no wind change to ramp against, no compressed
rain, and nothing left in the rig but two more seconds of prep a player does
anyway. Measured:

    settle    0 s     4 s      12 s     30 s
    speed    0.19    0.014    0.017    0.007  m/s    (pyrrhic quad: 0.23 -> 0.028)

An order of magnitude, in the direction the word means. Absolute check at
0.08 m/s, not a trend: the cloth breathes at 0.02 and the transient sits at
0.19, so there is real room for a line.

Promoted back from warn to a hard failure — and it earns that by being able to
fail. `the settled-at-entry guard can fail` flies the same line with NO settle
and asserts the guard refuses it. A guard nobody has seen fail is
indistinguishable from one that cannot, which is exactly how this one survived
two sprints being both vacuous and inverted. The control is flown up front in
run(), because Suite.test() does not await.

The old guard also left 43 kg of water and 4 s of storm load in the rig before
the storm started; the suite was flying a wet rig into a dry entry. It didn't
move the peaks (the storm's own peak dwarfs it) but it was never true.

D: this is yours to veto. Selftest 277 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 15:01:09 +10:00
parent e4436248b0
commit 6fa7644aba
2 changed files with 134 additions and 51 deletions

View File

@ -836,6 +836,33 @@ export class SailRig {
return this;
}
/**
* RMS speed of the cloth's nodes, m/s read straight out of verlet, since
* position IS the state here and velocity is just (pos - prev)/dt.
*
* This is what "is the sail settled" actually asks. Corner LOAD cannot answer
* it: measured on the dressed yard, a load-trend guard reads 17% on a rig with
* NO settle and 96% on a properly settled one, because what it really sees is
* the rig loading up when the wind changes the better-settled the cloth, the
* sharper that ramp. Motion inverts none of that. Under unchanged conditions:
*
* 0 s settle 0.19 m/s 12 s settle 0.02 m/s 30 s 0.007
*
* an order of magnitude, and in the direction the word "settled" means. Sample
* it over a window, not per-step: the cloth breathes and never reaches zero.
*/
nodeSpeed() {
const p = this.pos, q = this.prev;
if (!p || !q) return 0;
let sum = 0, n = 0;
for (let i = 0; i < p.length; i += 3) {
const dx = p[i] - q[i], dy = p[i + 1] - q[i + 1], dz = p[i + 2] - q[i + 2];
sum += dx * dx + dy * dy + dz * dz;
n++;
}
return n ? Math.sqrt(sum / n) / SIM_DT : 0;
}
/** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */
_checkFailure(dt) {
for (let k = 0; k < 4; k++) {

View File

@ -147,7 +147,7 @@ function shop(yard, ids, hw, spares = 0, tension = 1.0) {
* Fly a bought loadout through a storm and score it exactly as the game does.
* @returns {{hp:number, lost:number, cover:number, spent:number, pond:number}}
*/
async function fly(yard, session, stormName, { repair = false, broom = false } = {}) {
async function fly(yard, session, stormName, { repair = false, broom = false, settleSecs = 12 } = {}) {
const def = await loadStorm(stormName);
const wind = createWind(def);
// The settle below runs on the CALM day, because that is what main.js hands the rig outside a
@ -211,65 +211,88 @@ async function fly(yard, session, stormName, { repair = false, broom = false } =
// anything. The settle is a fidelity fix, not the corner-count cause. B's arithmetic was right:
// t2 pulls ~4.5 kN and a shackle is rated 3.2 — the line cannot hold, at any tension, settled or
// not. See THREADS.
{
const settleWind = calmWind || wind;
// Integrator fix (Sprint-8 merge): cycling t over the calm storm's FULL
// duration replayed all of storm_01's gusts inside 12 s of sim, so the rig
// arrived at "entry" mid-gust and D's guard (correctly) refused it. Prep in
// the live game is the calm day's opening minutes, not its highlight reel —
// hold the settle inside storm_01's pre-gust window (first gust >= 3 s).
const period = 3;
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) {
rig.step(FIXED_DT, settleWind, (i * FIXED_DT) % period);
const settleWind = calmWind || wind;
// Integrator fix (Sprint-8 merge): cycling t over the calm storm's FULL
// duration replayed all of storm_01's gusts inside 12 s of sim, so the rig
// arrived at "entry" mid-gust and D's guard (correctly) refused it. Prep in
// the live game is the calm day's opening minutes, not its highlight reel —
// hold the settle inside storm_01's pre-gust window (first gust >= 3 s).
const PREP_PERIOD = 3;
let prepT = 0;
/** Prep on the clock the player actually stands in. Returns mean cloth speed, m/s. */
const prep = (secs) => {
let sum = 0;
const n = Math.round(secs / FIXED_DT);
for (let i = 0; i < n; i++) {
rig.step(FIXED_DT, settleWind, prepT % PREP_PERIOD);
prepT += FIXED_DT;
sum += rig.nodeSpeed();
}
}
/** 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').
return sum / n;
};
if (settleSecs > 0) prep(settleSecs); // settleSecs 0 = the unsettled control, below
// D's settled-at-entry guard (SPRINT8 gate 0') — REDESIGNED, SPRINT9, B with
// D's demotion measurements. D: this is the change you're owed a veto on.
//
// 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.)`);
// THE OBSERVABLE WAS THE BUG, not the clock. The demoted guard compared the
// worst corner's mean LOAD across two windows and called a change a "trend".
// Three redesigns were measured on the dressed yard before one worked, and the
// two the integrator proposed are among the ones that don't — so, in full, in
// case anyone is tempted back:
//
// probe under CALM, held in prep 0 s settle 13% 12 s settle 17%
// probe under STORM, held clock 0 s settle 24% 12 s settle 104%
// probe under STORM, advancing clock 0 s settle 24% 12 s settle 104%
// probe under STORM, RAINLESS 0 s settle 17% 12 s settle 96%
//
// Every one of them is either flat (calm cannot excite the cloth, so the guard
// is vacuous and passes forever — an assert that cannot fail) or INVERTED: it
// fires hardest on the properly settled rig. Rain is not the culprit either;
// the rainless probe ponds 1 kg and still inverts. What a load-trend actually
// measures is the rig LOADING UP when the wind changes, and a taut settled
// cloth ramps HARDER than a limp unsettled one (0.63 -> 1.23 kN vs
// 0.44 -> 0.52). The metric was reading the storm's arrival, not the cloth.
// No threshold, clock or rain switch fixes an observable pointed at the wrong
// thing. (The integrator's ponding diagnosis was right about the mechanism and
// is also unfixable by clock: RAIN_TIME_COMPRESSION is 40×, so even 4 s of
// ADVANCING storm is 160 s of rain — 43 kg in the belly either way.)
//
// "Settled" is a statement about the CLOTH, so ask the cloth. rig.nodeSpeed()
// is RMS node speed straight out of verlet, sampled over a 2 s window of the
// same calm prep the rig is already standing in — no wind change to ramp
// against, no compressed rain, and nothing left behind in the rig but two more
// seconds of the prep a player does anyway. Measured, dressed yard:
//
// settle 0 s 4 s 12 s 30 s
// speed 0.19 0.014 0.017 0.007 m/s (pyrrhic quad: 0.23 -> 0.028)
//
// An order of magnitude, in the direction the word means. It is an ABSOLUTE
// check, not a trend: the old comment's "the cloth breathes, an instantaneous
// check can never pass" is true and is why this is a windowed MEAN — but the
// breathing sits at 0.02 m/s and the transient at 0.19, so there is a real gap
// to put a line in. 0.08 is ~3× above the worst settled rig here and ~2.4×
// below the loosest unsettled one.
//
// This is a FAILURE again, not a warning. It earns that by being able to fail:
// asserted below on a deliberately unsettled rig. — B, SPRINT9. D: your veto.
const SETTLED_SPEED = 0.08;
const entrySpeed = prep(2);
if (entrySpeed > SETTLED_SPEED) {
throw new Error(`yard is NOT settled at storm entry: the cloth is still moving at ` +
`${entrySpeed.toFixed(3)} m/s (settled is <${SETTLED_SPEED}) after a ${settleSecs} s settle. ` +
`Every balance number in this suite is measuring the attach transient — lengthen the settle ` +
`before trusting them. (Breathing is expected and fine; ~0.19 m/s is a cloth still falling ` +
`into shape.)`);
}
/** Peak corner load the instant the storm starts — settled, dry, on the calm day. */
const entryPeak = Math.max(...rig.corners.map((c) => c.load || 0));
let hp = 100, pond = 0, used = 0;
const steps = Math.round(def.duration / FIXED_DT);
for (let i = 0; i < steps; i++) {
@ -352,6 +375,24 @@ export default async function run(t) {
const gentleShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0);
const gentle = gentleShop ? await fly(yard, gentleShop, 'storm_01_gentle') : null;
// THE GUARD'S OWN CONTROL. A guard nobody has ever seen fail is indistinguishable
// from a guard that cannot fail — that is how the previous version survived being
// both vacuous and inverted for two sprints. So fly the same line with NO settle:
// the cloth is still falling into shape, and the guard must refuse it. Flown here,
// up front, because testkit's Suite.test() does NOT await — an async assert would
// pass forever while proving nothing (the standing house rule; see SPRINT6).
let unsettled;
{
const s = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0);
if (s) s.setFabric('cloth');
try {
await fly(yard, s, 'storm_02_wildnight', { settleSecs: 0 });
unsettled = { fired: false };
} catch (e) {
unsettled = { fired: /NOT settled at storm entry/.test(e.message), err: e.message };
}
}
// --- then judge -----------------------------------------------------------
// SPRINT8 gate 0' — CLOSED. The harnesses agree; the disagreement was a miscount.
@ -405,6 +446,21 @@ export default async function run(t) {
return `$${line.spent} · shade cloth · ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost — CLEAN WIN`;
});
// The guard is load-bearing for every number in this suite, so it does not get
// to be decoration. If this ever goes red, the guard has stopped being able to
// fail and its silence elsewhere means nothing.
t.test('balance: the settled-at-entry guard can fail — a 0 s settle trips it', () => {
if (!unsettled) throw new Error('the unsettled control never flew');
if (unsettled.fired === false) {
throw new Error(`the settled-at-entry guard PASSED a rig with no settle at all. It is now ` +
`vacuous — every "settled" claim in this suite rests on an assert that cannot fail. ` +
`Check rig.nodeSpeed() still reads verlet, and that SETTLED_SPEED is not above the ` +
`~0.19 m/s an unsettled cloth actually moves at.`);
}
if (!unsettled.fired) throw new Error(`the unsettled control died for the wrong reason: ${unsettled.err}`);
return 'a 0 s settle trips the guard — its silence on the real flights means something';
});
// FABRIC IS A DECISION, NOT A SKIN. Both fabrics are $0 — DESIGN.md wants the
// forecast to be the price — so the only thing that can justify the choice is
// that it changes the night. Measured, same $80 line, one variable: