sail: break diverged corners on the production path; enforce pond cap post-flow

A NaN corner load compares false to every rating, so a diverged corner
held forever while NaN spread node-to-node — watchDivergence only ever
guarded the tests. Non-finite load now breaks the corner instantly with
reason:'divergence', load zeroed for the HUD.

The pond per-node cap only ran on the rain-add: once rain stopped,
consolidation could stack a basin node past POND_MAX_KG_M2 unbounded.
Clamp moved post-flow, every step. Also fixes the dumpPond assert that
compared dumped to itself through a helper that returned its argument.

All three mutation-checked red before landing green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-18 19:03:36 +10:00
parent 3432ea0d1c
commit 9d6db356d7
2 changed files with 76 additions and 5 deletions

View File

@ -542,8 +542,6 @@ export class SailRig {
const a = this._nodeArea[n];
if (a > 1e-9 && kgPerM2PerSec > 0) {
w[n] += kgPerM2PerSec * this._nodeFlat[n] * dt; // rain lands on the horizontal projection
const cap = POND_MAX_KG_M2 * a;
if (w[n] > cap) w[n] = cap;
}
flow[n] = 0;
}
@ -569,7 +567,17 @@ export class SailRig {
const moved = Math.min(w[n], w[n] * POND_FLOW * bestGrad * dt);
flow[n] -= moved; flow[best] += moved;
}
for (let n = 0; n < N2; n++) w[n] += flow[n];
// The per-node ceiling is enforced AFTER the flow step, not on the rain-add:
// the old rain-add clamp only ran while it was raining, so once the rain
// stopped, downhill consolidation could quietly stack a basin node past
// POND_MAX_KG_M2 with nothing left to trim it (the belly-tear guard below
// only bounds the gross runaway). Water over the cap sheets off the cloth —
// it leaves the sail, same as the rim spill; it is not conserved.
for (let n = 0; n < N2; n++) {
w[n] += flow[n];
const a = this._nodeArea[n];
if (a > 1e-9 && w[n] > POND_MAX_KG_M2 * a) w[n] = POND_MAX_KG_M2 * a;
}
// A rim node spills over the edge ONLY when the edge is downhill — i.e. it
// has no lower interior neighbour to send water to. A hypar's low corners
@ -916,6 +924,22 @@ export class SailRig {
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
if (c.broken) continue;
// A non-finite load can never trip the rating check below — NaN compares
// false to everything — so a diverged corner would otherwise hold FOREVER
// while NaN spreads node-to-node through the spring network. Divergence
// is a failure, not weather: break the corner now, on the production
// path, not just under the test-only watchDivergence tripwire. The event
// carries reason:'divergence' so a log can tell it from an honest blow.
if (!Number.isFinite(c.load)) {
c.broken = true;
c.overload = 0;
c.load = 0;
c.loadVec.x = c.loadVec.y = c.loadVec.z = 0;
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
this.dumpPond('corner blew');
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t, reason: 'divergence' });
continue;
}
if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) c.overload += dt;
else c.overload = Math.max(0, c.overload - dt * OVERLOAD_RECOVER);
if (c.overload > OVERLOAD_SECS) {

View File

@ -197,6 +197,48 @@ test('sim stays finite through a full storm', () => {
return `peak ${kN(r.corners.reduce((m, c) => Math.max(m, c.peakLoad), 0))}`;
});
test('divergence BREAKS on the production path — a NaN corner cannot hold forever', () => {
// watchDivergence stays OFF here on purpose: that tripwire is test-only, and
// this test is about the SHIPPED path. Before the guard in _checkFailure,
// `load > rating` was false for NaN — a diverged corner never broke, never
// recovered, and the sail froze silently while NaN ate the spring network.
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
const broke = [];
r.events.on('break', (e) => broke.push(e));
const w = makeStubWind({ stormLen: 90 });
runStorm(r, w, 2);
assert(broke.length === 0, 'rig should be healthy before the poison');
// Poison one interior node — the shape a solver blow-up leaves behind.
r.pos[(r.N + 1) * 3 + 1] = NaN;
runStorm(r, w, 2);
assert(broke.some((e) => e.reason === 'divergence'),
`NaN spread through the cloth and no corner broke by divergence — the silent freeze is back (${broke.length} breaks)`);
const b = r.corners.find((c) => c.broken);
assert(b && b.load === 0, 'a corner broken by divergence must read load 0, not NaN, for the HUD');
return `${broke.filter((e) => e.reason === 'divergence').length}/4 corners let go by divergence, HUD reads 0`;
});
test('ponding: consolidation cannot stack one node past the cap (post-flow clamp)', () => {
// The rain-add clamp only ran WHILE raining; once rain stopped, downhill
// flow could pile a basin node arbitrarily high and nothing trimmed it.
// Inject an absurd pond onto the settled belly and take one step: the
// clamp must shed it — and it must be the CLAMP doing it, not a belly
// tear, or the assert is measuring the wrong mechanism.
const r = carportRig(UNBREAKABLE);
const still = { sample: (_p, _t, out) => (out ? out.set(0, 0, 0) : { x: 0, y: 0, z: 0 }), rainMmPerHour: () => 0 };
runStorm(r, still, 5); // settle: the belly is the basin
const dumps = [];
r.events.on('pondDump', (e) => dumps.push(e));
const belly = Math.floor(r.N / 2) * r.N + Math.floor(r.N / 2);
r.water[belly] = 2000; // ~7x any sane per-node cap
r.step(SIM_DT, still, 5 + SIM_DT);
assert(dumps.length === 0, `the belly tore (${dumps[0]?.reason}) — reduce the injection, this test is about the clamp`);
assert(r.water[belly] < 400,
`node ${belly} still holds ${r.water[belly].toFixed(0)} kg after a step — the per-node cap is not enforced once rain stops`);
return `injected 2000 kg, one step later the node holds ${r.water[belly].toFixed(0)} kg, no tear`;
});
test('sail sags under gravity when calm', () => {
const r = rig(HEIGHTS_FLAT);
runStorm(r, makeStubWind({ calm: true }), 6);
@ -846,12 +888,17 @@ test('ponding: mass conserves until something dumps it', () => {
const dry = { ...w, rainAt: () => 0, rainMmPerHour: () => 0 };
runStorm(r, dry, 5);
assert(r.pondMass() <= held + 1e-6, `pond GREW from ${held.toFixed(0)} to ${r.pondMass().toFixed(0)} kg with no rain`);
// The old assert here compared `dumped` to itself through a helper that
// returned its argument — a tautology that could not fail no matter what
// dumpPond returned. Say the real thing: what it reports dropping is what
// was on the cloth.
const before = r.pondMass();
assert(before > 1, `only ${before.toFixed(1)} kg left to dump — test is vacuous`);
const dumped = r.dumpPond('test');
assert(Math.abs(dumped - r_prev(r, dumped)) < 1e-9 || dumped > 0, 'dumpPond should report what it dropped');
assert(Math.abs(dumped - before) < 1e-9, `dumpPond reported ${dumped.toFixed(1)} kg but ${before.toFixed(1)} kg was on the cloth`);
assert(r.pondMass() === 0, 'dumpPond left water behind');
return `held ${held.toFixed(0)} kg, dumped ${dumped.toFixed(0)} kg, sail now dry`;
});
function r_prev(_r, d) { return d; }
test('ponding: a blown corner tips the pond off (DESIGN.md sudden dump)', () => {
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[1] });