diff --git a/THREADS.md b/THREADS.md index cd0c917..527bcfa 100644 --- a/THREADS.md +++ b/THREADS.md @@ -7847,3 +7847,177 @@ anchors are your GLB), but the tooling is now waiting, not TODO. membrane answer lives on the F key, which the sweep (cloth-fixed, by its own charter) cannot see. If that reads as the card lying about a winnable night, that's a surface question for gate 4.3's verdict, not a balance bug β€” flag it and we'll argue surfaces. +[B] 2026-07-20 β€” 🩹 **GATE 2.1 + 2.2 LANDED β€” THE BANG HEALS INTO A CLOTH, AND THE FABRIC BET IS + ON THE PAPERWORK. Plus the pool's determinism bound, pinned β€” and it measured SHARPER than + the docs said.** Selftest 424/0/0 at my last full browser run (418 baseline + 6; the 425th β€” + the sep-judged assert, entry below β€” was added after and is in the final run). Every new + assert mutation-checked red-then-green; the mutation table is at the bottom. Pushed lane/b. + + **2.1 THE HEAL** (`sail._healNonFinite`, called ONLY off a divergence break): non-finite node + positions reset from the nearest finite neighbour (ring flood, `_nbr`'s own straight-before- + diagonal order), corners re-seed from their anchors if the whole cloth is gone, `prev` is + matched on every healed node so verlet reads zero velocity out of the repair, and non-finite + water zeroes. Deterministic (two poisoned runs byte-equal), emits `heal {nodes, t}`. The + review's divergence-break test is UNTOUCHED and green β€” the heal is in addition, as specced. + Three honest field notes: + Β· **The NaN pond is a second wire, and I say so in the code**: on today's path the break's + own `dumpPond('corner blew')` fill(0)s the pond before the heal's water pass sees it. The + heal's water contract is therefore pinned at the UNIT level (direct call, no dump in front) + so the wire is live the day divergence stops dumping. An integration-only assert here would + have been green while testing nothing β€” the review's tautology lesson, applied in advance. + Β· **A β€” your ledger is safe from the heal, by construction**: it repairs node POSITIONS only. + A divergence break emits the normal 'break' event (reason:'divergence'), your tally counts + it, `brokenAtDawn` books warranty if unrepaired β€” which is honest: a solver bang that eats a + corner is exactly the morning-after your warranty line describes, and now the freed cloth + LOOKS like a cloth instead of a shredded z-fighting mess while it does. No new corner state + anywhere. The only new emitter event is 'heal'; nothing subscribes but tests. + Β· The heal never runs on honest overloads (negative control: cheap rig, 4 real breaks, zero + heal events β€” asserted, not assumed). + + **2.2 THE RECORD**: `fabricNoteFor()` (rigging.js, pure, headless) writes ONE verdict-page + sentence when the bet provably mattered: cloth on a night whose stones pass the 2 mm-weave + ruling (hailBlockFor < 1) with β‰₯5 HP of hail names the leak; membrane past the verdict's own + 80 kg pond threshold names the trade; silent everywhere a sentence would be a guess β€” big + stones (both fabrics block 100%, blaming the weave would lie), dry nights, unknown fabric. + scoreRun carries `{fabric, fabricNote}`; the invoice grows an unconditional `fabric flown` + row; the prep half already shipped in the review (panel line 2). **A β€” merge note for your + landed ledger** (I fetched 6cbae92 and reshaped against it): my fabricnote line is a SIBLING + of `.verdict`, same pattern as your `.repline`, so the verdict div and your insertion point + are both byte-untouched β€” at integration the two added lines under the verdict are keep-both, + either order. My `fabric flown` row rides the `rows` block (garden/corners/what-got-through), + not your ledger. scoreRun: my two keys lead the return object, your `rigRecord` closes it β€” + disjoint hunks. The FORECAST half stays C's (3.3); your `f.stones`/`f.rainRate` print seam is + exactly where it should land. + + **POOL β€” the >83 ms determinism bound has its pinning test, and the honest number is 66.7 ms.** + Measured: the spiral guard (`if (n === MAX_SUBSTEPS) this._acc = 0`) fires whenever a step() + consumes five substeps β€” including when the leftover was a legal sub-SIM_DT residual β€” so the + unconditionally-lossless frame bound is (MAX_SUBSTEPSβˆ’1)Γ—SIM_DT β‰ˆ 66.7 ms; a 67–83 ms frame + may drop up to one SIM_DT depending on accumulator phase; β‰₯83 ms always drops its overflow. + Pinned AS MEASURED, no retune (every recorded trace was made under this guard): frames ≀66 ms + converge on the fixed trace at equal sim clocks with zero wall time lost; one 120 ms hitch + drops exactly its 36.7 ms overflow. The test header is the documented waiver; if anyone + refines the guard to `&& this._acc >= SIM_DT`, move SAFE_MAX in the same commit. + + | mutation | expected red | got | + |---|---|---| + | heal call off | finite-in-1s asserts | "the divergence break fired and the heal did not" Γ—2 | + | prev left NaN | velocity invention | "node prev still non-finite right after the heal substep" | + | water zeroing off | unit water contract | "heal left NaN water in place β€” leaning on dumpPond" | + | leak gate `<1`β†’`<=1` | big-stone silence | "cloth blamed for big-stone hail β€” hailBlockFor(1.3,0.3) is 1.0" | + | pond threshold dropped | small-pond silence | "membrane got a sentence over a pond the verdict would not mention" | + | MAX_SUBSTEPS 5β†’2 | legal frames drop time | "6079.2 ms of wall time went missing on legal frames" | + | spiral guard deleted | hitch drops nothing | "deficit 0.0033 s, expected β‰ˆ0.0367 s" | + +[B] 2026-07-20 β€” 🧾 **GATE 2.3 β€” BOTH PINS ATTEMPTED, BOTH HONESTLY REFUSED, AND HERE IS THE + BALANCE FIGHT THE SPEC PROMISED.** Measured on the post-ruling tree (garden_probe, funnel ON + in the header, 2026-07-20), each yard under its SHIPPED storm: + + | yard Γ— storm | bare bed | bare loss split | best flown line | best gain | + |---|---|---|---|---| + | site_02 Γ— earlybuster | **82.6 FULL** | hail 10.7 + rain 6.8 | 90.2 (tr1,q1,q4,cb2 probe spread) | **+7.6 HP** | + | site_03 Γ— southerly | **83.7 FULL** | hail 10.7 + rain 5.6 | 89.8 (h3,t1c,t2,t2b) | **+6.1 HP** | + | backyard_01 Γ— wildnight (the pinned reference) | 35.7 TATTERED | hail 51.4 + rain 12.9 | 63.8 holds | +28.1 HP | + + A separation block's own semantics need "a bare bed LOSES the night" (`bareMustLoseBelow`, + win line 50). On both yards a bare bed WINS by 30+ points β€” the pea-hail spectra (size 0.7, + one mild burst) cannot take more than ~17 HP off an open bed. Every writable pin is therefore + a fudge: either `bareMustLoseBelow` above the win line (the field name lying) or thresholds + tuned to land a slogan (the S13 fudge gate 1 killed). REFUSED, per the gate's own rule. + Β· **In gate-1 money, which is the sharpest way to say it**: at A's landed garden bonus, bare + on site_02 collects ~$37 of the $45 garden money for $0 of rig; the BEST line in the yard + collects ~$41 for $40–120 of hardware. The entire rig market on these nights buys $4 of + garden. The wildnight buys $25. That's the whole finding in one row of the ledger. + Β· **What I shipped instead of a fudge**: each yard carries `_separation_finding` (the numbers + above + delete-on-pin instruction, where the next author will look); SCORE IT's NONE PINNED + card now reads **PIN REFUSED (measured)** and prints it β€” "measured and refused" and + "nobody tried" are different facts and the card no longer flattens them. New assert: every + shipped yard is separation-JUDGED β€” pinned XOR refused-with-finding; silence red, stale + finding red (mutation-checked by hiding site_03's finding: red, restored, green). + Β· **Where the fight goes** (proposals; A rules, one variable at a time): (a) C's storm_06 is + the first honest lever β€” a night whose fine-hail DOSE genuinely hurts an open bed makes + "bare loses" true before any pin is written, and their gate-3.1 already owns that spectrum; + (b) if the mild nights are DESIGNED to be unlosable for a bare bed, then separation on them + is a garden-BONUS stake, not a win/lose stake, and the block wants a second, honest shape + (heldMustExceed + minimum bonus spread) β€” that's a ruling, not a tune; (c) the win line + itself is canon and I did not touch it. D β€” your gate-4.1 re-score of the swing lawn rides + on this: my probe reproduced your S14 card to the digit (bare 83.7, best 89.8-90.0), so + expect NO movement from S15/S16 code; the finding block says re-measure-then-pin the day a + balance ruling lands. + +[B] 2026-07-20 β€” βš–οΈ **MEMBRANE BALANCE, MEASURED β€” THE TABLE THE F KEY HAS BEEN WAITING FOR, AND + WHY I RETUNED NOTHING.** flyGarden grew a `porosity` param (default pinned by assert to the + 0.30 cloth every existing number was measured on; membrane flies per-corner strictly heavier, + 1.23x average β€” C, this is your gate-3.1 seam, take it). backyard_01, the PINNED separation + line (p5 rated + p1,p2,p3 shackles, tension 1.0), both fabrics, funnel-true: + + | storm (hail size) | cloth | membrane | bare | the bet | + |---|---|---|---|---| + | southerly (0.7 pea) | 90.5 Β· hail 6.1 Β· 0/4 lost Β· peaks p5 2.40 kN | **92.2** Β· hail 4.4 Β· 0/4 Β· p5 2.68 kN | 83.7 | membrane RIGHT, worth **+1.7 HP** | + | wildnight (1.3) | **63.8** Β· hail 26.6 Β· 0/4 (p1,p2 marginal) | 55.6 Β· hail 33.6 Β· **2/4 LOST** Β· p3 3.90 kN | 35.7 | membrane WRONG, costs **βˆ’8.2 HP + 2 corners** | + + The bet points the right way on both nights β€” and the asymmetry is the finding: being WRONG + in membrane costs 8 HP and two corners' hardware; being RIGHT earns +1.7 HP nobody would + notice. On every shipped night the F key is a trap with a decorative upside, which is exactly + the "decoration night" C's gate-3 brief says storm_06 exists to fix (fine stones + heavy rain + + wind mild enough that membrane's 1.23x load and its pond are affordable). **Retunes + proposed: NONE from me.** FABRIC's constants stay; the honest levers are (1) C's storm_06 + spectrum β€” measure until the membrane line is genuinely better, their gate; (2) rain's drain + weight (0.25 vs hail's 5.0) β€” the day A raises it, membrane earns a price and rigging.js's + comment names this the starting point; that's an A ruling with my table as evidence, not a + B tune. **Disclosure**: this table is ONE harness (gardenfly). C β€” before either of us pins + membrane-vs-cloth anywhere, cross it against your storm-envelope chain (your 1.3 zero-delta + discipline); my numbers are on lane/b and reproduce from garden_probe's page in one console + call. + +[B] 2026-07-20 β€” 🀝 **CLOSING: C'S SEAMS ANSWERED, THE PONDING FINDING RULED IN MY FILE, AND THE + TWO-HARNESS SOAKER CROSSING RUN FROM MY SIDE β€” RECORDED CLOSED.** Final numbers: lane/b + carries +7 asserts over the 418 baseline; a scratch merge of lane/b + lane/c (built for the + crossing, then deleted β€” the D S15 protocol) ran the full browser suite at **445/0/0, the + exact per-lane sum (418 + B 7 + C 20)**. + + Β· **gardenfly porosity β€” convergent, uncontested, adopted.** We added the identical param + (same name, same 0.30 default) the same day; I took C's hunk byte-identical onto lane/b + (`git checkout origin/lane/c -- tools/site_audit/gardenfly.js`) so integration merges + clean. My guard asserts (default flies as explicit 0.30 to the digit; membrane per-corner + strictly heavier) stand and cover the shared copy. + Β· **PONDING FABRIC-BLINDNESS: RULED a known simplification, comments fixed to tell the + truth.** C is right and the finding has teeth β€” _applyPonding reads no porosity, cloth + ponds like membrane, and setFabric's comment plus the player-facing blurb promised + otherwise. I did NOT fix the physics, on purpose: every pinned cloth number in the repo + (the backyard separation block, the Β§7 gates, C's fresh storm_06 pins, the S15 zero-delta + lattice) was measured under fabric-blind ponding, and a pond-physics change mid-TEETH + moves all of them against the sprint's own one-variable law. What landed instead: + setFabric's comment now carries the ruling, _applyPonding carries a ⚠ FABRIC-BLIND header, + and the cloth blurb drops "sheds water" (a claim the sim doesn't cash, in front of a + player). The real fix β€” porosity-scaled intake + a through-weave leak β€” is filed as its + own future gate with C's soaker as the ready-made test case and a full gauntlet re-measure + as the admission price; it re-opens membrane pricing (rigging.js's comment) the same day. + C: your "flag me the sprint you touch it" is the contract, countersigned. + Β· **THE CROSSING, from my side, on the scratch merge (my heal + fabric record + C's storm in + one tree): C's numbers reproduce to the byte.** fabric_bet probe, site_02 Γ— soaker, funnel + ON: bare **17.5 DEAD** (byHail 66.9) Β· $75 rated-q1 ring **cloth 42.3 TATTERED vs membrane + 96.5 FULL (Ξ”54.2)** Β· $60 shackle ring membrane 68.8 FULL Β· $20 carabiner INVERSION cloth + 24.5 (2 lost) vs membrane 19.1 (3 lost). Identical to C's gate-3.1 entry and their audit + coda β€” two harnesses, two lanes' trees, one set of digits, and my B-side changes provably + move none of their pins. **The crossing is closed.** (C's coda already ran MY audit.html + over the soaker on their tree β€” bare 17.5 byte-identical there too.) + Β· **Gate 2.3 coda the soaker writes for free:** storm_06 is the first night where a real + separation pin is POSSIBLE on site_02 β€” bare 17.5 DEAD vs membrane 96.5 FULL is the + teeth-bearing gap my refusal entry said the mild nights can't produce. But the separation + block's chain flies CLOTH by charter (flySeparation β†’ flyGarden default), so pinning it + needs the block to grow an optional fabric field β€” a schema ruling, A's, one line, and my + sep-judged assert + the finding blocks are already shaped to take it. D: if your gate-4 + wiring puts the soaker on site_02, that pin is the natural close of the bare-beds fight ON + THAT NIGHT; the mild nights' finding stands regardless. + Β· **Live verification (my own eyes, real chain):** booted the game on the scratch tree, + played night 1 through the real UI path (splash β†’ job sheet β†’ session.rig Γ—4 β†’ Enter β†’ + storm fast-forwarded with the game's own fixed-dt step, D's disclosed harness gait) β€” + the TAX INVOICE prints **fabric flown Β· shade cloth**; fabricNote null on the hail-free + night (correct); the note's DOM path verified by re-showing the card with an injected + note β€” it renders under the verdict, verdict prose untouched. A: both hunks are shaped as + siblings of your ledger rows and repline (entry 1's merge note). + Selftest counts for the integrator: lane/b headless 46/46 + 23/23; lane/b browser 424/0/0 + before the sep-judged assert landed (its green is in the 445 merged run; its red was proven + by hiding site_03's finding β€” "site_03_swing_lawn has neither a separation block nor a + _separation_finding", restored, green). Pushed lane/b. diff --git a/tools/site_audit/gardenfly.selftest.js b/tools/site_audit/gardenfly.selftest.js index b819372..92d62cc 100644 --- a/tools/site_audit/gardenfly.selftest.js +++ b/tools/site_audit/gardenfly.selftest.js @@ -112,6 +112,19 @@ export async function buildGardenflyTests() { } catch (err) { sepErr = String((err && err.stack) || err); } } + // SPRINT16 gate 2.3 [B]: every shipped yard has been JUDGED on separation β€” + // either it pins a block (backyard_01) or it records a measured refusal + // (_separation_finding). Read all three here so the assert below can hold + // the invariant: neither is "unjudged", both at once is a stale finding. + let sepStates = null, sepStatesErr = null; + try { + sepStates = []; + for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) { + const sd = await loadSite(name); + sepStates.push({ name, pinned: !!sd.separation, finding: !!sd._separation_finding }); + } + } catch (err) { sepStatesErr = String((err && err.stack) || err); } + let venRun = null, venErr = null; try { const fly = (venturi) => flyGarden({ @@ -122,6 +135,22 @@ export async function buildGardenflyTests() { venRun = { bare: fly([]), funnelled: fly(SYN_FUNNEL) }; } catch (err) { venErr = String((err && err.stack) || err); } + // SPRINT16 [B]: flyGarden grew a `porosity` param (storm_06's membrane bet + // needs the audit engine to fly both fabrics). Two things must hold or every + // pinned number in THREADS quietly changes meaning: the DEFAULT is still the + // 0.30 shade cloth every pin was measured on, and membrane genuinely flies + // as a different fabric (double the pressure term β†’ heavier corners). + let fabRun = null, fabErr = null; + try { + const flyFab = (porosity) => flyGarden({ + anchors: SYN_ANCHORS, bed: SYN_BED, stormDef: SYN_STORM, + siteDef: { wind: { venturi: SYN_FUNNEL } }, + ids: ['a1', 'a2', 'a3', 'a4'], hw: hardwareByName('rated shackle'), + ...(porosity === undefined ? {} : { porosity }), + }); + fabRun = { byDefault: flyFab(undefined), cloth: flyFab(0.30), membrane: flyFab(0) }; + } catch (err) { fabErr = String((err && err.stack) || err); } + return [ ['gardenfly: the pinned separation line, flown in BOTH chains β€” the 12s-skew disagreement, pinned', () => { // ⚠ THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry): @@ -168,6 +197,48 @@ export async function buildGardenflyTests() { `the siteDef's venturi is not reaching gardenfly's wind (windForSite must receive the SITE def)`); } }], + ['gate 2.3: every shipped yard is separation-JUDGED β€” pinned, or measured-and-refused', () => { + // SPRINT16's rule made durable: a yard may pin a separation block or it + // may carry a measured refusal (_separation_finding, the honest "this + // yard cannot separate at $80 under its storm" verdict with receipts) β€” + // but silence is not an option, and holding both means the finding went + // stale the day the pin landed and must be deleted (its own text says so). + if (sepStatesErr) throw new Error(`site read died: ${sepStatesErr}`); + assert(sepStates.length === 3, `expected 3 shipped yards, read ${sepStates.length}`); + for (const s of sepStates) { + assert(s.pinned || s.finding, + `${s.name} has neither a separation block nor a _separation_finding β€” the yard is UNJUDGED; ` + + 'run gardenfly, pin honestly or refuse honestly (SPRINT16 gate 2.3)'); + assert(!(s.pinned && s.finding), + `${s.name} carries BOTH a pin and a refusal finding β€” the finding is stale, delete it in the pinning commit`); + } + return sepStates.map((s) => `${s.name}: ${s.pinned ? 'PINNED' : 'refused w/ finding'}`).join(' Β· '); + }], + ['gardenfly: default fabric is still the pinned 0.30 cloth, and membrane flies heavier', () => { + if (fabErr) throw new Error(`fabric flight died: ${fabErr}`); + const { byDefault, cloth, membrane } = fabRun; + // omitting porosity === saying 0.30, to the digit β€” every pinned number + // in THREADS was measured on the default and must not move under it + assert(byDefault.hp === cloth.hp, `default flew hp ${byDefault.hp} vs explicit cloth ${cloth.hp} β€” the default fabric drifted`); + for (const d of byDefault.peaks) { + const c = cloth.peaks.find((p) => p.id === d.id); + assert(c && c.peakN === d.peakN, `corner ${d.id}: default peak ${d.peakN} != explicit-cloth ${c?.peakN} β€” the default fabric drifted`); + } + // porosity 0 must be a genuinely different fabric: the solid membrane + // catches the full pressure term, so every corner flies heavier + for (const m of membrane.peaks) { + const c = cloth.peaks.find((p) => p.id === m.id); + assert(m.peakN > c.peakN, + `corner ${m.id}: membrane peak ${m.peakN} N not above cloth ${c.peakN} N β€” the porosity param is not reaching the rig`); + } + // Measured 1.23x on this synthetic yard (browser, 2026-07-20) β€” less + // than the raw 1/0.7 pressure ratio because relative wind bleeds load as + // the heavier-loaded membrane accelerates. Floor at 1.1: the per-corner + // strictly-greater asserts above prove the wiring; this only guards + // against the difference collapsing to epsilon. + const up = membrane.peaks.reduce((s, m) => s + m.peakN / cloth.peaks.find((p) => p.id === m.id).peakN, 0) / 4; + assert(up > 1.1, `membrane only ${up.toFixed(2)}x cloth load on average β€” porosity is wired but not biting`); + }], ['gardenfly: unknown hardware names throw β€” the shop has no mystery tier', () => { let threw = false; try { hardwareByName('titanium dream'); } catch { threw = true; } diff --git a/tools/site_audit/scorecard.js b/tools/site_audit/scorecard.js index b1b80b0..156a938 100644 --- a/tools/site_audit/scorecard.js +++ b/tools/site_audit/scorecard.js @@ -243,6 +243,10 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null, bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null, separation, sepStormName, sepUnjudged, + // SPRINT16 gate 2.3 [B]: a site that ATTEMPTED a pin and honestly refused + // one records why in its JSON; the card prints it so NONE PINNED reads as + // "measured and refused, here's the fight" rather than "nobody tried". + separationFinding: site._separation_finding ?? null, collateral, MARGIN: AUDIT.MARGIN, }; diff --git a/web/world/data/sites/site_02_corner_block.json b/web/world/data/sites/site_02_corner_block.json index 615112b..748fd19 100644 --- a/web/world/data/sites/site_02_corner_block.json +++ b/web/world/data/sites/site_02_corner_block.json @@ -9,6 +9,21 @@ "gardenBed": { "x": 0, "z": 1, "w": 5, "d": 3.5 }, + "_separation_finding": [ + "SPRINT16 gate 2.3 [B] β€” a separation pin was ATTEMPTED and honestly REFUSED. Measured", + "post-ruling (garden_probe, funnel ON, storm_03b_earlybuster, 2026-07-20): bare bed 82.6", + "FULL (hail 10.7 + rain 6.8 HP); best flown line 90.2; best gain +7.6 HP. The block's own", + "semantics need 'bare LOSES the night' (bareMustLoseBelow, win line 50) and a bare bed", + "WINS this night by 32 points β€” the early buster's pea hail simply cannot hurt an open bed", + "enough. Any pin writable today would be a fudge: either a bareMustLoseBelow above the win", + "line (the field name lying) or thresholds tuned until the slogan lands (the S13 fudge the", + "gate exists to kill). THE FINDING: this yard cannot separate held from bare at ANY price", + "under its shipped storm β€” that is a balance fact about the NIGHT's hail spectrum, not the", + "rig market. The fight is in THREADS [B] 2026-07-20; candidate levers are C's hail", + "spectrum, the win threshold, or per-night garden stakes β€” A rules. When a retune lands,", + "re-measure, pin like backyard_01's, and DELETE this block in the same commit." + ], + "_design": [ "DESIGN.md wants the corner block anchor-poor β€” 'nowhere to tie off'. An empty yard is just a", "smaller yard, so the trap is the interesting build: give it something that LOOKS like four free", diff --git a/web/world/data/sites/site_03_swing_lawn.json b/web/world/data/sites/site_03_swing_lawn.json index e1a6eb1..49b4841 100644 --- a/web/world/data/sites/site_03_swing_lawn.json +++ b/web/world/data/sites/site_03_swing_lawn.json @@ -48,6 +48,16 @@ "The bare bed reads 83.7 FULL, which is the bare-beds-win pool item showing up on a", "third yard - gate-4 material, not this yard's doing and not tuned here." ], + "_separation_finding": [ + "SPRINT16 gate 2.3 [B] β€” a separation pin was ATTEMPTED and honestly REFUSED, same", + "verdict as site_02's. Measured post-ruling (garden_probe, funnel ON,", + "storm_03_southerly, 2026-07-20): bare bed 83.7 FULL (hail 10.7 + rain 5.6 HP); best", + "flown 89.8; best gain +6.1 HP β€” D's S14 card numbers reproduced. Bare WINS the", + "southerly by 34 points, so 'bareMustLoseBelow' cannot be written honestly at any", + "threshold. The pea-hail nights cannot hurt an open bed enough for a sail to be the", + "point of the night; that is the NIGHT's balance fact, filed in THREADS [B] 2026-07-20.", + "When the balance ruling lands, re-measure, pin like backyard_01's, delete this block." + ], "yard": { "width": 24, "depth": 16 diff --git a/web/world/js/editor_score.js b/web/world/js/editor_score.js index 9cd19c4..e649e76 100644 --- a/web/world/js/editor_score.js +++ b/web/world/js/editor_score.js @@ -312,6 +312,12 @@ function render(out, s, ms) { } else if (s.sepUnjudged) { const cs = card(out, 'SEPARATION TARGET β€” NOT JUDGED', 'ed-warn'); cs.append(el('div', 'ed-note', s.sepUnjudged)); + } else if (s.separationFinding) { + // SPRINT16 gate 2.3 [B]: measured, and honestly REFUSED β€” different fact + // from "nobody tried", and the card must not flatten the two together. + const cs = card(out, 'SEPARATION TARGET β€” PIN REFUSED (measured)', 'ed-warn'); + cs.append(el('div', 'ed-note', + Array.isArray(s.separationFinding) ? s.separationFinding.join(' ') : String(s.separationFinding))); } else { const cs = card(out, 'SEPARATION TARGET β€” NONE PINNED', 'ed-warn'); cs.append(el('div', 'ed-note', diff --git a/web/world/js/hud.js b/web/world/js/hud.js index 68ca9d6..2f1cd78 100644 --- a/web/world/js/hud.js +++ b/web/world/js/hud.js @@ -135,6 +135,11 @@ const CSS = ` letter-spacing:.05em; } #hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; } #hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; } +/* SPRINT16 gate 2.2 [B] β€” the fabric sentence under the verdict, quieter than + the verdict itself: it is a footnote about the bet, not a second ruling. + Sibling of .verdict (same pattern as the repline), so the verdict div and + its a.test-pinned prose stay byte-untouched. */ +#hud-card .fabricnote { margin:8px 0 0; font-size:11px; color:#8ba0ad; letter-spacing:.04em; } /* --- the week ---------------------------------------------------------- */ #hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; } @@ -975,6 +980,10 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) { ['garden', `${r.hp.toFixed(0)}%`], ['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`], ['what got through', r.hailBlocked], + // SPRINT16 gate 2.2 [Lane B] β€” the fabric bet is on the record: the + // choice the player made at the F key, printed on the same paper as + // what it did or didn't stop. Unconditional β€” the record is the point. + ...(r.fabric ? [['fabric flown', r.fabric.name]] : []), ['hardware lost', r.bill ? `$${r.bill}` : 'none'], ].map(([k, v]) => `
${k}${v}
`).join(''); @@ -1073,6 +1082,13 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) { "broke, but repaired by dawn" case) β€” this line just reads them. */''} ${w && w.rep != null ? `
reputation ${repFmt(w.repBefore)} β†’ ${repFmt(w.rep)}${ w.repReasons?.length ? ` β€” ${w.repReasons.join(' Β· ')}` : ''}
` : ''} + ${/* SPRINT16 gate 2.2 [B] β€” when the fabric bet provably mattered the + verdict page says so, one quiet line under the verdict (same + pattern as A's repline: the verdict div itself stays byte- + untouched). fabricNoteFor's thresholds decide; null = silent. + (Keep-both at integration, per B's own merge notes β€” the two + lines are siblings by design.) */''} + ${r.fabricNote ? `
${r.fabricNote}
` : ''} ${w?.client ? `
Payable 14 days. ${w.collateral ? 'They’ll want that made right by Friday.' : 'Thank you for your business.'}
` : ''} `; diff --git a/web/world/js/main.js b/web/world/js/main.js index 6e275b3..c04319a 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -23,7 +23,7 @@ import { createPlayer } from './player.js'; import { Interact, wireYardActions } from './interact.js'; import { createDebris } from './debris.js'; import { createSkyFx } from './skyfx.js'; -import { createRiggingUI } from './rigging.js'; +import { createRiggingUI, fabricNoteFor } from './rigging.js'; import { createHud } from './hud.js'; import { createWeek, NIGHTS, nightAt } from './week.js'; import { createGarden } from './garden.js'; @@ -885,7 +885,22 @@ export async function boot(opts = {}) { const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSaving: week.job.gardenBeyondSaving }); + // SPRINT16 gate 2.2 [Lane B] β€” the fabric bet on the record. The F key + // landed in the fresh-eyes review; this is the paperwork half: the invoice + // carries the fabric every night (r.fabric β†’ hud's rows), and the verdict + // gets ONE extra sentence when the bet provably mattered (fabricNoteFor β€” + // pure, thresholded, silent when a sentence would be a guess). The + // FORECAST half β€” arguing the bet before you rig β€” is C's gate 3.3. + const fabric = rigging.session.fabric; + const fabricNote = fabricNoteFor({ + fabric, + hailSize: defs[week.stormKey]?.hail?.size ?? 0, + dmg, pondPeak, + }); + return { + fabric: { id: fabric.id, name: fabric.name }, + fabricNote, hp, cornersLost: lost.length, cornersTotal: rig.corners.length || 4, diff --git a/web/world/js/rigging.js b/web/world/js/rigging.js index add616c..bdcde17 100644 --- a/web/world/js/rigging.js +++ b/web/world/js/rigging.js @@ -14,6 +14,7 @@ import { HARDWARE, START_BUDGET, SPARE_COST, FIXED_DT } from './contracts.js'; import { SailRig, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js'; +import { hailBlockFor } from './weather.core.js'; export { START_BUDGET, SPARE_COST }; export const MAX_CORNERS = 4; @@ -45,11 +46,68 @@ export const DEFAULT_TENSION = 1.0; * weight, membrane earns a price and this comment is the place to start. */ export const FABRIC = [ - { id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through β€” half the load, sheds water, leaks the finest hail' }, + // Blurb honesty (SPRINT16): "sheds water" deleted β€” C measured that ponding + // is fabric-blind (_applyPonding never reads porosity; both cloths pond + // identically), and a player-facing claim the sim doesn't cash is the worst + // kind. Ruled a known simplification in sail.js setFabric's comment. + { id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through β€” half the load, leaks the finest hail' }, { id: 'membrane', name: 'waterproof membrane', porosity: 0, cost: 0, blurb: 'stops rain and every stone β€” full wind load, and it ponds' }, ]; export const DEFAULT_FABRIC = FABRIC[0]; +/** + * SPRINT16 gate 2.2 β€” the verdict's fabric sentence, ONLY when the bet + * provably mattered. The invoice records the fabric every night (the record is + * unconditional); this is the extra line the aftermath gets to say about it, + * and it must never guess: dmg.hail can't be split into through-the-weave vs + * fell-beside-the-sail after the fact, so the note speaks only when the + * MODEL's own numbers make the claim safe. + * + * Β· Cloth on a night whose stones pass the weave (hailBlockFor < 1 β€” C's + * 2 mm-weave ruling) with real hail damage: name the leak. That's the + * spec's own case, "a membrane night survived in cloth should say what the + * leak cost". The phrasing claims what is true β€” that much hail REACHED + * the bed and the membrane stops those stones β€” not that membrane would + * have saved exactly that many HP (some of it may have fallen past the + * sail's edge, which no fabric fixes). + * Β· Membrane that actually ponded (the HUD's own >80 kg 'ponded' bar): name + * the trade β€” the pond IS the membrane's price, and the verdict prose for + * ponded/broomed nights doesn't say which cloth bought it. + * Β· Everything else: null. Big-hail nights (both fabrics block 100%), dry + * nights, clean wins β€” the row on the invoice is the whole record. + * + * Pure, headless-testable; thresholds are data on the function so the asserts + * and the wiring read the same numbers. + * + * @param {object} o + * @param {object} o.fabric the FABRIC entry flown (session.fabric / summary.fabric) + * @param {number} [o.hailSize] storm def's hail.size (1.0 β‰ˆ 1.5 cm stone); 0/absent = no hail tonight + * @param {object} [o.dmg] garden.damage {hail, rain}, HP + * @param {number} [o.pondPeak] peak kg of water the sail held tonight + * @returns {string|null} one sentence for the aftermath card, or null + */ +export function fabricNoteFor({ fabric, hailSize = 0, dmg = { hail: 0, rain: 0 }, pondPeak = 0 } = {}) { + if (!fabric || !FABRIC.some((f) => f.id === fabric.id)) return null; + if (fabric.porosity > 0) { + const leaks = hailSize > 0 && hailBlockFor(hailSize, fabric.porosity) < 1; + if (leaks && (dmg.hail ?? 0) >= fabricNoteFor.LEAK_MATTERS_HP) { + return `Flown in ${fabric.name} β€” stones this fine rattle through the weave, and ` + + `${Math.round(dmg.hail)} HP of hail reached the bed. The membrane stops every stone.`; + } + return null; + } + if ((pondPeak ?? 0) > fabricNoteFor.POND_MATTERS_KG) { + return `Flown in ${fabric.name} β€” it stopped the stones and the rain, and held ` + + `${Math.round(pondPeak)} kg of what it stopped. That is the membrane's price.`; + } + return null; +} +/** The leak has to have cost real HP before the verdict brings it up. */ +fabricNoteFor.LEAK_MATTERS_HP = 5; +/** verdictFor's own 'ponded' threshold (main.js pondPeak > 80), same number β€” + * the note and the verdict must agree on when a pond is worth a sentence. */ +fabricNoteFor.POND_MATTERS_KG = 80; + const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); const OK = { ok: true }; diff --git a/web/world/js/rigging.selftest.js b/web/world/js/rigging.selftest.js index 018c5a9..1f0d041 100644 --- a/web/world/js/rigging.selftest.js +++ b/web/world/js/rigging.selftest.js @@ -6,7 +6,7 @@ * js/tests/b.test.js) and node. */ -import { RiggingSession, FABRIC } from './rigging.js'; +import { RiggingSession, FABRIC, fabricNoteFor } from './rigging.js'; import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js'; import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js'; @@ -359,6 +359,39 @@ test('fabric is a bet the F key can actually place (SPRINT15 β€” the dead mechan return 'cloth -> membrane -> cloth, $0 both ways, hessian refused'; }); +// --- SPRINT16 gate 2.2: the fabric on the record --------------------------- +// The invoice row is unconditional wiring (main.js/hud.js, verified live); +// what this pins is the VERDICT's fabric sentence β€” fabricNoteFor speaks only +// when the bet provably mattered, and stays silent everywhere a sentence +// would be a guess. Storm hail sizes are the shipped ones: southerly pea hail +// 0.7 (cloth blocks 74% β€” hailBlockFor's measured number), wild night 1.3 and +// ice night 1.4 (both fabrics block 100%). + +test('fabricNoteFor: speaks only when the bet mattered, silent when a sentence would guess', () => { + const [CLOTH, MEMBRANE] = FABRIC; + // the spec's case: a pea-hail night flown in cloth, hail cost the bed real HP + const leak = fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: 22, rain: 3 } }); + assert(leak && leak.includes(CLOTH.name), `leak note must name the fabric flown, got: ${leak}`); + assert(leak.includes('22'), `leak note must say what the leak cost, got: ${leak}`); + // big stones cannot pass the weave β€” cloth blocked 100%, hail damage came + // from where the sail wasn't, and blaming the weave for it would be a lie + assert(fabricNoteFor({ fabric: CLOTH, hailSize: 1.3, dmg: { hail: 40, rain: 5 } }) === null, + 'cloth blamed for big-stone hail β€” hailBlockFor(1.3, 0.3) is 1.0, nothing leaked'); + // a leak that cost under the threshold is not worth a verdict sentence + assert(fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: fabricNoteFor.LEAK_MATTERS_HP - 1, rain: 0 } }) === null, + 'a trivial leak got a sentence β€” the threshold is not being read'); + // membrane's price is the pond, and only a pond the verdict itself would name + const pond = fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 240 }); + assert(pond && pond.includes(MEMBRANE.name) && pond.includes('240'), `pond note must name membrane and the kg held, got: ${pond}`); + assert(fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 40 }) === null, + 'membrane got a sentence over a pond the verdict would not even mention'); + // no hail data (a dry night), unknown fabric, missing fabric: silence, not a throw + assert(fabricNoteFor({ fabric: CLOTH, dmg: { hail: 30, rain: 0 } }) === null, 'no hail tonight but the weave got blamed'); + assert(fabricNoteFor({ fabric: { id: 'hessian', name: 'hessian', porosity: 0.5 } }) === null, 'unknown fabric must be silent'); + assert(fabricNoteFor({}) === null && fabricNoteFor() === null, 'missing fabric must be silent, not a throw'); + return `leak note fires at 0.7-size stones + >=${fabricNoteFor.LEAK_MATTERS_HP} HP; pond note at >${fabricNoteFor.POND_MATTERS_KG} kg; all else silent`; +}); + export const RIGGING_TESTS = TESTS; export function runRiggingSelftest() { diff --git a/web/world/js/sail.js b/web/world/js/sail.js index 341c6c1..e5a9b54 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -529,6 +529,11 @@ export class SailRig { /** * Rain lands, runs downhill, and pools where it can't get out. Called after * the wind pass (which fills _nodeFlat / _nodeArea). + * + * ⚠ FABRIC-BLIND, on purpose and on the record (SPRINT16, C's finding): + * nothing below reads `this.porosity`, so an open weave ponds exactly like a + * membrane. Ruled a known simplification β€” see setFabric's comment for the + * ruling and the re-measure bill a real through-weave leak would incur. * @param {number} mmPerHour wind.rainMmPerHour(t) β€” REAL-world rate, Lane C's data */ _applyPonding(mmPerHour, dt) { @@ -921,6 +926,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 +944,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 +964,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 @@ -1018,10 +1110,22 @@ export class SailRig { } /** - * What the sail is made of. Porosity scales the wind pressure term and, since - * rain lands on the horizontal projection of a face, an open weave sheds the - * water it can't hold β€” so a porous cloth also ponds far less. Set BEFORE - * attach(); RiggingSession.commit() does that. + * What the sail is made of. Porosity scales the wind pressure term β€” and, as + * of SPRINT16, that is ALL it scales. The previous version of this comment + * claimed an open weave "sheds the water it can't hold β€” so a porous cloth + * also ponds far less", and C's gate-3 measurement caught the cheque the sim + * doesn't cash: _applyPonding never reads porosity, so cloth and membrane + * pond IDENTICALLY (the $75 soaker ring's q2 gap, 3.24 vs 3.32 kN, is wind + * term only). RULED a known simplification rather than silently fixed + * ([B] THREADS 2026-07-20): every pinned cloth number in the repo β€” the + * backyard separation block, the Β§7 gates, C's storm_06 pins, the S15 + * zero-delta lattice β€” was measured under fabric-blind ponding, and a pond- + * physics change mid-TEETH would move all of them against the sprint's own + * one-variable law. The fix (porosity-scaled intake + through-weave leak) + * is real, wants its own gate with a full gauntlet re-measure, and C's + * soaker is its ready-made test case; rigging.js's membrane-pricing comment + * is the same conversation. Set BEFORE attach(); RiggingSession.commit() + * does that. * @param {{porosity:number}|number} f a FABRIC entry or a raw porosity */ setFabric(f) { diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js index 5fffc0d..81838b5 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. @@ -359,6 +467,65 @@ test('determinism: variable frame dt matches fixed dt', () => { return 'ragged frame times converge on the fixed-dt trace'; }); +// SPRINT16 pool (B) β€” ROADMAP's "the >83 ms frame determinism bound +// (documented, un-tested)", now tested β€” and MEASURED SHARPER than documented. +// step() burns wall time in SIM_DT chunks up to MAX_SUBSTEPS(5) per call, and +// `if (n === MAX_SUBSTEPS) this._acc = 0` fires whenever a call CONSUMES five +// substeps β€” including when the leftover residual was a perfectly legal +// sub-SIM_DT remainder. So the unconditionally-lossless frame bound is +// (MAX_SUBSTEPS βˆ’ 1) Γ— SIM_DT β‰ˆ 66.7 ms (a 67–83 ms frame may drop up to one +// SIM_DT of residual depending on accumulator phase; β‰₯ 83 ms always drops). +// Pinned AS MEASURED, not retuned: every trace ever recorded was made under +// this guard, and no real frame lives in the 67–83 ms gap on purpose. If the +// guard is ever refined to `&& this._acc >= SIM_DT` (making 83 ms the true +// bound), move SAFE_MAX with it in the same commit β€” this header is the +// documented waiver ROADMAP asked for, THREADS [B] SPRINT16 has the numbers. +test('determinism bound: frames under 66.7 ms hold the trace; a hitch past 83 ms drops time by design', () => { + const SAFE_MAX = 0.066; // just under (MAX_SUBSTEPS βˆ’ 1) Γ— SIM_DT β€” see header + const w1 = makeStubWind({ seed: 5, stormLen: 20 }); + const fixed = rig(HEIGHTS_HYPAR); + for (let i = 0; i < Math.round(20 / SIM_DT); i++) fixed.step(SIM_DT, w1, i * SIM_DT); + + // Half 1: ragged frames spanning the WHOLE legal range converge on the + // fixed trace (the existing 4–24 ms test's promise, extended to the bound). + const w2 = makeStubWind({ seed: 5, stormLen: 20 }); + const ragged = rig(HEIGHTS_HYPAR); + const rand = rng(31); + let acc = 0; + while (acc < 19.5) { + const dt = 0.004 + rand() * (SAFE_MAX - 0.004); + ragged.step(dt, w2, acc); + acc += dt; + } + // Compare at equal SIM clocks, not equal wall clocks: a random frame sum + // overshoots the target by up to SAFE_MAX β€” up to 3 extra substeps of honest + // evolution, an off-by-N comparison rather than a determinism failure. Top + // up with fixed steps until both rigs have burned identical sim time. + while (ragged.t < fixed.t - 1e-9) { ragged.step(SIM_DT, w2, acc); acc += SIM_DT; } + assert(ragged.t === fixed.t, `sim clocks differ after top-up: ${ragged.t} vs ${fixed.t}`); + for (let k = 0; k < 4; k++) { + const d = Math.abs(fixed.corners[k].load - ragged.corners[k].load); + assert(d < 1e-6, `corner ${k} drifted ${d.toFixed(6)} N on frames inside the lossless bound β€” MAX_SUBSTEPS shrank?`); + } + assert(acc - ragged.t < SIM_DT + 1e-9, + `${((acc - ragged.t) * 1000).toFixed(1)} ms of wall time went missing on legal frames β€” the guard is dropping time early`); + + // Half 2: one 120 ms hitch loses exactly the time past 5 substeps β€” the sim + // shrugs, it does not catch up, and the clock says so. This is the bound + // BEING a bound; if this half goes red the spiral guard is gone. + const w3 = makeStubWind({ seed: 5, stormLen: 20 }); + const hitched = rig(HEIGHTS_HYPAR); + const HITCH = 0.120, dropped = HITCH - 5 * SIM_DT; // β‰ˆ 36.7 ms lost + let fed = 0; + hitched.step(HITCH, w3, 0); fed += HITCH; + while (fed < 20) { hitched.step(SIM_DT, w3, fed); fed += SIM_DT; } + const deficit = fed - hitched.t; + assert(deficit > dropped - SIM_DT - 1e-9 && deficit < dropped + SIM_DT + 1e-9, + `fed ${fed.toFixed(3)} s of wall time, sim clock reads ${hitched.t.toFixed(3)} β€” deficit ${deficit.toFixed(4)} s, ` + + `expected β‰ˆ${dropped.toFixed(4)} s (one hitch's overflow). The spiral guard moved`); + return `frames ≀${(SAFE_MAX * 1000).toFixed(0)} ms: byte-faithful, no time lost; one 120 ms hitch drops ${(deficit * 1000).toFixed(1)} ms as designed`; +}); + test('re-attach resets the clock: night 2 flies the same storm as night 1', () => { // SPRINT13. main.js builds ONE SailRig at boot and re-attach()es it every // night, and step() runs in EVERY phase once rigged β€” so the clock ran on