Lane B S16: gate 2.1 THE HEAL — divergence break now sanitizes the cloth

_healNonFinite(): on a divergence break, every non-finite node position
resets from its nearest finite neighbour (ring flood, corners re-seeded from
anchors if the whole cloth is gone), prev matched so verlet reads zero
velocity out of the heal, non-finite water zeroed (second wire behind the
break's dumpPond, unit-asserted so it can't rot). Deterministic, emits
'heal' {nodes, t}. Existing divergence-break test untouched and green.

Three new asserts (45/45 headless): finite-in-1s + prev matched + byte-equal
across two poisoned runs; NaN pond zeroed under live rain + direct-heal
water contract; negative control (honest overload breaks never heal).
Mutations, each red-then-green: heal off; prev left NaN; water zeroing off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 20:02:36 +10:00
parent ed62c7d4be
commit c6ded2db7d
2 changed files with 195 additions and 0 deletions

View File

@ -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

View File

@ -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.