diff --git a/F-progress.md b/F-progress.md index e720d85..0c84747 100644 --- a/F-progress.md +++ b/F-progress.md @@ -4,6 +4,70 @@ --- +## Round 39 (v9 WAVE 1 — THE CITY IS THE GAME) — five gates, five controls, and the interior margin is 162 + +*Full detail: [LANE_F_NOTES §39](docs/LANES/LANE_F_NOTES.md). (R38's close lives in +[LANE_F_NOTES §38](docs/LANES/LANE_F_NOTES.md) — it never got a row here.)* + +**`tools/qa.sh --strict --matrix` GREEN — 10 passed · 0 failed · 0 warn · 0 skipped**, on the true +final tree, with three new gates wired in as Gate 8. Lane A's selfcheck reproduces exactly: +**157,647/157,647 ALL GREEN, fingerprint `0x5f76e76` — zero goldens moved**, in a round that added an +address layer, a fog ledger, a spawn move and a new interior fitting. + +**THE ROUND'S BIGGEST NUMBER IS NOT THE ONE THE LEDGER ASKED FOR. The interior margin is 162 draws, +not 21 and never 6.** Lane E's transmission fix takes the worst room from **329 → 188** draws. +Measured with `tools/qa/r39_transmission.py`: two port-isolated no-store roots, identical inode for +inode except one file — the shipped `bookshelf` GLB vs a copy with `transmissionFactor: 1` +re-inserted (BIN sha1 identical, only the JSON chunk moved) — both driven through +`interior_test.html`'s own `drawSweep`. + +| `drawSweep` worst | glass bookshelf (= the pre-E tree) | E's fix (shipped) | Δ | +|---|---|---|---| +| GLB-on | **329** `opshop/hall` | **188** `dept/auto` | **−141** | +| GLB-off | 162 `opshop/hall` | 162 `opshop/hall` | 0 — the control's own control | +| margin vs the ≤350 law | **21** | **162** | **+141** | + +`opshop` 329→174 (−47%) · `book` 247→125 (−49%) · **every other type 0**, which is the shape Lane C +predicted from an isolated room, reproduced at sweep scale by a harness C did not write. **The worst +interior room in the game is now `dept/auto` — a room with no bookshelf in it.** The default boot +fetches these GLBs from the public depot, so I fetched all four R39 GLBs off `digalot.fyi` and +scanned them: **sha1-identical to the local tree, 0 transmissive materials, 4/4.** + +**The five gates, each with its control demonstrated in the same run:** + +| gate | verdict | the arm that could have failed | +|---|---|---| +| **39.1 address** (`r39_address.mjs`) | GREEN | **1,192/1,219 shops (97.8%)**, 23/23 `ways` supplier, 857/857 membership agree / 0 wrong. **RED ARM: a 3 m shift error raises adelaide 89 → 98 resolved shops** (Lane A's number to the digit) while inventing 9 names — `stats().shiftCheck` reads DISAGREE on all 72 perturbations, and it is the ONLY alarm that catches the 3 m case | +| **39.2 fog** (`r39_runtime.py`) | GREEN | **HIDES** 4/72 at spawn, 0 of 61 distant shops known · **REVEALS** 2,125 m → 58 shops · **FRONT TEST** 0 of 59 shopfronts learned when probed from 5 m behind · **+0 draws AND +0 tris**, four fresh browsers, both orders, drained, 3 reads each · 8/8 corrupt saves rejected · null store on classic/`game=0`/`fog=0` | +| **39.3 THE SIGN** (`r39_runtime.py`) | GREEN | shops in front of the player at spawn **4/4 · 4/4 · 4/4 · 1/1**; with the pre-R39 sign re-derived on the same tree, **0 on all four** | +| **39.4 rummage bin** (`r39_runtime.py`) | GREEN | 120 rooms: tub 60/60 both arms, `kind:'bin'` **0/60 default vs 60/60 armed**, 0 carves, 60/60 deterministic, reachable 0.80–1.03 m. **THE CONTENTS DID NOT SHIP** — on the default street boot the tub is `kind:'prop'`, 0 dig targets, E opens nothing | +| **39.5 transmission** (`r39_transmission.py --validator`) | GREEN | the validator **HARD FAILS rc 1** on a fabricated glass asset and **PASSES on the shipped tree** — both halves, or "it failed" only proves the gate is always red | + +**Re-baselined, without falsifying a release record** (`tools/qa/r39_shots.py` → +`docs/shots/laneF_r39/`): the R32 cluster-pose spawn frame and the R35 first-five splash were both +shot at the old pose. `v7_tour/02_the_strip.png` and `v7_beta/first_five_splash.png` are **kept as +history**; the re-baseline ships its own BEFORE, camera parked at the old `-sin` pose on the same +tree. Katoomba's BEFORE is a paddock and a blank back wall; the AFTER is the awnings and the +shopfront run. The R35 *gate* needed no re-baselining — `smoke_first_five` is pose-independent and +was green either side. + +**Three corrections, all mine to make and all measured:** LANE_A_NOTES §39's "a 30 m shift returns 0 +street names" — it returns 40/12/47 wrong ones, because on a dense grid a big shift lands you on the +next street · LANE_B_NOTES §39's "a fresh export has no `known` key" — true of `?fog=0`, no longer of +a default boot, because B's own `+sin` fix means you spawn in front of four shopfronts · and my own +harness bug, recorded: I first pointed the transmission control at `web/assets/models/` when +`validate_manifest.py` reads `pipeline/_normalized/`, and nearly filed a defect against Lane E on the +strength of it. **Aim the control at the code path, not the plausible path.** + +**Held → Lane E:** `check_transmission()` skips any GLB with no local `_normalized` copy and never +scans `web/assets/models/`. Demonstrated with a live control: remove the local copy and the same +glass asset passes rc 0. A depot-only transmissive GLB — the exact shape of `longbench` and +`streetlight` — would ship unseen. + +**No tag.** + +--- + ## Round 37 (v8 WAVE 0 — THE FLOOR) — the budget of record settled · two vacuous gates repaired **0.3 — THE BUDGET OF RECORD: 292 draws / 124,793 tris. The audit was right and the spike was diff --git a/docs/LANES/LANE_F_NOTES.md b/docs/LANES/LANE_F_NOTES.md index 5e9d1fd..2365d66 100644 --- a/docs/LANES/LANE_F_NOTES.md +++ b/docs/LANES/LANE_F_NOTES.md @@ -2708,3 +2708,294 @@ fixed**, because they are other people's files or other rounds' scope: ungated diagnostic override · **should `?classic=1` show the town selector at all**. **No tag.** Wave 1 is content; the epoch tags when it is whole. + +## §39 — R39: v9 WAVE 1, THE CITY IS THE GAME. F: five gates, five controls — and the interior margin is **162**, not 21 and never 6 + +**Verdict: v9 wave 1 SHIPS.** Four lanes landed the biggest single-round surface since the epoch +opened and every arm reproduces. **The round's biggest number is not the one the ledger asked for: +Lane E's transmission fix takes the worst interior room from 329 draws to 188, and the margin +against the ≤350 law from 21 to 162.** The op-shop bin that this round spent so much care costing +against a 21-draw margin is now spending against a 162-draw one. + +Everything below is measured on the true final tree (`43c7f42` + this lane's `tools/**`), on this +box, with each gate's falsifiability control demonstrated in the same run. New tools, all F-owned: +`tools/qa/r39_address.mjs` · `tools/qa/r39_transmission.py` · `tools/qa/r39_runtime.py` · +`tools/qa/r39_shots.py`, wired into `qa.sh` as Gate 8 (`--no-r39` skips the browser half). + +### §39.1 THE INTERIOR MARGIN, RE-MEASURED — **21 → 162, and the worst room is no longer an op shop** + +Fable's binding fact #3 was corrected in-round twice: "six" was R9-era, and Lane C measured today's +tree at **GLB-off 156 / GLB-on 319**, → **329** after the bin, margin **21**. That correction was +right *and it was taken before Lane E's transmission fix landed.* Re-measured here through +`interior_test.html`'s own `drawSweep` — the ≤350 law's own instrument, 12 types × 7 archetypes — +against **two port-isolated no-store roots that are the same inode for every byte except one file**: +the shipped `procity_fit_bookshelf_01.glb`, and a copy with `transmissionFactor: 1` re-inserted +(JSON chunk rewritten, **BIN sha1 identical**, `ab1fa806…`). `?localdepot=1` on both, so the GLB +comes off the served root and the swap is the only variable. + +| `drawSweep` worst room | with the glass bookshelf (= the pre-E tree) | with E's fix (shipped) | Δ | +|---|---|---|---| +| **GLB-on** | **329** (`opshop/hall`) | **188** (`dept/auto`) | **−141** | +| GLB-off | 162 (`opshop/hall`) | 162 (`opshop/hall`) | **0 — the control's own control** | +| **margin against the ≤350 law** | **21** | **162** | **+141** | + +Per type, GLB-on worst archetype, glass → fixed: **`opshop` 329 → 174 (−155, −47%)** · +**`book` 247 → 125 (−122, −49%)** · **every other type 0** — record 135, toy 158, video 123, pawn +136, milkbar 142, dept 188, stall 144, pub 112, band_room 111, rsl 122, all unchanged to the draw. +That is exactly the shape Lane C predicted from an isolated room (`opshop`/hall 191→104 −46%, +`book`/hall 101→51 −50%, `dept`/hall 116→116 as the control), reproduced at the sweep's own scale by +a harness C did not write. **The falsifiable arm E asked for fires: the sweep dropped.** + +Three things follow and they are worth writing down: + +1. **The worst interior room in the game is now `dept/auto` at 188 draws** — a room with no + bookshelf in it, which is why it did not move. The op shop was only the worst room because it was + being rendered twice. +2. **GLB-off is 162 on both roots**, which is what makes the −141 attributable: a GLB-off room loads + no GLB, so if the two roots differed for any other reason this row would have shown it. + *(Lane C reports GLB-off worst **161**; I measure **162**, same room, same tree. One draw, not + chased — recorded so the next person to quote a number knows two harnesses differ by it.)* +3. **The default boot fetches these GLBs from the public depot, not from `web/assets/models/`** + (`loaders.js:29` — `?localdepot` is opt-in), so I fetched all four R39 GLBs off + `https://digalot.fyi/3god/a/` and scanned them: **bytes sha1-identical to the local tree, 0 + transmissive materials, 4/4.** E's publish is verified independently of E's own verification. + +### §39.2 GATE — THE ADDRESS LAYER (39.1) · `node tools/qa/r39_address.mjs` · **GREEN** + +Fable asked for ≥97% corpus resolution and a loud failure with no roads cache, and Lane A's binding +warning set the shape: **coverage is not a proxy for correctness.** So the gate reports coverage and +then spends its remaining arms trying to make a *high*-coverage build fail. + +| arm | result | +|---|---| +| corpus resolution, counted here over all 23 caches | **1,192 / 1,219 shops (97.8%)**, 29,865/30,986 edges (96.4%), **100% on 18 of 23 towns**, worst adelaide 80.9% | +| supplier | `ways` on **23/23** | +| shift cross-derivation (`address.js`'s recovery vs `plan_osm`'s published `norm.shift`, 0.01 m) | **agree on 23/23** | +| wrong names, against an independently written point-membership resolver over shop-bearing edges | **857/857 agree, 0 disagree** — and the control **checked 857 edges**, asserted, because a control that quietly checks nothing is the R37 species | +| **no cache** (`null`, `undefined`, and `roads[].name` stripped) | supplier `district`, **0** named edges, **0** street names on all three — and it still labels **72/72** katoomba shops from district+block (*"the south end of the main street"*). A degrade, not an outage, and visible in `stats()` | +| synthetic, same code path | supplier `district`, **493/493** labelled, 0 nulls — one consumer contract, two suppliers | +| purity | **23/23** towns byte-identical after the call | + +**THE RED ARM — the wrong-shift sweep (8 directions × 3 magnitudes × 3 towns, 72 perturbations).** +A wrong shift is the one input that renames a whole town, so it is what the gate is built around: + +| adelaide_real (truth **89**/110 resolved, 0 wrong) | best of 8 directions | +|---|---| +| off by **3 m** | **98 shops resolved — NINE MORE THAN THE TRUTH**; 0 renamed outright, **9 given a street name the correctly-shifted build honestly refused**; 0/74 membership disagreements | +| off by 8 m | 96 resolved (+7); 10 invented | +| off by 30 m | 40 resolved (−49); 3 renamed outright, 2/28 membership disagreements | + +**Lane A's number reproduces to the digit: 89 → 98.** A gate that read coverage would have gone +*greener* on a broken build. `stats().shiftCheck` reads **DISAGREE on all 72 perturbations**, which +is what this gate actually stands on. + +**And the honest limit of the second alarm, which matters:** at 3 m the *membership* control reads +**0 disagreements** while 9 names are being invented — because those nine shops front ways OSM leaves +**unnamed**, so membership has no answer there and stays silent. **Only the shift cross-derivation +catches the 3 m case.** That is why this gate asserts the cross-derivation on all 23 towns and never +the coverage, and it is a load-bearing detail for anyone who later "simplifies" these two arms into +one. + +**And it is live in the game, not just in node.** Read off the running shell during the fog gate: +`katoomba_real` → `addresses.stats().supplier === 'ways'`, synthetic → `'district'`, and +`minimap.view.labels === true` on **both** — the consumer draws street labels through one code path +with no town-type branch, which is the whole design constraint A published. + +**One correction to LANE_A_NOTES §39, measured.** A's note says a 30 m shift error makes +castlemaine/katoomba/adelaide *"return 0 street names rather than a town full of wrong ones."* +Measured here, 30 m keeps **adelaide 40 · castlemaine 12 · katoomba 47** names and they include wrong +ones. **A big shift does not degrade to null on a dense grid — it lands you on the next street.** +The conclusion A drew from it is unchanged and if anything stronger: the shift arm must be +exact-or-nothing, because there is no magnitude at which being wrong announces itself in the output. + +### §39.3 GATE — THE SIGN (B, `a982f3c`) · `r39_runtime.py --only sign` · **GREEN** + +The picture-free arm Lane B specified, on four towns, with its "before" computed on the same tree: +both spawn derivations (`+sin` as shipped, `−sin` as it read from R32 until this round) are rebuilt +from the **live plan in the page**, the shipped pose is **read off the live player and asserted equal +to the `+sin` derivation** (4/4 towns, <0.01 m) — so the thing being scored is the shell, not a +re-implementation — and then one scorer runs at both poses. + +| town | shipped (`+sin`) | the pre-R39 sign, same tree | +|---|---|---| +| synthetic | (6.1, −19.8) · 4 within 25 m, **4 in front** | (25.5, −17.9) · 3 within 25 m, **0 in front** | +| katoomba_real | (−200.0, −474.4) · 4 within 25 m, **4 in front** | (−219.0, −471.3) · 3 within 25 m, **0 in front** | +| bowral_real | (1774.0, −171.7) · 4 within 25 m, **4 in front** | (1755.1, −168.5) · 2 within 25 m, **0 in front** | +| fitzroy_real | (−22.8, −184.9) · 1 within 25 m, **1 in front** | (−25.4, −204.0) · 1 within 25 m, **0 in front** | + +**> 0 on 4/4 after, ZERO on 4/4 before** — the gate discriminates, and it goes red the moment the +sign flips back. The shell's own probe agrees at boot (4 · 4 · 4 · 1 shops discovered). 0 console +errors across all four boots. Every one of Lane B's numbers is also mine. + +**THE TWO RE-BASELINED PICTURE GATES** (`tools/qa/r39_shots.py` → `docs/shots/laneF_r39/`): + +- **The R32 cluster-pose spawn.** `docs/shots/v7_tour/02_the_strip.png` photographs the old pose. + **I did not overwrite it** — it is an artefact of the tagged v7.0 epoch and re-shooting a release + tour on a v9 tree falsifies a record. The re-baseline is `the_strip-{BEFORE,AFTER}*.png` and + `the_strip_katoomba-{BEFORE,AFTER}*.png`, each pair shot on the **same tree** with the camera + parked at the two poses. Katoomba's BEFORE is the paddock and the blank back wall, exactly as B + reported; the AFTER is the awnings and the shopfront run. +- **The R35 first-five-minutes smoke needed no re-baselining as a GATE, only as a picture.** + `smoke_first_five` reads the hunt line and drives the beats through `DBG.enterShop`, so it is + pose-independent — it was green before and after, and it is green in this round's suite. What moved + is the frame its subject is *seen* in: `first_five-AFTER-plus-sin.png`, *"You're hunting HILLS + HOIST — $191 in your pocket"* over a street that now has shops in it. `v7_beta/first_five_splash.png` + is likewise kept as history. + +### §39.4 GATE — THE FOG (B, `0ee7a84`) · `r39_runtime.py --only fog` · **GREEN** + +Both arms on the same boot, or it is vacuous. The walk is a **shopper's walk** — the edges the most +shops front, not the longest edges (my first cut walked katoomba's 5.8 km of rural arterial and +revealed one shop, which measures the walker, not the fog). + +| | katoomba_real (72 shops) | synthetic (493 shops) | +|---|---|---| +| **ARM 1 — HIDES** | 4/72 known at spawn, **0 of the 61 shops beyond 120 m known** | 4/493 known, **0 of 410 beyond 120 m** | +| **ARM 2 — REVEALS** | 2,125 m walked → **58 shops / 51 segments** (+54/+50) | 2,100 m → **405 shops / 16 segments** (+401/+15) | +| **ARM 3 — the FRONT test** | stand 5 m **behind** each of the 14 still-unknown shopfronts and probe: **0 learned**, 14 refusals | 45 unknown shopfronts probed from behind: **0 learned**, 197 refusals | +| the map moves with the ledger | 3,478 → 135 px of shop colour (the frame grows with the walk) | 2,202 → 16,328 px | +| lot size on the map | fresh **29.4×39.2 px** vs the shipped full-plan **1.42×1.89 px** | 20.97×41.16 vs 5.22×10.24 | +| save round-trip | 58 shop + 51 edge ids, **567 B** | 405 + 16 ids, **1,758 B** | +| corrupt-save arms | **8/8 rejected, live state untouched** | 8/8 | + +ARM 3 is the side test's red arm and it is decisive: a radius-only fog would have handed over all 59 +of those shopfronts through their own back walls. On the shopper's walk itself the same test refuses +**6 times on katoomba and 0 on the synthetic strip** — free where a shopper stands, decisive behind +the buildings, which is the shape Lane B claimed. + +**ZERO DRAWS, and the arm is non-vacuous by assertion:** four freshly launched browsers, two orders +(default-first and `?fog=0`-first), each boot **drained** (`chunks.pending → 0`, settle, re-teleport +to read) and read **three times** so the arm carries its own noise floor; the fog flag and the store's +presence are asserted opposite on the two arms *before* their counters are compared. + +``` +yaw 0 : default 171 draws [171,172] / 24,507 tris [23196,27193] · ?fog=0 171 / 24,507 — identical +yaw 90: default 103 draws [102,103] / 25,121 tris [23058,25121] · ?fog=0 103 / 25,121 — identical +``` + +**Identical in BOTH counters, both yaws, both orders.** Map mode reads the same 103 draws in mode +`map` — structural, `index.html`'s frame loop never calls `composer.render()` there. + +*A method note worth keeping:* my first cut booted both arms **in one browser** and read a **12,000 +triangle gap at identical draw counts**. That is HTTP-cache warmth — the second boot has more of the +asset fleet resolved by sample time — and the only way to see it is to reverse the order. One browser +per arm makes the two readings identical to the byte. **Two arms in one browser is not an A/B.** + +**The null arm:** `?classic=1`, `?game=0` and `?fog=0` all produce a **null store** (3/3), which is +the pre-R39 map with no flag test in `minimap.js` at all. `?game=0` and `?fog=0` draw a +**pixel-identical** map (FNV `0xddb5b939`) and the default boot's differs (`0x672e276b`) — the hash +is sensitive to the map's content, which is what makes the equality mean something. + +**A CORRECTION TO LANE_B_NOTES §39, and it is B's own fix that caused it.** B records *"a fresh +export has no `known` key"*. On the shipped tree the default boot discovers **4 shops before the +player touches a key** — because the `+sin` spawn now puts you in front of four shopfronts — so a +default boot's first export **does** carry `known`. The claim the delta law is actually about +survives and is asserted instead: **a boot that learns nothing (`?fog=0`) exports +`[cash, collection, day, savedAt, schema, town, wants]` — no `known` key, the pre-R39 bytes.** + +### §39.5 GATE — THE RUMMAGE BIN (C, `eeba920`) · `r39_runtime.py --only bin` · **GREEN** + +Both arms, or the gate only proves that a tub exists. The tub is identified off the room's **own +placement summary** (`placement[].kind === 'rummageBin'`), not a bounding-box guess, and reachability +is a **4-connected flood fill from the room spawn** written here rather than a flag the builder set +about itself. + +| 6 archetypes × 6 seeds × GLB on/off = 120 rooms | default (`dig:false`) | ARMED (`{rummageBin:{dig:true}}`) | +|---|---|---| +| tub placed | **60/60** | **60/60** | +| `kind:'bin'` | **0/60** | **60/60** | +| dig aim targets in `places` | **0** | **60** | +| pathOK / corridor carves | 60/60 · **0** | 60/60 · **0** | +| deterministic (byte-equal placement over two builds) | 60/60 | 60/60 | +| nearest reachable cell to the tub | — | **0.80–1.03 m** (dig proximity 2.5 m, aimed reach 3.2 m) | + +**THE CONTENTS DID NOT SHIP, proven on the boot every player gets:** on the default street boot +(*"Second Time Redfern"*, fittings `counter · clothesRack ×2 · metalShelf ×2 · bookshelf ×2 · +crateStack · rummageBin · art ×3`) the tub is in the room as **`kind:'prop'`**, there are **0** +`kind:'bin'` objects and **0** dig targets, and standing in front of it and pressing **E opens +nothing**. Lane C's hold is intact and I am not the one to break it. + +Lane C's 72/72 is 6 archetypes; `ARCHETYPE_KEYS` on this tree is **5**, so my 120 rooms are +5 × 6 × 2 × 2 arms. C's reachability range (0.77–1.15 m) and mine (0.80–1.03 m) agree. + +### §39.5b THE MAGPIE'S GLB ADOPTION (B, `f0b9a1d`) — checked on the wire, because it is a blind-probe shape + +B wired E's magpie GLB as `furniture.js`'s use-if-ready second path and states that `?noassets` and +`?classic` still get the procedural bird. A module-scope `loadGLB` is exactly the shape that leaks a +fetch past the asset-free law, so I watched the wire rather than the flag: + +| boot | `magpie.state.asset` | depot/model fetches | magpie GLB fetched | +|---|---|---|---| +| default (`?magpie=1` to force the season) | **`glb`** | 37 | yes — `digalot.fyi/3god/a/procity_street_magpie_01.glb` | +| **`?noassets=1&magpie=1`** | **`primitive`** | **0** | **no** | +| `?classic=1` | **null — never constructed** | 25 (the classic layer's own, allow-listed) | no | + +`state.asset` is read off the live module, so the gate reads which bird is bound instead of inferring +it from a fetch. The matrix's `?noassets` arm (0 fetches on all ten towns) is the standing guard. + +### §39.6 GATE — THE TRANSMISSION VALIDATOR (E, `12eb571`) · **GREEN, and it FIRES** + +`validate_manifest.py`'s R39 hard-fail is live, so the gate is: **make it fire.** A fabricated glass +`bookshelf` (BIN sha1 identical, only the JSON chunk moved) dropped into a mirror of the repo where +every other path is a symlink: + +- **validator HARD FAILS, rc 1**: *"material[0] 'mtl_10218_Bookshelves_v1' has transmissionFactor 1.0 + — three.js will run a transmission PRE-PASS and issue every opaque draw in that scene TWICE"* +- **validator PASSES on the shipped tree** — the positive control, without which "it failed" only + proves the gate is always red +- shipped tree re-scanned: **0 transmissive materials in 59 GLBs under `web/assets/models/` and 59 + under `pipeline/_normalized/`**, and 0 in the 4 fetched off the public depot + +**MY OWN HARNESS BUG, RECORDED BECAUSE IT IS THIS ROUND'S OWN SPECIES.** My first cut planted the +glass asset in `web/assets/models/` and reported *"the validator did NOT fail — E's gate is +vacuous."* It was **my** error: `check_transmission()` reads `pipeline/_normalized/`. I pointed +a gate at a file it never opens and nearly filed a defect against another lane on the strength of it. +*Aim the control at the code path, not at the plausible path.* + +**→ LANE E, one ask (your file, not mine).** `check_transmission()` returns silently when +`pipeline/_normalized/` is absent (`# depot-only asset: nothing to parse here`), and it never +scans `web/assets/models/`. **Demonstrated, not inferred:** with the local `_normalized` copy removed +and the manifest still pointing at it, the same glass asset **passes, rc 0**. So a depot-only +transmissive GLB — exactly the shape of `longbench` and `streetlight`, which are in the manifest and +not wired — would ship unseen. Two lines fix it: scan `web/assets/models/` when `_normalized` +has no copy, and warn (not skip) when neither exists. + +### §39.7 THE SUITE — `tools/qa.sh --strict --matrix` on the true final tree + +``` +SUMMARY 10 passed · 0 failed · 0 warn · 0 skipped +● QA GREEN — every landed gate passed. +``` + +- **Lane A's selfcheck reproduces exactly: 157,647/157,647 ALL GREEN, fingerprint `0x5f76e76`.** + Zero goldens moved this round, on a round that added an address layer, a fog ledger, a spawn move + and a new interior fitting. +- flags harness GREEN (0 fails, 0 warns) — including `smoke_first_five`, which the spawn move does + not touch, and `smoke_classic_purity`. +- MATRIX GREEN, 10 towns × 7 gates; manifest validator `--depot` green; interior figure-scale green. +- Gate 8 (new): `r39_address` · `r39_transmission --validator` · `r39_runtime` — all green. +- A **baseline** run of the pre-Gate-8 suite on the same tree was also green (7/0/0/0), so the three + new gates added coverage without moving any existing verdict. + +### §39.8 HELD, AND HANDED ON + +1. **→ LANE E:** the validator's depot-only blind spot (§39.6). Demonstrated with a running control. +2. **→ FABLE / the record:** binding fact #3 needs its third and final value. **The interior margin + is 162 draws** (worst room `dept/auto` 188 / 350) as of `12eb571`. The lineage is worth keeping + because it is a lesson in three parts: **6** (a stale doc), **21** (a correct measurement of a + tree that was about to change), **162** (the measurement after the change). Every one of them was + honestly arrived at. +3. **→ LANE A (note, not a defect):** the 30 m shift claim in LANE_A_NOTES §39 (§39.2 above). +4. **→ LANE B (note, not a defect):** "a fresh export has no `known` key" is now true of `?fog=0` + only (§39.4 above). +5. **The 1-draw GLB-off disagreement with Lane C** (161 vs 162, same room, same tree). Not chased. +6. Carried, unchanged: **A's D1–D4** · the matrix's budget column is bookmark numbers · **`?r=3` + breaches at 307** and is still an ungated diagnostic override · should `?classic=1` show the town + selector at all · **nobody in this loop has ears**. + +**The street budget was not re-measured this round and does not need to be:** the fog and the address +layer are both zero-draw by construction and measured at zero, the spawn move is a pose, and the bin +is interior. The pin stands at **292/300**. What moved is the *interior* budget, and it moved in our +favour by 141 draws. + +**No tag.** diff --git a/docs/shots/laneF_r39/README.md b/docs/shots/laneF_r39/README.md new file mode 100644 index 0000000..6e88803 --- /dev/null +++ b/docs/shots/laneF_r39/README.md @@ -0,0 +1,29 @@ +# Lane F — R39: the spawn-pose RE-BASELINE (v9 wave 1) + +Lane B's `+sin` fix moved the non-classic spawn (`index.html:500`, R32's cluster-pose). **Two of this +lane's picture baselines were taken at the old pose and are therefore photographs of the defect:** + +| baseline | what it shows | status | +|---|---|---| +| `docs/shots/v7_tour/02_the_strip.png` | "the cluster-pose spawn: midday on the main street, shopfronts down the block" | **HISTORICAL** — kept. It is an artefact of the tagged v7.0 epoch; re-shooting it on a v9 tree would falsify a release record. | +| `docs/shots/v7_beta/first_five_splash.png` | the R35 first-five-minutes splash, shot at the spawn | **HISTORICAL** — kept, same reason. | + +The re-baseline lives here, and it ships its own BEFORE — the **same tree**, camera parked at the +old `-sin` pose — so the pair is a comparison rather than a claim. Regenerate with +`tools/.venv/bin/python tools/qa/r39_shots.py` (seed 20261990, `?roster=v1&pop=0`, segment 2). + +| frame | pose | what is in it | +|---|---|---| +| `the_strip-BEFORE-minus-sin.png` | synthetic (25.5, −17.9) yaw π | the shipped-since-R32 spawn: a footpath facing a plaza, not one shopfront in frame | +| `the_strip-AFTER-plus-sin.png` | synthetic (6.1, −19.8) yaw π | the fixed spawn: the strip, four shopfronts inside 25 m and all four in front of you | +| `the_strip_katoomba-BEFORE-minus-sin.png` | katoomba_real (−219.0, −471.3) yaw 0 | **standing in a paddock facing the blank back wall of a building** — the R31 playtest's own complaint, the one R32 was written to fix and, because of the sign, never did | +| `the_strip_katoomba-AFTER-plus-sin.png` | katoomba_real (−200.0, −474.4) yaw 0 | under the awnings on Katoomba Street with the shopfronts and their signage running away down the block | +| `first_five-AFTER-plus-sin.png` | synthetic, fresh game | the R35 frame re-shot: *"You're hunting HILLS HOIST — $191 in your pocket."* over a street that now has shops in it | + +**The automated R35 gate did not need re-baselining, only the picture did.** `smoke_first_five` +(`tools/flags_check.py`) reads the hunt line and drives the beats through `DBG.enterShop`, so it is +pose-independent; it was green before and after the fix. What the fix changed is the frame the gate's +subject is *seen* in. The falsifiable arm that now guards this pose is +`tools/qa/r39_runtime.py --only sign`: **at the spawn, the count of shops within 25 m that are in +front of the player must be > 0** — measured 4/4 · 4/4 · 4/4 · 1/1 on synthetic / katoomba / bowral / +fitzroy after the fix, and **0 on all four** with the old sign re-derived on the same tree. diff --git a/docs/shots/laneF_r39/first_five-AFTER-plus-sin.png b/docs/shots/laneF_r39/first_five-AFTER-plus-sin.png new file mode 100644 index 0000000..a1f8372 Binary files /dev/null and b/docs/shots/laneF_r39/first_five-AFTER-plus-sin.png differ diff --git a/docs/shots/laneF_r39/the_strip-AFTER-plus-sin.png b/docs/shots/laneF_r39/the_strip-AFTER-plus-sin.png new file mode 100644 index 0000000..66a0c74 Binary files /dev/null and b/docs/shots/laneF_r39/the_strip-AFTER-plus-sin.png differ diff --git a/docs/shots/laneF_r39/the_strip-BEFORE-minus-sin.png b/docs/shots/laneF_r39/the_strip-BEFORE-minus-sin.png new file mode 100644 index 0000000..2180009 Binary files /dev/null and b/docs/shots/laneF_r39/the_strip-BEFORE-minus-sin.png differ diff --git a/docs/shots/laneF_r39/the_strip_katoomba-AFTER-plus-sin.png b/docs/shots/laneF_r39/the_strip_katoomba-AFTER-plus-sin.png new file mode 100644 index 0000000..e9516f6 Binary files /dev/null and b/docs/shots/laneF_r39/the_strip_katoomba-AFTER-plus-sin.png differ diff --git a/docs/shots/laneF_r39/the_strip_katoomba-BEFORE-minus-sin.png b/docs/shots/laneF_r39/the_strip_katoomba-BEFORE-minus-sin.png new file mode 100644 index 0000000..9f517e8 Binary files /dev/null and b/docs/shots/laneF_r39/the_strip_katoomba-BEFORE-minus-sin.png differ diff --git a/tools/qa.sh b/tools/qa.sh index 06325c5..8539a2c 100755 --- a/tools/qa.sh +++ b/tools/qa.sh @@ -12,6 +12,7 @@ # 1. scaffold_check.mjs — scaffold + PRNG determinism law + lane-readiness matrix (F-owned) # 2. citygen selfcheck — node web/js/citygen/selfcheck.js (Lane A ships it) # 3. manifest validator — python3 pipeline/validate_manifest.py (Lane E ships it) +# 8. the R39 (v9 wave 1) gates — tools/qa/r39_address.mjs · r39_transmission.py · r39_runtime.py # # Browser-driven gates (determinism-PNG, 10-min soak, budget-HUD, ?noassets run) need the running # game; they live in tools/soak.md + tools/shots.md and run once Lane B's index.html lands. @@ -140,6 +141,26 @@ if [ "$MATRIX_RUN" = 1 ]; then fi fi +# ── Gate 8 (Lane F, round 39): the v9 wave-1 gates ─────────────────────────── +# 39.1 THE ADDRESS LAYER (node, no browser) · 39.4 THE TRANSMISSION PRE-PASS (validator control + +# the interior draw sweep against a fabricated glass asset) · 39.2 THE FOG / THE SIGN / THE RUMMAGE +# BIN (browser). Each ships its falsifiability control in the same run — see LANE_F_NOTES §39. +# --no-r39 skips the browser half on a fast determinism-only run; the node half always runs. +run_gate "R39 address layer (>=97% corpus · degrade-to-district · the wrong-shift red arm)" \ + node tools/qa/r39_address.mjs + +R39_SKIP=0; for a in "$@"; do [ "$a" = "--no-r39" ] && R39_SKIP=1; done +if [ "$R39_SKIP" = 1 ]; then + soft_skip "R39 transmission + runtime gates" "--no-r39" +elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then + run_gate "R39 transmission pre-pass (validator hard-fail control · interior sweep vs a glass asset)" \ + tools/.venv/bin/python tools/qa/r39_transmission.py --validator + run_gate "R39 runtime (THE SIGN · THE FOG both arms · THE RUMMAGE BIN, contents unarmed)" \ + tools/.venv/bin/python tools/qa/r39_runtime.py +else + soft_skip "R39 transmission + runtime gates" "Playwright venv absent" +fi + # ── Summary ────────────────────────────────────────────────────────────────── hr printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \ diff --git a/tools/qa/r39_address.mjs b/tools/qa/r39_address.mjs new file mode 100644 index 0000000..ec1b41e --- /dev/null +++ b/tools/qa/r39_address.mjs @@ -0,0 +1,255 @@ +#!/usr/bin/env node +// PROCITY Lane F — R39 §39.5 gate A: THE ADDRESS LAYER, gated on CORRECTNESS, not on coverage. +// +// Fable's brief: prove the layer resolves >=97% of corpus shops and FAILS LOUDLY on a town with no +// roads cache. Lane A's binding warning, which sets the whole shape of this gate: +// +// > COVERAGE IS NOT A PROXY FOR CORRECTNESS. A 3 m shift error RAISES adelaide from 89 to 98 +// > resolved shops while every one of those names is wrong. +// +// So this harness measures coverage (because the brief asks for a number) and then spends the rest of +// its arms trying to make a HIGH-coverage build fail — by perturbing the one input that can rename a +// whole town, and checking two independent things fire: +// (a) `stats().shiftCheck` — address.js's recovered shift vs plan_osm's published `norm.shift` +// (two derivations that share no code), which trips on 0.01 m; +// (b) POINT MEMBERSHIP — a plan edge is two consecutive points of ONE simplified way snapped on a +// 3 m lattice, so the way it came from must CONTAIN both endpoints' snap keys. Implemented here +// independently of both address.js and selfcheck.js, over the shop-bearing edges only. +// +// The red arm is the deliverable: under a +3 m shift adelaide's coverage goes UP and membership +// disagreement goes from 0 to nearly every edge. A gate that reads coverage would have gone green. +// +// Run: node tools/qa/r39_address.mjs [--json OUT] + +import { readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generatePlanOSM, generatePlan, createAddresses, STREET_TOLERANCE_M } from '../../web/js/citygen/index.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const TOWNS = join(ROOT, 'web', 'assets', 'towns'); +const SEED = 20261990; +const CORPUS_FLOOR_PCT = 97; // Fable's brief +const SNAP = 3; // plan_osm.js's lattice — replicated on purpose; see below + +let fails = 0; +const ok = (c, m) => { console.log(c ? ` \x1b[32m✓\x1b[0m ${m}` : ` \x1b[31m✗ FAIL\x1b[0m ${m}`); if (!c) fails++; }; +const head = (m) => console.log(`\n\x1b[1m${m}\x1b[0m`); +const note = (m) => console.log(` \x1b[33m·\x1b[0m ${m}`); + +// ── the independent control: which named way CONTAINS both endpoints of this edge? ─────────────── +// Deliberately the same coupling Lane A's own control declares (it replicates plan_osm's SNAP): if +// the lift's snapping changes, this must be re-derived rather than silently trusted. It is a +// different QUESTION from address.js's ("which named way is nearest at five samples along it?"), +// which is what makes the two comparable rather than circular. +function membership(plan, cache, shift) { + const cosLat = Math.cos(cache.center.lat * Math.PI / 180); + const px = (lon) => (lon - cache.center.lon) * 111320 * cosLat; + const pz = (lat) => (lat - cache.center.lat) * 111320; + const k = (x, z) => `${Math.round(x / SNAP)},${Math.round(z / SNAP)}`; + const at = new Map(), name = []; + (cache.roads || []).forEach((rd, i) => { + if (!rd || !Array.isArray(rd.pts) || rd.pts.length < 2) { name.push(null); return; } + name.push(typeof rd.name === 'string' && rd.name.trim() ? rd.name.trim() : null); + for (const p of rd.pts) { + if (!Array.isArray(p)) continue; + const key = k(px(p[1]), pz(p[0])); + const s = at.get(key); if (s) s.add(i); else at.set(key, new Set([i])); + } + }); + const nodeById = new Map(plan.streets.nodes.map((n) => [n.id, n])); + const out = new Map(); + for (const e of plan.streets.edges) { + const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue; + const A = at.get(k(a.x - shift.shx, a.z - shift.shz)), B = at.get(k(b.x - shift.shx, b.z - shift.shz)); + if (!A || !B) continue; + const nms = [...new Set([...A].filter((w) => B.has(w)).map((w) => name[w]).filter(Boolean))]; + if (nms.length) out.set(e.id, nms); + } + return out; +} + +function shopEdgeVerdict(plan, addr, ctrl) { + const lotById = new Map(plan.lots.map((l) => [l.id, l])); + const fronts = new Set(plan.shops.map((s) => (lotById.get(s.lot) || {}).frontEdge)); + let checked = 0, agree = 0, disagree = 0; + for (const [eid, nms] of ctrl) { + const got = addr.streetOf(eid); + if (!got || !fronts.has(eid)) continue; + checked++; + if (nms.includes(got)) agree++; else disagree++; + } + return { checked, agree, disagree }; +} + +const keys = existsSync(TOWNS) + ? readdirSync(TOWNS).filter((f) => f.endsWith('.json') && f !== 'index.json').map((f) => f.replace(/\.json$/, '')).sort() + : []; + +console.log(`\x1b[1mGATE R39 §39.5-A: THE ADDRESS LAYER\x1b[0m (${keys.length} caches · tolerance ${STREET_TOLERANCE_M} m · seed ${SEED})`); +if (!keys.length) { console.log(' no town caches on disk — nothing to gate'); process.exit(1); } + +// ── 1. the corpus roll-up, measured here rather than quoted ────────────────────────────────────── +head('1. corpus resolution — my own count, town by town'); +const roll = { towns: 0, shops: 0, street: 0, labelled: 0, edges: 0, edgesNamed: 0, + checked: 0, agree: 0, disagree: 0, shiftAgree: 0, ways: 0 }; +const perTown = {}; +for (const key of keys) { + const cache = JSON.parse(readFileSync(join(TOWNS, `${key}.json`), 'utf8')); + const report = {}; + const plan = generatePlanOSM(SEED, key, { cache, report }); + const addr = createAddresses(plan, cache); + const st = addr.stats(); + const ctrl = membership(plan, cache, report.shift); + const v = shopEdgeVerdict(plan, addr, ctrl); + const pct = st.shops ? (100 * st.shopsWithStreet) / st.shops : 0; + perTown[key] = { shops: st.shops, street: st.shopsWithStreet, pct: +pct.toFixed(1), supplier: st.supplier, + edges: st.edges, edgesNamed: st.edgesNamed, names: st.distinctStreets, ...v, + shiftCheck: createAddresses(plan, cache, { shift: report.shift }).stats().shiftCheck }; + roll.towns++; roll.shops += st.shops; roll.street += st.shopsWithStreet; roll.labelled += st.shopsLabelled; + roll.edges += st.edges; roll.edgesNamed += st.edgesNamed; + roll.checked += v.checked; roll.agree += v.agree; roll.disagree += v.disagree; + if (perTown[key].shiftCheck === 'agree') roll.shiftAgree++; + if (st.supplier === 'ways') roll.ways++; +} +const pctCorpus = (100 * roll.street) / roll.shops; +const worst = Object.entries(perTown).sort((a, b) => a[1].pct - b[1].pct)[0]; +const full = Object.values(perTown).filter((t) => t.pct === 100).length; +ok(pctCorpus >= CORPUS_FLOOR_PCT, + `corpus ${roll.street}/${roll.shops} shops resolve to a real street name (${pctCorpus.toFixed(1)}%) — floor ${CORPUS_FLOOR_PCT}%`); +note(`${roll.edgesNamed}/${roll.edges} edges named (${((100 * roll.edgesNamed) / roll.edges).toFixed(1)}%) · ` + + `100% on ${full}/${roll.towns} towns · worst ${worst[0]} ${worst[1].pct}%`); +ok(roll.ways === roll.towns, `all ${roll.ways}/${roll.towns} towns resolve through the 'ways' supplier`); +ok(roll.shiftAgree === roll.towns, + `the shift recovered by address.js agrees with plan_osm's published norm.shift on ${roll.shiftAgree}/${roll.towns} towns (0.01 m tolerance)`); + +// ── 2. the correctness arm: zero wrong names, and the control is NOT vacuous ────────────────────── +head('2. wrong names — the independent point-membership control'); +ok(roll.disagree === 0, + `${roll.agree}/${roll.checked} shop-bearing edges agree with the membership control, ${roll.disagree} disagree`); +ok(roll.checked > 800, + `the control actually checked ${roll.checked} edges — a control that quietly checks nothing is how a gate goes vacuous (R37)`); + +// ── 3. FAILS LOUDLY WITH NO CACHE — degrade to district labels, never to a wrong name ──────────── +head('3. no roads cache — degrade to district labels, never to a wrong name'); +{ + const key = 'katoomba_real'; + const cache = JSON.parse(readFileSync(join(TOWNS, `${key}.json`), 'utf8')); + const plan = generatePlanOSM(SEED, key, { cache }); + for (const [what, arg] of [['null', null], ['undefined', undefined], + ['names stripped (the pre-R39 state)', + { ...cache, roads: (cache.roads || []).map((r) => ({ kind: r.kind, pts: r.pts })) }]]) { + const st = createAddresses(plan, arg).stats(); + const a = createAddresses(plan, arg); + const wrong = a.cohort((l) => l.street !== null).length; + ok(st.supplier === 'district' && st.edgesNamed === 0 && wrong === 0, + `${key} with cache=${what}: supplier '${st.supplier}', ${st.edgesNamed} named edges, ${wrong} street names — visible in stats(), never a silent wrong name`); + } + // …and the degradation is USABLE: labels still come out, from the other supplier. + const a = createAddresses(plan, null); + const lab = a.cohort((l) => l.label !== null).length; + ok(lab > 0, `…and it still labels ${lab}/${plan.shops.length} shops from district+block (e.g. "${a.localityOf(plan.shops[0].id).label}") — a degrade, not an outage`); + // the synthetic is the same code path with no cache in sight, and must label EVERY shop + const syn = generatePlan(SEED); + const sa = createAddresses(syn, null), ss = sa.stats(); + ok(ss.supplier === 'district' && sa.cohort((l) => l.label === null).length === 0, + `synthetic: supplier '${ss.supplier}', ${syn.shops.length}/${syn.shops.length} shops labelled, 0 nulls — one consumer contract, two suppliers`); +} + +// ── 4. THE COVERAGE TRAP, swept rather than assumed. This is the arm the brief demands. ─────────── +// A wrong shift is the one input that can rename an entire town, so it is the perturbation the gate +// is built around. Eight directions x three magnitudes on three towns, and for each: how many shops +// still resolve (COVERAGE), how many carry a name different from the truth (WRONGNESS), and whether +// the two independent alarms fire. The pairing is the finding: coverage and correctness move apart. +head('4. COVERAGE IS NOT CORRECTNESS — the wrong-shift sweep'); +const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.707, 0.707], [-0.707, 0.707], [0.707, -0.707], [-0.707, -0.707]]; +const MAGS = [3, 8, 30]; +const trap = {}; +for (const key of ['adelaide_real', 'castlemaine_real', 'katoomba_real']) { + const cache = JSON.parse(readFileSync(join(TOWNS, `${key}.json`), 'utf8')); + const report = {}; + const plan = generatePlanOSM(SEED, key, { cache, report }); + const truth = createAddresses(plan, cache, { shift: report.shift }); + const tSt = truth.stats(); + const ctrl = membership(plan, cache, report.shift); + const rows = []; + for (const m of MAGS) for (const [dx, dz] of DIRS) { + const bad = { shx: report.shift.shx + dx * m, shz: report.shift.shz + dz * m }; + const a = createAddresses(plan, cache, { shift: bad }); + const st = a.stats(); + let changed = 0, invented = 0; + for (const s of plan.shops) { + const t = truth.localityOf(s.id).street, g = a.localityOf(s.id).street; + if (!g) continue; + if (t && t !== g) changed++; // the truth had a name and this build says another one + else if (!t) invented++; // the truth honestly said null and this build names it + } + rows.push({ m, dx, dz, street: st.shopsWithStreet, changed, invented, renamed: changed + invented, + shiftCheck: st.shiftCheck, ...shopEdgeVerdict(plan, a, ctrl) }); + } + trap[key] = { true: tSt.shopsWithStreet, shops: tSt.shops, rows }; + const byMag = MAGS.map((m) => { + const r = rows.filter((x) => x.m === m); + const best = r.reduce((p, q) => (q.street > p.street ? q : p)); + const wrongest = r.reduce((p, q) => (q.disagree > p.disagree ? q : p)); + return { m, maxStreet: best.street, maxChanged: Math.max(...r.map((x) => x.changed)), + maxInvented: Math.max(...r.map((x) => x.invented)), + maxDisagree: wrongest.disagree, checked: wrongest.checked }; + }); + trap[key].byMag = byMag; + console.log(` ${key}: truth ${tSt.shopsWithStreet}/${tSt.shops} shops resolved, 0 wrong`); + for (const b of byMag) { + const up = b.maxStreet - tSt.shopsWithStreet; + console.log(` off by ${String(b.m).padStart(2)} m (best of 8 directions): ${b.maxStreet} shops resolved ` + + `(${up > 0 ? `\x1b[31m+${up} — MORE than the truth\x1b[0m` : up}), up to ${b.maxChanged} shops given a DIFFERENT ` + + `name, ${b.maxInvented} given a name the truth honestly refused, ` + + `${b.maxDisagree}/${b.checked} shop-edges disagree with the membership control`); + } +} +{ + const anyUp = Object.entries(trap).filter(([, t]) => t.byMag.some((b) => b.maxStreet > t.true)); + ok(anyUp.length > 0, + `RED ARM: a wrong shift RAISES coverage on ${anyUp.length} of 3 towns — ` + + anyUp.map(([k, t]) => `${k} ${t.true} → ${Math.max(...t.byMag.map((b) => b.maxStreet))}`).join(' · ') + + ` — a coverage gate goes GREENER on a broken build`); + const ade = trap['adelaide_real'].byMag.find((b) => b.m === 3); + ok(ade.maxInvented > 0 || ade.maxChanged > 0, + `…and the extra coverage is fabricated: ${ade.maxInvented} adelaide shops get a street name that the correctly-shifted build honestly refused ` + + `(${ade.maxChanged} get a different name outright). Coverage 89 → 98 is 9 borrowed names, not 9 corrections.`); + const allFire = Object.values(trap).every((t) => t.rows.every((r) => r.shiftCheck === 'DISAGREE')); + ok(allFire, `stats().shiftCheck fires DISAGREE on all ${Object.values(trap)[0].rows.length * 3} perturbations (8 directions x 3 magnitudes x 3 towns) — the cross-derivation arm is what this gate stands on`); + const memFires = Object.values(trap).some((t) => t.rows.some((r) => r.disagree > 0)); + ok(memFires, `the membership control also discriminates: 0 wrong on the shipped tree, up to ` + + `${Math.max(...Object.values(trap).flatMap((t) => t.rows.map((r) => r.disagree)))} wrong under perturbation`); + // …and the honest limit of that second alarm, which matters for what this gate leans on. + const ade3 = trap['adelaide_real'].rows.filter((r) => r.m === 3); + note(`BUT the membership control does NOT catch the 3 m case: at 3 m adelaide reads ${Math.max(...ade3.map((r) => r.disagree))} disagreements ` + + `while inventing ${ade.maxInvented} names — because those shops front ways OSM leaves UNNAMED, so membership has no answer there and stays silent. ` + + `The only alarm that fires at 3 m is stats().shiftCheck. That is why this gate asserts the shift cross-derivation on all 23 towns and never the coverage.`); + // A's note says a 30 m error returns ZERO names on these three towns. It does not — measured below. + const at30 = Object.entries(trap).map(([k, t]) => [k, t.byMag.find((b) => b.m === 30)]); + note(`CORRECTION to LANE_A_NOTES §39 ("feed it a shift wrong by 30 m and castlemaine/katoomba/adelaide return 0 street names"): ` + + `measured here, 30 m keeps ${at30.map(([k, b]) => `${k} ${b.maxStreet}`).join(' · ')} names, and they are wrong ones ` + + `(up to ${at30.map(([, b]) => b.maxDisagree).join('/')} membership disagreements). A big shift does NOT degrade to null on a dense grid — ` + + `it lands you on the next street. Which is why the shift arm is exact-or-nothing and this gate never reads coverage.`); +} + +// ── 5. the layer never writes to a plan (the reason it costs nothing) ───────────────────────────── +head('5. purity — createAddresses does not mutate the plan'); +{ + let dirty = 0; + for (const key of keys) { + const cache = JSON.parse(readFileSync(join(TOWNS, `${key}.json`), 'utf8')); + const plan = generatePlanOSM(SEED, key, { cache }); + const before = JSON.stringify(plan); + createAddresses(plan, cache); + if (JSON.stringify(plan) !== before) dirty++; + } + ok(dirty === 0, `${keys.length}/${keys.length} towns byte-identical after the call (${dirty} mutated)`); +} + +const out = { corpusPct: +pctCorpus.toFixed(2), roll, perTown, trap, fails }; +if (process.argv.includes('--json')) writeFileSync(process.argv[process.argv.indexOf('--json') + 1], JSON.stringify(out, null, 1)); +console.log(); +if (fails) { console.log(`\x1b[31m${fails} FAIL\x1b[0m`); process.exit(1); } +console.log('\x1b[32mR39 §39.5-A GREEN\x1b[0m'); diff --git a/tools/qa/r39_runtime.py b/tools/qa/r39_runtime.py new file mode 100644 index 0000000..92a73eb --- /dev/null +++ b/tools/qa/r39_runtime.py @@ -0,0 +1,687 @@ +#!/usr/bin/env python3 +"""PROCITY Lane F — R39 §39.5 runtime gates: THE SIGN (B) · THE FOG (B) · THE RUMMAGE BIN (C). + +Three gates, one browser, each with its falsifiability control demonstrated in the same run. + + §39.5-SIGN index.html:428/:500 read `(-sin ry, -cos ry)` — the BACK of the building — since R8/R32. + The picture-free arm Lane B specified: AT THE SPAWN, the count of shops within 25 m that + are IN FRONT of the player must be > 0. It was 0 on 4 of 4 towns before. The red arm is + computed here from the plan by re-deriving the OLD spawn with the old sign and scoring + the same metric at it — so the gate carries its own "before" on every run. + + §39.5-FOG BOTH ARMS OR IT IS VACUOUS: unwalked shops hidden AND walked shops revealed, measured on + the same boot. Plus zero draws (default vs ?fog=0 at one pose, sim quiet), the save + round-trip and its rejects, and the null store on ?classic=1 / ?game=0 / ?fog=0. + + §39.5-BIN The op shop's rummage bin: present and dug-able when armed, and CONTENTS UNARMED on the + boot every player gets (Lane C held the contents for John). Both arms, or the gate only + proves that a tub exists. + +Run: tools/.venv/bin/python tools/qa/r39_runtime.py [--only sign,fog,bin] [--json OUT] +""" +import sys, os, json, time, socket, subprocess, pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent.parent +PORT = int(os.environ.get('PROCITY_R39_RT_PORT', '8751')) +HOST = f'http://127.0.0.1:{PORT}' +SEED = 20261990 +FOG_R = 25.0 + +fails, warns = [], [] +def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}") +def WARN(m): warns.append(m); print(f" \033[33m! WARN\033[0m {m}") +def OK(m): print(f" \033[32m✓\033[0m {m}") +def head(m): print(f"\n\033[1m{m}\033[0m") +def note(m): print(f" \033[33m·\033[0m {m}") + +NOSTORE = r''' +import sys, http.server, functools +class H(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate') + super().end_headers() + def log_message(self, *a): pass +http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), + functools.partial(H, directory=sys.argv[2])).serve_forever() +''' + + +def port_up(port): + with socket.socket() as s: + s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0 + + +def serve(root, port): + proc = subprocess.Popen([sys.executable, '-c', NOSTORE, str(port), str(root)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(80): + if port_up(port): return proc + time.sleep(0.1) + proc.terminate(); raise SystemExit(f'could not serve on :{port}') + + +def new_page(p): + b = p.chromium.launch() + pg = b.new_page(viewport={'width': 1280, 'height': 720}) + errs = [] + pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None) + pg.on('pageerror', lambda e: errs.append(str(e))) + return b, pg, errs + + +def boot(pg, query): + pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1' + (('&' + query) if query else '')) + pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=30000) + pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") + try: + pg.wait_for_function('() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)', timeout=12000) + except Exception: + pass + + +# ══════════════════════════════════════════════════════════════════════════════════════════════════ +# THE SIGN +# ══════════════════════════════════════════════════════════════════════════════════════════════════ +# Both spawn algorithms, re-derived from the LIVE plan in the page: `sgn = +1` is index.html:506 as +# shipped after Lane B's fix, `sgn = -1` is what it read from R32 until this round. Then one scorer +# for both poses. Nothing is monkey-patched: the shipped pose is READ off the live player and asserted +# equal to the +1 derivation, which is what makes the -1 derivation a fair "before". +JS_SIGN = r""" +(FOG_R) => { + const P = window.PROCITY, plan = P.plan; + const lotById = new Map(plan.lots.map(l => [l.id, l])); + const spawnWith = (sgn) => { + const msBlocks = new Set((plan.blocks || []).filter(b => b.kind === 'mainstreet').map(b => b.id)); + const doorOf = (s) => { + const l = lotById.get(s.lot); if (!l) return null; + const ry = l.ry || 0; + return { x: l.x + sgn * Math.sin(ry) * (l.d / 2 + 2.6), z: l.z + sgn * Math.cos(ry) * (l.d / 2 + 2.6), block: l.block }; + }; + let doors = plan.shops.map(doorOf).filter(Boolean); + const ms = doors.filter(d => msBlocks.has(d.block)); + if (ms.length >= 2) doors = ms; + if (doors.length < 2) return null; + let cx = 0, cz = 0; for (const d of doors) { cx += d.x; cz += d.z; } cx /= doors.length; cz /= doors.length; + let vx = 0, vz = 0; for (const d of doors) { vx += (d.x - cx) ** 2; vz += (d.z - cz) ** 2; } + const ax = vx >= vz ? 'x' : 'z'; + let stand = doors[0], bd = Infinity; + for (const d of doors) { const dd = (d.x - cx) ** 2 + (d.z - cz) ** 2; if (dd < bd) { bd = dd; stand = d; } } + let lo = Infinity, hi = -Infinity; + for (const d of doors) { lo = Math.min(lo, d[ax]); hi = Math.max(hi, d[ax]); } + const dir = (hi - stand[ax] >= stand[ax] - lo) ? 1 : -1; + return { x: stand.x, z: stand.z, yaw: ax === 'x' ? Math.atan2(-dir, 0) : Math.atan2(0, -dir) }; + }; + // the shopfront points the fog probe uses: facade centre + its outward normal (buildings.js's +Z) + const fronts = []; + for (const s of plan.shops) { + const l = lotById.get(s.lot); if (!l) continue; + const ry = l.ry || 0, nx = Math.sin(ry), nz = Math.cos(ry); + fronts.push({ id: s.id, x: l.x + nx * (l.d / 2), z: l.z + nz * (l.d / 2), nx, nz }); + } + const score = (pos) => { + if (!pos) return null; + let near = 0, front = 0; + for (const f of fronts) { + const dx = pos.x - f.x, dz = pos.z - f.z; + if (dx * dx + dz * dz > FOG_R * FOG_R) continue; + near++; + if (dx * f.nx + dz * f.nz > 0) front++; + } + return { near, front }; + }; + const live = { x: P.player.position.x, z: P.player.position.z }; + const fixed = spawnWith(1), old = spawnWith(-1); + return { + town: P.townKey || 'synthetic', + shops: plan.shops.length, + live, fixed, old, + liveMatchesFixed: !!fixed && Math.hypot(live.x - fixed.x, live.z - fixed.z) < 0.01, + scoreLive: score(live), scoreFixed: score(fixed), scoreOld: score(old), + discovered: P.discovery ? P.discovery.store.counts().shops : null, + }; +} +""" + + +def gate_sign(p, result): + head('GATE R39 §39.5-SIGN — at the spawn, are there shopfronts in front of you?') + towns = [('synthetic', ''), ('katoomba_real', 'plansrc=osm&town=katoomba_real'), + ('bowral_real', 'plansrc=osm&town=bowral_real'), ('fitzroy_real', 'plansrc=osm&town=fitzroy_real')] + rows = {} + b, pg, errs = new_page(p) + try: + for name, q in towns: + boot(pg, q) + r = pg.evaluate(JS_SIGN, FOG_R) + rows[name] = r + print(f" {name:16s} shipped(+sin) at ({r['fixed']['x']:.1f}, {r['fixed']['z']:.1f}): " + f"{r['scoreFixed']['near']} shops within 25 m, \033[1m{r['scoreFixed']['front']} in front\033[0m" + f" · old(-sin) at ({r['old']['x']:.1f}, {r['old']['z']:.1f}): " + f"{r['scoreOld']['near']} within 25 m, \033[31m{r['scoreOld']['front']} in front\033[0m") + finally: + b.close() + result['sign'] = rows + live_ok = all(r['liveMatchesFixed'] for r in rows.values()) + if live_ok: OK(f"the LIVE spawn equals the +sin derivation on {len(rows)}/{len(rows)} towns — the shipped shell is the thing being scored, not a re-implementation") + else: FAIL(f"the live spawn does not match the +sin derivation: {[(k, v['live'], v['fixed']) for k, v in rows.items() if not v['liveMatchesFixed']]}") + good = [k for k, r in rows.items() if r['scoreLive']['front'] > 0] + if len(good) == len(rows): + OK(f"THE ARM: shops in front of the player at spawn > 0 on {len(good)}/{len(rows)} towns — " + + ' · '.join(f"{k} {r['scoreLive']['front']}/{r['scoreLive']['near']}" for k, r in rows.items())) + else: + FAIL(f"spawn faces nothing on {[k for k in rows if k not in good]} — the sign has flipped back") + bad = [k for k, r in rows.items() if r['scoreOld']['front'] == 0] + if len(bad) == len(rows): + OK(f"THE RED ARM, on this same tree: with the pre-R39 sign the identical metric reads ZERO in front on {len(bad)}/{len(bad)} towns — the gate discriminates") + else: + FAIL(f"the red arm does not go red on {[k for k in rows if k not in bad]} — this gate cannot fail, so it is not a gate") + disc = {k: r['discovered'] for k, r in rows.items()} + OK(f"…and the shell's own probe agrees at boot: shops discovered at spawn {disc}") + if errs: WARN(f"{len(errs)} console error(s) across the sign boots; first: {errs[0][:140]}") + else: OK('0 console errors across all four boots') + + +# ══════════════════════════════════════════════════════════════════════════════════════════════════ +# THE FOG +# ══════════════════════════════════════════════════════════════════════════════════════════════════ +# Walk the town by driving the SHIPPED probe (`discovery.probe`) from stations along the street graph, +# rather than trusting a throttled rAF loop in a headless browser to cover ground. The cadence itself +# is checked separately (a plain teleport-and-wait must also learn something). +JS_WALK = r""" +(n) => { + const P = window.PROCITY; + const plan = P.plan, nodes = new Map(plan.streets.nodes.map(x => [x.id, x])); + // A SHOPPER'S WALK, not the longest roads: take the edges the most shops front. On a real town the + // longest edges are rural arterials with nothing on them (katoomba: 5.8 km of them reveals 1 shop), + // which measures the walker, not the fog. + const lotById = new Map(plan.lots.map(l => [l.id, l])); + const perEdge = new Map(); + for (const s of plan.shops) { const l = lotById.get(s.lot); if (!l) continue; + perEdge.set(l.frontEdge, (perEdge.get(l.frontEdge) || 0) + 1); } + const rank = new Map([...perEdge].sort((a, b) => b[1] - a[1]).slice(0, n).map(([id], i) => [id, i])); + const edges = plan.streets.edges.filter(e => rank.has(e.id)).map(e => { + const a = nodes.get(e.a), b = nodes.get(e.b); + return a && b ? { a, b, len: Math.hypot(b.x - a.x, b.z - a.z) } : null; + }).filter(Boolean); + let metres = 0, learned = 0, probes = 0; + for (const e of edges) { + const steps = Math.max(2, Math.ceil(e.len / 4)); + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const pos = { x: e.a.x + (e.b.x - e.a.x) * t, z: e.a.z + (e.b.z - e.a.z) * t }; + learned += P.discovery.probe(pos); probes++; + } + metres += e.len; + } + const c = P.discovery.store.counts(); + return { metres: Math.round(metres), probes, learned, shops: c.shops, edges: c.edges, + stats: P.discovery.stats, view: P.minimap.view }; +} +""" + +# count pixels on the minimap canvas that EXACTLY match one of minimap.js's ten shop-type colours. +# An exact palette match, not a threshold: the shipped instrument for "is a shop drawn at all". +JS_MAPPX = r""" +() => { + const P = window.PROCITY; + const PAL = ['#8a5aa8','#8a7a5a','#c86aa0','#7a6a3a','#4a6ab0','#c4a028','#c4863a','#5a6a7a','#5a8a6a','#6b5a48']; + const want = new Set(PAL.map(h => ((parseInt(h.slice(1), 16) << 8) | 255) >>> 0)); + const cv = document.querySelector('canvas#minimap, canvas.minimap') || + [...document.querySelectorAll('canvas')].find(c => c !== P.renderer.domElement && c.width >= 600); + if (!cv) return { err: 'no minimap canvas' }; + const g = cv.getContext('2d'); + const d = g.getImageData(0, 0, cv.width, cv.height).data; + let n = 0, fnv = 0x811c9dc5 >>> 0; + for (let i = 0; i < d.length; i += 4) { + const v = (((d[i] << 16) | (d[i + 1] << 8) | d[i + 2]) << 8 | d[i + 3]) >>> 0; + if (want.has(v)) n++; + fnv = Math.imul((fnv ^ d[i]) >>> 0, 0x01000193) >>> 0; + fnv = Math.imul((fnv ^ d[i + 1]) >>> 0, 0x01000193) >>> 0; + fnv = Math.imul((fnv ^ d[i + 2]) >>> 0, 0x01000193) >>> 0; + } + return { px: n, w: cv.width, h: cv.height, fnv: fnv >>> 0 }; +} +""" + +JS_OPENMAP = r""" +() => { window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyM' })); } +""" + + +def gate_fog(p, result): + head('GATE R39 §39.5-FOG — hides what you have not walked, reveals what you have') + out = {} + for town, q in (('katoomba_real', 'plansrc=osm&town=katoomba_real'), ('synthetic', '')): + b, pg, errs = new_page(p) + try: + boot(pg, q) + fresh = pg.evaluate("""() => { const P = window.PROCITY; + return { fog: !!P.knownStore, key: P.fogKey, total: P.plan.shops.length, + known: P.knownStore ? P.knownStore.counts() : null, view: P.minimap.view, + addr: P.addresses ? P.addresses.stats().supplier : null, + exportHasKnown: 'known' in JSON.parse(P.game.export()) }; }""") + pg.evaluate(JS_OPENMAP); pg.wait_for_timeout(400) + px_fresh = pg.evaluate(JS_MAPPX) + # which shops are FAR from the spawn? they must be unknown. + far = pg.evaluate("""() => { const P = window.PROCITY, p = P.player.position; + const lot = new Map(P.plan.lots.map(l => [l.id, l])); + let farTotal = 0, farKnown = 0; + for (const s of P.plan.shops) { const l = lot.get(s.lot); if (!l) continue; + if (Math.hypot(l.x - p.x, l.z - p.z) < 120) continue; + farTotal++; if (P.knownStore.hasShop(s.id)) farKnown++; } + return { farTotal, farKnown }; }""") + walk = pg.evaluate(JS_WALK, 12) + pg.wait_for_timeout(400) + px_walk = pg.evaluate(JS_MAPPX) + save = pg.evaluate("""() => { const P = window.PROCITY, g = P.game; + const blob = g.export(); const obj = JSON.parse(blob); + const before = P.knownStore.counts(); + const ids = P.knownStore.shopIds().slice(0, 5); + // round trip + const rt = g.import(blob); + const after = P.knownStore.counts(); + // rejects — each must leave live state untouched + const rej = []; + const mut = (f) => { const o = JSON.parse(blob); f(o); return JSON.stringify(o); }; + const cases = { + 'string id': mut(o => { o.known[P.fogKey].s[0] = 'x'; }), + 'negative id': mut(o => { o.known[P.fogKey].s[0] = -1; }), + 'float id': mut(o => { o.known[P.fogKey].s[0] = 1.5; }), + 'town = array':mut(o => { o.known[P.fogKey] = [1,2,3]; }), + 'known = array':mut(o => { o.known = [1,2,3]; }), + '8193 ids': mut(o => { o.known[P.fogKey].s = Array.from({length: 8193}, (_, i) => i); }), + '65 towns': mut(o => { for (let i = 0; i < 65; i++) o.known['t' + i] = { s: [1], e: [] }; }), + 'empty key': mut(o => { o.known[''] = { s: [1], e: [] }; }), + }; + for (const [why, blb] of Object.entries(cases)) { + const took = g.import(blb); + const now = P.knownStore.counts(); + rej.push({ why, took, intact: now.shops === after.shops && now.edges === after.edges }); + } + return { keys: Object.keys(obj), hasKnown: 'known' in obj, + ids: obj.known ? obj.known[P.fogKey].s.length : 0, + edgeIds: obj.known ? obj.known[P.fogKey].e.length : 0, + bytes: blob.length, before, after, roundTrip: rt, sample: ids, rejects: rej }; }""") + # THE SIDE TEST'S RED ARM, on the same boot: stand 5 m BEHIND each unknown shopfront and + # probe. A radius-only fog hands every one of them over through the back wall. + behind = pg.evaluate("""() => { const P = window.PROCITY; + const lot = new Map(P.plan.lots.map(l => [l.id, l])); + let tried = 0, learned = 0; + const b0 = P.discovery.stats.rejectedBehind; + for (const s of P.plan.shops) { + if (P.knownStore.hasShop(s.id)) continue; + const l = lot.get(s.lot); if (!l) continue; + const ry = l.ry || 0, nx = Math.sin(ry), nz = Math.cos(ry); + const fx = l.x + nx * (l.d / 2), fz = l.z + nz * (l.d / 2); + tried++; + P.discovery.probe({ x: fx - nx * 5, z: fz - nz * 5 }); // 5 m behind its own facade + if (P.knownStore.hasShop(s.id)) learned++; + } + return { tried, learned, rejected: P.discovery.stats.rejectedBehind - b0 }; }""") + out[town] = {'fresh': fresh, 'far': far, 'walk': walk, 'save': save, 'behind': behind, + 'pxFresh': px_fresh, 'pxWalk': px_walk, 'errs': len(errs)} + finally: + b.close() + # THE DELTA LAW, taken where it can actually be taken — and in its OWN browser, because a + # save written by the walk above lives in localStorage and would be re-exported verbatim + # (my first cut read `known` off a ?fog=0 boot that had simply LOADED the fogged save). + # Since the +sin spawn fix the DEFAULT boot discovers 4 shops before the player touches a key, + # so LANE_B_NOTES §39's "a fresh export has no `known` key" is now true of ?fog=0, not of a + # default boot. The claim the law is actually about — a boot that learns NOTHING writes the + # bytes it always wrote — is what this measures. + b, pg, _ = new_page(p) + try: + boot(pg, q + ('&' if q else '') + 'fog=0') + out[town]['nofog'] = pg.evaluate("""() => { const P = window.PROCITY; + const o = JSON.parse(P.game.export()); + return { store: !!P.knownStore, keys: Object.keys(o).sort(), hasKnown: 'known' in o }; }""") + finally: + b.close() + + result['fog'] = out + for town, r in out.items(): + f, w, s = r['fresh'], r['walk'], r['save'] + print(f"\n \033[1m{town}\033[0m {f['total']} shops · fogKey {f['key']}") + print(f" fresh boot : {f['known']['shops']} shops / {f['known']['edges']} segments known · " + f"map frame {w['view']['span']:.0f} m at walk-end (fresh {f['view']['span']:.0f} m) · " + f"lot {f['view']['lotPx']['w']}x{f['view']['lotPx']['d']} px (full-plan {f['view']['lotPxFull']['w']}x{f['view']['lotPxFull']['d']} px) · " + f"{r['pxFresh']['px']} px of shop colour on the map") + print(f" after {w['metres']:>5} m walked ({w['probes']} probes): {w['shops']} shops / {w['edges']} segments · " + f"{r['pxWalk']['px']} px of shop colour · {w['stats']['rejectedBehind']} in-radius rejections through a back wall " + f"({w['stats']['behindOnly']} shops refused and never learned from the front)") + # ARM 1 — HIDES + if f['known']['shops'] < f['total'] and r['far']['farKnown'] == 0 and r['far']['farTotal'] > 0: + OK(f"{town} ARM 1 (HIDES): {f['known']['shops']}/{f['total']} known at spawn, and 0 of the {r['far']['farTotal']} shops more than 120 m away are known") + else: + FAIL(f"{town} ARM 1: the fog is not hiding — fresh {f['known']['shops']}/{f['total']}, far known {r['far']}") + # ARM 2 — REVEALS + if w['shops'] > f['known']['shops'] and w['edges'] > f['known']['edges']: + OK(f"{town} ARM 2 (REVEALS): walking {w['metres']} m took it to {w['shops']} shops / {w['edges']} segments (+{w['shops'] - f['known']['shops']} / +{w['edges'] - f['known']['edges']})") + else: + FAIL(f"{town} ARM 2: walking revealed nothing — {f['known']['shops']} → {w['shops']} shops") + # ARM 3 — the SIDE TEST's red arm: a radius-only fog would hand these over through the wall + bh = r['behind'] + if bh['tried'] > 0 and bh['learned'] == 0 and bh['rejected'] > 0: + OK(f"{town} ARM 3 (the FRONT test): standing 5 m BEHIND each of the {bh['tried']} still-unknown shopfronts and probing learns " + f"\033[1m{bh['learned']}\033[0m of them — {bh['rejected']} refusals. A radius-only fog would have handed over all {bh['tried']}.") + else: + FAIL(f"{town} ARM 3: the side test does not refuse through a back wall — {bh}") + note(f"{town} on the shopper's walk itself the side test refused {w['stats']['rejectedBehind']} times " + f"({w['stats']['behindOnly']} shops never learned from the front) — free where a shopper stands, decisive behind the buildings") + # the map draws the fog + if r['pxWalk']['px'] != r['pxFresh']['px']: + OK(f"{town} the MAP moves with the ledger: {r['pxFresh']['px']} → {r['pxWalk']['px']} px of shop colour (the frame grows too, so this is a change, not a monotone)") + else: + FAIL(f"{town} the map drew the same pixels before and after the walk — it is not reading the ledger") + # save — the delta law + if not r['nofog']['hasKnown'] and not r['nofog']['store']: + OK(f"{town} THE DELTA LAW: a boot that learns nothing (?fog=0) exports {r['nofog']['keys']} — no `known` key, the pre-R39 bytes") + else: + FAIL(f"{town} ?fog=0 wrote a `known` key: {r['nofog']}") + if f['exportHasKnown']: + note(f"{town} on the DEFAULT boot the export DOES carry `known` from the first frame — the +sin spawn now puts you in front of " + f"{f['known']['shops']} shopfronts, so LANE_B_NOTES §39's \"a fresh export has no `known` key\" is true of ?fog=0 and no longer of a default boot") + if s['hasKnown'] and s['ids'] == w['shops'] and s['edgeIds'] == w['edges'] and s['roundTrip'] and s['after'] == s['before']: + OK(f"{town} save round-trip: {s['ids']} shop ids + {s['edgeIds']} edge ids, {s['bytes']:,} B, export→import restores the same counts") + else: + FAIL(f"{town} save round-trip wrong: {s}") + bad = [x for x in s['rejects'] if x['took'] or not x['intact']] + if not bad: + OK(f"{town} all {len(s['rejects'])} corrupt-save arms REJECTED and live state untouched: " + ', '.join(x['why'] for x in s['rejects'])) + else: + FAIL(f"{town} corrupt saves accepted or state clobbered: {bad}") + + # ── the null store: classic / game=0 / fog=0 ────────────────────────────────────────────────── + head('the null arm — ?classic=1 / ?game=0 / ?fog=0 are the pre-R39 map by construction') + nulls = {} + b, pg, errs = new_page(p) + try: + for q, name in (('classic=1', 'classic'), ('game=0', 'game0'), ('fog=0', 'fog0'), ('', 'default')): + boot(pg, 'plansrc=osm&town=katoomba_real' + (('&' + q) if q else '')) + pg.evaluate(JS_OPENMAP); pg.wait_for_timeout(400) + nulls[name] = pg.evaluate("""() => { const P = window.PROCITY; + return { store: !!P.knownStore, fog: P.minimap.view.fog, flagFog: P.flags.fog, + span: P.minimap.view.span, lot: P.minimap.view.lotPx }; }""") + nulls[name]['map'] = pg.evaluate(JS_MAPPX) + finally: + b.close() + result['fogNull'] = nulls + off = [k for k in ('classic', 'game0', 'fog0') if not nulls[k]['store'] and not nulls[k]['fog']] + if len(off) == 3: + OK(f"null store on all three ({', '.join(off)}) — minimap.js sees `known === null`, which is the pre-R39 map with no flag test in the file") + else: + FAIL(f"a fog store leaked into a null arm: {nulls}") + if nulls['game0']['map']['fnv'] == nulls['fog0']['map']['fnv']: + OK(f"?game=0 and ?fog=0 draw a PIXEL-IDENTICAL map (fnv {nulls['fog0']['map']['fnv']:#010x}) — two different routes to the same null store") + else: + FAIL(f"?game=0 and ?fog=0 draw different maps: {nulls['game0']['map']['fnv']:#010x} vs {nulls['fog0']['map']['fnv']:#010x}") + if nulls['default']['map']['fnv'] != nulls['fog0']['map']['fnv']: + OK(f"…and the DEFAULT boot's map differs from both (fnv {nulls['default']['map']['fnv']:#010x}) — the hash is sensitive to the map's content, so the equality above means something") + else: + FAIL('the fogged map hashes the same as the un-fogged one — the pixel comparison is vacuous') + note(f"katoomba at the shipped full-plan transform draws {nulls['fog0']['map']['px']} px of shop colour " + f"(lot {nulls['fog0']['lot']['w']}x{nulls['fog0']['lot']['d']} px) against the fogged map's " + f"{nulls['default']['map']['px']} px at lot {nulls['default']['lot']['w']}x{nulls['default']['lot']['d']} px") + + # ── zero draws ──────────────────────────────────────────────────────────────────────────────── + head('zero draws — the fog is information design and must cost nothing on the street') + # Each arm gets its OWN freshly launched browser, and the pair is run in BOTH orders. My first cut + # booted default-then-?fog=0 in ONE browser and read a 12k triangle gap at identical draw counts; + # order is a confound (the second boot has a warm HTTP cache, so more of the asset fleet has + # resolved by sample time) and reversing it is the only way to see that. Each boot is sampled + # DRAINED — teleport, wait for chunks.pending == 0, settle, re-teleport at the same pose to read — + # and three times over, so the arm carries its own noise floor. + def run_arm(name, pose): + q = '' if name == 'default' else 'fog=0' + b, pg, _ = new_page(p) + try: + boot(pg, 'roster=v1&pop=0&gigs=0' + (('&' + q) if q else '')) + pg.evaluate("() => window.DBG.setSegment(2)") + if pose is None: + pose = pg.evaluate("() => [window.PROCITY.player.position.x, window.PROCITY.player.position.z]") + + def settled(x, z, yaw, n=3): + pg.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [x, z, yaw]) + try: pg.wait_for_function("() => window.PROCITY.chunks.pending === 0", timeout=25000) + except Exception: pass + reads = [] + for _ in range(n): + pg.wait_for_timeout(1800) + reads.append(pg.evaluate("""([x, z, y]) => { window.DBG.teleport(x, z, y); const i = window.DBG.info(); + return { d: i.drawCalls, t: i.tris, chunks: i.chunks }; }""", [x, z, yaw])) + last = reads[-1] + return {**last, 'dRange': [min(r['d'] for r in reads), max(r['d'] for r in reads)], + 'tRange': [min(r['t'] for r in reads), max(r['t'] for r in reads)]} + + got = {'a': settled(pose[0], pose[1], 0), 'b': settled(pose[0], pose[1], 3.14159265 / 2)} + # non-vacuity: prove the two arms really are two arms before comparing their counters + got['fogOn'] = pg.evaluate("() => ({ flag: window.PROCITY.flags.fog, store: !!window.PROCITY.knownStore })") + pg.evaluate(JS_OPENMAP); pg.wait_for_timeout(400) + got['mapMode'] = pg.evaluate("() => { const i = window.DBG.info(); return { d: i.drawCalls, t: i.tris, mode: i.mode }; }") + return got, pose + finally: + b.close() + + zero, pose = {}, None + for run, order in enumerate((('default', 'fog0'), ('fog0', 'default'))): + for name in order: + zero[f'{name}#{run}'], pose = run_arm(name, pose) + result['fogZero'] = zero + arms_differ = all(zero[f'default#{r}']['fogOn']['flag'] and zero[f'default#{r}']['fogOn']['store'] and + not zero[f'fog0#{r}']['fogOn']['flag'] and not zero[f'fog0#{r}']['fogOn']['store'] for r in (0, 1)) + if arms_differ: + OK('the two arms ARE two arms: fog flag on + store present on the default boots, off + null on the ?fog=0 boots (without this, "identical counters" is two identical boots)') + else: + FAIL(f"the zero-draw arms are not distinguishable: {[(k, v['fogOn']) for k, v in zero.items()]}") + draws_same, tris_same = True, True + for run in (0, 1): + d0, d1 = zero[f'default#{run}'], zero[f'fog0#{run}'] + for k, lab in (('a', 'yaw 0 '), ('b', 'yaw 90')): + print(f" run {run} ({'default first' if run == 0 else '?fog=0 first '}) {lab}: " + f"default {d0[k]['d']} draws {d0[k]['dRange']} / {d0[k]['t']:,} tris {d0[k]['tRange']} · " + f"?fog=0 {d1[k]['d']} draws {d1[k]['dRange']} / {d1[k]['t']:,} tris {d1[k]['tRange']}") + if d0[k]['d'] != d1[k]['d']: draws_same = False + if d0[k]['t'] != d1[k]['t']: tris_same = False + if draws_same and tris_same: + OK('identical in BOTH counters, both yaws, both orders, four freshly launched browsers — the fog costs +0 draws and +0 triangles on the street') + elif draws_same: + OK(f"DRAWS identical in both orders at both yaws — the budget's own quantity does not move") + # is the triangle gap the FLAG or the ORDER? if it follows the order, it is cache warmth. + g0 = zero['default#0']['a']['t'] - zero['fog0#0']['a']['t'] + g1 = zero['default#1']['a']['t'] - zero['fog0#1']['a']['t'] + if g0 * g1 < 0: + OK(f"…and the triangle gap FOLLOWS THE ORDER, not the flag: {g0:+,} when default boots first, {g1:+,} when ?fog=0 does. " + f"The second browser has a warm HTTP cache and more of the asset fleet resolved by sample time. Not the fog.") + else: + WARN(f"triangles differ in the same direction in both orders ({g0:+,} / {g1:+,} at yaw 0) — not explained by cache warmth; " + f"the fog touches no scene graph, so this wants a look, but it is not a draw and not the budget's quantity") + else: + FAIL(f"the fog moved the DRAW counter: {zero}") + OK(f"map mode reads {zero['default#0']['mapMode']['d']} draws (mode '{zero['default#0']['mapMode']['mode']}') — the frame loop never calls composer.render() in the map branch, so the counter cannot move") + + +# ══════════════════════════════════════════════════════════════════════════════════════════════════ +# THE RUMMAGE BIN +# ══════════════════════════════════════════════════════════════════════════════════════════════════ +JS_BIN_ARMED = r""" +async ([arm, glb]) => { + const C = window.PROCITY_C; + const ARCH = C.ARCHETYPE_KEYS; + const out = { rooms: 0, withTub: 0, withBin: 0, carves: 0, pathOK: 0, deterministic: 0, + binless: [], reach: [], reachDist: [], digPlaces: 0 }; + const manifest = glb ? await fetch('assets/manifest.json').then(r => r.ok ? r.json() : null).catch(() => null) : null; + // 4-connected flood over walkable cells from the room spawn — layout.js's own reachability, redone + // here so "reachable" is a measurement and not a flag the builder set about itself. + const nearestReachable = (dbg, x, z) => { + const g = dbg.grid; + const idx = (cx, cz) => cz * g.cols + cx; + const start = Array.isArray(dbg.spawnCell) ? idx(dbg.spawnCell[0], dbg.spawnCell[1]) : dbg.spawnCell; + const seen = new Uint8Array(g.cols * g.rows); + const st = [start]; seen[start] = 1; + let best = Infinity; + while (st.length) { + const i = st.pop(), cx = i % g.cols, cz = (i / g.cols) | 0; + const wx = (cx + 0.5) * g.cw - g.W / 2, wz = (cz + 0.5) * g.cd - g.D / 2; + const d = Math.hypot(wx - x, wz - z); if (d < best) best = d; + for (const [dx, dz] of [[1,0],[-1,0],[0,1],[0,-1]]) { + const nx = cx + dx, nz = cz + dz; + if (nx < 0 || nz < 0 || nx >= g.cols || nz >= g.rows) continue; + const j = idx(nx, nz); + if (seen[j] || g.occ[j] !== 0) continue; + seen[j] = 1; st.push(j); + } + } + return best; + }; + const opts = arm ? { fittingOpts: { rummageBin: { dig: true } } } : {}; + for (const a of ARCH) { + for (let s = 0; s < 6; s++) { + const shop = { id: 'g' + s, type: 'opshop', seed: 1990 + s * 7717, storeys: 1 }; + const base = Object.assign({ archetype: a, useGLB: !!glb }, glb ? { manifest } : {}, opts); + const r = C.buildInterior(shop, C.THREE, base); + const r2 = C.buildInterior(shop, C.THREE, base); + out.rooms++; + const tubs = (r.placement || []).filter(p => p.kind === 'rummageBin'); + let bin = 0; + r.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bin++; }); + if (tubs.length) out.withTub++; else out.binless.push(a + '/' + s); + if (bin) out.withBin++; + if (r.pathOK !== false) out.pathOK++; + if (r.carved) out.carves++; + out.digPlaces += (r.places || []).filter(m => m.userData && m.userData.kind === 'bin').length; + if (tubs.length && r._debug) out.reachDist.push(+nearestReachable(r._debug, tubs[0].x, tubs[0].z).toFixed(2)); + // determinism: the placement summary is the deep-equal instrument the room already publishes + if (JSON.stringify(r.placement) === JSON.stringify(r2.placement)) out.deterministic++; + out.reach.push({ a, s, tub: tubs.length, bin, tubKind: tubs.length ? tubs[0].kindName : null, + fw: tubs.length ? tubs[0].fw : null, fd: tubs.length ? tubs[0].fd : null }); + r.dispose(); r2.dispose(); + } + } + return out; +} +""" + + +def gate_bin(p, result): + head('GATE R39 §39.5-BIN — the op shop can be dug, and the contents did NOT ship') + b, pg, errs = new_page(p) + res = {} + try: + # ── (a) the DEFAULT BOOT — the one every player gets. Furniture, no contents. ───────────── + boot(pg, '') + res['street'] = pg.evaluate("""() => { + const P = window.PROCITY, D = window.DBG; + D.setSegment(2); + const s = (P.plan.shops || []).find(x => x.type === 'opshop' && P.isOpen(x)); + if (!s) return { why: 'no open op shop on the default boot' }; + D.enterShop(s.id); + const cur = P.interiorMode.current; + let bins = 0; + cur.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bins++; }); + // the tub, identified off the room's OWN placement summary (kind is the recipe's fitting + // kind) — not a bounding-box guess. Then stand in front of it and press E anyway. + const spec = (cur.placement || []).find(p => p.kind === 'rummageBin'); + let opened = null, world = null; + if (spec) { + world = cur.group.localToWorld(new P.THREE.Vector3(spec.x, 0.76, spec.z)); + P.camera.position.set(world.x, 1.6, world.z + 1.3); P.camera.lookAt(world.x, world.y, world.z); + P.camera.updateMatrixWorld(); + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' })); + opened = !!P.interiorMode.digActive; + } + const digPlaces = (cur.places || []).filter(m => m.userData && m.userData.kind === 'bin').length; + const fittings = (cur.placement || []).map(x => x.kind); + D.exitShop(); + return { shop: s.name, bins, tub: !!spec, tubKind: spec ? spec.kindName : null, + opened, digPlaces, fittings }; }""") + r = res['street'] + if r.get('why'): FAIL(f"bin/default: {r['why']}") + else: + if r['tub'] and r['bins'] == 0 and r['digPlaces'] == 0 and r['opened'] is False: + OK(f"DEFAULT BOOT (\"{r['shop']}\", fittings {r['fittings']}): the tub IS in the room and it is `kind:'{r['tubKind']}'` — " + f"0 `kind:'bin'`, 0 dig targets, and standing in front of it and pressing E opens nothing. " + f"THE CONTENTS DID NOT SHIP, which is how Lane C held them for John.") + else: + FAIL(f"bin/default: contents may have shipped — tub {r['tub']} kind '{r['tubKind']}', kind:'bin' {r['bins']}, dig targets {r['digPlaces']}, E opened {r['opened']}") + finally: + b.close() + + # ── (b) ARMED, through interior_test.html's own opt ──────────────────────────────────────────── + b, pg, errs2 = new_page(p) + try: + for arm, glb, label in ((False, False, 'default · GLB off'), (True, False, 'ARMED · GLB off'), + (False, True, 'default · GLB on'), (True, True, 'ARMED · GLB on')): + pg.goto(f'{HOST}/interior_test.html?localdepot=1') + pg.wait_for_function('() => !!window.PROCITY_C', timeout=30000) + pg.wait_for_timeout(400) + res[label] = pg.evaluate(JS_BIN_ARMED, [arm, glb]) + g = res[label] + rd = g['reachDist'] + print(f" {label:18s} {g['rooms']} rooms: tub {g['withTub']}/{g['rooms']} · kind:'bin' {g['withBin']}/{g['rooms']} · " + f"dig targets {g['digPlaces']} · pathOK {g['pathOK']}/{g['rooms']} · carves {g['carves']} · " + f"deterministic {g['deterministic']}/{g['rooms']} · nearest reachable cell " + f"{min(rd):.2f}–{max(rd):.2f} m" if rd else '') + rooms = sum(res[k]['rooms'] for k in res if k.startswith(('default ·', 'ARMED'))) + dd = [res['default · GLB off'], res['default · GLB on']] + aa = [res['ARMED · GLB off'], res['ARMED · GLB on']] + n = sum(x['rooms'] for x in aa) + if all(x['withTub'] == x['rooms'] for x in dd + aa): + OK(f"the tub lands in {n}/{n} armed and {n}/{n} default op-shop rooms (6 archetypes x 6 seeds x GLB on/off) — " + f"Lane C's fallbackZones/`anywhere` machinery does what it says (it landed in 11 of 24 without it)") + else: + FAIL(f"the tub is missing from rooms — armed misses {[x['binless'] for x in aa]}, default misses {[x['binless'] for x in dd]}") + if all(x['withBin'] == 0 for x in dd) and all(x['withBin'] == x['rooms'] for x in aa): + OK(f"THE ARMING IS THE ONLY DIFFERENCE: 0/{n} default rooms carry a `kind:'bin'`, {n}/{n} armed ones do — same tub, one opt, and the opt is OFF in the shipped recipe") + else: + FAIL(f"the dig gate is not the switch: default bins {[x['withBin'] for x in dd]}, armed {[x['withBin'] for x in aa]}") + if all(x['digPlaces'] == 0 for x in dd) and all(x['digPlaces'] == x['rooms'] for x in aa): + OK(f"…and the AIM agrees: 0 `places` entries of kind 'bin' across the {n} default rooms, {n} across the armed ones — the dig cannot resolve to a tub that is not armed") + else: + FAIL(f"places/kind:'bin' wrong: default {[x['digPlaces'] for x in dd]}, armed {[x['digPlaces'] for x in aa]}") + if all(x['pathOK'] == x['rooms'] and x['carves'] == 0 for x in aa + dd): + OK(f"pathOK {n}/{n} on every arm and ZERO corridor carves — the tub never blocks the room it lands in") + else: + FAIL(f"rooms unwalkable or carved: {[(x['pathOK'], x['carves']) for x in aa + dd]}") + if all(x['deterministic'] == x['rooms'] for x in aa + dd): + OK(f"deterministic {n}/{n}: two builds of the same seed produce a byte-equal placement summary on both arms") + else: + FAIL(f"non-deterministic placement: {[x['deterministic'] for x in aa + dd]}") + allrd = [d for x in aa for d in x['reachDist']] + if allrd and max(allrd) < 2.5: + OK(f"REACHABLE {len(allrd)}/{len(allrd)}: the nearest walkable cell flood-reachable FROM THE ROOM SPAWN sits " + f"{min(allrd):.2f}–{max(allrd):.2f} m from the tub, against the dig's 2.5 m proximity / 3.2 m aimed reach") + else: + FAIL(f"the tub is out of reach in some rooms: {sorted(allrd)[-5:] if allrd else 'no measurements'}") + finally: + b.close() + result['bin'] = res + if errs2: WARN(f"{len(errs2)} console error(s) on the interior harness; first: {errs2[0][:140]}") + else: OK('0 console errors across the bin arms') + + +def main(): + from playwright.sync_api import sync_playwright + only = None + if '--only' in sys.argv: only = set(sys.argv[sys.argv.index('--only') + 1].split(',')) + out_json = sys.argv[sys.argv.index('--json') + 1] if '--json' in sys.argv else None + result = {} + proc = serve(ROOT / 'web', PORT) + try: + with sync_playwright() as p: + if not only or 'sign' in only: gate_sign(p, result) + if not only or 'fog' in only: gate_fog(p, result) + if not only or 'bin' in only: gate_bin(p, result) + finally: + proc.terminate() + if out_json: pathlib.Path(out_json).write_text(json.dumps(result, indent=1, default=str)) + print() + print(f"R39 runtime gates: {len(fails)} fail · {len(warns)} warn") + if fails: + for f in fails: print(f" \033[31m✗\033[0m {f}") + return 1 + print('\033[32mR39 RUNTIME GATES GREEN\033[0m') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/qa/r39_shots.py b/tools/qa/r39_shots.py new file mode 100644 index 0000000..54aef3d --- /dev/null +++ b/tools/qa/r39_shots.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""PROCITY Lane F — R39: RE-BASELINE the two frames that photograph the spawn pose. + +Lane B's `+sin` fix moved the non-classic spawn (`index.html:500`, the R32 cluster-pose). Two of this +lane's picture baselines were taken at the OLD pose and are therefore photographs of the defect: + + · `docs/shots/v7_tour/02_the_strip.png` — "the cluster-pose spawn: midday on the main street" + · `docs/shots/v7_beta/first_five_splash.png` — the R35 first-five-minutes splash, shot at the spawn + +Those two files are **not overwritten**: they are artefacts of the tagged v7.0 epoch and re-shooting +them on a v9 tree would falsify a release record. The re-baseline lands in `docs/shots/laneF_r39/` +with its own BEFORE (the same tree, camera parked at the old `-sin` pose), so the pair is a +comparison rather than a claim. + +Run: tools/.venv/bin/python tools/qa/r39_shots.py +""" +import sys, os, time, socket, subprocess, pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent.parent +OUT = ROOT / 'docs' / 'shots' / 'laneF_r39' +PORT = int(os.environ.get('PROCITY_R39_SHOT_PORT', '8761')) +HOST = f'http://127.0.0.1:{PORT}' +SEED = 20261990 + +NOSTORE = r''' +import sys, http.server, functools +class H(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header('Cache-Control', 'no-store'); super().end_headers() + def log_message(self, *a): pass +http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), + functools.partial(H, directory=sys.argv[2])).serve_forever() +''' + +# both spawn derivations, from the live plan — identical to tools/qa/r39_runtime.py's JS_SIGN +JS_POSES = r""" +() => { + const P = window.PROCITY, plan = P.plan; + const lotById = new Map(plan.lots.map(l => [l.id, l])); + const spawnWith = (sgn) => { + const ms = new Set((plan.blocks || []).filter(b => b.kind === 'mainstreet').map(b => b.id)); + const doorOf = (s) => { const l = lotById.get(s.lot); if (!l) return null; + const ry = l.ry || 0; + return { x: l.x + sgn * Math.sin(ry) * (l.d / 2 + 2.6), z: l.z + sgn * Math.cos(ry) * (l.d / 2 + 2.6), block: l.block }; }; + let doors = plan.shops.map(doorOf).filter(Boolean); + const m = doors.filter(d => ms.has(d.block)); if (m.length >= 2) doors = m; + if (doors.length < 2) return null; + let cx = 0, cz = 0; for (const d of doors) { cx += d.x; cz += d.z; } cx /= doors.length; cz /= doors.length; + let vx = 0, vz = 0; for (const d of doors) { vx += (d.x - cx) ** 2; vz += (d.z - cz) ** 2; } + const ax = vx >= vz ? 'x' : 'z'; + let stand = doors[0], bd = Infinity; + for (const d of doors) { const dd = (d.x - cx) ** 2 + (d.z - cz) ** 2; if (dd < bd) { bd = dd; stand = d; } } + let lo = Infinity, hi = -Infinity; + for (const d of doors) { lo = Math.min(lo, d[ax]); hi = Math.max(hi, d[ax]); } + const dir = (hi - stand[ax] >= stand[ax] - lo) ? 1 : -1; + return { x: stand.x, z: stand.z, yaw: ax === 'x' ? Math.atan2(-dir, 0) : Math.atan2(0, -dir) }; + }; + return { fixed: spawnWith(1), old: spawnWith(-1) }; +} +""" + + +def port_up(port): + with socket.socket() as s: + s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0 + + +def main(): + from playwright.sync_api import sync_playwright + OUT.mkdir(parents=True, exist_ok=True) + proc = subprocess.Popen([sys.executable, '-c', NOSTORE, str(PORT), str(ROOT / 'web')], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(80): + if port_up(PORT): break + time.sleep(0.1) + shots = [] + try: + with sync_playwright() as p: + b = p.chromium.launch() + pg = b.new_page(viewport={'width': 1280, 'height': 720}) + for town, q, seg, tag in (('synthetic', '', 2, 'the_strip'), + ('katoomba_real', 'plansrc=osm&town=katoomba_real', 2, 'the_strip_katoomba')): + pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1&roster=v1&pop=0' + (('&' + q) if q else '')) + pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=30000) + pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") + pg.wait_for_timeout(2500) + poses = pg.evaluate(JS_POSES) + pg.evaluate('(s) => window.DBG.setSegment(s)', seg) + for which in ('old', 'fixed'): + pz = poses[which] + pg.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [pz['x'], pz['z'], pz['yaw']]) + try: pg.wait_for_function('() => window.PROCITY.chunks.pending === 0', timeout=20000) + except Exception: pass + pg.wait_for_timeout(2000) + pg.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [pz['x'], pz['z'], pz['yaw']]) + name = f"{tag}-{'BEFORE-minus-sin' if which == 'old' else 'AFTER-plus-sin'}.png" + pg.screenshot(path=str(OUT / name)) + shots.append(name) + print(f" {name} @ ({pz['x']:.1f}, {pz['z']:.1f}) yaw {pz['yaw']:.3f}") + # the R35 first-five frame: a FRESH game at the fixed spawn, splash + hunt line visible + b2 = p.chromium.launch() + pg2 = b2.new_page(viewport={'width': 1280, 'height': 720}) + pg2.goto(f'{HOST}/index.html?seed={SEED}&dbg=1&roster=v1&pop=0') + pg2.wait_for_function('window.DBG && window.DBG.ready === true', timeout=30000) + pg2.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") + pg2.wait_for_timeout(2500) + pz = pg2.evaluate(JS_POSES)['fixed'] + pg2.evaluate('() => window.DBG.setSegment(2)') + pg2.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [pz['x'], pz['z'], pz['yaw']]) + try: pg2.wait_for_function('() => window.PROCITY.chunks.pending === 0', timeout=20000) + except Exception: pass + pg2.wait_for_timeout(2000) + pg2.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [pz['x'], pz['z'], pz['yaw']]) + hunt = pg2.evaluate("""() => { const h = document.getElementById('pc-hunt'); + return h ? { shown: h.style.display !== 'none', text: h.textContent } : null; }""") + pg2.screenshot(path=str(OUT / 'first_five-AFTER-plus-sin.png')) + shots.append('first_five-AFTER-plus-sin.png') + print(f" first_five-AFTER-plus-sin.png hunt line: {hunt}") + b2.close(); b.close() + finally: + proc.terminate() + print(f"\n{len(shots)} frames → {OUT}") + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/qa/r39_transmission.py b/tools/qa/r39_transmission.py new file mode 100644 index 0000000..aed7700 --- /dev/null +++ b/tools/qa/r39_transmission.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""PROCITY Lane F — R39 §39.5 gate E: THE TRANSMISSION PRE-PASS, measured on both sides. + +Lane E stripped `transmissionFactor: 1` from three GLBs (`bookshelf` shipped, `longbench` + +`streetlight` unwired) because three.js runs a **transmission pre-pass** when any material in the +scene has `transmission > 0` — `renderTransmissionPass()` renders the whole opaque list a second +time, so every opaque draw is issued twice. Lane C measured `opshop`/hall 191 -> 104 by zeroing it. + +**That measurement is the falsifiable arm and this tool runs it.** Two port-isolated no-store roots, +identical inode-for-inode EXCEPT one file: + + · TREATMENT — the shipped tree (E's stripped bookshelf) + · CONTROL — the same tree with `transmissionFactor: 1` REINSERTED into that one material, + fabricated here (JSON chunk rewritten, BIN copied through byte for byte) + +…both driven through `interior_test.html`'s own `drawSweep` — the >=350-draw law's own instrument — +with `?localdepot=1` so the GLB comes off the served root and the swap is the only variable. + +The fabricated glass GLB doubles as the validator's control asset: `--validator` points +`pipeline/validate_manifest.py` at a root containing it and asserts the R39 HARD FAIL fires. + +Run: tools/.venv/bin/python tools/qa/r39_transmission.py [--validator] [--json OUT] +""" +import sys, os, json, time, socket, struct, shutil, subprocess, pathlib, tempfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent.parent +PORT_TREAT = int(os.environ.get('PROCITY_R39_PORT', '8741')) +PORT_CTRL = PORT_TREAT + 1 +BOOKSHELF = 'procity_fit_bookshelf_01.glb' +MATERIAL = 'mtl_10218_Bookshelves_v1' + +fails, notes = [], [] +def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}") +def OK(m): print(f" \033[32m✓\033[0m {m}") +def head(m): print(f"\n\033[1m{m}\033[0m") + + +# ── GLB surgery (the same shape as pipeline/strip_transmission.py, run in reverse) ──────────────── +def glb_chunks(data): + assert data[:4] == b'glTF', 'not a GLB' + off, out = 12, [] + while off < len(data): + ln, ty = struct.unpack_from(' 0.""" + j = glb_json(path) + hits = [] + for m in j.get('materials', []): + t = (m.get('extensions') or {}).get('KHR_materials_transmission') + if t and float(t.get('transmissionFactor', 0)) > 0: + hits.append((m.get('name'), float(t['transmissionFactor']))) + return hits + + +def make_glass(src, dst, material=None, factor=1.0): + """Write `dst` = `src` with KHR_materials_transmission reinserted. BIN copied through unchanged; + the function returns (sha1_bin_src, sha1_bin_dst) so the caller can prove only JSON moved.""" + import hashlib + data = pathlib.Path(src).read_bytes() + chunks = glb_chunks(data) + j = None; binp = b'' + for ty, payload in chunks: + if ty == 0x4E4F534A: j = json.loads(payload.decode('utf-8')) + elif ty == 0x004E4942: binp = payload + touched = [] + for m in j.get('materials', []): + if material and m.get('name') != material: continue + m.setdefault('extensions', {})['KHR_materials_transmission'] = {'transmissionFactor': factor} + touched.append(m.get('name')) + if material: break + used = j.setdefault('extensionsUsed', []) + if 'KHR_materials_transmission' not in used: used.append('KHR_materials_transmission') + jb = json.dumps(j, separators=(',', ':')).encode('utf-8') + jb += b' ' * ((4 - len(jb) % 4) % 4) + bb = binp + b'\x00' * ((4 - len(binp) % 4) % 4) + total = 12 + 8 + len(jb) + (8 + len(bb) if binp else 0) + out = bytearray(b'glTF' + struct.pack(', which is the fabricated + glass copy. Every other byte the browser sees is the same inode.""" + d = pathlib.Path(tempfile.mkdtemp(prefix='procity-r39-glass-')) + src = ROOT / 'web' + for e in src.iterdir(): + if e.name != 'assets': (d / e.name).symlink_to(e) + a = d / 'assets'; a.mkdir() + for e in (src / 'assets').iterdir(): + if e.name != 'models': (a / e.name).symlink_to(e) + m = a / 'models'; m.mkdir() + for e in (src / 'assets' / 'models').iterdir(): + if e.name != glb_name: (m / e.name).symlink_to(e) + touched, s0, s1 = make_glass(src / 'assets' / 'models' / glb_name, m / glb_name, material) + return d, touched, s0, s1 + + +# ── the sweep ──────────────────────────────────────────────────────────────────────────────────── +def sweep(pg, host, glb): + pg.goto(f'{host}/interior_test.html?localdepot=1') + pg.wait_for_function('() => !!window.PROCITY_C', timeout=30000) + pg.wait_for_timeout(600) + return pg.evaluate('(glb) => window.PROCITY_C.drawSweep({ glb })', glb) + + +def main(): + from playwright.sync_api import sync_playwright + want_validator = '--validator' in sys.argv + out_json = None + if '--json' in sys.argv: out_json = sys.argv[sys.argv.index('--json') + 1] + result = {} + + head('GATE R39 §39.5-E: THE TRANSMISSION PRE-PASS — the shipped tree vs a fabricated glass control') + + shipped = ROOT / 'web' / 'assets' / 'models' / BOOKSHELF + hits = transmissive_materials(shipped) + if hits: FAIL(f'the SHIPPED {BOOKSHELF} still carries transmission: {hits}') + else: OK(f'shipped {BOOKSHELF}: 0 transmissive materials (E\'s strip is in the served tree)') + + gdir, touched, sha_src, sha_dst = glass_root() + result['glassMaterials'] = touched + if touched == [MATERIAL] and sha_src == sha_dst: + OK(f'control asset fabricated: {touched[0]} transmissionFactor 1 · BIN sha1 IDENTICAL ({sha_src[:12]}…) — only the JSON chunk moved') + else: + FAIL(f'control asset fabrication wrong: touched={touched} binSha {sha_src[:12]} vs {sha_dst[:12]}') + gh = transmissive_materials(gdir / 'assets' / 'models' / BOOKSHELF) + if gh: OK(f'control asset re-scans DIRTY, as intended: {gh}') + else: FAIL('control asset did not take the transmission flag — the control is vacuous') + + # ── the validator's hard fail, proven to FIRE (E's gate) ────────────────────────────────────── + if want_validator: + head('the validator control — pipeline/validate_manifest.py must HARD FAIL on the glass asset') + # validate_manifest.py reads pipeline/_normalized/ (NOT web/assets/models/), so the + # control asset has to land THERE or the gate is being pointed at a file it never opens. + # My own first cut patched web/assets/models/ and the control reported "did not fire" — the + # harness bug, not E's gate. Recorded because it is exactly the species this round is about. + vroot = mirror_except(ROOT, ('pipeline', '_normalized', BOOKSHELF), + gdir / 'assets' / 'models' / BOOKSHELF) + r = subprocess.run([sys.executable, 'pipeline/validate_manifest.py'], + cwd=str(vroot), capture_output=True, text=True) + blob = r.stdout + r.stderr + result['validator'] = {'rc': r.returncode, 'mentions': 'transmissionFactor' in blob or 'transmission' in blob.lower()} + if r.returncode != 0 and 'transmission' in blob.lower(): + line = next((l for l in blob.splitlines() if 'transmission' in l.lower()), '') + OK(f'validator HARD FAILS on the glass asset (rc {r.returncode}): {line.strip()[:150]}') + else: + FAIL(f'validator did NOT fail on a transmissive GLB (rc {r.returncode}) — E\'s gate is vacuous') + r2 = subprocess.run([sys.executable, 'pipeline/validate_manifest.py'], + cwd=str(ROOT), capture_output=True, text=True) + if r2.returncode == 0: OK('validator PASSES on the shipped tree (the positive control — the gate is not simply always-red)') + else: FAIL(f'validator red on the shipped tree: {(r2.stdout + r2.stderr)[-300:]}') + # ...and the gate's OWN blind spot, measured rather than assumed: check_transmission() + # returns silently when pipeline/_normalized/ is absent, so a depot-only GLB is never + # parsed. Demonstrated by deleting the local copy and re-running with the glass file live. + vroot2 = mirror_except(ROOT, ('pipeline', '_normalized', BOOKSHELF), gdir / 'assets' / 'models' / BOOKSHELF) + (vroot2 / 'pipeline' / '_normalized' / BOOKSHELF).unlink() + r3 = subprocess.run([sys.executable, 'pipeline/validate_manifest.py'], + cwd=str(vroot2), capture_output=True, text=True) + result['validatorDepotOnly'] = {'rc': r3.returncode} + if r3.returncode == 0: + notes.append('validate_manifest.py check_transmission() SKIPS any GLB with no local ' + 'pipeline/_normalized copy — a depot-only transmissive asset passes silently. ' + '(web/assets/models/, which ?localdepot=1 actually serves, is never scanned either.)') + print(f" \033[33mnote\033[0m the validator's own blind spot, demonstrated: with the local " + f"_normalized copy removed the same glass asset passes (rc {r3.returncode}) — the check is " + f"local-file-only. Ask filed to Lane E.") + shutil.rmtree(vroot2, ignore_errors=True) + shutil.rmtree(vroot, ignore_errors=True) + + if '--no-sweep' in sys.argv: + shutil.rmtree(gdir, ignore_errors=True) + print() + if fails: print(f"\033[31m{len(fails)} FAIL\033[0m"); return 1 + print("\033[32mvalidator arm GREEN\033[0m"); return 0 + + # ── the sweep, both roots ───────────────────────────────────────────────────────────────────── + head('the interior draw sweep — 12 types x 7 archetypes, GLB off and on, on both roots') + p_t = serve(ROOT / 'web', PORT_TREAT) + p_c = serve(gdir, PORT_CTRL) + try: + with sync_playwright() as p: + b = p.chromium.launch() + pg = b.new_page(viewport={'width': 1280, 'height': 720}) + errs = [] + pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None) + for name, port in (('treatment', PORT_TREAT), ('control_glass', PORT_CTRL)): + host = f'http://127.0.0.1:{port}' + off = sweep(pg, host, False) + on = sweep(pg, host, True) + result[name] = {'glbOff': off, 'glbOn': on} + print(f" {name:14s} GLB-off worst {off['worst']:4d} ({off['worstAt']}) " + f"GLB-on worst {on['worst']:4d} ({on['worstAt']})") + b.close() + finally: + p_t.terminate(); p_c.terminate(); shutil.rmtree(gdir, ignore_errors=True) + + t_on = result['treatment']['glbOn']; c_on = result['control_glass']['glbOn'] + t_off = result['treatment']['glbOff']; c_off = result['control_glass']['glbOff'] + delta = c_on['worst'] - t_on['worst'] + result['summary'] = {'glbOnWorst': t_on['worst'], 'glbOnWorstAt': t_on['worstAt'], + 'glassWorst': c_on['worst'], 'glassWorstAt': c_on['worstAt'], + 'saved': delta, 'law': t_on['law'], + 'marginTreatment': t_on['law'] - t_on['worst'], + 'marginGlass': c_on['law'] - c_on['worst'], + 'glbOffWorst': t_off['worst'], 'glbOffWorstAt': t_off['worstAt']} + head('the verdict') + print(f" GLB-on worst room · with E's fix: {t_on['worst']} ({t_on['worstAt']}) " + f"· with transmission restored: {c_on['worst']} ({c_on['worstAt']}) saved {delta}") + print(f" GLB-off worst room · {t_off['worst']} ({t_off['worstAt']}) vs {c_off['worst']} ({c_off['worstAt']}) " + f"(a GLB-off room loads no GLB, so this pair MUST be equal — it is the control's own control)") + if delta > 0: OK(f"the pre-pass is real and E's fix took it out: {c_on['worst']} -> {t_on['worst']} draws (-{delta}, -{100*delta/max(1,c_on['worst']):.0f}%)") + else: FAIL(f"the sweep did NOT drop with transmission stripped (delta {delta}) — E's fix is not reaching the served tree") + if t_off['worst'] == c_off['worst']: OK(f"GLB-off is identical on both roots ({t_off['worst']}) — the delta above is the GLB and nothing else") + else: FAIL(f"GLB-off differs between roots ({t_off['worst']} vs {c_off['worst']}) — the two roots are not otherwise identical") + if t_on['worst'] <= t_on['law']: OK(f"the >=350 law holds with {t_on['law'] - t_on['worst']} draws of margin (was {c_on['law'] - c_on['worst']} with the glass bookshelf)") + else: FAIL(f"the shipped tree BREACHES the {t_on['law']}-draw interior law at {t_on['worst']}") + + per = [(k, c_on['perType'].get(k, 0), t_on['perType'].get(k, 0)) for k in t_on['perType']] + print('\n per type (GLB-on worst archetype): glass -> fixed') + for k, c, t in sorted(per, key=lambda r: -(r[1] - r[2])): + print(f" {k:10s} {c:4d} -> {t:4d} {t - c:+d}") + + if out_json: pathlib.Path(out_json).write_text(json.dumps(result, indent=1)) + print() + if fails: + print(f"\033[31m{len(fails)} FAIL\033[0m"); return 1 + print("\033[32mR39 §39.5-E GREEN\033[0m"); return 0 + + +if __name__ == '__main__': + sys.exit(main())