diff --git a/THREADS.md b/THREADS.md index 2523e5e..5d94596 100644 --- a/THREADS.md +++ b/THREADS.md @@ -8649,3 +8649,157 @@ anchors are your GLB), but the tooling is now waiting, not TODO. lead-0 card pins, the cross, the cross's teeth). No wind-panel polish this sprint โ€” the optional slot went to crossing all three yards instead of one, filed here with its reason: a complete cross is worth more than a prettier panel the week the board ships. + +[C] 2026-07-21 โ€” ๐Ÿค **CONVERGENCE, DECLARED FIRST: TWO LANE-C SESSIONS RAN GATE 1 IN PARALLEL, + and I found out by having my push REJECTED. What follows is landed ON TOP of `f029274` + with NOT ONE LINE OF IT CHANGED โ€” I did not force, I did not rebase over it, and I dropped + every part of my own work that duplicated it.** Selftest **494/0/0** (their 490 + my 4). + + The split turned out clean, which is luck rather than planning. `forecastHonest(def)` + answers *"can the forecast describe this STORM"* โ€” validator, finite stats, the stone + vocabulary ceiling โ€” and it is better than the storm-side half I had written, so mine is + gone. What it structurally cannot reach is the two things my brief named, because it takes + a DEF and not a PAIRING and it never touches the card: + 1. **WHAT THE CARD PRINTS.** Their gate can pass a storm whose card then omits the fact + that matters. It does, tonight, on night 7 โ€” see below. + 2. **WHICH YARD the storm is printed over.** `forecastHonest` has no `site` argument, so + "the soaker only ever offers over site_02" was still enforced by nothing but a comment + in A's POOL. + My work is now exactly those two, added after a divider in weather.js that says so. What I + abandoned: my own `forecastRefusal` (theirs is better), my exposure cross (theirs crosses + A's `exposureOf` directly and item-wise โ€” strictly stronger than mine, which could only + cross the data against the runtime), and the `forecast.js` pure-module split. **The split + is the one thing I still think was right and am NOT relitigating today** โ€” it made the + whole worded surface run in the node one-second loop, and 4 of my 6 mutation checks ran + there in seconds instead of two minutes a go. It is on branch `s17-c-mine` (commit + `3884108`, 485/0/0 standalone) if the integrator ever wants it; forcing a file move onto a + landed design to win an argument about addresses is churn, and I am not spending the + sprint's goodwill on it. + +[C] 2026-07-21 โ€” ๐Ÿ“‡ **THE OFFER BAND โ€” and the measured finding that A's BOARD LIES ABOUT + NIGHT 7 as pushed.** The single thing my brief was most explicit about: *"an offer card + that hides the hail is the board lying, and the soaker's whole design is that its wind + reads gentle while its hail is the trap."* It does, and it was. + + **MEASURED FIRST, before a line was written.** A's card prints `f.wind` and `f.stones` โ€” a + reasonable pair. Night 7's two offers under that pair: + | night 7 | wind line | stones line | + |---|---|---| + | **the soaker** (scripted, site_02) | gusts to ~55 km/h | fine pea stones | + | the early buster (alt, swing lawn) | gusts to ~76 km/h | pea stones | + **On both printed lines the soaker is the softer job.** It is not: 14.9 s of hail against + 2.4 s, and 44 mm/hr of rain against 16.5. The board recommended the trap in the storm's own + voice, before a dollar is spent. Not A's mistake to have made โ€” the fields were there and + the card picked two of them, which is exactly why the fix is not "also print rainRate". + + **`offerBand(def, lead)` returns an ORDERED LIST** of `{key, text}` โ€” wind, hail (iff the + storm hails), rain rate (always), change (always), confidence (iff hedged). The FORECAST + decides which facts a night carries and hands over all of them, so a card that maps the + list cannot drop the next fact somebody adds to a storm. Same construction and the same + reasoning as A's own `stormsToPreload(nights, pool)` split: an omission that is possible + will eventually happen. Night 7 now reads, in the real card, my own eyes on :8826 โ€” + ยท soaker โ€” `gusts to ~55 km/h` ยท **`hail likely ยท fine pea stones ยท 15s of it`** ยท + **`rain to 44 mm/hr`** ยท `southerly change at 40s` ยท $46 ยท $205 at risk + ยท buster โ€” `gusts to ~76 km/h` ยท `hail possible ยท pea stones ยท 2s of it` ยท + `rain to 16 mm/hr` ยท `southerly change at 18s` ยท $57 ยท $255 at risk + The choice is now real and genuinely hard: **the soaker pays LESS, risks LESS, and is the + worse night.** That is the game arguing with the player instead of flattering them. + + **โš ๏ธ SECOND FINDING โ€” THE CHANGE LINE IS NOT DECORATION.** `storm_03_southerly` and + `storm_03b_earlybuster` are IDENTICAL in every number the band prints โ€” sustained 13.0, + gustPeak 21.4/21.2, rain 16.5 mm/hr, hail 2.4 s at size 0.70 โ€” and differ ONLY in when the + change lands, 30 s against 18 s. **The board offers exactly that pair** (the buster is the + drawn alternative on nights 5 AND 7). Drop the change line and the board offers two jobs + whose weather it has just claimed to describe, described identically, and calls it a + choice. Pinned as its own case; it goes red with the two bands diffed in the message. + + **A โ€” THE HUNK, verbatim.** I applied it on a scratch tree to LOOK at the card, then + reverted it โ€” hud.js and main.js are yours (the leadFor precedent). + 1. `main.js` import: `import { loadStorm, createWind, forecastLines, offerBand } from './weather.js';` + 2. `main.js` `pricedOffers()`, one line beside `forecast:` โ€” which I left in place, so + nothing of yours has to move and the card can adopt on its own schedule: + `band: def ? offerBand(def, 0) : null,` + 3. `hud.js` `offerHtml`, replacing your two forecast lines with: + ``${(o.band ?? []).map((l) => `
${l.text}
`).join('')}`` + plus two CSS rules in your SPRINT17 board block: + `#hud-card .offer .line.wx { color:#8ba0ad; }` and + `#hud-card .offer .line.hail span { color:#cbd8e0; }` โ€” the hail line is the one a + player must not skim past. `const f = o.forecast;` then goes unused in `offerHtml`. + **lead 0 and only lead 0, and I am not proposing a dial.** Both offers are for TONIGHT โ€” + you are choosing which yard to stand in, not which night to work. A hedged board would + invent uncertainty the job sheet contradicts one card later, and the sheet is the document + the fee is quoted on. + +[C] 2026-07-21 โ€” โš–๏ธ **THE PAIRING LAW โ€” A's three POOL exclusions stop being a comment.** + `forecastHonest` asks whether the forecast can describe a STORM. A band can be perfectly + honest about a storm and still be an invitation to a night nobody can work โ€” that is a fact + about GEOMETRY ร— STORM, it needs a `site`, and nothing enforced it before this. + + `PAIRING_LAW` (weather.js): storm key โ†’ `{sites, owner, why}`, with the measurement in the + data. **A โ€” two of the three entries are YOUR reasons and are recorded as yours** (wildnight + and icenight off backyard_01: the pinned separation, the measured beyond-saving flag). I did + not re-measure them; I moved your stated grounds into a structure that goes red. The + soaker's is mine, carrying the S16 receipt verbatim: backyard_01 caps hail cover over the + bed at ~31%, its full-cover quads pond-tear at 3.3โ€“3.7 kN on BOTH fabrics, best membrane + 37.5 against cloth 17.3 โ€” a fabric bet with no win in it. **An absent key means + UNRESTRICTED, deliberately:** this table records refusals somebody PAID FOR, and inventing + one for a pairing nobody flew is the same offence in the other direction. Pinned in both + directions, including `pairingRefusal('storm_03_southerly', 'site_03_swing_lawn') === null` + โ€” that is night 4, and a law that refused it would be refusing the shipped ladder. + + `pairingRefusals(pairings)` is **deliberately NOT a filter.** Silently dropping a bad entry + leaves a morning with one offer and no explanation โ€” a runtime surprise wearing a fix's + clothes. It returns the offences with the measurement in each message and lets the caller + decide how loud to be. + + **RECEIPTS.** The check walks NIGHTS โˆช POOL carrying the SITE โ€” the same union the other + session's storm walk uses, extended by the dimension that walk cannot see (their own reason + is the right one: today every POOL storm is also in NIGHTS, so a NIGHTS-only walk is green + by coincidence). **12 pairings, 0 refusals.** The gate accuses nobody today, which is the + correct outcome and exactly why it carries a vacuity guard in the same case: move the + soaker onto backyard_01 and at least one refusal must appear, or the clean result above is + being produced by a law that cannot say no. + + **A โ€” if you want it at DATA level rather than in my suite**, `import { pairingRefusals } + from './weather.js'` in `board.js` and throw on a non-empty result at load. My brief's words + were "a DATA-level constraint on the pool agreed with A in THREADS โ€” never a runtime + surprise", and the suite is where I can put it without touching your file. Yours to promote. + +[C] 2026-07-21 โ€” ๐Ÿ”ฌ **MUTATIONS, THE DISCLOSURE, AND A FALSE ALARM I KILLED BY MEASURING IT.** + Six run on my own tree before the convergence, and the two that guard the surviving code + re-run on THIS tree after the code changed address (the whole point of re-running: a + mutation receipt is about the shipped file, not about a file that used to exist): + | mutation | result | + |---|---| + | offer band drops the HAIL line (the night-7 trap) | RED โ€” "storm_02_wildnight: hails for 11.4s but the band's hail line is MISSING" | + | soaker's restriction deleted from `PAIRING_LAW` | RED ร—2 โ€” "the soaker over the backyard is offerable โ€” the ~31% hail-cover measurement has stopped being enforced" | + | offer band drops the CHANGE line | RED โ€” "the two busters differ on `sustained to 13 m/sโ€ฆ` โ€” expected the CHANGE TIME to be the difference" | + | offer band drops the rain-rate line | RED โ€” "storm_01_gentle: no rain-rate line on the offer band" | + | night 7's soaker moved onto backyard_01 in week.js | RED โ€” the ladder gate names the yard AND the measurement; D's two week laws also went red, which is them working | + | `collateralKey: "swing_set"` stripped from site_03 JSON | RED โ€” reachability; superseded by the other session's stronger cross, recorded because it was run | + All restored; final run green. + + **โš ๏ธ A HOLE IN MY OWN GATE, found by probing it rather than reading it** โ€” and worth + recording even though the code it lived in is gone, because the OTHER session's + `STONE_WORD_CEILING` fixes the top of exactly this range and nothing yet fixes the bottom: + a def with `hail.size: 0` and a live hail curve worded itself **"fine pea stones"**, because + `stoneWord` has no FLOOR and `0 < 0.6`. The card names a stone the storm does not carry, on + the axis the entire fabric bet is decided on. Their ceiling refuses 2.6; nothing refuses 0. + **Filed for whoever holds the pen on `forecastHonest` next** (one line beside the ceiling + check): `if (s.hailSeconds > 0 && !(s.hailSize > 0)) errors.push(โ€ฆ)`. Not landed by me โ€” it + is inside their predicate and this is the wrong sprint for two people editing one function. + + **THE FALSE ALARM, recorded because the process is the point.** I thought my dressed worlds + had taken the selftest from ~25 s to ~115 s and was about to trim the harness. Measured it + instead: **baseline 485/0/0 takes 131 s; 496/0/0 with my work took 115 s.** The "25 s" was + never a measurement โ€” it was the first number I happened to read off a poll. Dressing all + three sites costs ~90 ms total (loadSite 4โ€“5 ms, dress 28โ€“32 ms each). Nothing was trimmed, + and the suspicion is dead rather than parked. Measure, don't reason. + + **Two smalls for A, neither urgent.** (1) Pool ids `corner_block_no_house` and + `corner_block_cheapest` โ€” the first is `site_03_swing_lawn`, not the corner block (the + `_why`, the client and the brief are all right; only the id reads wrong, and ids surface in + `takenOffer` and the settlement). (2) On most nights the two offers carry DIFFERENT STORMS + for the same night โ€” a fiction question, not a bug. Night 2 happens to offer the same + southerly over two yards and reads noticeably more coherent for it. Yours to rule if it + ever matters; the band tells the truth either way. diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index a174731..da6cc57 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -19,12 +19,15 @@ import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '.. import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor, forecastHonest, stormStats, STONE_WORD_CEILING, + // SPRINT17, the second half of gate 1 (see weather.js's divider): what the + // card PRINTS, and which yard a storm may be printed over. + offerBand, PAIRING_LAW, pairingRefusal, pairingRefusals, } from '../weather.js'; import { loadSite, createWorld } from '../world.js'; // SPRINT17 gate 1 โ€” the board's weather side. NIGHTS โˆช POOL is every storm an // offer card can print; both are data, so the honesty walk below tracks them // without a test edit the day D's pool yard lands. -import { NIGHTS } from '../week.js'; +import { NIGHTS, nightAt } from '../week.js'; import { POOL, exposureOf } from '../board.js'; // GATE 2.3 โ€” the GAME's own wind wiring, IMPORTED rather than re-typed. A pin // that copied main.js's two lines would agree with a copy of the game forever, @@ -1382,6 +1385,133 @@ export default async function run(t) { } }); + t.test('GATE 1 (S17): the offer band carries every fact the storm delivers, and none it does not', () => { + // โš ๏ธ THE FINDING THIS CASE EXISTS FOR. A's board prints `f.wind` and + // `f.stones` โ€” a reasonable pair โ€” and under that pair NIGHT 7 LIES: + // soaker (scripted) gusts to ~55 km/h ยท fine pea stones + // early buster (alt) gusts to ~76 km/h ยท pea stones + // On both printed lines the soaker is the softer job, while delivering + // 14.9s of hail against 2.4s and 44 mm/hr against 16.5. `offerBand` returns + // the ORDERED LIST instead, so the forecast decides which facts a night + // carries and a card cannot drop the next one somebody adds. + for (const nm of offerStormNames) { + const def = offerDefs[nm]; + const st = stormStats(def); + const lines = offerBand(def, 0); + const keys = lines.map((l) => l.key); + const text = (k) => lines.find((l) => l.key === k)?.text ?? ''; + + assert(keys.includes('wind'), `${nm}: no wind line on the offer band`); + assert(keys.includes('rain'), `${nm}: no rain-rate line on the offer band`); + assert(keys.includes('change'), `${nm}: no change line on the offer band`); + assert(!keys.includes('confidence'), + `${nm}: tonight's band hedged its own confidence โ€” lead 0 is exact`); + + // HAIL, BOTH DIRECTIONS โ€” the assert night 7 needed. Present iff it hails: + // a missing line recommends the trap, an invented one teaches that the + // absence of a hail line means somebody checked. + assert(keys.includes('hail') === (st.hailSeconds > 0), + `${nm}: hails for ${st.hailSeconds.toFixed(1)}s but the band's hail line is ` + + `${keys.includes('hail') ? 'present' : 'MISSING'}`); + if (st.hailSeconds > 0) { + assert(/fine pea|pea|marble|golf ball/.test(text('hail')), + `${nm}: hail line has no stone word โ€” "${text('hail')}"`); + assert(/\ds of it/.test(text('hail')), + `${nm}: hail line does not say how long it hails โ€” "${text('hail')}"`); + } + // The rate is the OTHER half of the soaker's trap, and the unit ponding + // reads โ€” its absence is what made the two-field card lie. + assert(new RegExp(`${Math.round(st.rainPeakMmPerHour)} mm/hr`).test(text('rain')), + `${nm}: peaks at ${st.rainPeakMmPerHour.toFixed(1)} mm/hr, band says "${text('rain')}"`); + if (st.changeAt != null) { + assert(text('change').includes(`${Math.round(st.changeAt)}s`), + `${nm}: changes at ${st.changeAt}s, band says "${text('change')}"`); + } else { + assert(!/\d/.test(text('change')), `${nm}: never changes, band says "${text('change')}"`); + } + for (const l of lines) { + assert(!/NaN|Infinity|undefined/.test(l.text), + `${nm}: the ${l.key} line does not word its own numbers โ€” "${l.text}"`); + } + } + }); + + t.test('GATE 1 (S17): the two buster variants are TELLABLE APART on a board card', () => { + // storm_03 and storm_03b are IDENTICAL in every number the band prints + // (sustained 13.0, gustPeak 21.4/21.2, rain 16.5 mm/hr, hail 2.4s at size + // 0.70) and differ ONLY in when the change lands, 30s against 18s. The + // board offers exactly that pair โ€” the buster is the drawn alternative on + // nights 5 and 7. Drop the change line and the board offers two jobs whose + // weather it has just claimed to describe, described identically, and calls + // it a choice. That is why the band is a list and not two named fields. + const a = offerBand(storms.storm_03_southerly, 0).map((l) => l.text); + const b = offerBand(storms.storm_03b_earlybuster, 0).map((l) => l.text); + const differing = a.filter((line, ix) => line !== b[ix]); + assert(differing.length > 0, + 'storm_03 and storm_03b word IDENTICAL offer bands โ€” the board would offer two ' + + 'indistinguishable jobs and call it a choice'); + assert(differing.some((l) => /change/.test(l)), + `the two busters differ on ${differing.join(' / ')} โ€” expected the CHANGE TIME, since ` + + 'that is the only thing that differs in the storms'); + }); + + // โ”€โ”€ The PAIRING half of gate 1. `forecastHonest` above asks whether the + // forecast can describe a STORM; a band can be perfectly honest about a storm + // and still be an invitation to a night nobody can work. NIGHTS โˆช POOL again, + // this time carrying the SITE โ€” the dimension the storm-side walk cannot see. + const offerPairings = [ + ...NIGHTS.map((_, ix) => { + const n = nightAt(ix); + return { id: `night ${ix + 1}`, storm: n.storm, site: n.site }; + }), + ...POOL.map((p) => ({ id: `pool:${p.id}`, storm: p.storm, site: p.site })), + ]; + + t.test('GATE 1 (S17): every offerable PAIRING respects the measured yard/storm law', () => { + assert(offerPairings.length >= 12, + `only ${offerPairings.length} pairings found โ€” the NIGHTS โˆช POOL walk is broken`); + const refused = pairingRefusals(offerPairings); + assert(refused.length === 0, + `the board would offer ${refused.length} pairing(s) refused by measurement:\n ` + + refused.map((r) => `${r.id}: ${r.why}`).join('\n ')); + // Vacuity guard, on this exact input: without it the assert above passes on + // a law that returns [] unconditionally โ€” the failure mode I shipped in S13 + // and named. Move ONE night's yard and exactly one refusal must appear. + const moved = offerPairings.map((p) => + (p.storm === 'storm_06_soaker' ? { ...p, site: 'backyard_01' } : p)); + assert(pairingRefusals(moved).length >= 1, + 'moving the soaker onto backyard_01 did not trip the law โ€” the assert above is ' + + 'passing on a gate that cannot say no'); + }); + + t.test('GATE 1 (S17): the pairing law refuses what it was measured to refuse, and nothing else', () => { + // MINE, with the S16 receipt in the message: the soaker over backyard_01 + // has no win in it (hail cover capped at ~31%, both fabrics pond-tearing). + assert(pairingRefusal('storm_06_soaker', 'backyard_01') !== null, + 'the soaker over the backyard is offerable โ€” the ~31% hail-cover measurement has ' + + 'stopped being enforced'); + assert(/31%/.test(pairingRefusal('storm_06_soaker', 'backyard_01')), + 'the refusal does not carry the measurement that justifies it'); + assert(pairingRefusal('storm_06_soaker', 'site_03_swing_lawn') !== null, + 'the soaker over the swing lawn is offerable, and nobody has ever flown it'); + assert(pairingRefusal('storm_06_soaker', 'site_02_corner_block') === null, + 'the soaker over site_02 is REFUSED โ€” that is the pairing it shipped for'); + // A's two, enforced the same way and credited to A in the data. + assert(pairingRefusal('storm_02_wildnight', 'site_02_corner_block') !== null, + 'the wild night is offerable off backyard_01, where its separation pin lives'); + assert(pairingRefusal('storm_02b_icenight', 'site_03_swing_lawn') !== null, + 'the ice night is offerable off backyard_01, where gardenBeyondSaving was measured'); + assert(PAIRING_LAW.storm_02_wildnight.owner === 'A' && PAIRING_LAW.storm_06_soaker.owner === 'C', + 'the law has lost track of who ruled which entry โ€” the owner field is how a reader ' + + 'knows whose measurement to go and read'); + // And it must NOT over-refuse: absence of a measurement is not a refusal. + assert(pairingRefusal('storm_03_southerly', 'site_03_swing_lawn') === null, + 'the southerly over the swing lawn is refused โ€” that is night 4, the shipped ladder'); + assert(pairingRefusal('storm_99_unmeasured', 'anywhere') === null, + 'a storm with no entry in the law was refused โ€” inventing a refusal for a pairing ' + + 'nobody flew is the same offence in the other direction'); + }); + // โ”€โ”€ GATE 1.2 (S17): THE EXPOSURE CROSS โ€” A's exposureOf vs the failure // route, the S14 pin pattern (two harnesses, one number, by construction). // Prep up front (Suite.test can't await); the tests skip honestly offline. diff --git a/web/world/js/weather.js b/web/world/js/weather.js index fc8fdaa..6979778 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -224,6 +224,186 @@ export function forecastHonest(def, name = def?.name ?? 'storm') { return { ok: errors.length === 0, errors }; } +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// THE OFFER BAND, and THE PAIRING HALF OF THE GATE (SPRINT17 gate 1) +// +// Landed ON TOP of forecastHonest above, by the other half of Lane C this +// sprint โ€” two sessions converged on gate 1 and split it cleanly without +// meaning to. `forecastHonest` answers "can the forecast describe this STORM", +// which is the storm-side gate and stays exactly as it landed. These two add +// the parts it does not reach: WHAT THE CARD ACTUALLY PRINTS, and WHICH YARD +// the storm may be printed over. Nothing above this line was changed. +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * THE HONEST BAND FOR AN OFFER CARD โ€” an ORDERED LIST of lines, not a bag of + * named fields, and the shape is the whole point. + * + * โš ๏ธ **WHY A LIST, when `forecastLines` already returns every string a card + * could want.** A card that reads named fields prints the fields its author + * remembered. A's board shipped `f.wind` and `f.stones` โ€” a reasonable pair โ€” + * and that pair MEASURABLY LIES ABOUT NIGHT 7: + * + * | night 7's two offers | wind line | stones line | + * |---|---|---| + * | the soaker (scripted) | gusts to ~55 km/h | fine pea stones | + * | the early buster (alt) | gusts to ~76 km/h | pea stones | + * + * On both printed lines the soaker is the SOFTER job. It is not: it delivers + * **14.9 s of hail against 2.4 s, and 44 mm/hr of rain against 16.5**. The + * soaker's whole design is that its wind reads gentle while its hail is the + * trap (C, S16 gate 3.1) โ€” so a board printing only wind and stone size + * recommends the trap, in the storm's own voice, before a dollar is spent. + * + * The fix is not "also print rainRate". The fix is that the FORECAST decides + * which facts a night carries and hands over all of them, so the next fact + * added to a storm cannot be silently dropped by a card that predates it. Same + * construction, and the same reasoning, as A's `stormsToPreload(nights, pool)` + * split: an omission that is possible will eventually happen. + * + * โš ๏ธ **It never renders MORE than the truth either.** A hail line appears if and + * only if the storm hails โ€” "hail: none" on a hail-free night teaches that the + * absence of a hail line means nobody checked. + * + * @param {object} def parsed storm JSON + * @param {number} [lead] 0..1 โ€” the BOARD passes 0, and only 0: both offers are + * for TONIGHT. You are choosing which yard to stand in, not which night to + * work, and a hedged board would invent uncertainty the job sheet contradicts + * one card later. + * @returns {{key:string, text:string}[]} reading order: wind, hail, rain, + * change, confidence. `key` is for styling; `text` is the whole printed line. + */ +export function offerBand(def, lead = 0) { + const f = forecastLines(def, lead); + const ff = forecastFor(def, lead); + const t = f.truth; + const i = (v) => v.toFixed(0); + const lines = [{ key: 'wind', text: f.wind }]; + + // Hail, with its DURATION. `chance` at lead 0 is a duration threshold wearing + // a probability word (hailSeconds > 6 โ‡’ 'likely'), which is honest enough on + // a job sheet and far too coarse on a board where two hail nights sit side by + // side: 'likely' covers both the soaker's 15 s and the ice night's 21 s, and + // 'possible' covers the buster's 2.4 s. The seconds are what a tradie buys + // steel against, so the seconds print. + if (f.stones) { + lines.push({ + key: 'hail', + text: `hail ${f.hail.chance} ยท ${f.stones} ยท ${bandStr(ff.hail.seconds, i)}s of it`, + }); + } + + // The rain RATE, always โ€” the line A's card had no field for, and half of the + // soaker's trap. mm/hr is the unit the ponding model reads, so the card and + // the sim agree on what "heavy" means instead of each having an opinion. + lines.push({ key: 'rain', text: f.rainRate }); + + // The change, always โ€” including "no change forecast", which is information, + // not filler. storm_03_southerly and storm_03b_earlybuster are IDENTICAL in + // every other number this band prints (sustained 13.0, gustPeak 21.4/21.2, + // rain 16.5 mm/hr, hail 2.4 s at size 0.70) and differ ONLY in when the change + // lands, 30 s against 18 s. The board offers exactly that pair โ€” the buster is + // the drawn alternative on nights 5 and 7. Drop this line and the board offers + // two jobs whose weather it has just claimed to describe, described + // identically, and calls it a choice. + lines.push({ + key: 'change', + text: t.changeAt == null ? 'no change forecast' + : (lead > 0 ? `southerly change ${bandStr(ff.changeAt, i)}s` + : `southerly change at ${i(t.changeAt)}s`), + }); + + if (f.confidence) lines.push({ key: 'confidence', text: f.confidence }); + return lines; +} + +/** `band` under its own name โ€” the module-scope helper, reused rather than retyped. */ +function bandStr(b, fmt) { return band(b, fmt); } + +/** + * โš–๏ธ **THE PAIRING LAW โ€” which yards a storm may be OFFERED over.** + * + * `forecastHonest` above asks whether the forecast can describe a STORM. This + * asks the other half of SPRINT17 gate 1's rule, the half that is a fact about + * GEOMETRY ร— STORM rather than about wording: a band can be perfectly honest + * about a storm and still be an invitation to a night nobody can work. + * + * โš ๏ธ **These three were three prose comments before they were a rule.** A's POOL + * carries them as a comment listing what is "deliberately NOT here" โ€” and this + * repo's standing verdict on that arrangement is A's own, on CONSTRAINT_KIND: + * an unenforced enum is decoration, and the carport shipped typed as a post for + * a sprint behind a JSDoc comment saying it couldn't. A comment cannot fail when + * somebody adds the sixth pool entry. Promoted to data, checked, red in the suite. + * + * **A โ€” two of these three are YOUR reasons and are recorded as yours.** I have + * not re-measured a separation pin or a beyond-saving flag; I have moved your + * stated grounds into a structure that goes red. Reword freely โ€” the mechanism + * is what I am defending, not the prose. + * + * An absent key means UNRESTRICTED, deliberately: this table records refusals + * somebody PAID FOR, and inventing one for a pairing nobody flew is the same + * offence in the other direction. + */ +export const PAIRING_LAW = Object.freeze({ + storm_06_soaker: { + sites: ['site_02_corner_block'], + owner: 'C', + why: 'MEASURED (C, S16 gate 3.1, both shipped yards flown through gardenfly before the ' + + 'storm shipped): backyard_01\'s buyable geometry caps hail cover over the bed at ' + + '~31%, and its full-cover quads pond-tear at 3.3โ€“3.7 kN in this mild wind on BOTH ' + + 'fabrics โ€” best membrane line 37.5 against cloth 17.3, a fabric bet with no win in ' + + 'it. The soaker\'s forecast reads "fine pea stones ยท rain to 44 mm/hr" over there ' + + 'exactly as it does over site_02, and over there that band is an invitation to a ' + + 'night nobody can work. The swing lawn is not measured under it at all, which is ' + + 'the same refusal for a cheaper reason.', + }, + storm_02_wildnight: { + sites: ['backyard_01'], + owner: 'A', + why: 'A (S17 gate 1, POOL): its separation is PINNED to backyard_01\'s site data. Fly it ' + + 'elsewhere and the pin describes a night nobody plays.', + }, + storm_02b_icenight: { + sites: ['backyard_01'], + owner: 'A', + why: 'A (S17 gate 1, POOL), on A\'s S13 measurement: `gardenBeyondSaving` is a fact about ' + + 'THAT BED under that ice, not a property of the storm. Offered over another yard the ' + + 'night would carry the excuse without the evidence.', + }, +}); + +/** + * May this storm be offered over this yard? + * @returns {string|null} the refusal in English, or null when the pairing is offerable + */ +export function pairingRefusal(stormKey, siteKey) { + const law = PAIRING_LAW[stormKey]; + if (!law || !law.sites) return null; + if (law.sites.includes(siteKey)) return null; + return `${stormKey} over ${siteKey}: not an offerable pairing โ€” ${law.why} ` + + `(offerable: ${law.sites.join(', ')}; ruled by ${law.owner})`; +} + +/** + * Every pairing refusal in a list of candidate nights, as data. + * + * โš ๏ธ Deliberately NOT a filter. Silently dropping a bad entry leaves a morning + * with one offer and no explanation โ€” a runtime surprise wearing a fix's + * clothes. The caller (c.test today; a `board.js` load-time assert the day A + * wants one) decides how loud to be, and gets the measurement in the message. + * + * @param {Array<{id?:string, storm:string, site:string}>} pairings + * @returns {{id:string, why:string}[]} empty = every pairing is offerable + */ +export function pairingRefusals(pairings = []) { + const out = []; + for (const p of pairings) { + const why = pairingRefusal(p.storm, p.site); + if (why) out.push({ id: p.id ?? `${p.storm}ร—${p.site}`, why }); + } + return out; +} + // 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/. // import.meta.url is right under both, and under whatever Lane A does next.