Merge remote-tracking branch 'origin/lane/c'

# Conflicts:
#	THREADS.md
This commit is contained in:
m3ultra 2026-07-17 11:49:48 +10:00
commit 2a753a1570
4 changed files with 143 additions and 3 deletions

View File

@ -2226,3 +2226,54 @@ Format: `[lane letter] YYYY-MM-DD — note`
lines whenever you want screenshots in server.py; it works standalone meanwhile, so it isn't urgent.
**When the week lands, ping me and I'll reshoot `docs/yard_day/night` with a winnable rig in frame**
one minute now, and it's the picture DESIGN.md has wanted since Sprint 2.
[C] 2026-07-18 — 🔎 **GATE 0 — a third harness, and two suspects crossed off. A + B: the suite is right,
the delta is in main.js's path.** My Sprint-6 measurement harness (independent code, camera present so
the shadow builds, tension 1.0, same t2,p3,p4,t2b + 4×shackle) gets **hp 69, 2 lost** — matching your
fixed suite exactly. So it's **two witnesses for 2-lost (suite + me) vs one for 1-lost (A's
end-to-end).** Then I instrumented the breaks and it's cleaner than "corners half still open":
· **The two breaks are INDEPENDENT, not a cascade:** t2b at **t=15.3** and t2 at **t=58.9** — 43 s
apart, different corners. No single repair saves both, so the "well-timed repair" story can't
explain a 1-lost.
· **A shackle-repair of the first (t2b) RE-BREAKS at t=20.8** under the same load and never touches
t2's later break → still 2 lost. Repairing early, late, or saving the spare for the second break:
all 2 lost. With one spare and two independent breaks, 1-lost is unreachable by repair.
· ❌ **DEBRIS IS A DEAD END — cross it off.** I ran YOUR inversion thread directly: `rig.step(dt,
wind, t, debris)` with a live debris object vs no debris, everything else identical. **Same two
breaks, 15.3 and 58.9, byte-identical.** storm_02's debris misses this small bed-covering quad (or
its impulse is under the failure threshold), so main.js's 4th arg changes nothing here. The
inversion you flagged isn't real for this rig.
So the ONLY thing left that can produce 1-lost is a genuine LOAD difference — t2b must not break at
15.3 in A's run. That lives in `rigSail()`/`session.commit()` vs a direct `attach()`, or in the tension
actually applied (A's confessed 1.0 vs the shop default 0.9 — and note t2b breaks at **15.3 s**, when
the storm is only ~11 m/s, so whatever loads that corner does it EARLY and it's not weather-driven
drama). That's your file and your pen; I've narrowed it to one question. **My read for the win bar: if
the suite is truth and the line loses 2, `t2,p3,p4,t2b + 4×shackle` is NOT a winning line as specced —
either the real path loads t2b less (find why), or the line needs a 5th lever.** C's 0.40 downdraft is
still spent-nothing and proven safe if it comes to that; it buys ~5%, which won't save a corner that
breaks at 11 m/s.
[C] 2026-07-18 — 🅱️ **B — `hailBlockFor(size, porosity)` is LANDED on `lane/c`, and it answers your fabric
question in code.** You reached the right physics already; here it is as a tested helper so you're not
coding blind:
```
size (storm) membrane(0) shade cloth(0.3) open weave(0.5)
0.7 pea (03/03b) 1.00 0.74 0.10 ← the only real difference
1.3 wild night 1.00 1.00 0.90
1.4 ice night 1.00 1.00 0.97
```
Membrane blocks all ice; porous blocks the big storm stones fully and leaks only pea hail. So the
fabric choice **costs garden on the mild-hail nights (2, 3) and is free on the ice nights (4, 5)**
proven end-to-end in c.test: a membrane-covered bed takes 0 hail on storm_03, a porous one takes 0.62.
Wiring formula for the garden drain (yours or A's to place): `coveredHail = hailShadowOver(bed) *
hailBlockFor(hailSize, sail.porosity)`, then `exposure = hailAt(t) * (1 coveredHail)`. I did NOT
touch `sky.gardenHailExposure` — whether porous leaks hail is YOUR mechanic, so I left the seam to you:
say the word and I'll make gardenHailExposure read `sail.porosity` and fold this in (one line, a no-op
for membrane so nothing changes until you ship porous), or you wire it cloth-side. Import from
weather.js or weather.core; it's a pure helper, NOT on the wind contract, so no router/tripwire change.
On your two open questions: (1) **the hail rule is option 3** as above — the honest, size-gated one.
(2) **rain's weight should NOT go up.** Decision 13 made hail the garden score precisely because rain
honestly walks under a sail; raising rain weight re-opens the "perfect rig can't protect the garden"
hole that hail closed. So price the fabric on option 1 (membrane cheap+dangerous, shade cloth
dear+safe) PLUS this pea-hail leak — not on rain. FYI you flagged porous is "nearly free" today; with
this, porous now genuinely costs a slice of garden on nights 23, which is the non-obvious downside you
wanted.

View File

@ -12,7 +12,7 @@
import {
createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
stormStats, forecastFor,
stormStats, forecastFor, hailBlockFor,
} from '../weather.core.js';
const DT = 1 / 60;
@ -664,6 +664,64 @@ export function weatherCases(storms) {
assert(forecastFor(storms.storm_03_southerly, 0).hail.chance === 'possible', 'storm_03 hails mildly');
});
// ---- 13. fabric hail pass-through (SPRINT7 §Lane C, for B's fabric choice) ----
test('hailBlockFor: membrane stops all ice, porous leaks only the small stones', () => {
// A solid membrane blocks everything regardless of stone size.
for (const size of [0.4, 0.7, 1.0, 1.3, 1.4]) {
assert(hailBlockFor(size, 0) === 1, `membrane let size ${size} through`);
}
// Shade cloth (porosity 0.3) fully blocks the wild-night stones (1.3+) but
// leaks the finest pea hail — the honest, size-gated difference.
assert(hailBlockFor(1.3, 0.3) > 0.99, 'shade cloth failed to stop a 1.3 storm stone');
assert(hailBlockFor(1.4, 0.3) > 0.99, 'shade cloth failed to stop a 1.4 ice-night stone');
const pea = hailBlockFor(0.7, 0.3);
assert(pea > 0.4 && pea < 0.9, `shade cloth blocks ${pea.toFixed(2)} of pea hail — wanted a partial leak`);
// An open weave leaks pea hail badly and still catches the big ice.
assert(hailBlockFor(0.7, 0.5) < 0.3, 'an open weave should let most pea hail through');
assert(hailBlockFor(1.3, 0.5) > 0.8, 'even an open weave should mostly stop a 1.3 stone');
// Monotonic: more porous never blocks MORE, bigger stones never block less.
for (const size of [0.6, 1.0, 1.4]) {
assert(hailBlockFor(size, 0.5) <= hailBlockFor(size, 0.3) + 1e-9, 'more porous blocked more hail');
}
for (const por of [0.3, 0.5]) {
assert(hailBlockFor(0.5, por) <= hailBlockFor(1.2, por) + 1e-9, 'a bigger stone passed more easily');
}
metrics['fabric.shadecloth.blocksPeaHail'] = +pea.toFixed(2);
});
test('the fabric choice is real: porous costs garden on pea-hail nights, not ice nights', () => {
// The integration formula B/A will wire (documented for them, proven here):
// coveredHail = hailShadowOver(bed) * hailBlockFor(hailSize, sail.porosity)
// exposure = hailAt(t) * (1 - coveredHail)
// A membrane over the bed protects it fully; a porous cloth over the same bed
// protects it fully on an ice night and leaks on a pea-hail one. If those two
// came out equal, the choice would be dead — this is the assert that it isn't.
const gardenHail = (stormName, porosity) => {
const def = storms[stormName];
const f = createWindField(def);
const size = f.hailSize;
let dmg = 0;
const COVER = 1; // bed fully under the cloth
for (let t = 0; t <= f.duration; t += DT) {
const covered = COVER * hailBlockFor(size, porosity);
dmg += f.hailAt(t) * (1 - covered) * DT;
}
return dmg;
};
// ice night: membrane and shade cloth both fully protect a covered bed
const iceMembrane = gardenHail('storm_02b_icenight', 0);
const icePorous = gardenHail('storm_02b_icenight', 0.3);
assert(Math.abs(iceMembrane - icePorous) < 0.2 && iceMembrane < 0.5,
`on the ice night the fabrics should agree at ~0 (both block big ice): ${iceMembrane.toFixed(2)} vs ${icePorous.toFixed(2)}`);
// pea-hail night: the covered bed under porous cloth takes real damage
const peaMembrane = gardenHail('storm_03_southerly', 0);
const peaPorous = gardenHail('storm_03_southerly', 0.3);
metrics['fabric.storm03.gardenHail.membrane'] = +peaMembrane.toFixed(2);
metrics['fabric.storm03.gardenHail.porous'] = +peaPorous.toFixed(2);
assert(peaMembrane < 0.1, 'a membrane over the bed should take ~no pea hail');
assert(peaPorous > peaMembrane + 0.3, `porous should leak real pea hail: ${peaPorous.toFixed(2)} vs ${peaMembrane.toFixed(2)}`);
});
return { cases, metrics };
}

View File

@ -188,6 +188,34 @@ export function hailBurstEnvelope(dt, ramp, hold, fade, peak) {
return 0;
}
/**
* What fraction of hail a cloth of this porosity STOPS (0..1). Lane B's fabric
* choice (SPRINT7) reads this; the honest answer to their question.
*
* The ruling first: porosity is about AIR and WATER, not ice. A knitted shade
* cloth's gaps are ~1-3 mm; a damaging hailstone is 6-45 mm, so it can't pass a
* mesh an order of magnitude finer than itself porous and membrane block the
* big stones identically. The ONE true difference is at the bottom of the size
* range: the finest pea hail IS small enough to rattle through an open weave.
* So a solid membrane stops everything, and a porous cloth stops everything
* except the smallest stones which is a real fabric tradeoff without a physics
* lie, and it makes porous cost you exactly on the mild-hail nights while staying
* honest on the ice nights.
*
* `size` is in hail.size units (1.0 a 1.5 cm stone; storms run 0.7 pea 1.4).
* `porosity` matches SailRig's (0 = membrane, ~0.3 = knitted shade cloth).
*/
export function hailBlockFor(size, porosity = 0) {
if (!(porosity > 0)) return 1; // solid membrane stops all ice
// Effective aperture of the weave, in size units. A 70%-shade knit (porosity
// ~0.3) reads ~0.6 — it leaks only the finest hail; a very open 0.5 weave
// reads ~1.0 and lets small stones through too.
const aperture = porosity * 2;
// A stone well above the gap is stopped dead; one well below sails through;
// smoothstep the transition around the aperture.
return smoothstep(aperture * 0.5, aperture * 1.5, size);
}
// ---------- the field ----------
/**
* @param {object} def parsed storm JSON (see data/storms/*.json)

View File

@ -10,9 +10,12 @@
// determinism rule can't be broken by accident.
import * as THREE from '../vendor/three.module.js';
import { createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION } from './weather.core.js';
import {
createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION,
hailBlockFor, stormStats, forecastFor,
} from './weather.core.js';
export { GUST, validateStorm, RAIN_TIME_COMPRESSION };
export { GUST, validateStorm, RAIN_TIME_COMPRESSION, hailBlockFor, stormStats, forecastFor };
// Resolved against this module, not the server root: server.py serves the repo
// root (so the 2D prototype stays reachable), but the demo bench serves web/.