diff --git a/docs/A-progress.md b/docs/A-progress.md index cc6887c..0e19219 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,8 +1,73 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed; self-check ALL GREEN (156,352/156,352); rounds 2–30 closed**. -R30 (v7.0-alpha ledger #6): the rotation boundary VERIFIED — F's `day` salt rides runtime streams only, -plan generation structurally cannot receive it; all goldens frozen, zero re-pins.* +*Status: **all deliverables landed; self-check ALL GREEN (157,407/157,407); rounds 2–30 + 37 closed**. +R37 (v8 wave 0 §0.1): the poster-clearance gate was VACUOUS — it validated my own placer against my own +law and stayed green while all 14 posters on the default boot stood on B's bitumen. Corrected to three +arms with a permanent control; proven to fail on the pre-ruling geometry where the old gate cannot. +All goldens frozen, zero re-pins.* + +## Round 37 (2026-08-03) — v8 WAVE 0 §0.1: THE KERB (ruling-side) — the gate I owned was the vacuous one + +Full detail in LANE_A_NOTES §37. Four jobs, all measured before they were written. + +**1. Verified the audit against my own lane — CONFIRMED, exactly, with one refinement it did not make.** +Default boot (synthetic, seed 20261990, gigs default-ON): **14 posters, all 14 inside `e.width/2` (the +bitumen B paints), 0 inside the ruled kerb `roadWidth/2`.** The refinement: they are two populations — +**12 spine posters at `poleOffset` = 6 m are 8 m inside the paint (genuinely mid-road); 2 frontage posters +clip the corridor edge by 0.06 m** (13.94 of 14, 6.94 of 7 — `FACADE_PROUD` pushes them a fingernail past +their venue's facade plane). Both are inside the paint; "14 in the middle of the road" would over-claim. +Also confirmed: B's `ground.js` road quad is `hQuad(…, e.width, 0)` — the full corridor, kerbs at +`e.width/2 + 0.12`, nine metres outside mine on a main. + +**2. Diagnosed WHY the gate was green, and it is our own documented lesson recurring.** My placer seats +posters via `poleOffset`, which is *derived from* `roadWidth`; my selfcheck asserted clearance against +`roadWidth/2`. Gate and subject were the same function agreeing with itself — the R27 cross-convention +trap, inside the very file whose header (`selfcheck.js:33-40`) explains it. It never read a number that +came from B, so no change to the street could make it fail. + +**3. Corrected it — three arms, the third a permanent control.** (1) no poster on a carriageway, measured +through `vergeBand(e)[0]`, the published law itself — the same call B's ground now consumes; (2) every +**spine** poster stands *inside* some edge's `vergeBand` (the outer bound the old check never had — +frontage exempt **by measurement**: on 18/108 gig plans a frontage poster legitimately stands past every +corridor edge, worst 3.98 m, launceston_real; spine violations 0/108); (3) **the control** — count the +posters that would stand on bitumen under the pre-ruling `?verge=0` ground and assert it is **> 0** on +every gig plan (measured min 3 / max 212 / never 0 across 510 plans), **pinned at exactly 14** on the +default boot, because a control allowed to drift to zero is how a gate goes vacuous in the first place. +The replica is pinned to B's **flag**, not to a line number that will drift as B edits. + +**Proved it fails on the old geometry, and that the old gate cannot.** Simulated `?verge=0` honestly — +`vergeBand` switched to the pre-ruling reading, `poleOffset` pinned to the true band so the **placer is +byte-identical** (it is a render flag: the paint changes, the plan does not; fingerprint and every gig +golden stayed green in both runs, so the gate's reading is the only variable). **Corrected gate: ✗ FAIL, +1020 failures — 2 arms × 510 plans. Pre-R37 gate: ✓ ALL GREEN 156,352/156,352.** + +**4. Confirmed the corridor-law exports for B and published the whole contract** (LANE_A_NOTES §37 — +B is consuming them in a parallel session, so it is written as a warning, not a reference). Semantics +unchanged; nothing ambiguous was altered mid-round. Flagged loudly: `roadWidth` returns a **full** width +and so does `edge.width`, but one is carriageway and the other corridor — that naming is *why* this bug +existed; **`lane`'s band is not the literal `[2,2]` the round instructions state** (2 synthetic edges are, +**56 real lane edges are `[4,4]`** — branch on `outer <= inner`); `arcade` is all footpath with coincident +kerbs to skip; and the inward extension **never inverts** (`roadWidth > e.width`: **0 of 31,039 edges**, +though a `side` under 6 m would — guard it). Two charter numbers corrected: the "9 m band / 18 m of every +main" figures are **synthetic-only** (main 28 m); **on all real towns a main is 24 m ⇒ 7 m band**, and the +corpus's dominant edge is `side`@12 — **22,415 of 31,039 (72%)**, a **3 m** footpath. + +**GOLDEN VERDICT: nothing moved, zero re-pins** — correct and predicted, since the only file I changed on +the generator side is the *selfcheck*, which validates output and never produces it. `0x5f76e76`, +`GIG_GOLDEN 0xec7a2d39`, 25 base town goldens and 24 real-town gig goldens all green and untouched. +Checks **156,352 → 157,407** (+1,055), runtime 10.3 s. + +**Filed, not fixed — v8 debt ledger, four entries** (LANE_A_NOTES §37): D1 `chunkIndex` (two impls, three +divergences, and a header comment promising a swap that would break three ways — owner B); D2 `isOpen` +(three impls plus an ad-hoc fourth site; A's canonical one has **zero importers**; the live shell copy +treats *no hours* as **closed** where A's law says *open* — latent today, fires the moment v8 adds an +hours-less lot — owner F); D3 two shop registries (B's missing all three venue kinds ⇒ `buildings.js:344` +falls back to **op-shop** signage on every gig venue since R13 — owner B); **D4, new, found this round**: +my own poster gate's inner bound is **nearest-edge**, and an `arcade` (kerb 0) camouflages a real +carriageway — 8 real-town plans hide a spine poster up to **1.78 m inside a main**, zero on synthetic. +Cost measured decision-grade: synthetic **0/4 plans move**, **11/26 town gig goldens re-pin**, poster +counts unchanged. Deliberately deferred to R38 — it moves plan output for a reason that is not the kerb +ruling, and the "did the kerb ruling move a golden?" answer must stay a clean **no**. ## Round 30 (2026-07-18) — the rotation boundary verify (ledger #6, small round as briefed) diff --git a/docs/CITY_SPEC.md b/docs/CITY_SPEC.md index b8c3754..7c66649 100644 --- a/docs/CITY_SPEC.md +++ b/docs/CITY_SPEC.md @@ -394,10 +394,28 @@ barrel: **`roadWidth(edge)`** (full carriageway width — main 10 m, side 6–8, kerb sits at `roadWidth/2` from the centreline), **`vergeBand(edge) → [inner,outer]`** (where footpath furniture lives), **`poleOffset(edge)`** (where a pole/poster seats — just behind the kerb). Poles, verandah posts and **spine** posters belong in the verge -band; the selfcheck asserts no **spine** poster stands within `roadWidth/2` of any edge centreline. A -**frontage** poster is exempt — it is bound to its venue building (the +Z facade B draws), so at a corner -venue whose facade abuts a crossing street it may legitimately sit near that street's kerb (B's building -geometry, not a mid-road defect). +band. A **frontage** poster is exempt from the band — it is bound to its venue building (the +Z facade B +draws), so at a corner venue whose facade abuts a crossing street it may legitimately sit near that +street's kerb (B's building geometry, not a mid-road defect); on a real town it may legitimately stand +**past** every corridor edge, because `plan_osm.js` sets a lot back at `max(KERB, roadWidth/2 + FOOTPATH)` +— measured worst 3.98 m (launceston_real, R37). + +**ROUND37 — THE KERB IS FOOTPATH (John's ruling), and the poster gate that was green over a wrong +street.** The band between `roadWidth(e)/2` and `e.width/2` is **footpath**, and Lane B's ground paints +it as footpath rather than as road. The selfcheck's poster-clearance check used to ask one question — +"is the poster outside `roadWidth/2`?" — which A's own placer already guaranteed, because `poleOffset` is +*derived from* `roadWidth`. Gate and subject were the same function agreeing with itself, so it stayed +green for 24 rounds while all **14** gig posters on the default boot stood on painted bitumen. It is now +**three arms**: (1) no poster on a carriageway, measured through `vergeBand(e)[0]` — the published law +itself, the same call B's ground consumes, so gate and renderer cannot drift apart; (2) every **spine** +poster stands *inside* some edge's `vergeBand` (the bound the old check never had — "not on the road" +also passed for a poster in a paddock); (3) **the control** — count the posters that would stand on +bitumen under the pre-ruling ground (`?verge=0`, road quad = full `e.width`) and assert that count is +**not zero**, pinned at exactly 14 on the default boot. Arms 1–2 prove nothing unless arm 3 shows the two +geometries are distinguishable. Proof of falsifiability, measured: with `vergeBand` switched to the +pre-ruling reading and the placer held byte-identical, the corrected gate goes **red 1020×** (2 arms × +510 plans) while the pre-R37 gate is **ALL GREEN 156,352/156,352** — structurally blind to where the +road is painted. **Band names + the drop-in.** `bandName(seed)` generates 90s-Aussie pub-rock names from wordlists. `web/assets/custom_bands.json` is an **OPTIONAL** editable drop-in (schema `{ "bands": ["The Feral diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index 1902a62..beb55c2 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,194 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## Round 37 (2026-08-03) — v8 WAVE 0 §0.1: THE KERB, and the gate that was green over a wrong street + +### → Lane B (URGENT, you are consuming these RIGHT NOW): the corridor law, stated plainly + +**The exports are correct and you should consume them as-is. I changed no semantics this round.** +`roadWidth` / `vergeBand` / `poleOffset` in `web/js/core/registry.js:217-240`, re-exported from the +barrel `citygen/index.js:18`. But **four things about them will bite you**, and three of them are things +the round instructions state in a way that is true only for the synthetic town: + +**1. `roadWidth` is a TRAP OF A NAME, and it is why this bug existed.** It returns a **full width**, and +`edge.width` is *also* a full width — but `edge.width` is the **corridor** (road + both footpaths) and +`roadWidth(e)` is the **carriageway alone**. Two full widths, similar names, nine metres apart on a main. +Your `ground.js` painted the road quad `e.width` wide for 34 rounds because of exactly this. The kerb is +at **`roadWidth(e)/2`**, i.e. `vergeBand(e)[0]`. Prefer `vergeBand(e)[0]` over `roadWidth(e)/2` in new +code — same number by construction, but it reads as what it is and it survives a change to the split. + +**2. `lane`'s verge band is NOT `[2,2]`.** The round instructions say it is; that is the **synthetic** +lane (width 4). Measured over 31,039 edges across synthetic + all 26 town caches: **58 lane edges, 2 at +`[2,2]` (synthetic, width 4) and 56 at `[4,4]` (real, width 8).** The invariant is not a literal — it is +**`outer === inner` ⇒ the edge has no footpath at all** (a lane is all carriageway by law). **Branch on +`outer <= inner`, never on the number 2**, or you emit a zero-width strip on the synthetic town and a +wrong-width one on every real town. + +**3. `arcade` is `roadWidth 0` ⇒ band `[0, width/2]` — the WHOLE corridor is footpath.** 1,102 arcade +edges in the corpus (1 synthetic at width 5, 1,101 real at width 6). Skip the road quad *and* both kerb +strips: at `roadWidth 0` your two kerbs are coincident on the centreline and will z-fight. + +**4. The inward extension is never negative — measured, not assumed.** `roadWidth(e) > e.width` would +make your inward footpath strip negative-width. **0 occurrences in 31,039 edges.** It is nonetheless +*reachable*: `side` returns a flat 6 for any width < 18, so a `side` edge narrower than 6 m would invert. +No such edge exists in any shipped cache. Guard it anyway (`Math.max(0, …)`) — it costs a call. + +**The whole contract, exhaustively — the entire corpus reduces to nine (kind, width) pairs:** + +| kind | `edge.width` | `roadWidth` | `vergeBand` | footpath each side | `poleOffset` | count | where | +|---|---|---|---|---|---|---|---| +| `arcade` | 5 | 0 | `[0, 2.5]` | 2.5 | 1 | 1 | synthetic | +| `arcade` | 6 | 0 | `[0, 3]` | 3 | 1 | 1,101 | real | +| `lane` | 4 | 4 | `[2, 2]` | **0** | 2 | 2 | synthetic | +| `lane` | 8 | 8 | `[4, 4]` | **0** | 4 | 56 | real | +| `main` | **24** | 10 | `[5, 12]` | **7** | 6 | 7,446 | **real** | +| `main` | 28 | 10 | `[5, 14]` | 9 | 6 | 6 | synthetic | +| `side` | 12 | 6 | `[3, 6]` | 3 | 4 | **22,415** | both | +| `side` | 14 | 6 | `[3, 7]` | 4 | 4 | 8 | synthetic | +| `side` | 20 | 8 | `[4, 10]` | 6 | 5 | 4 | synthetic | + +**Two corrections to the charter's numbers, both measured.** The charter and the audit both describe +"the 9 m band" and "18 m of every main street stops being painted as road". **That is the synthetic town +only** — its main is 28 m wide. **On every real town a main is 24 m**, so the band is **7 m** and 14 m of +each main stops being road. And the dominant edge in the whole corpus is not a main at all: **`side` at +width 12 is 22,415 of 31,039 edges (72%)**, with a **3 m** footpath. Size your tri estimate off that, not +off the 9 m headline. (The +0 draws claim is yours to prove; I have no visibility into your merge.) + +**On `?verge=0`:** keep it reverting to *exactly* the pre-ruling geometry — road quad at the full +`e.width`, kerb at `e.width/2 + 0.12`, footpaths outside the corridor. My selfcheck now pins the count of +posters standing on that geometry at **exactly 14** on the default boot, and Lane F's control leg asserts +the same failure at runtime. If `?verge=0` drifts into something that is not the pre-ruling street, three +gates in two lanes stop meaning what they say. + +### → Fable / Lane F: the vacuous gate was mine, and it is now falsifiable — with the proof + +**The audit's claim against my lane is CONFIRMED, exactly, and I can add a number it did not have.** +Default boot (synthetic, golden seed 20261990, gigs default-ON since R16): **14 posters. All 14 stand +inside `e.width/2` — the bitumen B paints. Zero stand inside the ruled kerb `roadWidth/2`.** The audit's +"all 14 gig posters are standing in the road" is precise, not rhetorical. + +*One honest refinement it did not make:* the 14 are not one population. **12 are spine posters seated at +`poleOffset` = 6 m, i.e. 8 m inside painted bitumen — genuinely mid-road.** The other **2 are frontage +posters clipping the corridor edge by 0.06 m** (13.94 m of 14, and 6.94 m of 7) — they sit on their +venue's facade plane, and `FACADE_PROUD = 0.06` pushes them a fingernail past it. Both classes are inside +the paint and the fix lands both, but "14 posters standing in the middle of the road" would be an +over-claim: **12 are, 2 are 6 cm over the kerb line.** + +**Why the gate was green.** My placer seats spine posters via `poleOffset(e)`, which is *derived from* +`roadWidth`. My selfcheck asserted clearance against `roadWidth(e)/2`. **The gate and its subject were +the same function agreeing with itself** — the R27 cross-convention lesson recurring inside the very file +that documents it (`selfcheck.js:33-40` already says a self-consistent check over a self-contradicting +spec passes under either convention). It could not see B's geometry because it never read a number that +came from B. + +**The correction (`selfcheck.js`, districtInvariants), three arms — arm 3 is the control:** +1. **No poster on a carriageway** — now measured through `vergeBand(e)[0]`, the published law itself, the + same call B's ground consumes. Floors unchanged (frontage ≥ `POSTER_CLEAR`−0.02, spine ≥ 0; a `lane` + pole seats exactly *on* its kerb, so 0 must stay legal). +2. **Every SPINE poster stands INSIDE some edge's `vergeBand`** — the bound the old check never had. + "Not on the road" also passed for a poster standing in a paddock; this asserts it stands on paint B + actually lays. Frontage posters exempt **by measurement, not convenience**: on 18 of 108 gig plans a + frontage poster legitimately stands past every corridor edge (worst **3.98 m**, launceston_real), + because `plan_osm.js:459` sets a real-town lot back at `max(KERB, roadWidth/2 + FOOTPATH)`. Spine + violations over the same corpus: **0/108**. +3. **THE CONTROL** — count the posters that would stand on bitumen under the **pre-ruling** ground (B's + road quad at full `e.width`; the `?verge=0` geometry, replicated in `preRulingRoadPosters()` and + pinned to the **flag**, not to a line number that will drift as B edits). Assert it is **> 0** on every + gig plan, and **exactly 14** on the default boot. Arms 1–2 prove nothing unless the two geometries are + distinguishable; this is what makes them non-vacuous, and pinning 14 rather than `> 0` alone is + deliberate — *a control allowed to drift toward zero is how a gate goes vacuous in the first place.* + Measured range across 510 plans: **min 3, max 212, never 0.** + +**THE FALSIFIABILITY PROOF — the new gate fails on the old street, the old gate cannot.** Simulated +`?verge=0` honestly: switched `vergeBand` to the pre-ruling reading (`[width/2, width/2]` — whole +corridor is carriageway) in a scratch copy of the tree, and **pinned `poleOffset` to the true band so the +placer is byte-identical** — `?verge=0` is a *render* flag, the paint changes and A's plan does not. +Fingerprint `0x5f76e76` and every gig golden stayed green in both runs, so the only variable is what the +gate reads. Result: + +| gate | under the ruled geometry | under `?verge=0` geometry | +|---|---|---| +| **corrected (R37)** | ALL GREEN 157,407/157,407 | **✗ FAIL — 1020 failures** (2 arms × 510 plans) | +| **pre-R37 (HEAD)** | ALL GREEN 156,352/156,352 | **✓ ALL GREEN 156,352/156,352** | + +The old gate is not merely wrong about the street — it is **structurally blind to it**. No change to +where the road is painted can make it fail. That is the vacuous-gate species in one table. + +**GOLDEN VERDICT: NOTHING MOVED. Zero re-pins.** Fingerprint `0x5f76e76`, `GIG_GOLDEN 0xec7a2d39`, all +25 base town goldens and all 24 `REAL_TOWN_GIG_GOLDENS` green and untouched. Correct, and predicted: the +only generator-adjacent file I touched is the *selfcheck*, which validates output and never produces it. +Check count **156,352 → 157,407** (+1,055 new assertions), runtime 10.3 s. + +### → Fable: v8 DEBT LEDGER — filed, not fixed (four entries, three of them the audit's) + +**D1 — `chunkIndex`: two implementations, THREE divergences, and a header comment that is false.** +A's `citygen/plan.js:414` `chunkIndex(plan, chunkSize = CHUNK)` vs B's `world/planutil.js:37` +`chunkIndex(plan)`. The audit named one divergence; there are three: +(a) **bucketing** — A puts a lot in every chunk its rotated-AABB touches, B puts it in the chunk +containing its centre only; (b) **container** — A returns a plain object keyed `"cx,cz"`, B returns a +`Map`; (c) **element type** — A's buckets hold **ids** (`lots:[lot.id]`), B's hold **objects** +(`lots:[lot]`). `planutil.js:4` says *"If Lane A later ships an official chunkIndex(plan) we swap the +import; the shapes match."* — **the shapes do not match, on any of the three axes**, and that sentence is +the trap: it invites exactly the one-line swap that would break the streamer three ways at once. +Consumers are split today: **the runtime streamer uses B's** (`chunks.js:7,16`), **the debug map uses +A's** (`map.html:89,315`), and `sim.js:48-55` re-derives the key math a *third* time, locally, on purpose +(so the standalone sim page needs no Lane B module) — that third copy is defensible and documented. +*Canonical:* **B's for the runtime** (centre-bucketing is what a streamer wants — an AABB-spanning lot +would build in up to four chunks). A's AABB form is right for *coverage* questions, which is what my own +selfcheck asks of it (`selfcheck.js:130-155`). **Cost:** rename, not reconcile — give them distinct names +(`chunkIndexByCentre` / `chunkIndexByCoverage`), delete the false comment, and state which question each +answers. Half a round. **Owner: B** (the file and the runtime consumer are B's); A supplies the rename. + +**D2 — `isOpen`: three real implementations plus an ad-hoc fourth site, and A's canonical one has ZERO +importers.** Canonical: `citygen/plan.js:28`, half-open `open ≤ hour < close`, **no hours ⇒ open**, +re-exported at `citygen/index.js:10`. **Nobody imports it** — grep-verified across `world/`, `citizens/`, +`interiors/`. The live one is `web/index.html:454`, published to `window.PROCITY.isOpen` (`:603`) and +consumed by `world/audio.js:270` and **14 call sites across `tools/`** (`flags_check.py`, `shots.py`, +`v2_tour.py`, `qa/interior_scale_check.py`). **It has a real semantic divergence:** +`shop && shop.hours && …` returns **falsy for a shop with no hours**, where A's law says *no hours ⇒ +always open*. Third: `citizens/sim.js:363` `_openAt(hours)` — A's semantics, but its own clock +(`(this.timeOfDay % 1) * 24`) instead of `currentHour()`. Fourth site, ad-hoc rather than a rewrite: +`world/dbg.js:241,279,286` inline hour predicates (`hours[1] >= 22`, `hours[0] <= 12 && hours[1] > 13`) +for screenshot bookmarks. **No shipped shop lacks `hours`** (my selfcheck asserts every shop has sane +hours), so the divergence is **latent, not live** — it fires the day a venue, cart, stall or civic lot +carries no hours, which v8's whole slate is about adding. **Cost:** the shell imports A's `isOpen` and +passes `currentHour()`; sim keeps its clock but calls the same predicate; ~5 lines, 1 line of risk. +**Owner: F** (the shell is F's; A's export is already correct and needs no change). + +**D3 — two shop registries, and the venue signs are the visible bite.** A's `core/registry.js:32` +`SHOP_TYPES` = 12 types with `sign:{bg,fg,accent,style}`; B's `world/fixture_plan.js:28` `SHOP_REGISTRY` += **9 types**, missing `pub` / `band_room` / `rsl` entirely, with only `signBg`/`signFg`. Its own header +(`:26-27`) says *"Lane A owns web/js/core/registry.js; it hasn't landed yet, so Lane B carries the slice +it needs"* — **registry.js landed in R13; that comment has been stale for 24 rounds.** The bite is one +line: `buildings.js:344` `const reg = SHOP_REGISTRY[shop.type] || SHOP_REGISTRY.opshop;` — so every gig +venue renders **op-shop** signage (`#2c2820` on `#f0e2c0`) instead of the registry's gilt / stencil / club +palettes, on 2–4 venues per town across 27 towns, since R13. Confirmed as the audit reported it. +*Also drifted, but harmless:* the facade pools differ on four types (`toy` 3→2, `book` 3→2, `pawn` 4→3, +`stall` 2→1) — **dead config on the live path**, because `buildings.js:405` takes the skin from +`shop.facadeSkin` (A's plan field); B's pool is only reachable on the `fixture_plan` fallback when citygen +is absent (`index.html:138`). **Cost:** `buildings.js` imports A's `SHOP_TYPES` and reads +`.sign.bg`/`.sign.fg`; `SHOP_REGISTRY` shrinks to the fallback-only table it actually is, with an honest +header. Sign atlas colours change on venue lots ⇒ **a screenshot golden may move; not a plan golden.** +Half a round. **Owner: B**, with F re-pinning any shot. + +**D4 — NEW, found this round, and it is the same disease one layer down: the poster gate's inner bound +is NEAREST-EDGE, and an `arcade` edge camouflages a carriageway.** Both my selfcheck's arm 1 and my +placer's `gigs.js:304 onCarriageway()` resolve the *nearest* edge and test against that one edge's kerb. +The nearest edge is not always the binding one. Measured over 108 gig plans: the nearest-edge rule reports +**0 violations**; an **all-edges** rule finds **8 plans** (all real towns) where a spine poster stands +inside a *non-nearest* edge's carriageway — **worst 1.78 m inside a main on northbridge_real seed 42**, +where the nearest edge is an `arcade` at 3.22 m whose `roadWidth` is **0**, so it "clears" by 1.45 m and +hides a main whose kerb is 5 m away. `arcade` edges are actively camouflaging: kerb 0 means they win the +nearest-edge test and always pass it. **Zero occurrences on synthetic, including the default boot** — the +kerb ruling is unaffected. **Cost, measured decision-grade, not estimated:** I built the all-edges variant +and diffed the whole corpus — **synthetic 0/4 plans move** (so `0x5f76e76`, `GIG_GOLDEN` and the classic +covenant are untouched), **11 of 26 town gig goldens re-pin**, and **poster counts do not change at all** +(the R20 cap re-subsamples a smaller candidate pool to the same size). So: one placer edit, one gate edit, +11 hash re-pins, no budget change, no visual count change. **Deliberately NOT done this round**: it moves +plan output for a reason that is *not* the kerb ruling, in a shared tree, while F is gating the matrix — +exactly the scope creep a floor round forbids, and it would muddy the "did the kerb ruling move a golden?" +signal, which must stay a clean **no**. **Owner: A. Recommend R38, one small item, self-contained.** + ## Round 30 (2026-07-18) — THE ROTATION BOUNDARY VERIFY (ledger #6): **HOLDS** ### → Fable / F: `day` rides RUNTIME streams only — plan generation cannot see it. Measured, not read. diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index 79aedf2..723857f 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -11,7 +11,7 @@ import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan. import { generatePlanOSM, osmTownKeys, validateTownCache, registerTownCache, MIN_TOWN_SHOPS, medianShopSpacing, MAX_MEDIAN_SPACING_M } from './plan_osm.js'; import { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.js'; -import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js'; +import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, vergeBand } from '../core/registry.js'; import { xmur3 } from '../core/prng.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -218,41 +218,104 @@ function districtInvariants(plan, label) { } ok(clash === null, `${label}: no band plays two venues on one night` + (clash !== null ? ` (night ${clash})` : '')); - // ── posters: town-wide, never on the bitumen — with a real FRONTAGE floor (ROUND16 ledger #6) ── + // ── posters vs THE CORRIDOR LAW — three arms, the third of which is the control (ROUND37 §0.1) ── const gigIds = new Set(plan.gigs.map(g => g.gigId)); ok(Array.isArray(plan.posters) && plan.posters.every(p => gigIds.has(p.gigId) && isFiniteNum(p.x) && isFiniteNum(p.z) && isFiniteNum(p.ry)), `${label}: posters reference a real gig with finite coords`); ok(plan.posters.every(p => plan.gigs.find(g => g.gigId === p.gigId).night === 0), `${label}: posters advertise tonight's gigs`); - // Two poster classes, two floors. A FRONTAGE poster sits on its venue's +Z street facade (B's canon: - // buildings.js facade + venue.js door/queue/camera are +Z); ROUND16 pickVenues now PREFERS a band_room/ - // rsl lot whose facade clears every carriageway, so — no more R15 blanket exemption — a frontage poster - // must clear its nearest kerb by ≥ POSTER_CLEAR. A SPINE poster is free-standing on the verge and keeps - // the plain no-bitumen floor (≥ 0, the R12 regression guard). −0.02 absorbs r2 poster-coord rounding vs - // pickVenues' un-rounded clearance test (worst observed frontage clearance is ~0.51 m). + // + // ROUND37 CORRECTION — THE VACUOUS GATE, NAMED. Until this round this block asked exactly one + // question: "is the poster outside `roadWidth/2`?". But A's own placer seats spine posters at + // `poleOffset(e)`, which is DERIVED FROM `roadWidth` — so the gate and its subject were the same + // function agreeing with itself. It was green for 24 rounds while all 14 gig posters on the default + // boot stood on painted bitumen, because Lane B's ground painted its road quad the full `e.width` + // (28 m on a main, kerb nine metres inside it) and nothing in this suite could see that number. + // That is precisely the R27 cross-convention lesson recurring (see facadeQuadWorld above): a + // self-consistent check over a contested geometry passes under EITHER reading. So this now does what + // the R27 gate does — replicate the OTHER lane's maths and compare the two against each other. + // + // ARM 1 no poster stands on a CARRIAGEWAY — measured through `vergeBand(e)[0]`, the published law + // itself, i.e. the same call Lane B's ground now consumes. Gate and renderer cannot drift + // apart silently: change the law and both move together. + // ARM 2 every SPINE poster stands ON A FOOTPATH — inside `vergeBand(e)` of SOME edge that has one + // (`outer > inner`; a `lane` is all carriageway, so its band is degenerate and hosts no pole). + // This is the bound the old check never had: "not on the road" also passed for a poster + // standing in a paddock. After the kerb ruling it asserts the poster stands on paint B lays. + // ARM 3 THE CONTROL. If arms 1–2 pass under BOTH geometries they prove nothing, so count the + // posters that WOULD stand on bitumen under the PRE-RULING ground and assert that count is + // NOT ZERO. That is the falsifiability control living plan-side, permanently. + // + // Two poster classes, two floors (unchanged). A FRONTAGE poster sits on its venue's +Z street facade + // (B's canon: buildings.js facade + venue.js door/queue/camera are +Z); ROUND16 pickVenues PREFERS a + // band_room/rsl lot whose facade clears every carriageway, so a frontage poster must clear its nearest + // kerb by ≥ POSTER_CLEAR. A SPINE poster is free-standing on the verge and keeps the plain no-bitumen + // floor (≥ 0 — a `lane` has vergeBand [w/2,w/2], so its pole seats exactly ON the kerb and 0 is legal). + // −0.02 absorbs r2 poster-coord rounding vs pickVenues' un-rounded clearance test. + // Frontage posters are EXEMPT FROM ARM 2 by measurement, not convenience: a frontage poster rides its + // venue's facade plane, and `plan_osm.js` sets a real-town lot back at `max(KERB, roadWidth/2+FOOTPATH)`, + // so on 18 of 108 gig plans it legitimately stands past every corridor edge — worst 3.98 m + // (launceston_real, R37 measurement). SPINE violations over the same corpus: 0/108. const lotByIdD = new Map(plan.lots.map(l => [l.id, l])); const onFacade = p => venues.some(v => { const lot = lotByIdD.get(v.lot); if (!lot) return false; const off = lot.d / 2 + 0.06; // gigs.js buildPosters item 1 (FACADE_PROUD) return Math.hypot(p.x - (lot.x + Math.sin(lot.ry) * off), p.z - (lot.z + Math.cos(lot.ry) * off)) < 0.5; }); - let onRoad = null; + const segs = edgeSegments(plan); + let onRoad = null, offFootpath = null; for (const p of plan.posters) { - let best = Infinity, bestEdge = null; - for (const e of plan.streets.edges) { - const a = nodeById2(plan, e.a), b = nodeById2(plan, e.b); - if (!a || !b) continue; - const [nx, nz] = nearestOnSeg(p.x, p.z, a.x, a.z, b.x, b.z); + const frontage = onFacade(p); + let best = Infinity, bestEdge = null, onSomeFootpath = false; + for (const [e, ax, az, bx, bz] of segs) { + const [nx, nz] = nearestOnSeg(p.x, p.z, ax, az, bx, bz); const d = Math.hypot(p.x - nx, p.z - nz); if (d < best) { best = d; bestEdge = e; } + const [inner, outer] = vergeBand(e); + if (outer > inner && d >= inner - 1e-6 && d <= outer + 1e-6) onSomeFootpath = true; } if (!bestEdge) continue; - const frontage = onFacade(p); - const clearance = best - roadWidth(bestEdge) / 2; + const clearance = best - vergeBand(bestEdge)[0]; // ARM 1 — the kerb IS vergeBand's inner edge const floor = frontage ? POSTER_CLEAR - 0.02 : -1e-6; - if (clearance < floor) { onRoad = [p.id, bestEdge.id, +clearance.toFixed(2), frontage ? 'frontage' : 'spine']; break; } + if (clearance < floor && onRoad === null) onRoad = [p.id, bestEdge.id, +clearance.toFixed(2), frontage ? 'frontage' : 'spine']; + if (!frontage && !onSomeFootpath && offFootpath === null) offFootpath = [p.id, bestEdge.id, +best.toFixed(2)]; } - ok(onRoad === null, `${label}: every poster clears its kerb (frontage ≥ ${POSTER_CLEAR} m, spine ≥ 0)` + (onRoad ? ` (${onRoad[3]} poster ${onRoad[0]} clears edge ${onRoad[1]} by only ${onRoad[2]} m)` : '')); + ok(onRoad === null, `${label}: no poster stands on a carriageway — vergeBand inner edge (frontage ≥ ${POSTER_CLEAR} m, spine ≥ 0)` + + (onRoad ? ` (${onRoad[3]} poster ${onRoad[0]} clears edge ${onRoad[1]}'s kerb by only ${onRoad[2]} m)` : '')); + ok(offFootpath === null, `${label}: every spine poster stands INSIDE some edge's vergeBand (the footpath B paints in to roadWidth/2)` + + (offFootpath ? ` (poster ${offFootpath[0]} is ${offFootpath[2]} m from nearest edge ${offFootpath[1]} and inside no verge band)` : '')); + const preRuling = preRulingRoadPosters(plan); + ok(!plan.posters.length || preRuling > 0, + `${label}: CONTROL — the kerb arms DISCRIMINATE: ${preRuling}/${plan.posters.length} poster(s) stand on bitumen under the PRE-RULING ground (?verge=0)`); } const nodeById2 = (plan, id) => plan.streets.nodes.find(n => n.id === id); +// Every edge resolved once to [edge, ax, az, bx, bz] — the poster arms are O(posters × edges). +function edgeSegments(plan) { + const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n])); + const out = []; + for (const e of plan.streets.edges) { + const a = nodeById.get(e.a), b = nodeById.get(e.b); + if (a && b) out.push([e, a.x, a.z, b.x, b.z]); + } + return out; +} +// ROUND37 §0.1 — LANE B'S PRE-RULING GROUND, REPLICATED HERE ON PURPOSE (the R27 cross-lane pattern). +// `ground.js` painted its road quad the FULL corridor — `hQuad(cx, cz, dir, perp, roadLen, e.width, 0)` +// — i.e. bitumen out to `e.width/2`, with A's kerb at `roadWidth(e)/2` nine metres inside it on a main. +// John's R37 ruling moves the paint in to the kerb; Lane B's permanent `?verge=0` flag keeps THIS +// geometry in the codebase forever, so the replica is pinned to the FLAG, not to a line number that +// will drift. It counts a floor, never an over-count: ground.js overruns each segment by OVERLAP=1.5 m +// at both ends and this clamps to the segment. Used as the control arm above and pinned at the default +// boot's exactly-14 in §3c. +function preRulingRoadPosters(plan) { + let n = 0; + const segs = edgeSegments(plan); + for (const p of plan.posters || []) { + for (const [e, ax, az, bx, bz] of segs) { + const [nx, nz] = nearestOnSeg(p.x, p.z, ax, az, bx, bz); + if (Math.hypot(p.x - nx, p.z - nz) < ((e.width || 0) / 2) - 1e-9) { n++; break; } + } + } + return n; +} // ── 1. determinism: two runs byte-identical ───────────────────────────────────────── section('determinism (deep-equal across two runs)'); @@ -342,6 +405,19 @@ const GIG_GOLDEN = 0xec7a2d39; // ROUND16 re-pin: pickVenues prefers band_room/r if (GIG_GOLDEN === 0) console.log(` ⚠ gig golden UNPINNED → set GIG_GOLDEN = 0x${hash.toString(16).padStart(8, '0')}`); ok(GIG_GOLDEN !== 0 && hash === (GIG_GOLDEN >>> 0), `gig layer golden 0x${hash.toString(16)} matches pinned 0x${(GIG_GOLDEN >>> 0).toString(16)}`); } +// ROUND37 §0.1 — THE PRE-RULING COUNT, PINNED ON THE DEFAULT BOOT. The city-patterns audit reported +// "all 14 gig posters are standing in the road"; Lane A re-measured it and it is exact — 14 posters on +// the default boot (synthetic, golden seed, gigs default-ON since R16), all 14 inside `e.width/2`, and +// ZERO inside the ruled kerb `roadWidth/2`. Pinned here, not just asserted `> 0`, because a control that +// is allowed to drift toward zero is how a gate goes vacuous in the first place — the thing this round +// exists to stop. If a future change moves posters out past `e.width/2`, this fires and the ?verge=0 +// falsifiability control (Lane B's flag, Lane F's gate leg) has to be re-argued rather than quietly lost. +const PRE_RULING_ROAD_POSTERS = 14; +{ + const n = preRulingRoadPosters(generatePlanFor(GOLDEN.seed, 'synthetic', { gigs: true })); + ok(n === PRE_RULING_ROAD_POSTERS, + `default boot: exactly ${PRE_RULING_ROAD_POSTERS} posters stood on bitumen under the PRE-RULING ground (?verge=0) — measured ${n}`); +} // ── 3d. district invariant sweep (ROUND14): 400 synthetic seeds + osm graceful placement ─────────── // Release-round Lane A gate: the district contract must hold at SCALE, not just the 4 hero seeds. Runs