Decision 11: the 60% bar passes. I was wrong; bump the storm to 0.45

Re-measured on A's dressed yard. The bar clears at every value tested —
67% at the landed 0.12, 84% at 0.40, 85% at 0.45 — and the twisted rig
holds 4/4 at all three. storm_02 downdraftOfTotal 0.12 -> 0.45 per
decision 11. Both physics gates now close on the same storm JSON.

My "mathematically unachievable" claim was wrong, and the algebra says
exactly why: ratio = (f / (sin p + cos p·f))^2 depends on the REFERENCE
PITCH far more than on the downdraft. My synthetic reference was pitched
16.7 deg, where the bar genuinely is unreachable (asymptote 109%, needs
f=0.86). The yard is pitched 4.8 deg — house fascia 2.60 m to posts
3.95 m over ~16 m — where 0.45 gives 72% on paper and 85% measured. The
equation was right and the rig was fiction: I was measuring against a
sail the game cannot build. The integrator's "measured beats modelled"
call was correct, and C's 0.45 was right on the geometry that ships.

The assert now derives its reference from the yard's real pitch and says
so, with the pitch table in a comment, so the next person to touch this
can see in ten seconds why the number moves.

Also re-pointed §7's twisted rig off the 145 m2 quad decision 2 retired
onto a real 23 m2 one ('t1,p1,p2,p3', most twisted in the 18-45 band).
The repair leg's dodgy carabiner moves to p2: measured peaks are t1 2.43
/ p2 2.35 / p3 0.82 / p1 0.60 kN, and it had been sitting on p1 — the
lightest corner — so it rode out the storm and the leg skipped. It now
blows and one repair finishes 4/4. Removed its stale skip guard, which
still tested the old gusts.downdraft key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 02:21:27 +10:00
parent 5d8264f13f
commit 7c699e776d
2 changed files with 74 additions and 52 deletions

View File

@ -20,7 +20,7 @@
"powBase": 3, "powBase": 3,
"powRand": 5, "powRand": 5,
"powRamp": 7, "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]], "dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],

View File

@ -48,9 +48,9 @@ function realWind(def = STORM_02, opts = {}) {
/** Lane A's yard, verbatim (THREADS: "yard layout is now FACT"). */ /** Lane A's yard, verbatim (THREADS: "yard layout is now FACT"). */
const YARD = [ const YARD = [
['h1', 'house', -5, 2.6, -9.9], ['h2', 'house', 0, 2.6, -9.9], ['h3', 'house', 5, 2.6, -9.9], ['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.2, 2], ['t2', 'tree', 8, 3.1, -2], ['t1', 'tree', -9, 3.22, 2], ['t2', 'tree', 8, 3.08, -2],
['p1', 'post', -6.4, 3.9, 7.4], ['p2', 'post', 5.3, 3.9, 8], ['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]) => { ].map(([id, type, x, y, z]) => {
const pos = { x, y, z }; const pos = { x, y, z };
// Static on purpose: tree sway is world.js's, and mixing it in here would make // 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 }; return { id, type, pos, sway: () => pos };
}); });
/**
* SPRINT4 decision 11: §7's twisted rig re-pointed off the old 145 quad onto
* a real one from A's decision-2 yard 23 , inside the 18-45 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) => const yardRig = (ids, hw, tension) =>
new SailRig({ anchors: YARD, gridN: 10 }) new SailRig({ anchors: YARD, gridN: 10 })
.attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension); .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', () => { 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) // 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. // — 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(); const w = realWind();
let peak = 0; let peak = 0;
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) { 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 // 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 // ($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. // 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) // An $80-exact loadout on the decision-11 quad: rated t1 ($30) + shackle p1
// + carabiner t2 ($5) + spare ($15). The carabiner goes on t2 because that is // ($15) + carabiner p2 ($5) + shackle p3 ($15) + spare ($15).
// 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 // The carabiner goes on p2 because that is where the load actually IS —
// lightest) is what a player does by accident and it survives the storm // measured peaks on this quad are t1 2.43 / p2 2.35 / p3 0.82 / p1 0.60 kN.
// having proved nothing; putting it on t2 is the real bet. // 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( const rig = yardRig(
['h1', 't2', 'p1', 't1'], TWISTED_QUAD, // ['t1','p1','p2','p3']
[HARDWARE[2], HARDWARE[0], HARDWARE[1], HARDWARE[1]], [HARDWARE[2], HARDWARE[1], HARDWARE[0], HARDWARE[1]],
0.85, 0.85,
); );
const w = realWind(); 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 // 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 // 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. // with downdraft 0.3, never without). Lights up by itself on merge.
assert( // Decision 11 landed the downdraft for real, so there is no longer an excuse
!STORM_02.gusts?.downdraft, // for nothing breaking: a vacuous pass here would mean the §7 repair leg —
'storm_02 HAS a downdraft and still could not blow a shackle rig — the repair scenario is vacuous', // 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');
return 'SKIPPED — nothing blew; needs Lane C decision 3 downdraft to threaten a shackle rig';
} }
assert(lost <= 1, `after one repair the rig still lost ${lost}/4 — not survivable`); assert(lost <= 1, `after one repair the rig still lost ${lost}/4 — not survivable`);
return `${repairs} repair, finished ${4 - lost}/4 corners intact`; 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 ------------------ // --- SPRINT2 decision 3 / B-6: the flat-horizontal loophole ------------------
// My Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all // 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 // because a horizontal plate in horizontal wind has almost no drag — which
// wind has almost no drag — which inverted DESIGN.md's "big, flat, low = death // inverted DESIGN.md's "big, flat, low = death in a storm". Lane C closed it by
// in a storm". Lane C closed it by making gusts descend. This is the assert // making the wind descend (decision 8: a fraction of TOTAL speed, present
// decision 3 asks Lane B for. // 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', () => { test('decision 3: flat-horizontal is no longer a free lunch', () => {
const downdraft = STORM_02.gusts?.downdraft ?? 0; const f = STORM_02.gusts?.downdraftOfTotal ?? STORM_02.gusts?.downdraft ?? 0;
if (!downdraft) { assert(f > 0, 'storm_02 has no downdraft at all — decision 3/8 has regressed out of the data');
// 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. // Footprint sized and pitched like a real quad from the dressed yard, spun
return 'SKIPPED — storm_02 has no gusts.downdraft yet (Lane C decision 3 not merged)'; // 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
if (downdraft < 0.5) { // like a sweep and measure nothing about direction.)
// Integrator finding (2026-07-17, measured at merge): a gust-only downdraft const S = Math.sqrt(30); // ~30 m², mid of A's 18-45 band
// CANNOT clear the 60% bar without killing §7 — at 0.45 the twisted mixed const rise = Math.tan((YARD_PITCH_DEG * Math.PI) / 180) * S;
// rig loses a corner and the ratio is still 42%; at 0.58 it's 48% and the const PITCHED = [3.2 + rise / 2, 3.2 + rise / 2, 3.2 - rise / 2, 3.2 - rise / 2];
// rig still dies. The two asserts pincer. Clearing both needs Lane B's const HORIZ = [3.2, 3.2, 3.2, 3.2];
// preferred semantic — downdraft as a fraction of TOTAL wind speed, not
// gust power — which loads a flat roof steadily without spiking the gust const foot = [[-S / 2, -S / 2], [S / 2, -S / 2], [S / 2, S / 2], [-S / 2, S / 2]];
// peak that breaks the twisted rig. That is a weather.core change (joint const at = (hs, th) => foot.map(([x, z], i) => {
// B+C, SPRINT3). Until it lands, storm data stays at C's tuned 0.3 and const c = Math.cos(th), s = Math.sin(th);
// this assert self-skips rather than shipping a red main or a lying bar. const pos = { x: x * c - z * s, y: hs[i], z: x * s + z * c };
return `SKIPPED — gust-only downdraft ${downdraft} cannot reach the 60% bar without breaking §7; needs fraction-of-total semantics (SPRINT3 joint B+C)`; return { id: `a${i}`, type: 'post', pos, sway: () => pos };
} });
const FLAT_H = [3.25, 3.25, 3.25, 3.25]; const sweep = (hs) => {
// 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) => {
let worst = 0; let worst = 0;
for (let k = 0; k < 8; k++) { 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); .attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0);
// full duration: storm_02's own note says the peak lands just AFTER the // 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)); worst = Math.max(worst, runStorm(r, realWind(), STORM_02.duration));
} }
return worst; return worst;
}; };
const pitched = sweep(HEIGHTS_FLAT);
const horizontal = sweep(FLAT_H); const pitched = sweep(PITCHED);
const horizontal = sweep(HORIZ);
const ratio = horizontal / pitched; 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`); 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', () => { test('runs against the shared contracts.js stub wind', () => {