Lane F R39 (v9 WAVE 1, CLOSE): QA GREEN 10/0/0/0 — and the interior margin is 162, not 21 and never 6
FIVE GATES, FIVE CONTROLS, each demonstrated in the same run. New F-owned tools wired into qa.sh as Gate 8: r39_address.mjs · r39_transmission.py · r39_runtime.py · r39_shots.py (--no-r39 skips the browser half). 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. Two port-isolated no-store roots, the same inode for every byte except one file — the shipped bookshelf GLB vs a copy with transmissionFactor:1 re-inserted (BIN sha1 IDENTICAL, only the JSON chunk moved) — both through interior_test.html's own drawSweep: GLB-on worst 329 (opshop/hall) -> 188 (dept/auto) -141 GLB-off worst 162 (opshop/hall) -> 162 (opshop/hall) 0 — the control's own control margin vs <=350 21 -> 162 +141 opshop 329->174 (-47%) · book 247->125 (-49%) · every other type 0, exactly the shape Lane C predicted from an isolated room, 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 all four R39 GLBs were fetched off digalot.fyi and scanned: sha1 identical to the local tree, 0 transmissive materials, 4/4. Binding fact #3's third and final value. 39.1 ADDRESS — GREEN. 1,192/1,219 shops (97.8%), 23/23 'ways', 857/857 membership agree / 0 wrong (and the control is asserted to have CHECKED 857 edges). No cache -> supplier 'district', 0 names, still labels 72/72 from district+block. 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 the honest limit: at 3 m the membership control reads 0 disagreements, because those shops front ways OSM leaves unnamed — only the shift cross-derivation catches it. 39.2 FOG — GREEN, both arms on one boot. HIDES 4/72 at spawn, 0 of 61 distant shops known. REVEALS 2,125 m -> 58 shops. FRONT TEST: probe from 5 m BEHIND each of 59 unknown shopfronts, 0 learned. +0 DRAWS AND +0 TRIS, four freshly launched browsers, both orders, drained, 3 reads each, with the arms asserted distinguishable first. 8/8 corrupt saves rejected, state untouched. Null store on classic/game=0/fog=0; game=0 and fog=0 pixel-identical, default differs. 39.3 THE SIGN — GREEN. Shops in front of the player at spawn: 4/4 · 4/4 · 4/4 · 1/1, and ZERO on all four with the pre-R39 sign re-derived on the same tree. The live spawn is asserted equal to the +sin derivation, so the shell is what is scored. Re-baselined WITHOUT falsifying a release record: the v7 tour and v7-beta frames are kept as history; docs/shots/laneF_r39/ ships the new pair with its own BEFORE. The R35 gate itself is pose-independent and was green either side. 39.4 RUMMAGE BIN — 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 by a flood fill written here. THE CONTENTS DID NOT SHIP: on the default street boot the tub is kind:'prop', 0 dig targets, E opens nothing. 39.5 TRANSMISSION VALIDATOR — GREEN and it FIRES: hard-fails rc 1 on the fabricated glass asset AND passes on the shipped tree. THREE CORRECTIONS, ALL MEASURED. A's "a 30 m shift returns 0 street names" — it returns 40/12/47 wrong ones; a big shift lands you on the next street, it does not degrade to null. B'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: I first aimed 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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
43c7f4281e
commit
076b972f71
@ -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
|
||||
|
||||
@ -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/<file>`. 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/<file>` 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/<file>` 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.**
|
||||
|
||||
29
docs/shots/laneF_r39/README.md
Normal file
29
docs/shots/laneF_r39/README.md
Normal file
@ -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.
|
||||
BIN
docs/shots/laneF_r39/first_five-AFTER-plus-sin.png
Normal file
BIN
docs/shots/laneF_r39/first_five-AFTER-plus-sin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 770 KiB |
BIN
docs/shots/laneF_r39/the_strip-AFTER-plus-sin.png
Normal file
BIN
docs/shots/laneF_r39/the_strip-AFTER-plus-sin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 771 KiB |
BIN
docs/shots/laneF_r39/the_strip-BEFORE-minus-sin.png
Normal file
BIN
docs/shots/laneF_r39/the_strip-BEFORE-minus-sin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 799 KiB |
BIN
docs/shots/laneF_r39/the_strip_katoomba-AFTER-plus-sin.png
Normal file
BIN
docs/shots/laneF_r39/the_strip_katoomba-AFTER-plus-sin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 629 KiB |
BIN
docs/shots/laneF_r39/the_strip_katoomba-BEFORE-minus-sin.png
Normal file
BIN
docs/shots/laneF_r39/the_strip_katoomba-BEFORE-minus-sin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 680 KiB |
21
tools/qa.sh
21
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' \
|
||||
|
||||
255
tools/qa/r39_address.mjs
Normal file
255
tools/qa/r39_address.mjs
Normal file
@ -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');
|
||||
687
tools/qa/r39_runtime.py
Normal file
687
tools/qa/r39_runtime.py
Normal file
@ -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())
|
||||
126
tools/qa/r39_shots.py
Normal file
126
tools/qa/r39_shots.py
Normal file
@ -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())
|
||||
290
tools/qa/r39_transmission.py
Normal file
290
tools/qa/r39_transmission.py
Normal file
@ -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('<II', data, off); off += 8
|
||||
out.append((ty, data[off:off + ln])); off += ln
|
||||
return out
|
||||
|
||||
|
||||
def glb_json(path):
|
||||
for ty, payload in glb_chunks(pathlib.Path(path).read_bytes()):
|
||||
if ty == 0x4E4F534A:
|
||||
return json.loads(payload.decode('utf-8'))
|
||||
raise AssertionError('no JSON chunk')
|
||||
|
||||
|
||||
def transmissive_materials(path):
|
||||
"""[(material name, factor)] for every material with transmissionFactor > 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('<II', 2, total))
|
||||
out += struct.pack('<II', len(jb), 0x4E4F534A) + jb
|
||||
if binp: out += struct.pack('<II', len(bb), 0x004E4942) + bb
|
||||
pathlib.Path(dst).write_bytes(bytes(out))
|
||||
return touched, hashlib.sha1(binp).hexdigest(), hashlib.sha1(bb).hexdigest()
|
||||
|
||||
|
||||
# ── servers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
def port_up(port):
|
||||
with socket.socket() as s:
|
||||
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0
|
||||
|
||||
|
||||
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')
|
||||
self.send_header('Pragma', 'no-cache')
|
||||
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 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 {root} on :{port}')
|
||||
|
||||
|
||||
def mirror_except(src, path_parts, replacement):
|
||||
"""A temp mirror of `src` in which every entry is a SYMLINK to the original except the single
|
||||
file at `path_parts`, which is a copy of `replacement`. Only the directories on that path are
|
||||
real; everything else is the same inode, so the one file is the only variable."""
|
||||
dst = pathlib.Path(tempfile.mkdtemp(prefix='procity-r39-mirror-'))
|
||||
def walk(s, d, parts):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
for e in pathlib.Path(s).iterdir():
|
||||
if parts and e.name == parts[0]:
|
||||
if len(parts) == 1: shutil.copy2(replacement, d / e.name)
|
||||
else: walk(e, d / e.name, parts[1:])
|
||||
else:
|
||||
(d / e.name).symlink_to(e)
|
||||
walk(src, dst, list(path_parts))
|
||||
return dst
|
||||
|
||||
|
||||
def glass_root(glb_name=BOOKSHELF, material=MATERIAL):
|
||||
"""web/ mirrored symlink-for-symlink EXCEPT assets/models/<glb_name>, 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/<file> (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/<file> 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())
|
||||
Loading…
Reference in New Issue
Block a user