Lane C S17 gate 1 (cont.): the OFFER BAND and the PAIRING law — night 7 stops lying
Landed ON TOP of f029274 with not one line of it changed. Two Lane C sessions
ran gate 1 in parallel; I found out by having my push rejected. forecastHonest
answers "can the forecast describe this STORM" and is better than the storm-side
half I had written, so mine is deleted. These are the two things it structurally
cannot reach — it takes a def, not a pairing, and it never touches the card.
weather.js is +180/-0. The only line of theirs I touch anywhere is widening one
c.test import from { NIGHTS } to { NIGHTS, nightAt }.
1. THE OFFER BAND, and the finding that motivates it. A's card prints f.wind
and f.stones, 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. It is not: 14.9s of hail
against 2.4s, 44 mm/hr against 16.5. The soaker's whole design is that its
wind reads gentle and its hail is the trap, so the board recommended the trap
in the storm's own voice. offerBand(def, lead) returns an ORDERED LIST, so
the forecast decides which facts a night carries and a card cannot drop the
next one added — same construction as A's stormsToPreload(nights, pool).
Verified in the real card on :8826.
Second finding: storm_03 and storm_03b are identical in every number the band
prints except the change time (30s vs 18s), and the board offers exactly that
pair on nights 5 and 7. Without the change line it offers two indistinguish-
able jobs and calls it a choice. Pinned.
2. PAIRING_LAW. A band can be perfectly honest about a storm and still be an
invitation to a night nobody can work. A's three POOL exclusions were prose;
promoted to checked data with the measurement in each message, credited by
owner (the soaker's is mine with the S16 ~31% receipt; wildnight and icenight
are recorded as A's reasons, not re-measured by me). Absent key = unrestricted
deliberately — inventing a refusal for a pairing nobody flew is the same
offence inverted, and that direction is pinned too. pairingRefusals is NOT a
filter: silently dropping an entry is a runtime surprise wearing a fix's
clothes. Walks NIGHTS u POOL carrying the SITE: 12 pairings, 0 refusals, with
a vacuity guard in the same case.
Mutation-checked on THIS tree after the code changed address: hail line dropped
(RED, names the storm and the seconds), soaker restriction deleted (RED x2,
including the vacuity guard), change line dropped (RED, names the two busters).
All restored, 494/0/0.
Filed in THREADS, not landed: the hud/main hunk for A (leadFor precedent), and
a floor for stoneWord — hail.size 0 words itself "fine pea stones" because
stoneWord has no floor. Their STONE_WORD_CEILING fixes the top of that range;
nothing fixes the bottom. It belongs inside forecastHonest, which is theirs.
Selftest 494/0/0 (their 490 + 4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f029274940
commit
3548b6edc1
154
THREADS.md
154
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) => `<div class="line wx ${l.key}"><span>${l.text}</span></div>`).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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user