Compare commits

..

8 Commits

Author SHA1 Message Date
type-two
a7a417c02a Merge remote-tracking branch 'origin/main'
# Conflicts:
#	THREADS.md
2026-07-21 06:35:05 +10:00
type-two
a012225259 Sprint 17 integration [I] entry: the board live, seven nights, convergence recorded 2026-07-21 06:32:13 +10:00
type-two
9498dfc1b9 S17 integration: apply C's offerBand hunk — the board stops lying about night 7 (hail on every offer card) 2026-07-21 06:25:14 +10:00
type-two
8edf461286 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-21 06:21:56 +10:00
type-two
3717ef0d54 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-21 06:21:16 +10:00
type-two
5ae696040d Lane D S17: [D] THREADS — convergence declared, the other D session's pool yard + cold-play adopted
Two Lane D sessions ran gates 3.2 + 4 in parallel; found via push rejection.
Their site_04_pool_yard + seven-night cold-play (515/0/0) STAND — dropped my
whole duplicate cut (a second, two-corroded-post pool yard + a second cold-play),
did not force, did not rebase over theirs. Verified their tree green (515/0/0)
before adding a line.

Additive-only: a measured receipt for their second-rust-post landmine (a real
two-post yard splits the exposure harnesses, exposureOf $115 vs
collateralExposure $45 — C's gate-1.2 cross going red by construction); and an
independent live repro of C's night-7 board-card omission (card "33 km/h · fine
pea stones" vs sheet "rain heavy · 44 mm/hr · hail likely") flagged as the #1
board fix. Scratch merge judged the wider tree (main + newest b/c) at 517/0/0,
per-lane sum verified, nothing lost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:14:11 +10:00
type-two
4cdb2fead8 Lane B S17 gate 3: the corroded tier prices honestly in the card
The piece the 0.2/0.3/2 pass explicitly deferred. Pins E's corroded tier
through the card's own pricing surfaces, off E's real resolver (not a
hand-typed 0.55):

- STEEL half (rigging.selftest): factoryExtras('corroded_post') -> hint 0.55
  + collateral key; _effRating on a corroded corner is rating x 0.55, so the
  same load a carabiner holds on a sound post is over a carabiner on a
  corroded one. The rust costs a tier.
- COLLATERAL half (gardenfly.selftest, real dressed world): a node-less
  corroded placement -> collateralFor =  'the corroded post', summed by
  collateralExposure -- through the resolver, not the baked GLB value direct
  (the carport-to-null shortcut).

Cross-lane seam: the tier lives on lane/e, so both pins skip-with-disclosure
on bare lane/b (SKIPPED, visible in the report) and go live at integration.
Measured GREEN on a scratch merge of lane/a + lane/e (hint 0.55, collateral
). Browser 485/3/0, node sail 47/47 rigging 29/29.

Test-only + THREADS; adopted the landed 0.2/0.3/2 unchanged rather than
overwrite pushed work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 03:59:53 +10:00
type-two
3548b6edc1 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>
2026-07-21 03:07:02 +10:00
7 changed files with 756 additions and 5 deletions

View File

@ -9147,6 +9147,308 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
worth saying out loud); fees carry standing multipliers down to ×0.88 at ★0.0, so the
rep bleed is FELT in the wallet, which is REP doing its job unmeasured or not.
[D] 2026-07-21 — 🤝 **CONVERGENCE, DECLARED FIRST: TWO LANE-D SESSIONS RAN GATES 3.2 AND 4 IN
PARALLEL. I found out by having my push REJECTED. The pool yard (site_04) and the seven-night
cold play landed ABOVE are the other session's, and they STAND — I did not force, I did not
rebase over them, and I dropped my whole duplicate cut (a second pool yard + a second
cold-play).** Their pool yard is the better one and for the exact reason this convergence is
lucky: **it ships ONE corroded post on purpose to dodge the second-rust-post landmine — the
one my cut walked straight into.** I authored TWO corroded posts (the "different sets" thesis
read to me as needing a genuine choice of rust corners), scored it green, and only at exposure
time did the landmine they filed bite. So I bring their filing a measured receipt and nothing
else it doesn't already have. Verified their tree with my own eyes before adding a line:
selftest 515/0/0 on :8827, my own fronted tab.
**THE RECEIPT FOR THEIR SECOND-RUST-POST FILING (E/A — a number for when per-instance keys get
plumbed).** On a real two-corroded-post yard (both structures baking `collateralKey:
'corroded_post'`), I measured the two exposure harnesses in the browser and they DISAGREE:
· A's `exposureOf(siteJson)` (the OFFER CARD) reads **$115** — corroded $45 + corroded $45 +
gnome $25, per STRUCTURE.
· B/C's `collateralExposure` (SCORE IT / the failure route) reads **$45** — the corroded post
$45 ONCE (de-duped by key), and the gnome not counted at all (no anchor reaches it).
That is C's gate-1.2 exposure cross going RED by construction — the cross WORKING, on the first
yard that would trip it. The other session avoided it by shipping one post; the number is here
so the fix (per-instance key plumbing + whether the gnome belongs in `exposureOf`) has a datum,
because arc 2's glasshouse/granny-flat yards will want more than one priceable hazard someday.
**A SECOND PLAYTESTER'S TOP BOARD FIX, from an independent seventh night: the offer card LIES
about the soaker, live, exactly as C measured.** I took the N7 soaker off the board and read
the card against the sheet it leads to:
· board CARD: `sustained to 9 m/s (33 km/h) · fine pea stones` — reads as the SOFT job (the
alternative buster card reads 76 km/h, windier).
· job SHEET (one click later): `rain heavy · southerly change at 40s · hail likely · rain to
44 mm/hr`.
The card hides the 44 mm/hr downpour and the hail that ARE the soaker's trap, so on the board
alone the trap reads as the safe pick — the board recommending the trap in the storm's own
voice. C's `offerBand` hunk (hail duration + rain rate + change, mapped so a dropped fact can't
hide) is FILED and NOT INTEGRATED. **From the chair: this is the #1 board fix for the next
integration** — it is the one place the board is dishonest, and it is dishonest on the week's
signature trap. Everything else on the card (client, site, wind, fee, exposure, ⚖ terms) is
honest and load-bearing; the exposure line in particular is what a broke wallet reads to stay
off the carport.
Everything else my pass would have said, the landed cold-play above already says better (it
mouse-rigged where mine went through `session.rig` for want of a pointer-capture orbit, it
took the pool yard AND the $45 cap as its alternatives, and its declining-costs-nothing and
$5-tier clean/marginal findings are sharper than anything I had). No duplicate lands. My
scratch merge judged the wider tree (main + the newest lane/b + lane/c) at **517/0/0**,
per-lane sum verified, nothing lost in the merge — flagged for the integrator as the number to
reproduce when b/c's newest commits and this pool yard all land together.
[B] 2026-07-21 — 🦀 **GATE 3 LANDED: THE CORRODED TIER PRICES HONESTLY IN THE CARD — the piece the
0.2/0.3/2 pass deferred, now pinned. Plus: I re-ran 0.2/0.3/2 independently and they STAND.**
A recovered/parallel session (this one) rebuilt gates 0.2/0.3/2 from 75d5f9a before finding the
landed work on origin/lane/b. Rather than overwrite three pushed, receipted, mutation-checked
commits, I **reset to the landed versions and added only gate 3** — the one thing the earlier
pass explicitly filed as DEFERRED ("gate 3 support is armed and DEFERRED as briefed… Corroded
steel prices through ratingHint, which every sweep already reads live"). The parallel
reimplementation is kept in a local ref for reference; nothing from it overwrote the landed
lane/b. selftest still green (node sail 47/47, rigging 29/29 = 27 + the 2 gate-3 pins).
**What gate 3 pins**, off E's REAL resolver, not a hand-typed 0.55:
· **The STEEL half** (`rigging.selftest`): `factoryExtras('corroded_post')` → hint 0.55 +
collateral key, and `_effRating` on a corroded corner is `rating × 0.55` — so a load a
carabiner holds on a sound post (1200 N) is OVER a carabiner on a corroded one (660 N). The
rust costs a tier, measured, and if it EVER prices as sound steel the trap inverts into the
safest anchor in the yard (E's load-bearing `_v1` warning).
· **The COLLATERAL half** (`gardenfly.selftest`, a real dressed world, ~40 ms): a node-less
corroded placement (E's "plain entry, no node field" case — the harder one, rating and
collateral both from the factory) → `world.collateralFor('corroded_post')` = $45 "the
corroded post", summed by `collateralExposure` — NOT the baked GLB value read directly, the
shortcut that priced the carport to null for five sprints.
**Mutations** (run on the scratch merge where the tier is live): `collateralFor` drops the
price → card reads null; `factoryExtras` returns hint 1 → the steel pin reddens. Both caught.
⚠️ **CROSS-LANE SEAM, disclosed.** The corroded tier (0.55, the GLB, the gen entry) is on
lane/e. On bare lane/b `factoryExtras('corroded_post')` is null, so **both gate-3 pins
SKIP-WITH-DISCLOSURE here** and go live at integration — the "build against the shape as
pushed" pattern. Measured GREEN on a scratch merge of lane/a + lane/e (hint 0.55, collateral
$45). Browser suite on bare lane/b: 0 failures, the corroded pins visibly skipped with their
reason in the run.
**D — your pool yard's corroded corners price right the moment you place them**, node or
node-less; the card names the $45 collateral and the 0.55 steel with no round-trip to me. The
tier is E's, the pricing surfaces are pinned, and the pin activates itself when your yard and
E's tier meet on the integrated tree.
**On 0.2/0.3/2 (the landed pass): reviewed, sound, nothing to change.** One optional
strengthening I found while reimplementing, filed NOT forced — the 0.2 negative control's
"over-arming is EQUIVALENT" finding is real (the heal is pure repair and emits only when it
healed, so an over-arm is a silent no-op to an event-counter). It IS observable if the control
spies the heal CALL rather than the event: a stub that arms on finite state calls
`_healNonFinite` every substep of a fully-broken-but-finite sail. If a future pass wants to
close that mutation gap, a `let calls=0; wrap _healNonFinite` in the finite-lost-sail control
turns the recorded "equivalent" into a red. Not this landing; the current control is honest
about what it does and does not catch.
[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.33.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 45 ms, dress 2832 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.
[I] 2026-07-21 — **SPRINT 17 INTEGRATION (main) — THE BOARD IS LIVE, SEVEN NIGHTS, arc 2 open.**
Selftest **522/0/0**, the exact sum (D's 515-integration + lane/b's 3 corroded cross-lane pins,
green now that lane/e is present + lane/c's 4 offerBand/pairing pins). Verified in the browser
and deployed to partly.party/hardyards. This integration was UNUSUAL and worth recording:
**Two full sets of lane sessions ran — mine (a workflow, on Opus after the Fable limit killed
the first wave) and a parallel set on John's other Mac.** Every lane hit the collision and
CONVERGED rather than clobbered: B reset to the landed 0.2/0.3/2 and added only gate 3; C reset
onto the other session's better forecast design and added only the offer band + pairing law; D
dropped their whole pool-yard cut and adopted the other session's (which ships ONE corroded post,
dodging the two-rust-post exposure-harness split their own cut walked into). E resumed the
Fable-killed session's uncommitted tree, found it was mid-negative-control (both GLBs were the
UNTELLED post, two asserts defective — one that couldn't fail, one whose stated reason was
backwards), and rebuilt it honestly. The convergence protocol C invented in S14 held across four
lanes and two machines. lane/d's own "pt-1 integration" commit did the heavy merge; I layered
lane/b and lane/c's newest commits on top.
**The one integrator hunk, applied verbatim (C filed it; hud/main are A's files):** C measured
that A's board LIED ABOUT NIGHT 7 — the soaker's offer read SOFTER than the buster's on the two
printed lines (55 vs 76 km/h gusts) while carrying 6× the hail (14.9 s vs 2.4) and 2.7× the rain.
The board was recommending its own trap in the storm's own voice. `offerBand(def, lead)` returns
the ORDERED list (wind · hail · rain · change · confidence) so the card can never drop a fact by
picking two fields. Applied the main.js `band:` line + the hud.js card map + two CSS rules (the
hail line lit brighter — the one a player must not skim). VERIFIED LIVE: night 7's two cards now
read the soaker as pays-less/risks-less/worse-night, the game arguing instead of flattering. The
fix is the same species as A's own `stormsToPreload(nights, pool)` split — an omission that is
possible eventually happens.
**What shipped, gate by gate:** the JOB BOARD (A) — two offers a morning, seeded deterministically
off week+night, the chosen job carrying a WHOLE night entry so sheet/quote/invoice/ledger/warranty/
rep read it through the seven surfaces they already had, ZERO changed lines; CONSTRAINTS (A data +
B enforcement) — house-ban refused at pick time in the client's own words, budget cap thrown at
commit, SCORE IT drops banned candidates and prices at the client's ceiling; the CORRODED TIER (E)
— 0.55 on the ladder because it is the only rung whose true capacity is unknowable by inspection,
$45, the tell verified to read at 20 m by the game's own renderer; the POOL YARD (D) — authored
cold, the legal tie-offs and the trustworthy tie-offs deliberately DIFFERENT SETS, in the game only
by being chosen off the board (the Karalis place, live on night 1's board with the corroded post
billed in its risk column).
**Rulings (A):** hail-light balance is CANON (the mild-night market buys the steel and the
collateral, not the bed — the refused separation pins already say so); constraint premiums +25%
(anchor ban) / +20% (budget cap), both flagged UNMEASURED and cut-first; the rain drain-weight
lever DECLINED at its proper price (its own gate, full re-measure).
**Carried to SPRINT18 (its callout types are John's gate-4 pick):** A's `stormsToPreload` preload
assert can't currently fail (every pool storm also lives in NIGHTS — the calm-day coincidence
again; split it so a pool-only storm can bite); C's `stoneWord` has no floor (size 0 words itself
"fine pea stones"); D's two-corroded-post exposure-harness split ($115 per-structure vs $45 per-key)
is the receipt for arc-2's multi-hazard yards; B's optional heal-control strengthening (spy the
call, not the event).
**Gate 4 stands and it now steers the next sprint: John plays the seven — partly.party/hardyards,
a board offering choices, a constrained client or two — and picks Sprint 18's callout type:
emergency mid-storm arrival, the cowboy audit, or warranty-as-a-full-night.**
[I] 2026-07-21 — **SPRINT 17 (THE BOOK OF CLIENTS, PART 1) INTEGRATION COMPLETE (main).** D's
second pass merged fast-forward; selftest **515/0/0** on the final tree (510 part-1 + D's
5 pins, the exact sum), verified in the browser. Deploy NOT yet run: the session's

View File

@ -33,6 +33,15 @@ import { createSkyFx } from '../../web/world/js/skyfx.js';
import { createGarden } from '../../web/world/js/garden.js';
import { FIXED_DT } from '../../web/world/js/contracts.js';
import { flyGarden, flySeparation, hardwareByName } from './gardenfly.js';
import { buildScoringWorld, collateralExposure } from './scorecard.js';
import { factoryExtras } from '../../web/world/js/ratings.js';
// SPRINT17 gate 3 [B] — CROSS-LANE SEAM. The corroded tier (rating_hint 0.55,
// the GLB, the gen entry) lives on lane/e. On a bare lane/b it is absent, so
// this pin SKIPS WITH A DISCLOSURE and goes live at integration — the same
// "build against the shape as pushed" seam the sprint runs on. Measured GREEN
// on the scratch merge (lane/a + lane/e): hint 0.55, collateral $45.
const CORRODED_TIER_LIVE = !!(factoryExtras('corroded_post'));
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
@ -156,7 +165,62 @@ export async function buildGardenflyTests() {
fabRun = { byDefault: flyFab(undefined), cloth: flyFab(0.30), membrane: flyFab(0) };
} catch (err) { fabErr = String((err && err.stack) || err); }
// SPRINT17 gate 3 support [B]: the corroded tier priced through the CARD'S
// OWN path, in a real dressed world. E landed the post; the 0.2/0.3/2 pass
// deferred gate 3; this pins that a corroded anchor prices honestly the
// moment it is placed. Node-less placement (E's THREADS: "place with a plain
// entry and no node field and the rating still lands") — the harder case,
// where the rating and the collateral both come from the factory, not a
// dress. buildScoringWorld only DRESSES (no sweep), so this is ~40 ms.
let corRun = null, corErr = null;
try {
if (!CORRODED_TIER_LIVE) throw { skip: true };
const corSite = await loadSite('backyard_01');
corSite.structures = [...(corSite.structures ?? []), {
id: 'cor1', model: 'sail_post_corroded_v1', wreckedModel: 'sail_post_corroded_wrecked_v1',
x: 6, z: 2, rotYDeg: 0, solid: true,
collateralKey: 'corroded_post', collateralValue: 45, collateralLabel: 'the corroded post',
anchors: [{ id: 'cor1_a1', type: 'corroded_post', work: 'cloth' }],
}];
const built = await buildScoringWorld(corSite);
const anc = built.anchors.find((a) => a.id === 'cor1_a1');
const col = {};
for (const a of built.anchors) {
if (!a.collateral) continue;
const p = built.world.collateralFor(a.collateral);
col[a.id] = p ? { key: a.collateral, cost: p.cost, label: p.label }
: { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
}
corRun = { anchor: anc, priced: built.world.collateralFor('corroded_post'),
exposure: collateralExposure({ collateral: col }), dressed: built.dressed, dressError: built.dressError };
} catch (err) { if (err && err.skip) corErr = 'skip'; else corErr = String((err && err.stack) || err); }
return [
['gate 3: a corroded post prices honestly in the card — 0.55 hint, $45 collateral, dressed', () => {
if (!CORRODED_TIER_LIVE) return 'SKIPPED — corroded tier lands with lane/e; measured GREEN on '
+ 'the scratch merge (hint 0.55, collateral $45 through collateralFor)';
if (corErr) throw new Error(`corroded pricing died: ${corErr}`);
assert(corRun.dressed, `the corroded world did not dress (${corRun.dressError}) — GLB missing or broke`);
const a = corRun.anchor;
assert(a, 'the corroded anchor cor1_a1 was not created');
// The STEEL half, node-less: factoryExtras had to reach it, or the tier
// math prices this trap as the best steel in the game (adoptAnchor's
// `?? 1`, E's load-bearing _v1 warning).
assert(a.ratingHint === 0.55, `corroded anchor rated ${a.ratingHint}, not 0.55 — a node-less placement `
+ 'lost E\'s factory hint and the card would price it as sound steel');
assert(a.collateral === 'corroded_post', `corroded anchor collateral "${a.collateral}", not "corroded_post"`);
// The COLLATERAL half, through the card's OWN resolver and exposure sum —
// not the baked GLB value read directly (the carport-to-null shortcut).
assert(corRun.priced && corRun.priced.cost === 45,
`world.collateralFor('corroded_post') gave ${JSON.stringify(corRun.priced)}, expected $45`);
assert(corRun.priced.label === 'the corroded post', `collateral label "${corRun.priced.label}"`);
const line = corRun.exposure.priced.find((e) => e.key === 'corroded_post');
assert(line && line.cost === 45,
`collateralExposure has no $45 corroded line (${JSON.stringify(corRun.exposure.priced.map((e) => [e.key, e.cost]))})`);
assert(!corRun.exposure.unpriced.some((e) => e.key === 'corroded_post'),
'the corroded post landed in UNPRICED — the exposure total silently drops its $45');
return `corroded_post: hint ${a.ratingHint}, collateral $${corRun.priced.cost} "${corRun.priced.label}", in the exposure sum`;
}],
['gardenfly: the pinned separation line, flown in BOTH chains — the 12s-skew disagreement, pinned', () => {
// ⚠ THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry):
// the pinned recipe HOLDS and rigging matters by a full state's width —

View File

@ -331,6 +331,11 @@ const CSS = `
#hud-card .offer .line { display:flex; justify-content:space-between; gap:14px;
font-size:12px; color:#8ba0ad; padding:2px 0; }
#hud-card .offer .line b { color:#dde5ea; font-weight:700; }
/* SPRINT17 [C]: the weather band. The hail line is lit brighter than the rest
because the soaker's whole trap is that its wind reads gentle the one line
a player must not skim past. */
#hud-card .offer .line.wx { color:#8ba0ad; }
#hud-card .offer .line.hail span { color:#cbd8e0; }
/* The two numbers a tradie decides on. The fee is the reason to say yes and
the exposure is the reason to say no, so they are weighted against each
other rather than both being grey: money in, money at risk. */
@ -816,14 +821,17 @@ export function createHud(d) {
const offerHtml = (o, i) => {
const n = o.night;
const cons = o.constraints ?? [];
const f = o.forecast;
// SPRINT17 [C], applied by the integrator: the offer maps C's ORDERED
// band (wind · hail · rain · change · confidence) instead of picking
// two forecast fields, so an offer card can never hide a night's hail.
// The .hail line is lit brighter because it is the one a player must
// not skim past — the soaker's whole trap is a gentle-reading wind.
return `
<button class="offer${cons.length ? ' constrained' : ''}" data-offer="${i}">
<span class="tag">${o.kind === 'scripted' ? 'tonight, as booked' : 'callout'}</span>
<div class="who">${n.client ?? 'no client'}</div>
<div class="addr">${n.addr ?? ''}</div>
${f?.wind ? `<div class="line"><span>${f.wind}</span></div>` : ''}
${f?.stones ? `<div class="line"><span>${f.stones}</span></div>` : ''}
${(o.band ?? []).map((l) => `<div class="line wx ${l.key}"><span>${l.text}</span></div>`).join('')}
${o.fee != null ? `<div class="line fee"><span>the job pays${
o.premium ? ` · +${Math.round(o.premium * 100)}% on their terms` : ''
}</span><b>$${o.fee}</b></div>` : ''}

View File

@ -17,7 +17,7 @@ import * as THREE from '../vendor/three.module.js';
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
import { createWorld, loadSite } from './world.js';
import { createCameraRig, spawnYawFor } from './camera.js';
import { loadStorm, createWind, forecastLines } from './weather.js';
import { loadStorm, createWind, forecastLines, offerBand } from './weather.js';
import { SailRig, createSailView } from './sail.js';
import { createPlayer } from './player.js';
import { Interact, wireYardActions } from './interact.js';
@ -1041,6 +1041,12 @@ export async function boot(opts = {}) {
// C's honest lines at lead 0 — tonight, exact. The same call the job
// sheet makes, so the board can never be more confident than the sheet.
forecast: def ? forecastLines(def, 0) : null,
// SPRINT17 [C], applied by the integrator: the ORDERED band the offer
// card maps, so a night's hail can never be dropped by the card picking
// two fields. Night 7 read softer than the buster on wind+stones alone
// (55 vs 76 km/h) while carrying 6× the hail — the board recommending
// its own trap. offerBand carries every fact the storm has.
band: def ? offerBand(def, 0) : null,
fee: def ? calloutFee(def, week.rep, night.constraints) : null,
exposure: exp.total,
exposureItems: exp.items,

View File

@ -9,6 +9,7 @@
import { RiggingSession, FABRIC, fabricNoteFor } from './rigging.js';
import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
import { factoryExtras } from './ratings.js';
const [CARABINER, SHACKLE, RATED] = HARDWARE;
@ -494,6 +495,66 @@ test('gate 2: the constraint door is a checked enum, and the terms survive reset
return 'five malformed shapes thrown out at the door; terms survive reset, cleared only explicitly';
});
// --- SPRINT17 gate 3 support: THE CORRODED TIER PRICES HONESTLY -------------
// E landed the corroded post (rating_hint 0.55, its own ANCHOR_TYPE word, a
// $45 collateral); the gates-0.2/0.3/2 pass DEFERRED gate 3, and this is it.
// B owns the pricing surfaces the card reads, so the number the shop charges
// for a corroded corner is pinned HERE, off E's real resolver — not a
// hand-typed 0.55. The collateral half ($45 through world.collateralFor → the
// exposure line) is pinned in gardenfly.selftest, where a dressed world exists;
// this is the STEEL half: a corroded post is weaker, so the same load costs
// MORE steel to hold, which is what "prices honestly" means and is B's tier
// math (_effRating), not E's mesh.
//
// ⚠️ CROSS-LANE SEAM. The corroded tier lives on lane/e; on lane/b in
// isolation `factoryExtras('corroded_post')` is null (no gen entry, no GLB).
// These pins SKIP WITH A DISCLOSURE on a bare lane/b and go live the moment
// lane/e merges — the "build against the shape as pushed" seam the sprint runs
// on. Measured GREEN on the scratch merge (lane/a + lane/e): hint 0.55.
const CORRODED_TIER_LIVE = !!(factoryExtras('corroded_post'));
const CORRODED_SKIP = 'SKIPPED — corroded tier is on lane/e, absent on bare lane/b; this pin is '
+ 'inert here and goes live at integration (measured GREEN on the scratch merge, hint 0.55)';
test('gate 3: the corroded tier is 0.55, read off E\'s resolver not a literal', () => {
if (!CORRODED_TIER_LIVE) return CORRODED_SKIP;
const ex = factoryExtras('corroded_post');
assert(ex && ex.ratingHint === 0.55,
`factoryExtras('corroded_post') gave ${JSON.stringify(ex)} — the card reads this for a node-less `
+ 'corroded placement, and if E re-rules the number it must move here, not be re-typed');
assert(ex.collateral === 'corroded_post',
'the corroded anchor carries no collateral key — the exposure line would read "not scored" over a $45 trap');
return `corroded_post → hint ${ex.ratingHint}, collateral "${ex.collateral}"`;
});
test('gate 3: a corroded corner costs MORE steel than a sound post at the same load', () => {
if (!CORRODED_TIER_LIVE) return CORRODED_SKIP;
// _effRating is what the panel's arrow and the audit's tier both price
// against: hardware.rating × the anchor's ratingHint. A corroded post fails
// at 0.55 of the steel's spec, so a load a carabiner holds on a sound post
// may need a shackle on a corroded one. Built off E's real hint.
const hint = factoryExtras('corroded_post').ratingHint;
const SOUND = { id: 'ps', type: 'post', pos: { x: -6.4, y: 3.9, z: 7.4 }, sway: () => ({ x: -6.4, y: 3.9, z: 7.4 }) };
const COR = { id: 'pc', type: 'corroded_post', pos: { x: 5.3, y: 3.9, z: 8 }, ratingHint: hint,
sway: () => ({ x: 5.3, y: 3.9, z: 8 }) };
const s = new RiggingSession({ anchors: [SOUND, COR, ...ANCHORS] });
s.rig('ps'); s.rig('pc'); s.rig('h1'); s.rig('t1');
const sound = s._effRating(s.pickOf('ps'));
const cor = s._effRating(s.pickOf('pc'));
// Both start on carabiners (rating 1200): the sound post holds 1200 N, the
// corroded holds 1200 × 0.55 = 660 N. Same steel, less strength — measured.
assert(sound === CARABINER.rating, `sound post effRating ${sound}, expected the carabiner's ${CARABINER.rating}`);
assert(Math.abs(cor - CARABINER.rating * hint) < 1e-9,
`corroded effRating ${cor}, expected ${CARABINER.rating} × ${hint} = ${CARABINER.rating * hint}`);
assert(cor < sound,
'a corroded post held as much as a sound one — the trap is priced as safe steel, which inverts it');
// The tier consequence: a load in the corroded gap (6601200 N) is HELD by a
// carabiner on the sound post and OVER a carabiner on the corroded one.
const load = 900;
assert(load < sound && load > cor,
`pick a load in the corroded gap (${cor}..${sound} N) so the tier difference is real`);
return `same carabiner: sound holds ${sound} N, corroded holds ${cor} N — the rust costs a tier`;
});
export const RIGGING_TESTS = TESTS;
export function runRiggingSelftest() {

View File

@ -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.

View File

@ -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.33.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.