diff --git a/web/world/js/sail.js b/web/world/js/sail.js index 341c6c1..075f971 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -921,6 +921,7 @@ export class SailRig { * undefined` is `load > NaN`: always false, i.e. an UNBREAKABLE anchor. */ _checkFailure(dt) { + let diverged = false; for (let k = 0; k < 4; k++) { const c = this.corners[k]; if (c.broken) continue; @@ -938,6 +939,7 @@ export class SailRig { 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' }); + diverged = true; continue; } if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) c.overload += dt; @@ -957,9 +959,94 @@ export class SailRig { this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t }); } } + // SPRINT16 gate 2.1 — THE HEAL. The break above made divergence detectable + // (the fresh-eyes review's fix); this makes it survivable to LOOK at. The + // freed corner otherwise keeps integrating from the NaN the solver left in + // the spring network, and the cloth a player sees after the bang is a + // shredded z-fighting mess, not a cloth. Heal ONLY on a divergence break — + // an honest overload break has finite state and must not be touched. + if (diverged) this._healNonFinite(); if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; } } + /** + * Reset every non-finite node from its nearest finite neighbour (grid rings, + * straight neighbours before diagonals — _nbr's own order), or from the + * corners' anchors when the whole cloth is poisoned. `prev` is matched to + * `pos` on every healed node so verlet reads zero velocity out of the heal + * instead of inventing a launch. Non-finite WATER is zeroed too: a NaN pond + * re-poisons the healed positions on the very next substep through the + * weight term (F += GRAVITY * w), so healing positions alone heals nothing. + * + * Deterministic (fixed iteration order, no randomness, no clock), and pure + * repair: a rig with fully finite state is untouched byte-for-byte — the + * negative control in sail.selftest.js asserts a clean storm never emits + * 'heal'. Emits 'heal' {nodes, t} so a log can count what a bang cost. + * + * @returns {number} nodes healed + */ + _healNonFinite() { + const pos = this.pos, prev = this.prev, w = this.water; + const n = this.invMass.length; + // A NaN pond is poison whether or not its node's position survived. On + // the divergence path today this is a SECOND wire — the break's + // dumpPond('corner blew') already fill(0)s the whole pond — but the heal's + // own contract must not depend on the dump's side effect: if divergence + // ever stops dumping (a "the cloth healed, keep the water" retune), the + // NaN pond would corrupt pondMass/centroid/broom forever. Unit-asserted + // directly in sail.selftest.js for exactly that reason. + for (let i = 0; i < n; i++) if (!Number.isFinite(w[i])) w[i] = 0; + + const sick = new Uint8Array(n); // 1 = position needs a donor + let sickCount = 0, anyFinite = false, healed = 0; + for (let i = 0; i < n; i++) { + const x = i * 3; + if (Number.isFinite(pos[x]) && Number.isFinite(pos[x + 1]) && Number.isFinite(pos[x + 2])) { + anyFinite = true; + // finite position, poisoned velocity: match prev, keep the position + if (!Number.isFinite(prev[x]) || !Number.isFinite(prev[x + 1]) || !Number.isFinite(prev[x + 2])) { + prev[x] = pos[x]; prev[x + 1] = pos[x + 1]; prev[x + 2] = pos[x + 2]; + healed++; + } + } else { sick[i] = 1; sickCount++; } + } + if (sickCount) { + // Whole cloth gone: re-seed the corners from their anchors — the one + // world position a rig always knows — then flood from there. + if (!anyFinite) { + for (let k = 0; k < 4; k++) { + const ci = this.cornerIdx[k], x = ci * 3; + const p = this._anchorPos(this.corners[k].anchor, this.t); + pos[x] = prev[x] = p.x; pos[x + 1] = prev[x + 1] = p.y; pos[x + 2] = prev[x + 2] = p.z; + if (sick[ci]) { sick[ci] = 0; sickCount--; healed++; } + } + } + // Ring-by-ring flood: each pass, a sick node bordering the finite region + // copies that neighbour and joins it, so positions march inward from the + // nearest healthy cloth. Terminates: the grid is connected, so every + // pass with sick nodes left heals at least one. + let changed = true; + while (sickCount && changed) { + changed = false; + for (let i = 0; i < n; i++) { + if (!sick[i]) continue; + for (let k = 0; k < 8; k++) { + const m = this._nbr[i * 8 + k]; + if (m < 0 || sick[m]) continue; + const x = i * 3, mx = m * 3; + pos[x] = prev[x] = pos[mx]; + pos[x + 1] = prev[x + 1] = pos[mx + 1]; + pos[x + 2] = prev[x + 2] = pos[mx + 2]; + sick[i] = 0; sickCount--; healed++; changed = true; + break; + } + } + } + } + if (healed) this.events.emit('heal', { type: 'heal', nodes: healed, t: this.t }); + return healed; + } + // --- Lane D's seam (SPRINT2 decision 4) -------------------------------- // D landed first and duck-typed these against the rig, so B conforms to D's // spelling rather than the other way round. Thin aliases on purpose: the diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js index 5fffc0d..91b1422 100644 --- a/web/world/js/sail.selftest.js +++ b/web/world/js/sail.selftest.js @@ -219,6 +219,114 @@ test('divergence BREAKS on the production path — a NaN corner cannot hold fore return `${broke.filter((e) => e.reason === 'divergence').length}/4 corners let go by divergence, HUD reads 0`; }); +// --- SPRINT16 gate 2.1: THE HEAL ------------------------------------------- +// The break above (fresh-eyes review) made divergence detectable; these pin +// that it is also SURVIVABLE TO LOOK AT. Before the heal, the freed corner +// integrated from the NaN the solver left in the spring network and the cloth +// after a bang was a shredded mess, not a cloth. The three tests are one +// mechanism, three poison shapes: position NaN, velocity (prev) NaN via the +// pond weight, and the negative control (honest breaks must never heal). + +test('divergence heal: after the bang every node is finite within 1 s, prev matched', () => { + // Shipped path: watchDivergence OFF, same rig shape as the break test above. + const scenario = () => { + const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 }); + r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0); + const broke = [], heals = []; + r.events.on('break', (e) => broke.push(e)); + r.events.on('heal', (e) => heals.push(e)); + const w = makeStubWind({ stormLen: 90 }); + runStorm(r, w, 2); + assert(heals.length === 0, 'a healthy cloth healed something — the heal must be pure repair, never maintenance'); + const poisoned = r.N + 1; // interior node, review's shape + r.pos[poisoned * 3 + 1] = NaN; + // step one substep at a time so the break substep is observable exactly + let steps = 0; + while (!broke.some((e) => e.reason === 'divergence') && steps < Math.round(2 / SIM_DT)) { + r.step(SIM_DT, w, (2 + steps * SIM_DT)); steps++; + } + assert(broke.some((e) => e.reason === 'divergence'), 'poison never broke a corner — the review\'s break regressed'); + assert(heals.length > 0 && heals[0].nodes > 0, 'the divergence break fired and the heal did not'); + // Immediately after the break substep: finite, and the healed node's prev + // is MATCHED — verlet must read zero velocity out of the heal, not a launch. + for (const v of r.pos) assert(Number.isFinite(v), 'node position still non-finite right after the heal substep'); + for (const v of r.prev) assert(Number.isFinite(v), 'node prev still non-finite right after the heal substep'); + for (let k = 0; k < 3; k++) { + assert(r.pos[poisoned * 3 + k] === r.prev[poisoned * 3 + k], + 'healed node has pos != prev — the heal invented velocity for verlet to integrate'); + } + // ...and the spec's bound: still finite a full second of sim later. + runStorm(r, w, 1); + for (const v of r.pos) assert(Number.isFinite(v), 'cloth went non-finite again inside 1 s — the heal did not hold'); + for (const v of r.water) assert(Number.isFinite(v), 'water went non-finite inside 1 s of the heal'); + return { pos: Float64Array.from(r.pos), heals: heals.length }; + }; + const a = scenario(), b2 = scenario(); + assert(a.pos.length === b2.pos.length, 'determinism runs differ in size'); + for (let i = 0; i < a.pos.length; i++) assert(a.pos[i] === b2.pos[i], `heal is nondeterministic: node component ${i} differs across identical runs`); + return `poison → break → healed (${a.heals} heal event(s)), finite 1 s on, byte-equal across two runs`; +}); + +test('divergence heal: the NaN pond is zeroed with the positions, and stays zeroed under live rain', () => { + // Poison shape 2: divergence with the WATER poisoned too. This is what a real + // blow-up leaves behind while it rains — NaN face normals feed NaN into + // _nodeFlat and the rain-add writes it into the water field — so the test + // injects both halves deterministically rather than racing the rain clock. + // A heal that only fixed positions would leave pondMass() NaN forever: the + // weight guard's `w[n] > 0` happens to filter NaN today, but the HUD's + // ponding line, the broom's centroid and the conservation asserts all read + // the water field directly, and one refactor of that guard makes a NaN pond + // live poison. The heal zeroes it at the same moment it heals the cloth. + const r = rig(HEIGHTS_FLAT, { hw: UNBREAKABLE }); + r.watchDivergence = false; // shipped path, as above + const broke = [], heals = []; + r.events.on('break', (e) => broke.push(e)); + r.events.on('heal', (e) => heals.push(e)); + const w = realWind(); // real storm: rain keeps landing after the heal + runStorm(r, w, 40); + assert(r.pondMass() > 50, `only ${r.pondMass().toFixed(0)} kg ponded before the poison — the water half of this test is vacuous`); + const belly = Math.floor(r.N / 2) * r.N + Math.floor(r.N / 2); + r.water[belly] = NaN; + r.pos[belly * 3 + 1] = NaN; + runStorm(r, w, 2); + assert(broke.some((e) => e.reason === 'divergence'), 'the poisoned belly never broke a corner — the review\'s break regressed'); + assert(heals.length > 0, 'the poison broke a corner and nothing healed'); + runStorm(r, w, 1); + for (const v of r.pos) assert(Number.isFinite(v), 'cloth non-finite 1 s after the heal — the pond re-poisoned it'); + for (const v of r.water) assert(Number.isFinite(v), 'water still non-finite after the heal — the heal is not zeroing the pond'); + assert(Number.isFinite(r.pondMass()), 'pondMass still NaN after the heal — the HUD broom line would read NaN'); + // Honest disclosure, measured: on the storm path above the water is ALREADY + // clean by heal time, because the divergence break's dumpPond('corner blew') + // fill(0)s the pond first — so the asserts above cannot isolate the heal's + // own water pass. Pin that contract at the unit level instead: the heal must + // zero non-finite water WITHOUT a dump in front of it, so the second wire is + // live the day the dump's semantics change. + r.water[belly] = NaN; + const healedNodes = r._healNonFinite(); + assert(r.water[belly] === 0, 'heal left NaN water in place — it is leaning on dumpPond to clean the pond'); + assert(Number.isFinite(r.pondMass()), 'pondMass still NaN after a direct heal'); + return `poisoned belly + pond → divergence break at t=${broke.find((e) => e.reason === 'divergence').t.toFixed(1)}s → healed, ` + + `pond finite under live rain; direct heal zeroes NaN water (${healedNodes} position(s) touched)`; +}); + +test('divergence heal: negative control — honest overload breaks never trigger it', () => { + // The heal must be IN ADDITION to the break, not a hand that touches every + // failure: a cheap rig blowing corners honestly (finite loads, real weather) + // must ride the whole storm without one heal event. If this goes red, the + // heal has started editing healthy state, which is a sim change wearing a + // repair's name. + const w = makeStubWind({ seed: 11, stormLen: 90 }); + const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 }); // the flogging test's rig: it blows corners + const broke = [], heals = []; + r.events.on('break', (e) => broke.push(e)); + r.events.on('heal', (e) => heals.push(e)); + runStorm(r, w, 90); + assert(broke.length > 0, 'nothing blew — the control is vacuous, pick a cheaper rig'); + assert(broke.every((e) => e.reason !== 'divergence'), 'an honest overload break carried reason divergence'); + assert(heals.length === 0, `${heals.length} heal event(s) on honest breaks — the heal is touching finite state`); + return `${broke.length} honest break(s), zero heals — the heal only answers divergence`; +}); + 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.