From 9d6db356d7829f5741d2d00400f0d2fe56ca4477 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Sat, 18 Jul 2026 19:03:36 +1000 Subject: [PATCH] sail: break diverged corners on the production path; enforce pond cap post-flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/world/js/sail.js | 30 ++++++++++++++++++--- web/world/js/sail.selftest.js | 51 +++++++++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/web/world/js/sail.js b/web/world/js/sail.js index 614a46d..341c6c1 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -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) { diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js index 2036f34..5fffc0d 100644 --- a/web/world/js/sail.selftest.js +++ b/web/world/js/sail.selftest.js @@ -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] });