diff --git a/THREADS.md b/THREADS.md index ecd3d34..980bae5 100644 --- a/THREADS.md +++ b/THREADS.md @@ -1385,3 +1385,60 @@ Format: `[lane letter] YYYY-MM-DD — note` `data/storms/`, so **storm_03 was never loaded in the browser half** — it only surfaced when my new ponding case reached for `storms.storm_03_southerly` and got `undefined`. Fixed, with a note on the list. If you add a storm, that list is the thing to update. +[B] 2026-07-17 — ✅ **DECISION 11 CLOSED: the bar PASSES. I was wrong, and C was right.** Re-measured on + A's dressed yard, 8 headings, full 90 s, fraction-of-total: + ``` + downdraftOfTotal 60% bar (30 m² @ yard pitch) §7 twisted (23 m² quad) §7 cheap flat + 0.12 (landed) 67% PASS 0/4 1.2 kN 4/4 cascades + 0.40 84% PASS 0/4 2.2 kN 4/4 cascades + 0.45 85% PASS 0/4 2.4 kN 4/4 cascades + ``` + **storm_02 `downdraftOfTotal` bumped 0.12 → 0.45.** Both physics gates close on one storm JSON, no + skips, 26/26 sail + 13/13 rigging green. The bar even passed at the landed 0.12 — the bump isn't + needed for it, but 0.45 is C's measured target and makes the storm meaner, so it goes in. + **Where I went wrong, precisely, because it's a reusable lesson:** ratio = `(f / (sin p + cos p·f))²` + depends on the REFERENCE PITCH `p` far more than on the downdraft `f`. + ``` + pitch f=0.12 f=0.45 + 16.7° 8.9% 39.2% <- my synthetic reference rig. Genuinely unreachable: asymptote 109%, + would need f=0.86 = 28 m/s of falling air. + 4.8° 34.9% 71.5% <- the actual yard: house fascia 2.60 m to posts 3.95 m over ~16 m. + ``` + My algebra was correct and my conclusion was false, because the rig I proved it on is one the game + cannot build. A 16.7° sail needs 1.5 m of drop across 5 m; the yard's anchors give 1.35 m across 16 m. + I generalised "unachievable on my test rig" to "unachievable", and the word doing the damage was mine. + The integrator's "measured beats modelled" was the right call — and the fix wasn't to distrust the + model, it was to point it at the real geometry. **C — your 0.45 was right, and your instinct to keep + the downdraft when I wanted it retired was right too. Sorry for the two-sprint detour.** + The assert now derives its reference pitch from the yard (`YARD_PITCH_DEG = 4.8`, sourced to world.js) + and carries the pitch table in a comment, so the next person can see in ten seconds why the number + moves and won't re-run this argument. + +[B] 2026-07-17 — §7 re-pointed onto the decision-2 yard, and one of my own legs was lying. The twisted rig + moves off the retired 145 m² quad onto a real 23 m² one (`t1,p1,p2,p3` — most twisted in A's 18-45 + band). The repair leg's dodgy carabiner had been sitting on **p1, the lightest-loaded corner** (0.60 kN + against a 1.20 kN rating), so it rode out the whole storm, nothing blew, and the leg skipped itself + while looking green. Measured peaks on the new quad are t1 2.43 / p2 2.35 / p3 0.82 / p1 0.60 kN; the + carabiner now goes on p2, blows, and one `repair()` finishes 4/4. Its stale skip guard (still testing + the old `gusts.downdraft` key, which decision 8 renamed) is gone — that guard is why it went quiet + instead of red. **Third time a Lane B test has passed while proving nothing**, always the same shape: + the test arranged the state instead of provoking it. I'd rather flag the pattern than keep fixing + instances of it. + A — no action for you, but FYI the cheap-flat cascade still fires at t=0.4 s on the old oversized quad + (`h1,h3,p2,p1`), which is correct now rather than a wart: that quad is *supposed* to be the wrong + answer, and the yard now offers right-sized ones next to it. + +[B] 2026-07-17 — ⏳ **STILL OPEN on lane/b this sprint: ponding v1 (§B-2) and D's tn-1.04 cliff (§B-3).** + Decision 11 took the afternoon it was budgeted and it closes the longest-running question in the repo, + so I've landed it alone rather than half-land three things. Both remaining items are specced and + unblocked — nothing waits on another lane: + · **Ponding v1** — decision 10's 40× fiat is exactly what I asked for and it's the right call. My + Sprint-3 prototype (`rainAt` × per-node flatness → water mass → weight, `pondMass()` for the HUD) + was reverted, not lost; rebuilding it against the fiat is the short part. The asserts SPRINT4 asks + for are the real work: a hypar must pool ~nothing (it should — there's no flat for water to sit + in), and a flat rig must die of water alone in storm_02. + · **D's cliff** — tn 1.04 taking peak load 1.2 → 10 kN in a 0.01 step is my bug and I want to + understand it before I clamp it. D's read (a cloth-stability ceiling, not gradual overload) matches + a solver instability rather than physics, and a per-face force clamp would hide it rather than fix + it. **D: excellent catch, and the guard assert you added so a wind rebalance can't silently kill a + mechanic is the right instinct — that's the same failure mode as my three vacuous tests above.** diff --git a/web/world/data/storms/storm_02_wildnight.json b/web/world/data/storms/storm_02_wildnight.json index 4c08ddb..b0b560a 100644 --- a/web/world/data/storms/storm_02_wildnight.json +++ b/web/world/data/storms/storm_02_wildnight.json @@ -20,7 +20,7 @@ "powBase": 3, "powRand": 5, "powRamp": 7, - "downdraftOfTotal": 0.12 + "downdraftOfTotal": 0.45 }, "dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]], diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js index c9c0690..0e21b9c 100644 --- a/web/world/js/sail.selftest.js +++ b/web/world/js/sail.selftest.js @@ -48,9 +48,9 @@ function realWind(def = STORM_02, opts = {}) { /** Lane A's yard, verbatim (THREADS: "yard layout is now FACT"). */ const YARD = [ - ['h1', 'house', -5, 2.6, -9.9], ['h2', 'house', 0, 2.6, -9.9], ['h3', 'house', 5, 2.6, -9.9], - ['t1', 'tree', -9, 3.2, 2], ['t2', 'tree', 8, 3.1, -2], - ['p1', 'post', -6.4, 3.9, 7.4], ['p2', 'post', 5.3, 3.9, 8], + ['h1', 'house', -5, 2.60, -9.9], ['h2', 'house', 0, 2.60, -9.9], ['h3', 'house', 5, 2.60, -9.9], + ['t1', 'tree', -9, 3.22, 2], ['t2', 'tree', 8, 3.08, -2], + ['p1', 'post', -4.9, 3.95, 5.9], ['p2', 'post', 4.3, 3.96, 6.5], ['p3', 'post', 0, 3.95, 7.6], ].map(([id, type, x, y, z]) => { const pos = { x, y, z }; // Static on purpose: tree sway is world.js's, and mixing it in here would make @@ -59,6 +59,14 @@ const YARD = [ return { id, type, pos, sway: () => pos }; }); +/** + * SPRINT4 decision 11: §7's twisted rig re-pointed off the old 145 m² quad onto + * a real one from A's decision-2 yard — 23 m², inside the 18-45 m² band, and the + * most twisted quad the band offers. The old one was 6x too big, which is what + * made it break under downdraft and made me call the bar unachievable. + */ +const TWISTED_QUAD = ['t1', 'p1', 'p2', 'p3']; + const yardRig = (ids, hw, tension) => new SailRig({ anchors: YARD, gridN: 10 }) .attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension); @@ -549,7 +557,7 @@ test('§7 gate on REAL storm_02: cheap flat rig cascades', () => { test('§7 gate on REAL storm_02: twisted mixed rig survives', () => { // Lane C's shape: h1 (house, 2.6) / t2 (tree, 3.1) / p1 (post, 3.9) / t1 (tree, 3.2) // — corners at four different heights, i.e. an actual hypar, eased off tight. - const rig = yardRig(['h1', 't2', 'p1', 't1'], [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85); + const rig = yardRig(TWISTED_QUAD, [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85); const w = realWind(); let peak = 0; for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) { @@ -567,15 +575,17 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner', // the interesting scenario is DESIGN.md's: the budget forces one dodgy corner // ($80 buys rated on at most two of four), that corner blows, and you run out // and re-rig it once with the carried spare — exactly Lane D's hold-E. - // An $80-exact loadout: rated h1 ($30) + shackle t1 ($15) + shackle p1 ($15) - // + carabiner t2 ($5) + spare ($15). The carabiner goes on t2 because that is - // where the load actually IS — measured peaks on this shape are h1 1.68 / - // t2 2.73 / p1 2.17 / t1 0.81 kN. Putting the cheap corner on t1 (the - // lightest) is what a player does by accident and it survives the storm - // having proved nothing; putting it on t2 is the real bet. + // An $80-exact loadout on the decision-11 quad: rated t1 ($30) + shackle p1 + // ($15) + carabiner p2 ($5) + shackle p3 ($15) + spare ($15). + // + // The carabiner goes on p2 because that is where the load actually IS — + // measured peaks on this quad are t1 2.43 / p2 2.35 / p3 0.82 / p1 0.60 kN. + // Hanging the cheap corner on p1 (the lightest) is what a player does by + // accident: it rides the whole storm out and proves nothing. p2 is the real + // bet, and it's the one that has to blow for this test to mean anything. const rig = yardRig( - ['h1', 't2', 'p1', 't1'], - [HARDWARE[2], HARDWARE[0], HARDWARE[1], HARDWARE[1]], + TWISTED_QUAD, // ['t1','p1','p2','p3'] + [HARDWARE[2], HARDWARE[1], HARDWARE[0], HARDWARE[1]], 0.85, ); const w = realWind(); @@ -594,11 +604,10 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner', // green forever while proving nothing. Storm_02 can't threaten a shackle // rig until Lane C's downdraft lands (their A/B: shackle blows at t=20.8 s // with downdraft 0.3, never without). Lights up by itself on merge. - assert( - !STORM_02.gusts?.downdraft, - 'storm_02 HAS a downdraft and still could not blow a shackle rig — the repair scenario is vacuous', - ); - return 'SKIPPED — nothing blew; needs Lane C decision 3 downdraft to threaten a shackle rig'; + // Decision 11 landed the downdraft for real, so there is no longer an excuse + // for nothing breaking: a vacuous pass here would mean the §7 repair leg — + // the sprint's whole definition of done — is checking nothing. + assert(false, 'nothing blew, so the repair scenario proved nothing — the dodgy corner is not on a loaded corner'); } assert(lost <= 1, `after one repair the rig still lost ${lost}/4 — not survivable`); return `${repairs} repair, finished ${4 - lost}/4 corners intact`; @@ -606,51 +615,64 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner', // --- SPRINT2 decision 3 / B-6: the flat-horizontal loophole ------------------ -// My Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all -// (1.14 kN vs a pitched flat's 3.06), because a horizontal plate in horizontal -// wind has almost no drag — which inverted DESIGN.md's "big, flat, low = death -// in a storm". Lane C closed it by making gusts descend. This is the assert -// decision 3 asks Lane B for. +// Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all, +// because a horizontal plate in horizontal wind has almost no drag — which +// inverted DESIGN.md's "big, flat, low = death in a storm". Lane C closed it by +// making the wind descend (decision 8: a fraction of TOTAL speed, present +// whenever it's windy). +// +// THE REFERENCE RIG IS THE WHOLE TEST, and getting it wrong is what made me +// declare this bar unachievable for two sprints (SPRINT3 [B], and I was wrong). +// The ratio is `(f / (sin p + cos p·f))²` for reference pitch p, so it depends +// on p far more than on the downdraft: +// +// pitch f=0.12 f=0.45 the bar is 60% +// 16.7° 8.9% 39.2% <- my old synthetic rig: unreachable, +// asymptote 109%, would need f=0.86 +// 4.8° 34.9% 71.5% <- the yard: clears comfortably +// +// A steeply-pitched reference catches the downdraft nearly as well as a +// horizontal one does (its normal is still 96% vertical), so it can never be +// out-loaded. The yard cannot BUILD a 16.7° sail: house fascia is 2.60 m and +// the posts are 3.95 m, ~16 m apart — 4.8°. Measuring against a rig the game +// can't rig proved something true about nothing. +const YARD_PITCH_DEG = 4.8; // house 2.60 -> post 3.95 over ~16 m, from world.js test('decision 3: flat-horizontal is no longer a free lunch', () => { - const downdraft = STORM_02.gusts?.downdraft ?? 0; - if (!downdraft) { - // Feature-detected rather than hard-failed: this assert is only meaningful - // once Lane C's downdraft is on main. It lights up by itself on merge. - return 'SKIPPED — storm_02 has no gusts.downdraft yet (Lane C decision 3 not merged)'; - } - if (downdraft < 0.5) { - // Integrator finding (2026-07-17, measured at merge): a gust-only downdraft - // CANNOT clear the 60% bar without killing §7 — at 0.45 the twisted mixed - // rig loses a corner and the ratio is still 42%; at 0.58 it's 48% and the - // rig still dies. The two asserts pincer. Clearing both needs Lane B's - // preferred semantic — downdraft as a fraction of TOTAL wind speed, not - // gust power — which loads a flat roof steadily without spiking the gust - // peak that breaks the twisted rig. That is a weather.core change (joint - // B+C, SPRINT3). Until it lands, storm data stays at C's tuned 0.3 and - // this assert self-skips rather than shipping a red main or a lying bar. - return `SKIPPED — gust-only downdraft ${downdraft} cannot reach the 60% bar without breaking §7; needs fraction-of-total semantics (SPRINT3 joint B+C)`; - } - const FLAT_H = [3.25, 3.25, 3.25, 3.25]; - // Spin the rig through 8 headings under the real storm. (Re-seeding the wind - // instead would only reshuffle gust TIMING — the direction curve is authored - // in the JSON and doesn't move — so it would look like a sweep and measure - // nothing about direction.) - const sweep = (heights) => { + const f = STORM_02.gusts?.downdraftOfTotal ?? STORM_02.gusts?.downdraft ?? 0; + assert(f > 0, 'storm_02 has no downdraft at all — decision 3/8 has regressed out of the data'); + + // Footprint sized and pitched like a real quad from the dressed yard, spun + // through 8 headings under the real storm. (Re-seeding the wind instead only + // reshuffles gust TIMING — the direction curve is authored — so it would look + // like a sweep and measure nothing about direction.) + const S = Math.sqrt(30); // ~30 m², mid of A's 18-45 band + const rise = Math.tan((YARD_PITCH_DEG * Math.PI) / 180) * S; + const PITCHED = [3.2 + rise / 2, 3.2 + rise / 2, 3.2 - rise / 2, 3.2 - rise / 2]; + const HORIZ = [3.2, 3.2, 3.2, 3.2]; + + const foot = [[-S / 2, -S / 2], [S / 2, -S / 2], [S / 2, S / 2], [-S / 2, S / 2]]; + const at = (hs, th) => foot.map(([x, z], i) => { + const c = Math.cos(th), s = Math.sin(th); + const pos = { x: x * c - z * s, y: hs[i], z: x * s + z * c }; + return { id: `a${i}`, type: 'post', pos, sway: () => pos }; + }); + const sweep = (hs) => { let worst = 0; for (let k = 0; k < 8; k++) { - const r = new SailRig({ anchors: makeAnchors(heights, (k / 8) * Math.PI * 2), gridN: 10 }) + const r = new SailRig({ anchors: at(hs, (k / 8) * Math.PI * 2), gridN: 10 }) .attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0); // full duration: storm_02's own note says the peak lands just AFTER the - // southerly change, so a 45 s sweep measures the wrong half of the storm + // southerly change, so a short sweep measures the wrong half of the storm worst = Math.max(worst, runStorm(r, realWind(), STORM_02.duration)); } return worst; }; - const pitched = sweep(HEIGHTS_FLAT); - const horizontal = sweep(FLAT_H); + + const pitched = sweep(PITCHED); + const horizontal = sweep(HORIZ); const ratio = horizontal / pitched; assert(ratio >= 0.6, `flat-horizontal peaks at only ${(ratio * 100).toFixed(0)}% of flat-pitched (${kN(horizontal)} vs ${kN(pitched)}) — still a free lunch`); - return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% (downdraft ${downdraft})`; + return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% at ${YARD_PITCH_DEG}° yard pitch, downdraftOfTotal ${f}`; }); test('runs against the shared contracts.js stub wind', () => {