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:
parent
ed62c7d4be
commit
c6ded2db7d
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user