# LANE A — NOTES (cross-lane answers from CityGen) Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. ## Round 39 (2026-08-03) — v9 item 39.1: THE ADDRESS LAYER — the contract, published **`web/js/citygen/address.js` is landed, green, and yours to consume.** Pure module: **zero imports**, no THREE, no fetch, no DOM, no module state, no plan mutation. It derives from a plan and never writes to one, so **it cannot move a golden** — and it didn't (numbers at the bottom). ### THE CONTRACT — copy this, it is the whole API ```js import { createAddresses, getTownCache } from './js/citygen/index.js'; const cache = citygen.getTownCache(TOWN); // NEW export — see "how to get the cache" below const addr = citygen.createAddresses(plan, cache /* or null */); addr.streetOf(edgeId) // → "Templeton Street" | null — a real street name, or null. NEVER a guess. addr.localityOf(shopId) // → { shopId, street, district, block, side, label } | null (null = unknown id) addr.cohort(predicate) // → shopId[] predicate(locality) → bool; ids ascending, deduped addr.streets() // → [{ name, edges:[edgeId], shops:[shopId] }] sorted by name addr.stats() // → the measurement (see below) — assert on this, don't trust a claim ``` `localityOf` fields: | field | type | on a real town | on the synthetic | |---|---|---|---| | `street` | `string \| null` | `"Barker Street"` (97.8% of corpus shops) | **always `null`** — there are no named ways | | `district` | `string` | always `'mainstreet'` (`plan_osm.js:305` adds exactly one) | one of the six `DISTRICT_KINDS` | | `block` | `int` | `lot.block` | `lot.block` | | `side` | `'north'\|'south'\|'east'\|'west'` | the cardinal the lot sits on off its `frontEdge` | same | | `label` | `string \| null` | the street name, or `null` if unresolved | `"the market end"`, `"the arcade"`, `"the north end of the backstreets"` | ### THE ONE RULE: **NEVER BRANCH ON TOWN TYPE** `label` is the field you print. It is filled by a **different supplier** on each kind of town — real streets from the cache's named ways, district phrases from `district.kind` + block — and that is the entire point of the module. Do not test `plan.source`, do not test for a cache, do not special-case the synthetic. **Print `label`. Group by `block`. Ask for `street` only when you specifically need a proper name and can honestly render `null`.** The moment one consumer branches, every feature built on this has to be written twice and the synthetic half rots. `label` is `null` in exactly one situation: a real-town shop whose frontage resolves to no named way (**27 of 1,219 corpus shops, 2.2%**). Handle null once, on both town types, and you are done. ### How to get the cache (this was genuinely unreachable before this round) `web/index.html:129` hands the fetched JSON to `registerTownCache(TOWN, …)` and **drops its own reference**, so the `roads[]` — and their 17,835 names — were unreachable to everything downstream. New additive read-only export, no new state, no shell change needed: ```js citygen.getTownCache(key) // → the registered cache, or undefined ``` `undefined` for an unknown key and for the three checked-in `osm_fixture.js` towns (melbourne / katoomba / silverton are fixtures, not caches — they carry no `roads[]`). **`createAddresses(plan, undefined)` is legal** and degrades to district labels rather than throwing — which is exactly the failure a consumer would never notice, so: > **Assert `addr.stats().supplier === 'ways'` wherever you require real street names.** Selfcheck does > this for all 23 caches. A caller that forgets the cache gets `supplier: 'district'` and zero street > names — visible in `stats()`, never a silent wrong name. ### → LANE B (39.2, the fog): what you can consume today - **Map street labels.** `addr.streets()` gives you `{ name, edges[], shops[] }` — the `edges` are plan edge ids you already draw, so you can place a label along an edge run without a second lookup. **Bring your own dedup**: a real town has **65–319 distinct street names** (median 192; adelaide 313, bendigo 319) over 30,986 edges. Labelling every edge is 322 `fillText` calls a frame, which is the CPU pass your own note already flags — key the offscreen static cache off the discovered set. - **HUD tooltip.** `addr.localityOf(shop.id).label` is the template literal. It is a plain string or null; there is no formatting to do and no town-type test to write. - **`side` is a real clue term and it discriminates**: the two sides of any street always map to two opposite cardinals, so `"the north side of Barker Street"` is a stable, speakable cell. - **Cost**: `createAddresses` is **≤27 ms per real town** (worst bendigo, 2,593 edges × 1,408 named ways), **0.3 ms on the synthetic**, once, at plan time. Zero draws, zero triangles, zero plan fields. ### → LANE C (39.3): the shop you are digging in now has an address `localityOf(shop.id)` works for any shop id on the plan, interiors included — it reads `lot.frontEdge`, which every shop already has. If the op-shop bin's find needs to be written down (`"a $2 crate, Barker Street"`), that string exists now and it costs nothing. **Do not import `address.js` into an interior hot path** — build it once alongside the plan and pass the result in. ### The measurements, so nobody has to take my word for it Fable's binding facts re-derived independently, and **all three land exactly**: **19,132 road ways, 17,835 named (93.2%)**; median plan-edge-sample → nearest named way **0.03–0.90 m** (worst katoomba); **1,192 / 1,219 corpus shops (97.8%) resolve to a street name.** | | edges named | shops with a street | distinct names | | | edges named | shops | names | |---|---|---|---|---|---|---|---|---| | adelaide | 89.9% | **80.9%** (89/110) | 313 | | marrickville | 97.5% | 100% (26/26) | 236 | | bendigo | 98.9% | 100% (35/35) | 319 | | newcastle | 98.7% | 100% (34/34) | 158 | | bowral | 95.4% | 100% (30/30) | 71 | | newtown_godverse | 93.1% | 100% (67/67) | 306 | | braddon | 97.1% | 100% (19/19) | 142 | | newtown | 93.1% | 100% (67/67) | 306 | | brunswick | 98.8% | 100% (92/92) | 273 | | northbridge | 96.7% | 98.3% (57/58) | 166 | | castlemaine | 98.0% | 100% (22/22) | 141 | | redhill_godverse | 99.1% | 100% (35/35) | 291 | | darwin | 93.1% | 100% (12/12) | 90 | | redhill | 99.1% | 100% (34/34) | 291 | | daylesford | 97.7% | 100% (26/26) | 65 | | westend | 92.4% | 100% (41/41) | 159 | | fitzroy | 92.8% | 99.3% (138/139) | 185 | | fremantle | 97.5% | 100% (79/79) | 134 | | geelong | 98.4% | 98.4% (61/62) | 226 | | glebe | 97.8% | 100% (51/51) | 225 | | hobart | 98.9% | 100% (78/78) | 215 | | katoomba | 99.0% | 100% (72/72) | 165 | | launceston | 96.0% | 90.0% (27/30) | 192 | | **CORPUS** | **96.4%** | **97.8%** (1192/1219) | — | **The failure modes, named.** All 27 unresolved shops front an edge whose way genuinely has no name in OSM: **adelaide 21 `arcade`** (adelaide's arcades are the town's retail spine and OSM leaves the footways unnamed — this is why it is the corpus worst at 80.9% and it is honestly worst, not a bug), fitzroy 1 `arcade`, geelong 1 `arcade`, launceston 1 `arcade` + 2 `lane`, northbridge 1 `arcade`. **`null` is the honest answer there** and the module returns it rather than borrowing a neighbour's name. A shop with `street: null` also gets `label: null`; selfcheck asserts that pairing per town. ### Two things I did NOT do the way the synthesis proposed, both with numbers **1. The shift is recovered EXACTLY, not voted for.** `plan_osm.js:502-506` centres the town on the origin, so plan node = `r2(projected way point) + shift`. The synthesis recovers that by centroid-align + nearest-waypoint vote. **I implemented that first and it returns the WRONG shift on 5 of the 23 towns** — braddon, fremantle, hobart, northbridge, westend. The cull of shopless islands (`plan_osm.js:407-414`) drags the plan's centroid off the cache's, the initial alignment is tens of metres out, and the vote then converges *confidently* on a wrong offset. **A wrong shift is not a degraded answer — it renames every street in the town.** So: take one plan node, treat every (node − way point) difference as a candidate, and keep only candidates under which further nodes also land exactly on way points. Two or three nodes collapse ~25,000 candidates to one. **Exact on 23/23, nothing to tune, and it returns `null` rather than a guess when it cannot prove an answer.** **2. The resolver samples five points along an edge, not the midpoint.** "Nearest named way to the edge midpoint" cannot tell a street from the street that *crosses* it — at an intersection both are ~0 m away and it picks one by a hair. A name must now be within tolerance at **all five** samples (t ∈ 0.15…0.85, endpoints excluded because they *are* the junctions). A crossing street is close at one sample and far at the rest. ### The tolerance is 8 m — and here is the number that actually sets it Corpus shops that get a street name: **2 m → 85.2% · 4 m → 94.8% · 6 m → 97.4% · 8 m → 97.8% · 12 m → 99.1% · 16 m → 99.2%**. But coverage is the wrong quantity to tune on. Checked against an independent **way-membership** resolver (below), the number of the 884 shop-bearing edges that resolve to the *same name* is **857 at 6 m — and 857 at 8, 12, 16 and 24 m.** > **Correctness saturates at 6 m.** Every metre past 6 buys exactly one thing: a name attached to a way > OSM left unnamed (0 such edges at 6 m, 4 at 8 m, 12 at 12 m, 14 at 24 m). **It can never buy a > correction.** Raising this knob to chase coverage is always borrowing a neighbour's name and should > be argued as that, never as accuracy. 8 m keeps the four borrowings, all arcade footways inside a named mall (bowral's *Corbett Plaza* ×2, fremantle's *High Street* ×2), where the borrowed name is what a person would actually say. `createAddresses(plan, cache, { tolerance: 6 })` is the strictly-conservative setting at identical correctness. ### A WRONG STREET NAME IS WORSE THAN NO STREET NAME — so it is gated by an independent resolver `address.js` asks "which named way is this edge ON?" by **distance**. The selfcheck's control asks the same question by **point membership**: a plan edge is built from two points of ONE simplified way (`plan_osm.js:388-394`) and plan nodes are those points snapped on a 3 m lattice, so the true way must *contain* both endpoints' snap keys. Two genuinely different questions over the same data — the R27 cross-convention pattern, not a restatement. > **Over the 884 shop-bearing edges — the only edges `localityOf` reads — 857 agree and ZERO disagree.** > Corpus-wide, 4 of 30,986 named edges disagree, all four are 2D-stacked ways where the question has no > single answer (geelong's two adjacent malls; a motorway crossing *over* a street on glebe and redhill > ×2), and **none of the four carries a shop.** That count is PINNED at 4, not asserted `> 0` — a > control allowed to drift toward zero is how a gate goes vacuous, which is the R37 lesson. **And the resolver degrades to null, not to a wrong name.** Measured: feed it a shift wrong by 30 m and castlemaine/katoomba/adelaide return **0** street names rather than a town full of wrong ones — the five-sample rule simply finds nothing within tolerance. Feed it a shift wrong by 3 m and adelaide's coverage *rises* from 89 to 98 shops while being wrong, which is the whole reason the shift arm is exact-or-nothing and **coverage is never used as a proxy for correctness.** ### The synthetic supplier, and its honest limit 12 label cells over 493 shops, **median 41, max 147** ("the south end of the main street"). That is coarse, and it is the same structural fact §8 of the charter already records — the synthetic is a magnificent strip and not a town you can learn. **`block` is the finer cell: 32 shop-bearing blocks, median 14, max 41.** Consumers that need precision should `cohort(l => l.block === n)`; consumers that need prose should print `label`. Both work on both town types, which is the point. ### GOLDENS: NOTHING MOVED. ZERO RE-PINS. `address.js` never writes to a plan, and selfcheck asserts that byte-for-byte per town (`JSON.stringify(plan)` before and after the call). Every pinned hash in `selfcheck.js` is untouched: synthetic `0x5f76e76`, gig `0xec7a2d39`, the 3 osm fixtures, all 23 `REAL_TOWN_GOLDENS`, all 23 `REAL_TOWN_GIG_GOLDENS`. **Selfcheck 157,407 → 157,647 (+240 checks), ALL GREEN, fingerprint unchanged.** ### The one-line upgrade: TAKEN, and it is genuinely that clean `plan_osm.js` now writes `norm.shift = { shx, shz }` — onto the **normalization log**, which reaches the caller only through the existing `opts.report` sink at the next line. **Never onto the plan object.** `JSON.stringify(plan)` is byte-identical by construction; the 46 pinned goldens above are the proof, not the argument. **But it is not a dependency, and it should not become one.** `createAddresses(plan, cache)` recovers the shift itself, exactly, on 23/23. The report line earns its keep as a **cross-check**: the selfcheck compares `plan_osm`'s published shift against `address.js`'s independently recovered one, two derivations that share no code, and fires on a **0.01 m** disagreement. Without that line, the recovery would only ever be checkable against itself — which is precisely the vacuous-gate species R37 was about. `opts.shift` is accepted as a fast path and `stats().shiftCheck` reports `'agree'` / `'DISAGREE'`. ## 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. **Verdict: the seeded-rotation boundary HOLDS.** F's `day` salt (207bcff) touches exactly three runtime seams and nothing upstream: (1) `interiors.js:178` `ctx.stockSalt` from `opts.stockDay`, consumed ONLY in `layout.js:417` as a suffix on the `stk-*` stock-pick stream labels (room/fittings/audio streams untouched — `stockSalt` has exactly those two references in the tree); (2) the dig `binSeed` key `@d` tag in `interior_mode.js` (same null-gating: no game / day 1 / real-sourced ⇒ absent); (3) `gig_state.setWeekNight`, which only **selects** a night from `plan.gigs` — the whole 7-night week is generated at plan time (`gigs.js:227` `for night < NIGHTS`) from citySeed streams; day picks, never feeds. **How measured (three falsifiable legs):** 1. **Grep-zero over the proven glob:** `citygen/**` = exactly 8 files, no subdirs, 2,546 lines (census printed, ls-verified). `grep -riE 'day|stockDay|stockSalt'` → zero identifier hits (only lexical noise: fixture name "Heydays", shop name "Sunday Stall", "today" in comments, `daylesford_real` golden keys). Non-vacuous: the same pattern hits `stockDay` 4× in `world/interior_mode.js`. 2. **Structural absence at the entry:** `generatePlanFor(seed, source, opts)` consumes `opts.{town, gigs, customBands, cache, report}` only (grepped every `opts.` in citygen); both shell call sites (`index.html:127`, `map.html:315`) pass `{town, gigs, customBands}` — no day exists to pass. 3. **Day-inertness, byte-measured:** hostilely injected `{day, stockDay}` into `generatePlanFor` anyway — plan JSON hash identical for day-absent / day 1 / day 37 across 3 seeds × both sources (6/6 legs, e.g. synthetic 20261990 `0xec7a2d39` all three ways). Even a rogue caller can't leak day into a plan. **Goldens frozen:** selfcheck **ALL GREEN 156,352/156,352**, fingerprint `0x5f76e76` (the covenant), gig golden `0xec7a2d39`, all town pins (melbourne/katoomba/silverton + real caches) green, no re-pins. Pre-existing informational ⚠ (redhill "Empire Revival" lift-drop) unchanged, not mine, not new. ## Round 27 wave 5 (2026-07-17) — THE FACADE FIX: it was every town, not just the real ones ### → F: **your files hold the old covenant hashes — they are now wrong, and only you can fix them** This is the one thing blocking your re-gate. `scaffold_check` is **RED** until you re-pin, and I can't touch your files. Exact values (verified byte-identical across two fresh processes): | your file | constant | old | **new** | |---|---|---|---| | `tools/flags_check.py:30` | `GOLDEN_HASH` | `0x3fa36874` | **`0x5f76e76`** | | `tools/flags_check.py:31` | `GIG_GOLDEN` | `0xb1d48ea1` | **`0xec7a2d39`** | | `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.melbourne` | `0x34cfdec0` | **`0x9c7e76b3`** | | `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.katoomba` | `0x0f652510` | **`0xf3aafec8`** | | `tools/qa/scaffold_check.mjs:225` | the comment quoting all three | — | same three | | `tools/qa/tour_v5.py:42`, `tour_shots.py:10` | captions quoting `0x3fa36874` | — | **`0x5f76e76`** | Also: `flags_check.py:148` greps stdout for the literal `'3fa36874'` — that will silently stop matching. ### The finding: the ruling's premise was wrong — **synthetic was broken too** The wave-5 ruling says *"synthetic follows B's canon so every golden has been green over a wrong product since R18"*. Half right, and the wrong half is load-bearing. **Synthetic does not follow B's canon** — it is broken identically, and so is the marched path, and so are the three checked-in fixtures. It is not "every real town since R18": it is **every town, every path, since v1**. Measured in B's canon before touching anything, then confirmed with my own eyes in the browser: | path | before | after | |---|---|---| | synthetic (`plan.js`) — **the classic covenant** | **0 toward / 681 away** | 681 / 0 | | marched-osm + the 3 fixtures | 0 / 80, 0 / 95, 0 / 19, 0 / 12 | all toward | | real-roads (B's finding) | 0 / 72 katoomba | **72 / 72** — matches B exactly | **The screenshot is the proof:** standing on the synthetic main street at `?classic=1`, lot 0 — three blank grey boxes. Walk *behind* them, into the block, and there is NUMBAT PLAYTHINGS: sign, door, windows, awning, facing the dirt. Same camera after the fix: CROWN TOYS & GAMES · MARBLES & KITES · NUMBAT PLAYTHINGS, a strip of shopfronts facing the road. **637 of 681 synthetic lots had their facade pointing at no street at all.** **Why it survived**: all four sites are the same one-sign error — `atan2(outward)` where the intent was `atan2(−outward)` — and *every one of them says so in its own comment* (`plan.js:122` "facade faces back down the outward normal, to the street"). The code never did what its comment said, and CITY_SPEC:71 blessed the result. Nobody was wrong; the spec was. ### The covenant moved — John ruled it Fixing synthetic necessarily moves `0x3fa36874`, frozen "forever" since R16. That was not mine to decide. **John's ruling: amend the covenant, fix all paths** — the covenant is anti-drift machinery, not a defect preservative. **51 goldens re-pinned in one commit** (classic `0x3fa36874 → 0x5f76e76`, gig `0xb1d48ea1 → 0xec7a2d39`, 3 fixtures, 23 towns × base+gig). The amendment is recorded in CITY_SPEC as a one-time, ruling-gated precedent — from this hash forward the law reads as it always did. ### A second bug the fix exposed (fixed here, counted) `buildRealRoads` seated blocks at `KERB = 4 m` **from the centreline** — but a `main` carriageway is 10 m wide, so its kerb is at 5 m. **Every main-street building has stood 1 m inside the road since R18.** The footprint never moved; only which face is the front — so it was invisible until the shopfront turned around and its poster landed in the traffic lane (−1.07 m). Blocks now seat behind the kerb per A's own corridor law (`roadWidth/2 + FOOTPATH`). Pack drops **6.4% → 6.6%**, counted, not hidden. ### The cross-convention gate — built to F's lesson, not to a spec line F proved that measuring lots against CITY_SPEC:71's own convention is **self-agreement**: 72/72 "facing" over a town of blank walls. So the new gate does not restate a spec line. It **replicates Lane B's own placement maths** from `buildings.js` (`toWorld(lot, 0, y, d/2 + 0.02)`) and asks a physical question neither spec line can fudge: *does the quad the renderer actually draws land nearer the street than the lot centre?* It fires if A's `ry` sign drifts **or** if B's `zf` sign drifts. It would have caught this on day one of v1. ## Round 27 (2026-07-17) — v5.0: the docs freeze — CITY_SPEC v5 + the risk list judged (ledger #6) ### → F + Fable: **A's docs half is done and the tree is green; nothing of mine gates your tag** selfcheck **ALL GREEN 156,212/156,212**, classic `0x3fa36874` / gig `0xb1d48ea1` frozen, **no golden moved** (none should — tier 2 touches no plan, no cache, no atlas bytes). CITY_SPEC has its **v5 section** (the tier ladder · `sourcing` vs `tier` · the three id-space fences · the manifest · Ruling 2 · A's one seam), and the charter risk list is judged line by line in `V5_REAL_SHOP.md`. ### The risk list: I did NOT pre-retire your gate Written in wave 2, so **risk #1 (latency/stutter at tier 2) is CARRIED, not retired** — your reader and kill-the-server gate don't exist yet, and **nothing retires that line except the gate going green**. Retiring it from a doc while the code is unwritten would be the exact species this epoch spent four holds catching: a rule meeting a case that doesn't exist yet. #2 retired (measured), #3 retired (designed out), #4 **split** — write-back retired by John's ruling-as-mechanism, the `gone` read carried to your #4. Two new carried lines (#6 orphaned atlas, #7 "real" is one shop wide). ### The gate that armed itself — worth seeing, since it's the law working unattended My R25 orphaned-atlas gate **SKIPped** on `newtown_godverse` all through R25 ("no committed atlas keys to this town" — subject absent, so it said so instead of passing). The moment G's 14 mint atlases landed, it **started binding on its own** and passed: selfcheck went 156,211 → **156,212**, and that +1 is the gate arming itself. Nobody edited it. That's what the vacuous-gate law buys — a gate that waits, in the open, for its subject. **Live near-miss on the books → G:** `redhill_godverse`'s lift drops keyed shop **2286 "Empire Revival"**; you stocked its numeric neighbour **2287**. Nothing is orphaned and the gate is green — but **stocking 2286 would orphan it today**, and the hazard grows with every shop stocked. Carried as risk #6. ### One correction to the round doc's framing, for the record The brief lists "the manifest" and "sourcing" as if `tier` were superseded. The artifacts say otherwise, and **C's ruling is the precise one**: every atlas index carries **both** — `tier: 1` (the ladder rung; a mint atlas is *still* a static file, so still rung 1) **and** `sourcing: "real"|"mint"` (where the contents came from). They are different axes. The spec documents both, because collapsing them is exactly how a mint crate would come to read as real. ## Round 25 (2026-07-17) — v5.0-alpha: the sweep reconciled with Ruling 2, six goldens pinned (ledger #1) ### → F: **the tree is green — all 8 reds are gone, nothing of mine is pending** (the handshake) selfcheck **ALL GREEN 156,211/156,211**; scaffold + consistency green; classic `0x3fa36874` and gig `0xb1d48ea1` frozen. The six goldens are pinned: `newtown_godverse` base `0xcf69b387` / gig `0x1e266f60`, `redhill_real` base `0x6046700f` / gig `0x673785ee`, `redhill_godverse` base `0xb2224939` / gig `0xc6f80e18`. Cross-checked two ways before pinning — **byte-identical across two fresh processes**, and the three values G stated independently in `86e2985` match mine exactly. **Your third attempt is unblocked from my side.** Two lines still print and **both are deliberate, neither is pending**: - `⊘ SKIP real/newtown_godverse: orphaned-atlas check` — correct: the only committed atlas (3962749) keys to Red Hill, so newtown has no subject. The vacuous-gate law says say so rather than pass quietly. - `⚠ real/redhill_godverse: 1 keyed shop(s) dropped by the lift — 2286 "Empire Revival"` — an honest disclosure, not a defect (see below). ### My R24 gate asserted Ruling 2 away — that's the whole of my 6 reds, and it was my error I built the identity gate on an assumption that was true when I wrote it and false the moment G shipped: that a godverse cache is *all* census shops. Ruling 2 makes it **mixed** — a keyed GODVERSE layer plus an inherited texture layer — so `newtown_godverse` (18 keyed + 54 texture) and `redhill_godverse` (10 + 27) are **healthy towns my gate called broken**. Every keyed assert now runs **per-layer, over the keyed subset only**; unkeyed texture shops raise nothing. The validator's "N/M missing" warn is gone with it — the only honest requirement left at that layer is that a `godverse+osm` cache have a **non-empty** godverse layer at all (else it's an osm cache wearing the wrong `source`), which stays a warn for the R24 charter reasons. ### The uniqueness check was broken in both directions — rebuilt so it can actually fail The R24 version was `new Set(all ids).size === shops.length`. Every `undefined` collapses into **one** Set entry, so it (a) failed structurally on any mixed cache — 54 texture shops became one entry — and (b) could **hide a real duplicate behind that same collapse**. It now runs over **defined ids only**. And per the vacuous-gate law I made it prove it bites: `validateTownCache` rejects duplicates at the front door, so a dup can never reach the plan that way — the test feeds one **straight to the lift** (`generatePlanOSM` does not validate) and asserts the predicate catches it, plus a healthy mixed cache passes the same predicate. A check nobody can watch fail is not a check. ### New — the orphaned-atlas gate (my R24 finding, now enforced instead of just filed) The lift legitimately drops shops (overlap-resolve, counted since R19). Dropping a shop that **has a committed atlas** means real stock keyed to a shop that isn't in the town — exactly the failure I filed in R24, and one F's #7b would only catch at the *end* of a round, in a browser. It's now a node gate: for each godverse town, every `stock_godverse//` on disk whose id belongs to that cache **must be seated**, else FAIL. Subject-aware — SKIPs by name when a town has no atlas. **It already found something real, benign today:** redhill's lift drops keyed shop **2286 "Empire Revival"** (10 keyed in cache → 9 seated). It has no atlas, so nothing is orphaned and the gate passes. **→ G: if you stock Empire Revival at the beta, it will orphan** — the shop isn't on the plan. Monster Robot Party is safely seated (lot 8), which is the one that matters this round. ## Round 24 (2026-07-17) — v5.0-alpha: the identity field (ledger #2) ### → G: **`godverseShopId` is live — emit it, and mind the id-space trap** (the handshake for your #5) The field is published and the lift carries it: put `godverseShopId` on shop entries in a `source: "godverse+osm"` cache and it lands on `plan.shops[]`, which is what F's per-shop `base` reads. Contract in `web/assets/towns/README.md`; `validateTownCache` enforces it; selfcheck gates it. **The round doc says "the POS shop id IS the id — no mapping table". That's true for census shops and false for the shop this whole alpha is about** — and your own docstring is what told me: | shop | id source | equals `shop.id`? | |---|---|---| | census shops (`newtown_godverse`) | thriftgod `shop.id` (max 2992) | yes, incidentally | | **Monster Robot Party** | **dealgod store 3962749** — not in thriftgod's census | **no** | So I built it as an **opaque key**, not a census id: no equality assert, no derivation from `id`, no mapping table. Both spaces ride it, and your disjointness (2992 vs 3962749) is what makes that safe. **Uniqueness is an error**, because two shops keyed to one atlas is mis-stocking (charter risk #3). **⚠ The thing that will bite you in wave 2 — `redhill_godverse` cannot contain Monster Robot from either source. Measured, not inferred.** `godverse_town.py` takes **roads from the OSM donor** and **shops from thriftgod's census**. The shop is in neither: - **Not in the census** — your own R23 finding (that's *why* its id is a dealgod store id). - **Not in E's `redhill_real` donor** — I checked the cache the moment it landed: **36 shops, zero of type `record`, nothing matching /monster|robot/**. OSM's Red Hill doesn't know the shop exists. So the adapter as written produces a Red Hill of census charity shops, and the atlas at `stock_godverse/3962749/` keys to a shop that **isn't in the town** — F's #7b id-equality assertion would fail, correctly, at the end of the round. **Inject it explicitly**: name, `type: "record"`, its real lat/lon (147 Musgrave Rd), `godverseShopId: 3962749`. The schema is ready for it — that's exactly the case the opaque-key design exists to serve. **Second, cheaper trap on the same path:** `redhill_godverse` still has to clear **`MIN_TOWN_SHOPS` (6)** from the census bbox alone. Newtown yielded 18; Red Hill is a Brisbane suburb and thriftgod's census is thin there. If the bbox returns < 6, the cache is **rejected outright** (hard error, not a warn) and the town won't exist. I can't measure that from here — no census DB on m3 — but you'll see it instantly. If it bites, the count includes any shop you inject. **E's donor is otherwise good for you:** valid, no warnings, **73.3 m median spacing** — half the warn floor, a proper high street to hang the shop on. ### → F: the seam you need for #6a `plan.shops[].godverseShopId` is the per-shop `base` key — read it off the **plan**, not the cache. It's absent (not `undefined`) on every non-godverse shop, so `'godverseShopId' in shop` is a clean tier test: present ⇒ tier 1 candidate, absent ⇒ tier 0 parody. That absence is also why **no golden moved this round**. ### → Fable: one recorded departure from the ledger, and why Ledger #2 says `validateTownCache` **requires** the field on godverse shops. I implemented **missing → warn** (malformed and duplicate → hard errors). Three reasons, and it's yours to overrule: 1. **The charter's determinism boundary** — *world = seeded, frozen, gated; stock = data tiers*. An error inverts it: the **town** would fail to load because its **stock** identity is absent. Tier 0 must boot. 2. **Charter risk #3 names the remedy** — identity gaps "fail-soft to tier 0, never mis-stocked". Missing *is* the soft case; duplicate is the hard one. They're different failures and now get different arms. 3. **Sequencing** — `newtown_godverse` carries 0 ids today, it's G's to re-emit in wave 2, and I can't do it here (their namespace; no census DB on m3). A hard error would red the tree for C and E through all of wave 1 to say what the warn already says. The requirement still has teeth, in the place R23 taught us to put them: selfcheck **fails on partial keying** and prints an explicit **SKIP with the count** at zero — it cannot pass vacuously. ### Also: E's toowoomba retirement showed my spacing metric is gameable (worth knowing) E tried the re-bbox before retiring and found it **fixed my metric while making the town worse** — median NN 395.7 → 89.4 m ("passes") but hub density 3/12 → 2/12, the pack's worst. My warn is **necessary, not sufficient**: it catches scatter, and it can be satisfied by tightening the box around a scatter. D's hub test is the complement, and E used both. Retired pins removed from both golden maps (a golden for a deleted town can never fire — the vacuous-gate law one layer down). **Ballarat (255 m) is still flagged and still E's call this round; if it moves, I pin it.** ## Round 23 (2026-07-16) — v5.0-alpha: the spacing warn (D's thin-tail finding as law) ### → E: **the warn is live — and it flags TWO towns, not one** (the handshake for your #4) `validateTownCache` now warns when **`medianShopSpacing(cache) > 150 m`**. Import the metric from the barrel (`medianShopSpacing`, `MAX_MEDIAN_SPACING_M`) rather than re-deriving it — and if you re-implement in Python, the definition is: *per shop, distance to the nearest OTHER shop; take the median*, in equirectangular metres about `center.lat`. Warn, never error: **you curate, I only flag.** **Use the warn as your acceptance test.** Re-bbox toowoomba, re-run the validator; when the warn goes silent, the town has a high street. That's the whole loop, no judgement call needed. | town | median spacing | verdict | |---|---|---| | `toowoomba_real` | **396 m** | your ledger #4 — D proved it dead (1,417 patronage checks → 0 visits) | | `ballarat_real` | **255 m** | **nobody has looked at this one.** Same class, 1.7× inside the line | | braddon (worst passing) | 119 m | alive by D's hub test — 7 shops within 160 m, same as darwin | **Ballarat is the finding beyond the brief.** The round doc names only toowoomba, because D only spot-checked the two 12-shop towns. Measuring all 23 says ballarat is the same shape — and it's one of the three towns my R22 graded cluster fallback fired on (no venue candidate at the ≥3-shops/160 m floor), so two independent signals agree. Your call whether it re-bboxes or ships as a known thin-tail; counted either way. **If your fix moves toowoomba's (or ballarat's) cache, ping me — the goldens are mine to re-pin, and only what you move.** ### ⚠ The number in my validator will NOT match the number in D's notes — both are right D's notes say darwin **27 m**; my validator says darwin **68.3 m**. Neither is wrong: **D measured PLAN space** (post-lift metres), **the validator measures CACHE space** (raw lat/lon, before any lift exists — it has to, that's the layer it runs at). The lift marches shops at a regularised cadence, so it *compresses* dense towns and leaves sparse ones alone: | town | cache | plan | plan/cache | | |---|---|---|---|---| | darwin | 68.3 | 27.2 | **0.40** | dense ⇒ the lift compresses hard | | toowoomba | 396.1 | 386.8 | **0.98** | sparse ⇒ the lift barely moves it | | ballarat | 254.9 | 257.7 | **1.01** | sparse ⇒ ditto | That asymmetry is *why cache space is safe here*: the error is concentrated at the dense end, and the warn only ever fires at the sparse end, where the two spaces agree to within 2%. I verified all 23 towns in both spaces — **exactly one disagrees.** ### → G + Fable: `newtown_godverse` is the one town that flips (filed, not acted on) Godverse is the single cache where the two spaces straddle the line: **94.4 m cache (passes) → 158.3 m plan (would warn)**. My warn does **not** flag it, and I think that's correct on both counts: (a) no cache-space threshold can catch it without also flagging braddon, which D's own test says is alive; (b) godverse is a **census subset** — 18 thriftgod shops over the same Newtown footprint that carries 72 OSM shops — so its sparsity is *coverage*, not a bad bbox, and re-bboxing (the warn's whole remedy) cannot fix it. **But it's thin by two independent signals** — plan-space 158 m, and it's the third town my R22 cluster fallback fired on. Since R23 stocks a real record shop *in this town*, and the beta asks "fitzroy_godverse if the census is dense there", that's worth knowing now rather than at beta. Not my call — filing it. ## Round 22 (2026-07-16) — v4.0: the pack absorbed, 36 goldens pinned (the epoch close) ### → C + D + F: **the pack is in — 23 towns, all pinned, start your verifies** (the handshake) E's 17 new towns + Lane G's `newtown_godverse` fed through the lift. **All 36 waiting goldens pinned in this one commit** (`REAL_TOWN_GOLDENS` + `REAL_TOWN_GIG_GOLDENS` now hold **23 towns each**, zero unpinned). selfcheck **ALL GREEN 161,300/161,300**; scaffold + consistency GREEN; cross-process deterministic (pack-xor `0xca140954`). classic `0x3fa36874` / gig `0xb1d48ea1` / marched fixtures **frozen**. ### The pack absorbed — everything built this epoch held at 23-town scale, first try | | | |---|---| | towns | **23** (22 real + `newtown_godverse`) | | shops | 1,210 → **1,133 seated** (77 dropped, **6.4%**, all counted by cause) | | graph | **1 connected component on every town** (junction protection + cull/bridge) | | district | **3 venues on every town** (cluster bias + facade clearance) | **fitzroy (160 shops — 2× anything absorbed before)**: 139 seated, 1 component, 3 venues, 212 posters, all invariants green. The junction pipeline, fragmentation ruling, cluster bias, poster cap and capacity fence all ran at mecca scale with **no new hardening needed** — the pack needed absorbing, not fixing. ### One refinement this round: a graded cluster fallback for genuinely thin towns At pack scale three towns have **no candidate at the floor at all** — `ballarat`, `toowoomba`, `newtown_godverse` top out at **2** shops within 160 m. R21's bias switched off entirely there, stranding venues at 0. It now falls back to the **densest available**, so the venue still lands on what little crowd the town has: ballarat **0,2,0 → 2,2,2**, godverse **0,1,1 → 2,2,2**, toowoomba **1,0,0 → 1,2,1**. It only fires where the floor is unreachable ⇒ **no already-pinned town moved** (verified). **Acceptance (R21's, at 23-town scale): 20 of 23 towns have every venue ≥3 shops/160 m.** The three above sit at their town's genuine ceiling — the "no better candidate exists" case the acceptance carved out. **Saying so explicitly, as asked.** ### Drop outliers (counted, not hidden) `daylesford` **19%** (6/32, all overflow — 32 shops on a 171-road graph, the thinnest graph in the pack) and `fitzroy` **13%** (21/160, 17 overflow). Both are the R20 capacity fence doing its job: `RGAP` is already 0.5 m and `NODE_CLEAR` 3 m, so the only lever left is spilling overflow onto a *different* street — which buys a few percent by putting a shop on a road it isn't on. Same call as R20: **drop and count beats teleport**. Everything ≤6.4% pack-wide. ### → B / F (the tri diet, ledger #1): my side of the diagnosis My poster cap scales off **shop count**, so it auto-tracked the widening rather than fighting it (fitzroy 160 shops → 212 posters; katoomba 80 → 111). If B's layer-by-layer names **plan geometry** (lot or poster counts) as the `venue_night` driver, I own the fix and will take it. My read from the plan side: the growth is *more shops ⇒ more night building geometry at the venue block* — E's density landing as B's render cost — so I'd expect the driver to be buildings/LOD rather than posters. B's numbers decide it, not my expectation. **CLOSED — B's diagnosis landed (`0c4dad5`): the fix is not A's.** The driver is E's four furniture GLBs at **149,161 of 163,015 tris (91.5%)** ⇒ **asset tris ⇒ Lane E owns it** by the ledger's own rule. Plan geometry is nowhere near the bill: **posters 222 tris** (B: the cap "already worked and was never the lever"), **buildings 348** (the R3 atlas/instancing holding), venue pools 0. **A is clear on ledger #1 — F, don't wait on me for the re-measure.** Worth recording that B's numbers corrected my expectation too: I guessed buildings/LOD, and B measured it at 0.2%. B also found the real coupling — **shop count doesn't drive this, ROAD density does** (the bench/bin/shelter cadence runs on a 966-edge graph), which means my absorb of E's widening was never the cause either. The brief is a hypothesis; the measurement wins — mine included. ## Round 21 (2026-07-16) — the venue cluster bias (D's relocation finding) + goldens re-pinned ### → C + D: **cluster bias landed, goldens re-pinned — start your verifies** (the handshake) **Only the GIG goldens moved** (venue selection is gig-layer; the base town goldens are untouched): katoomba `0xbf586b77`, newtown `0xacdb3eee`, fremantle `0xcf3426fc`, bendigo `0x646b2c23`, castlemaine `0xf9713a69`. Base unchanged (katoomba `0x420f677d`, …). selfcheck **ALL GREEN 66419/66419**; classic `0x3fa36874` / gig `0xb1d48ea1` / marched fixtures **frozen**. ### → D: your relocation finding is resolved — and it was systemic You found katoomba's pub at 1 shop within 160 m. Measuring all five, **11 of 15 venues** sat under the threshold (newtown's were 0/0/1 — every venue stranded). After the bias, **0 of 15**: | town | before (shops within 160 m) | after | |---|---|---| | katoomba | pub **1**, rsl 10, band_room 7 | band_room 10, rsl 12, **pub 9** | | newtown | band_room 0, pub 0, rsl 1 | band_room 7, rsl 11, pub 5 | | fremantle | pub 1, rsl 2, band_room 2 | band_room 8, pub 3, rsl 3 | | bendigo | pub 1, rsl 0, band_room 0 | rsl 10, band_room 10, pub 5 | | castlemaine | band_room 6, rsl 4, pub 1 | pub 5, band_room 6, rsl 4 | Castlemaine cleared without needing the floor exception Fable flagged. Your venue-win % and the "gig with no passing crowd" observation should both improve — the pub is now in the retail heart on every town. ### How it works (and why synthetic is frozen BY CONSTRUCTION, not by luck) `pickVenues` builds a **cluster-adjacent field** first (candidates with ≥ `CLUSTER_MIN` 3 shops within `CLUSTER_R` 160 m), then the kind flavors (pub corner / band_room fringe / rsl off-spine) bias **within** that field rather than overriding it — a fringe band_room in the cluster beats a fringe band_room a kilometre from anyone. A town with no cluster falls back to the whole pool. **Gated on `plan.source === 'osm'`** — real-data towns only. Fable's brief assumed a blanket filter would be a no-op because "the synthetic town is one dense cluster anyway". **It isn't:** I measured **4 synthetic candidates per seed** sitting under the threshold (min cluster 0–1), so a blanket filter *would* have moved the frozen synthetic golden. The `source` gate makes it impossible instead of unlikely. The marched fixtures are 'osm' too but genuinely dense (min cluster 3/11/13) ⇒ verified no-op. ### FYI: E's v4.0 town pack is already booting clean `glebe_real`, `marrickville_real`, `newcastle_real` landed and my loader auto-validates + boots them through the full structural + gig suites — **all pass**, listed UNPINNED (a non-fatal warn). That's per the brief: **I pin the pack in R22**, and it must not gate the beta tag. Early signal: the pack needs no new hardening. ## Round 20 (2026-07-16) — v4.0-beta: the poster cap (density absorb pending E's widened caches) ### → F: **poster cap landed — re-measure `venue_night`** (your +965-tri edge should close) My R19 finding (438 posters on katoomba) is fixed. `gigs.js` now **caps the spine pole run at `POSTER_PER_SHOP × shops` (1.5/shop, floor 12)** — a seeded subsample. Real katoomba **438 → 31 posters** (~2× the synthetic feel), fremantle 32, castlemaine 14. Scaled by **shop count, not edge count**, so the `venue_night` stress view (was 200,965 tris, +0.5% over 200k) should drop well under. Re-measure and close it. ### Why no base-golden re-pin (and what I DID pin) Posters live in the **gig layer** (`withGigs`), not the base `plan_osm` output — so the pinned real-town **base** goldens are unchanged by the cap (correctly green). To regression-guard the capped output byte-exact (not just via `districtInvariants`), I added a **`REAL_TOWN_GIG_GOLDENS`** map (the full gig plan per town: district + capped posters) — katoomba `0xbd332e83`, etc. Synthetic gig golden `0xb1d48ea1` **frozen** — the cap never binds on the shop-dense synthetic town (493 shops ⇒ cap ~740 ≫ its ~15 posters). ### → all: **densities absorbed, goldens pinned** (ledger #2 — E's widened caches landed) E's five caches at **3–6× shops** (katoomba 20→80, fremantle 21→80, newtown 18→72, bendigo 9→36, castlemaine 6→24 = 292 total) are absorbed. **All five place 3 venues**; the poster cap auto-scaled off the live shop count exactly as designed (katoomba 111 posters at 80 shops). selfcheck **ALL GREEN 51866/51866**. - **Capacity widened** (the R18 overflow fence, load-tested). At 4× the dominant drop cause was **overflow** (an edge-side holds more shops than fit) — katoomba 10 of 11 drops. Tightened the run: `NODE_CLEAR` 6→3 and a **roads-only `RGAP` 2→0.5** (the shared marched `GAP` is untouched ⇒ marched fixtures stay frozen). Drops **7.9% → 5.5%** (16/292). 0.5 m also reads truer — a real AU main street is continuous terrace shopfronts. Residual drops stay **counted, now by cause** (`dropped_overflow` / `_overlap` / `_stranded`). I stopped there deliberately: the next lever is spilling overflow onto a *different* street, which buys ~1% at the cost of putting a shop on a road it isn't on. Dropping a crowded shop is the honest trade. - **Venue facade bias now covers `pub`.** R16 exempted pub ("spine-fronted, +Z always open") — true on synthetic/marched, but on a REAL graph at density the pub landed where its frontage poster overhung a cross street (newtown, −0.91 m). The `POSTER_CLEAR` filter now covers every venue kind; still a **no-op on synthetic** (every pub candidate already clears) so the gig golden `0xb1d48ea1` is frozen. - **Goldens re-pinned once** (base + gig, all five): katoomba `0x420f677d` / `0x2115732a`, newtown `0x741c18ec` / `0x17ab31bb`, fremantle `0x73d385df` / `0x82c27397`, bendigo `0xc175194e` / `0xf5ce58c4`, castlemaine `0x26c128c9` / `0x48272da9`. classic/gig/marched fixtures **frozen**. - **Fixed:** my real-cache loader globbed `*.json` and tried to validate E's new `index.json` as a town cache. It now skips it (it's B's selector index, not a cache). ## Round 19 (2026-07-16) — the alpha close: fragmentation resolved + findings hardened + goldens pinned ### → B / C / D / F: **`katoomba_real` boots on real roads, golden pinned, findings resolved** (the handshake) The A→E→A finalization landed. `katoomba_real` golden = **`0xb7ffbca2`** (pinned); the other four real towns pinned too (newtown `0x6f984c81`, fremantle `0xfb197000`, bendigo `0x21179b3b`, castlemaine `0x7c47f639`). selfcheck **ALL GREEN 51670/51670**; classic/gig/marched-fixtures frozen (`0x3fa36874` / `0xb1d48ea1` / …). ### The fragmentation fix (D's finding — Fable's ruling, my details) **katoomba: 105 components → 1; 58% → 100% of street-metres in the main net.** Two mechanisms: 1. **Junction-protected Douglas–Peucker** (the root cause). A point shared by ≥2 ways is a junction; simplify only *between* junctions, never through one — the collinear junction-drop where a side street tees into a straight main road. This alone took every town to 94–100% main-net. 2. **Cull/bridge** (`JOIN_TOL` 12 m, shop-island bridge ≤ 200 m): main component + joined near-fragments; far **shopless** islands culled (bbox-clipped noise + dead streaming budget = half of B's tris problem). Per-town: katoomba 2 islands culled/0 bridges, newtown 3/4, fremantle 4/0, bendigo 3/1, castlemaine 0/1. **Every dropped shop is counted** in `norm.dropped` (ruling): katoomba kept 19/20 (1 overflow/overlap crowd drop — the R18 overflow prediction, now measured + counted), the rest kept all. ### → D: your re-measure inputs (post-fix) Component count → **1** (all towns). Street-metres in main net → **100%**. Stranded shops → **0** (all connected or counted-dropped). Near-crowd on fragments should collapse to ~0 (one component). The crafted irregular-town scaffold still runs; the real katoomba golden is pinned so your reruns are stable. ### → F: E's poster finding resolved Spine posters that overhang a **real crossing carriageway** are now skipped (measured exactly as the selfcheck kerb floor). No-op on synthetic (gig golden frozen); the 23 real-town clearance failures → 0. ### New finding (v4.0-beta, non-blocking): spine-poster density scales with edges Real katoomba emits **438 posters** (2 per main edge × a dense real graph) vs ~14 synthetic. Draws stay low (instanced per gigId, B measured ~1 draw), and every poster clears its kerb — but 438 pole-posters town-wide reads busy. **Beta candidate:** cap posters-per-town (seeded subsample of the spine run). Not fixed this round — invariants hold, it's density polish. ## Round 18 (2026-07-16) — v4.0-alpha REAL ROADS: cache schema v2 (roads[]) + the real-graph lift ### → E: **schema v2 is live (committed first) — fetch roads to it** A v2 cache adds `roads: [ { kind, pts:[[lat,lon],…], id?, name? } ]` (`kind` = OSM `highway=*`). Full contract in `web/assets/towns/README.md`; `validateTownCache` enforces it (schema `…/1` still valid → marched). **`katoomba_real` first** (the alpha gate). Bound the `highway=*` query to the same per-town bbox as the shops. Snap tolerance is 3 m, so ship intersections as **shared coordinates** where ways meet (OSM already does) — that's how the lift finds real intersections. Drop each cache in; the selfcheck auto-builds + validates the real graph and prints the golden. Ping me → I pin `katoomba_real`'s real-roads golden. ### The graph lift (`plan_osm.buildRealRoads`) — implemented + proven on a synthetic irregular graph Real OSM ways → the CityPlan street graph, **same schema out** (B/C/D/F unchanged by contract). Pipeline: project → simplify (Douglas–Peucker ε≈6 m) → snap coincident points (≤3 m) to shared nodes = real intersections → straight-segment edges → classify `main` by OSM class + shop density → seat each shop on its **nearest real edge** (real order, real side), marched, overlap-resolved. Passes the **full structuralSuite + gig district + determinism** on a synthetic town with a main street, two cross streets (a real intersection + an acute angle), a dead-end lane and a curve. `roads` absent ⇒ the v1 marched fallback — **all osm goldens frozen** (melbourne/katoomba/silverton), classic/gig frozen. NOT YET run against real katoomba geometry — that's the A→E→A finalization (E fetches, I pin + harden). ### Fidelity decision (charter risk #4 — A owns the knob) + the alpha failure list I chose **real edge + real order + real side, regularised spacing** — NOT pixel-exact shop positions, because real shops overlap and the no-overlap invariant is load-bearing (venues, chunk streaming, sim all assume it). Written down for the alpha (fixed or fenced): 1. **Overflow drop.** A short real edge can't seat all its shops (marched at footprint+gap); overflow shops drop (real order kept). `norm.dropped` counts them. Katoomba (20 shops, real street lengths) should keep all — verify when E's roads land. *Fence if it bites: widen edge capacity or split long shop runs.* 2. **Intersection topology = shared coordinates only.** Two ways that cross WITHOUT a shared OSM node become topologically-disconnected edges (visually crossing, not graph-connected). **→ D:** the sim graph may not route across such crossings; if Katoomba has any, that's the risk to measure. E ships shared coords at junctions (OSM convention) so most are fine. 3. **Blocks are per-edge frontage strips**, not planar faces. **→ B:** your ground/pavement render reads these polys; a per-edge strip may look thin/overlapping at a tight corner — audit + file with a measurement (it's my lot geometry to fix, not yours). 4. **Overlap-resolve drops crowded-intersection lots** (deterministic, by shop id). Rare; counted in `norm.dropped`. ### → B/C/D: handshake state `katoomba_real` still MARCHES today (it's v1 — E hasn't fetched roads yet), so I can't yet say "boots with real roads, golden pinned." I'll fire that handshake the moment E's katoomba roads land and I pin the golden. Per Fable's note, your audit prep (scaffolding, assertion lists) isn't blocked on me — the graph lift + the failure list above tell you exactly what irregular geometry to point your audits at. ## Round 17 (2026-07-16) — the town-cache contract (published FIRST) + plan_osm hardened for real data ### → E: **the town-cache contract is live — build `build_towns.py` to it** (half-day handshake) Your pipeline outputs one JSON per real town in the processed shape; `plan_osm` marches it like the fixtures. **Home: `web/assets/towns/.json`** (full spec in that dir's `README.md`). Shape: ``` { schema:"procity-town-cache/1", key, town, source:"osm", license, attribution, center:{lat,lon}, shops:[ {id, name, type, lat, lon, suburb?} ] } // + optional bbox/counts/fetchedAt ``` - **Authoritative validator:** `validateTownCache(cache)` in `plan_osm.js` — `{ok, errors[], warnings[]}`. Import it; don't hand-roll the check. **Hard errors:** finite `center.{lat,lon}`, `shops` ≥ **6** (`MIN_TOWN_SHOPS`), finite shop `lat`/`lon`. **Warnings (absorbed):** blank name → type label, unknown `type` → `opshop`, dup ids, **span > 5 km**. - `type` should be a registry `SHOP_TYPE`; map OSM `shop=*` tags to those (unknowns → `opshop`). - **Katoomba-real:** use a DISTINCT key (`katoomba_real`, not `katoomba`) so it sits alongside the hand-made fixture for the comparison — `osmTownKeys()` dedupes but same-key would shadow the fixture. - Drop each cache in and the selfcheck auto-validates + boots + prints its golden to pin; ping me (E→A handshake) and I'll pin `REAL_TOWN_GOLDENS` + it enters the sweep. - **ODbL:** ship `SOURCES.md` + the `license`/`attribution` fields; cache raw Overpass so re-runs don't refetch. ### plan_osm hardening — verified against real data (2,828 real Canberra shops) I ran thriftgod's real `city_source.json` through the hardened `plan_osm`: **it boots a valid plan** (0 bad-coord lots, 0 "undefined" names). The marching regularises messy real coords, so dup/odd positions are a non-issue. Added: blank-name → registry-label default; the contract validator gates the rest. ### → Fable / v4 charter — the ugly-real-geometry RISK LIST (the scout's real finding) 1. **The mega-strip (the big one).** An unbounded Overpass bbox = a whole region. All 2,828 Canberra shops marched into an **11.6 km** single strip (350 shops/avenue) — a valid plan, an unusable "town". **Mitigation shipped:** `validateTownCache` warns at span > 5 km; the contract says a cache is ONE compact town. **v4 risk:** town SELECTION (bbox per town) is E's pipeline's job and the real quality lever — the generator can't rescue an over-broad query. Charter needs a per-town bbox/centre discipline. 2. **Sparse compact towns.** A tight 2 km radius of inner Canberra held only 6 secondhand shops — real small towns can fall below a usable density. `MIN_TOWN_SHOPS` is 6 (the functional floor for the district); E should curate towns with real shopping density (Katoomba 19, Melbourne 95 clear it easily). 3. **Not a risk (settled):** osm facades face `−Z`/south with rows a fixed 54 m apart, so the R16 `+Z` clearance bias is a no-op on osm towns (facades always clear) — real towns won't trip the frontage floor. ### Scope held: no new plan features, no golden moved Classic `0x3fa36874`, gig `0xb1d48ea1`, all three osm fixtures frozen. selfcheck **15277/15277** (added the contract stress suite + the ready-and-empty real-cache path). The scout proved the *existing* contract eats real data — nothing more, per the fence. ## Round 16 (2026-07-16) — corner-poster fix via pickVenues clearance bias (ledger #6) + no-spine osm fixture (#7) ### → B + F: **new gig golden `0x4f4a549d → 0xb1d48ea1`** (classic/synthetic `0x3fa36874` unchanged) The round's one sanctioned move. **F**: the default-boot gate pins to `0xb1d48ea1` (not the R15 value the brief cited). **B**: re-shoot against this. Amendment law; §v3 marker records it. ### The corner-poster fix (NOT a nudge — Fable's brief premise was wrong) The R15 finding was that a corner venue's frontage poster sits near a crossing kerb. The brief proposed nudging the poster ALONG the facade away from the crossing-street *end* — but I proved that's geometrically impossible: the crossing edge is **parallel** to the facade and spans it (0/187 along-facade slides recover any clearance). Root cause: band_room/rsl land on **through-lots on narrow warehouse-fringe blocks**, so the `+Z` wall (B's door side, where the poster seats) faces the block's *other* street, overlapping its carriageway by up to ~3 m. **Fable's ruling (asked mid-round): bias `pickVenues`.** So: - `gigs.js pickVenues` now prefers a band_room/rsl candidate whose `+Z` facade clears every carriageway by **≥ `POSTER_CLEAR` (0.5 m)**; if the warehouse fringe is uniformly narrow it broadens to any off-spine clear lot before falling back. Proven: **0 of 851** frontage posters across 400 seeds now sit below the floor (worst clearance 0.51 m). Pub is untouched (spine-fronted, `+Z` open — the hero shot doesn't move). - The R15 blanket frontage *exemption* in the selfcheck is now a real **floor**: frontage posters must clear their kerb by ≥ `POSTER_CLEAR`; spine posters keep the ≥ 0 no-bitumen floor. `POSTER_CLEAR` is exported from the barrel — import it, don't re-guess. (`−0.02` in the assert absorbs r2 poster-coord rounding.) ### The no-spine osm fixture (ledger #7): `silverton` `osm_fixture.js` gains **`silverton`** — a *constructed* single-row town (all shops at one latitude ⇒ plan_osm bins them into one avenue ⇒ **0 `main` edges**). Closes the R14 sweep gap (both real fixtures had a spine): it proves `pickVenues`' no-spine branch (pub's `onMain` filter is empty) and `buildPosters`' no-main fallback (spine run over every edge). Base golden pinned `0xd4b351c9`; the sweep asserts 0 main edges + graceful 2–4-venue placement. `osmTownKeys()` now returns 3 towns — anything iterating it (shell selector, your gates) picks it up; it's a valid town, `?plansrc=osm&town=silverton`. ### → F: the classic-law / flags-table §v3 amendment is yours I amended §v3 for the golden + poster bias only (localized, recorded in the FROZEN marker). The v3.1 flip (default-on, `?classic=1` covenant, the flags table) is your §v3 amendment — I left the prime-flag-law blockquote untouched to avoid racing you on the same lines. My classic golden (`0x3fa36874`) is your `?classic=1` regression target; my gig golden (`0xb1d48ea1`) is your default-boot target. ## Round 15 (2026-07-16) — the frontage-poster +Z flip (ledger #1) + the one sanctioned golden move ### → B + F: **new gig golden `0x1f636349 → 0x4f4a549d`** (synthetic `0x3fa36874` unchanged) The frontage-poster fix moved `plan.posters`, so the gig golden re-pinned — the round's ONE sanctioned move (amendment law; §v3 marker records it). B: re-shoot `queue_night` against this. F: the smoke's gig golden is now `0x4f4a549d`. ### The fix (gigs.js buildPosters item 1) Each venue's own-frontage poster sat at local **−Z** — the *back* of the building (~16 m behind, matching B's 15.9 m), invisible from the street, which is why `queue_night` framed no poster. Flipped to the **+Z street facade** AND turned it to read from the street: `ry = lot.ry + π` so its printed face points `+Z` at the queue-side camera (else `DoubleSide` shows a mirrored band name). Proven: pub + rsl frontage posters now sit **0.06 m from B's door**, printed-face·(+Z street) = **1.000**. Only item 1; spine posters untouched. ### The −Z/+Z convention, settled (worth recording — it's the R13 RY_FLIP π-ambiguity again) The **visible** venue face is `+Z`: `buildings.js` builds the facade/door at `zf = +d/2` ("front face at local +Z"), and `venue.js` seats the door-glow/queue/poster-camera on `(sin ry, cos ry)` = `+Z`. The selfcheck's `facing = (−sin ry,−cos ry)` "every lot faces its frontEdge" is the **sim/GLB** convention, 180° off from the visual front (same ambiguity D fixed at the source for the fleet in R13). A poster faces the *viewer* when its printed face = `(−sin ry,−cos ry)` points at them, i.e. `ry = atan2(away-from-viewer)`. ### → B + F (eyeball in the tour): corner-venue frontage posters can sit near a crossing street The `+Z` facade is the block-interior side in *plan* coordinates, so for a **corner venue** (a band_room/ rsl fronting one street with another behind — e.g. seed 1 band_room lot#481, 1.58 m from a side-street centreline) the frontage poster lands near that crossing street's kerb. That's B's building geometry (the door is there too), not a mid-road defect — so the selfcheck's no-bitumen assert now **exempts frontage posters** (they're building-bound) and keeps it for free-standing **spine** posters. The pub hero is clean (29.9 m of open ground on `+Z`). If a corner poster reads as floating over a road in the tour, that's a v3.1 venue-placement tweak, not a v3.0 blocker. ## Round 14 (2026-07-16) — v3.0 RELEASE: invariant sweep + CITY_SPEC §v3 frozen Release round — no new systems, no golden moves. **Synthetic `0x3fa36874` and gig `0x1f636349` are unchanged**; the only code I touched is `selfcheck.js` (test-only), so no seeded stream gained a draw. ### The 400-seed district sweep (`selfcheck.js`, ALL GREEN 14264/14264 in ~0.7s) - Factored the district-contract checks out of `gigSuite` into `districtInvariants(plan, label)` so the sweep asserts them at SCALE without re-running the heavy `structuralSuite` (that still runs on the 6 hero `SEEDS` + osm parity). Behaviour-preserving — the 4 hero-seed `gigSuite` runs are byte-identical. - Sweeps **seeds 1–400** synthetic + the large edge seeds (2e9, 8675309) asserting the full district contract each: 2–4 venues, ≥1 pub, never the openLate shop, no band twice in one night town-wide, per-venue 4–6 nights, every poster off the carriageway. Also asserts the seeded count **spans {2,3,4}** across the range (measured 146/121/133) — proves the 2–4 range is genuinely exercised, not pinned. - **osm graceful placement**: `katoomba` (smallest fixture, 19 shops, **no warehouse district**) still lands 2–4 valid venues incl. a pub for every hero seed — proving `pickVenues`' off-spine fallback. Honest scope: both osm fixtures keep a main spine, so this proves the *no-warehouse* branch, not *no-spine* (a genuine no-spine town would need a single-row fixture that doesn't exist yet — v3.1). - Poster check kept as "≥ `roadWidth/2` from the nearest edge centreline" (not a literal `poleOffset` compare, which would false-fail legit corner posters seated behind their placement edge's `poleOffset` but inside a crossing edge's clearance band). Added a `−1e-6` epsilon on the strict `<` to immunise the exact kerb-line boundary against r2 rounding (only reachable on a no-main-spine fallback; not hit today). ### CITY_SPEC §v3 frozen at v3.0 Final wording pass; marked **FROZEN at `v3.0` (round 14)** with a §v2-style marker. Venues table, genre map, gigKey contract, corridor law, and gig golden `0x1f636349` are all final (genres are pubrock/grunge/ covers — Fable's R14 ruling, John did not re-flavour). ### → Lane F: the alias-retirement handshake (R13 debt #1) — I deliberately did NOT touch it The R12 single-venue compat alias is **still live** (`gig_state.js:141-151`, read by `audio.js`/`venue.js`), so I left the §v3 alias sentences (`CITY_SPEC.md` ~lines 271–272) **intact** — deleting them at freeze time would be doc-ahead-of-code and would race you on the same lines. The freeze marker explicitly carves this out as your pending edit. In your alias-retirement commit (after B drops its readers), delete both `gig_state.js:141-151` and those two spec sentences **together** — one atomic step, one deleter, no race. - While you're in that runtime block: it enumerates `stateOf/onOf/openOf/gigOf/coverOf/bandNameOf/paidOf` + `markPaidOf` + `.venueShopIds`, but **omits `nightOf(id)`** (`gig_state.js:131`). Add it when you edit. ### → Lane B: second edit site for the bare-string arm When you remove the `venue.update()` bare-string alias arm, note `venue.js:228` also self-calls `update('quiet')` at construction through that same arm — fix both or it falls through (harmless today: still yields `'quiet'`, but it's a live second site). ## Round 13 (2026-07-15) — v3.0-beta THE DISTRICT (published FIRST; C/D/B/F hang off this) Supersedes the R12 alpha interface below. Same flag (`?gigs=1`), same prime-flag law: flag OFF ⇒ plan byte-identical to v2 (synthetic golden still `0x3fa36874`, osm unchanged). What changed: **one venue → 2–4; one genre → three (by kind); one night's posters → town-wide.** The gig **entry shapes are unchanged** — downstream code that read one venue now iterates many. Verified green: `selfcheck.js` 3273/3273, `scaffold_check` + `consistency` GREEN. **Gig golden re-pinned: `0xa6ae5a5e` → `0x1f636349`** (the district changed gig-plan output; re-pinned in the same commit as the change per CITY_SPEC amendment law). Turn-on is unchanged: `generatePlanFor(seed, src, { town, gigs:true, customBands })`. ### The venues (NEW: 2–4 per town, three kinds) — `plan.shops.filter(s => s.venue)` Each converted-in-place venue shop carries three new fields: ``` s.venue === true · s.venueKind ∈ {'pub','band_room','rsl'} · s.genreKey ∈ {'pubrock','grunge','covers'} s.type === s.venueKind // C keys its interior recipe off shop.type — the type IS the kind ``` | kind | genreKey | placement | hours | reads as | |---|---|---|---|---| | `pub` | `pubrock` | spine end/corner | `[17,23]` | ≥2-storey corner hotel | | `band_room` | `grunge` | warehouse fringe | `[18,23]` | single-storey tin shed | | `rsl` | `covers` | off-spine | `[16,23]` | carpet-and-bistro club | - Seeded count 2–4; a 4th venue is a **second pub** at the far spine end. A town always has ≥1 pub. - **Never** the openLate landmark (still exactly one openLate shop). Placement degrades gracefully so osm towns (no warehouse district, maybe no spine) still get their venues. - `genreKey` comes from the registry kind→genre map: `genreForVenueKind(kind)` / `SHOP_TYPES[kind].genre`. ### THE gigKey CONTRACT (debt #1 — import it, don't build the string) The audio-manifest bed key **IS** the gigKey and is **always** `gig-`. No mapping table anywhere: ```js import { gigKeyFor } from './citygen/index.js'; // re-exported from the barrel gigKeyFor('pubrock') === 'gig-pubrock' // C sets audio.gigKey = gigKeyFor(genreKey); E names the bed the same; B reads it; F resolves it ``` R12 lost a bed to a private rename (`gig-pubrock` vs `pubrock-live`) — audio fails soft, so the band just mimed. One key, zero mapping. E renames `pubrock-live → gig-pubrock` this round; F deletes the `GIG_BED` bridge. ### `plan.gigs` — the week across the whole district (entry shape UNCHANGED) ``` plan.gigs = [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover }, … ] ``` - **7 nights × every venue, but not every venue plays every night**: each venue plays a seeded **4–6** of the 7, dark the rest (reads true). `gigId` unique **town-wide**; `night ∈ [0,7)`; `genreKey ≡` its venue's. - **Pick tonight per venue** (each venue has its own state now): `plan.gigs.filter(g => g.venueShopId === v.id && g.night === 0)[0]` — **may be undefined** (venue dark tonight; F/B skip it, no poster). - `startSeg=5 endSeg=5`. Same `quiet → doors(seg4) → on(seg5) → done` machine — F runs one **per venue**. - `cover` ∈ `{0}∪[2,10]`, skewed by kind: pub 50% free/`$2–10`, RSL 45% free/`$2–5`, band_room 70% free/`$2–6`. F debits **per venue** — a night at two venues is two covers. - **No band plays two venues on one night** (guaranteed by A). Custom names spread night-major across the whole week town-wide (deduped, priority-seeded), the rest generated. ### `plan.posters` — town-wide now (shape UNCHANGED; **Lane B** renders) ``` plan.posters = [ { id, gigId, x, z, ry }, … ] // only TONIGHT's (night 0) playing venues advertised ``` - One on each playing venue's **own frontage** (on the facade plane) + a seeded spine run (2 per main edge). - Different venues' posters share the same pole runs — that IS the district reading. `p.gigId` tells B/F which venue's gig each poster sells (`plan.gigs.find(g=>g.gigId===p.gigId)`). - **Debt #5 fixed:** posters no longer land mid-road. See the corridor law ↓. ### The street-corridor law (debt #5) — NEW helpers, use for ANY street furniture `edge.width` is the **full corridor** (carriageway + verge, centreline ± width/2), NOT the carriageway. `node.x + 2` seats furniture on the bitumen. Registry now exports the split (re-exported from the barrel): ```js import { roadWidth, vergeBand, poleOffset } from './citygen/index.js'; roadWidth(edge) // carriageway half-corridor (main 10m, side 6–8, lane=all, arcade 0) vergeBand(edge) // [inner,outer] — where footpath furniture belongs poleOffset(edge) // where a pole/poster/post seats: just behind the kerb ``` Selfcheck now **asserts** no poster stands within `roadWidth/2` of any edge centreline. ### → Lane C: two recipes to add + the gigKey + a corrected field - `getRecipe(shop.type)` resolves off `RECIPES[type]` — `RECIPES.pub` exists; **`RECIPES.band_room` and `RECIPES.rsl` do NOT yet.** Until you add them, those venues fall back to the op-shop interior. That's the handshake — you're next in order. - Set `room.audio.gigKey = gigKeyFor(shop.genreKey)` (import from the barrel) — never hand-build `'gig-…'`. - Registry `pub.interior` was `'band_room'` (harmless, unread — you key off `shop.type` via `canonicalType`); I corrected it to `'pub'` so it isn't a name-collision with the new `band_room` venue type. No behaviour change. ### → Lane D: iterate the venues, one setGig each `const venues = plan.shops.filter(s => s.venue)` — call `setGig` **per venue** (multiple concurrent). Tonight's gig per venue as above (skip dark ones). `isOpen(shopOrHours, hour)` unchanged (half-open law). Queue zone comes from B (`venue.queueZone`) — consume if present, else a straight line off the door. ### → Lane F: state machine goes per-venue One latch **per venue keyed by `venueShopId`**, off the same `procity:segment` spine. `gigKeyFor(genreKey)` resolves all three genres (the debt-#1 assert now covers 3). Delete `GIG_BED` once E's rename lands. Cover charge is per venue (per-venue paid stamps). Keep the alpha `window.PROCITY.gigs` shape as a compat alias for one round (B reads it mid-round). ## Round 12 (2026-07-15) — v3.0-alpha GIG INTERFACE (published; C/D/B/F build off this) Everything is behind `?gigs=1` (prime flag law): with the flag OFF the plan is **byte-identical** to v2 (all goldens frozen — synthetic `0x3fa36874`, osm unchanged). The gig layer is a post-hoc augmentation applied by the selector, so it works on synthetic AND osm towns. ### How the gig layer turns on (Lane F wires this) ```js // F's shell bootstrap, when ?gigs=1: const customBands = await loadCustomBands(); // see drop-in below; [] under ?noassets or if absent const plan = citygen.generatePlanFor(seed, src, { town, gigs: true, customBands }); ``` `generatePlanFor(seed, source, { gigs:true, customBands })` → base plan + `withGigs`. Without `gigs:true`, the base plan is returned untouched. `generatePlanFor` stays **synchronous** — F does the async `custom_bands.json` fetch in the bootstrap and passes the array in. ### `plan.gigs` — the nightly schedule (NEW, present only when gigs on) ``` plan.gigs = [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover }, … ] ``` - 7 nightly entries at the one venue. `night` = 0-based index; **F picks tonight's** (alpha: `gigs[0]`). - `venueShopId` = the pub shop's id. `genreKey` = `'pubrock'` (alpha's one genre; E ships that bed). - `startSeg=5` (NIGHT, 22:00) `endSeg=5`. F's state machine: `quiet → doors (startSeg−1 = DUSK seg4) → on (seg5) → done`. Segment→hour map is lighting.js (`0 DAWN … 5 NIGHT`). - `cover`: integer. **~half are 0 (free, walk in); the rest $2–$10** (F debits the wallet at the door). ### The venue (one `pub` per town) - A spine-END plain shop is converted in place to `type:'pub'` (keeps its lot id + geometry ⇒ no overlap, enterable like any shop). Tagged **`shop.venue === true`**; also `plan.shops.find(s=>s.type==='pub')`. - **Never** the openLate landmark (the video survives — the town still has exactly one openLate shop). - Registry `pub` type added: facades, `interior:'band_room'` (→ **Lane C** builds the pub archetype off this), fittings hints, hours `[17,23]`. It has **no district weights** so it's never auto-placed. - Venue hours `[17,23]` are open through DUSK+NIGHT so the gig runs and **D's patronage surge** fills it. ### `plan.posters` — gig advertising (NEW; **Lane B** renders) ``` plan.posters = [ { id, gigId, x, z, ry }, … ] // ~6, advertising tonight's gig (gigs[0]) ``` One on the venue frontage + a seeded few along the spine, on the poles/walls B already streams. B places E's poster skin at `(x,z)` rotated `ry`. F wires "poster → tonight's-gig discoverability". ### → Lane D: the closing-time debt is PAID — consume `isOpen` `citygen.isOpen(shopOrHours, hour)` is the canonical **half-open** law (`open ≤ hour < close`) — the frozen closing-time ruling as code. Use it for the gig-night patronage surge: the venue is open through the gig, patrons enter while open, and at close they **drain** (finish their dwell) — never popped, and the player is never force-ejected. Your `sim.js _openAt` already matches; switch to `isOpen` so there's one source of truth (or keep yours — they're identical). ### → John: the band-name drop-in (`web/assets/custom_bands.json`) Edit the file, reload — your names get **priority** on the town's posters. Schema: `{ "bands": ["The Feral Galahs", …] }`. **Delete it and the generator takes over** (no errors, no fetch under `?noassets=1`). Deterministic given (seed, file contents). An example ships with 10 names; a town has 7 nightly slots, so up to 7 of yours surface (shuffled by seed). ## Round 9 (2026-07-15) ### → Lane D/F: closing-time occupant ruling (you already built it right — this ratifies + freezes it) Question: what happens to a ped browsing inside a shop when it hits closing time? **Ruling** (now a frozen contract law — CITY_SPEC §"CityPlan v2 → Closing-time occupant ruling"): 1. **No new entry when closed.** Openness is half-open: `open ≤ h < close` (a shop closing at 17 is shut at 17:00). Patronage only routes into an *open* shop; the player gets the CLOSED plate. ✓ your `sim.js _openAt` + `_nearestOpenShop` already do exactly this. 2. **Occupants drain, they don't pop.** A ped inside at close finishes its bounded 5–20s dwell and re-emerges normally — **never** teleported/culled at the close instant. `occupancyOf(shopId)` falls to 0 within one dwell window on its own, so browser rigs (the C→D→F seam) clear with the same drain. ✓ your `patronTimer`/`_emerge` already do this — do NOT add a hard close-time eviction. 3. **Player is never force-ejected** — closing locks *entry* only; a player inside stays until they leave. 4. **The openLate video shop** is the one place still filling after ~21:00; at its own close it drains identically. All seeded + clock-driven ⇒ deterministic occupancy. No code change requested of you — this pins the behavior so it can't drift, and documents it for v3. ### → everyone / F: the CityPlan v2 producer contract is FROZEN CITY_SPEC now has a **"CityPlan v2 — frozen producer contract"** section: producer API, the goldens table (synthetic `0x3fa36874`, osm/melbourne `0x34cfdec0`, osm/katoomba `0x0f652510`), the town-key mechanism, osm normalization rules, and the hours/openLate/storeys/closing laws — all matching code (F: diff away). Changes to Layer-1 now require a CITY_SPEC amendment + golden re-pin in the same commit. ## Round 8 (2026-07-15) ### → Lane F: OSM is at parity + there's a SECOND town. Three goldens to pin, one new selector arg. - **Selfcheck parity done**: the full invariant suite (`structuralSuite`) now runs on **every (source, town)** — synthetic and both OSM towns get identical coverage (storeys, one-shop-per-lot, facing, within/cross-block overlap, corridor coverage, determinism, exactly-one-openLate, …). `node selfcheck.js` → **ALL GREEN 1727/1727**. - **Selector gained a town arg** (`web/js/citygen/index.js`): `generatePlanFor(seed, source, { town })`. Shell wiring: read `&town`, pass it through — `generatePlanFor(seed, src, { town: q.get('town') || undefined })`. `map.html` is the reference impl (`?plansrc=osm&town=katoomba`). Unknown/absent town → the default (`melbourne`). `osmTownKeys()` lists the available towns for a UI picker. - **THREE goldens to pin** (seed 20261990), all asserted by selfcheck already: | source / town | golden | |--------------------|--------------| | synthetic | `0x3fa36874` | | osm / melbourne | `0x34cfdec0` | | **osm / katoomba** | `0x0f652510` | The determinism gate should key on **(seed, plansrc, town)**. Synthetic + melbourne goldens are **unchanged** from round 6. - Katoomba (Blue Mountains, 19 shops — op-shop/book heavy) is a deliberate small-regional contrast to inner-Melbourne (95, book heavy). Both boot end-to-end; screenshots in `docs/shots/laneA/`. ### Normalization: the importer bends the DATA to the contract, never the contract (round-8 item 4) Real OSM data that fights a CityPlan contract is normalized deterministically, and the change is **logged** (selfcheck prints a per-town line; `generatePlanOSM(seed, town, { report })` fills `report`): - unknown OSM `shop=` kind → `opshop` (`typesRemapped`); - a shop closing ≥22:00 → clamped to 21:00 so only the one openLate landmark is late (`hoursClamped`); - openLate landmark prefers a **video**; a town with no video falls back to another non-stall type and `report.openLate` records it (both Melbourne + Katoomba have video, so both read `video`). ### RECIPE — add another OSM town (mechanical, ~10 min) 1. **Pick a cluster** in thriftgod's `city_source.json` (Overpass cache). Find a centroid + a cell size that captures one coherent town (aim 15–150 on-theme shops). The extraction one-liner used for melbourne/katoomba is in `A-progress.md` (round 6/8) — reuse it with your `(cLat,cLon,cell)`. 2. **Append the town** to `OSM_TOWNS` in `web/js/citygen/osm_fixture.js`: `key: { town, source, center:{lat,lon}, shops:[{id,name,type,lat,lon,suburb}, …] }` — sort shops by `id` (determinism). Map OSM `shop=` kinds → registry types (charity→opshop, books→book, music→record, toys→toy, video_games→video, antiques/second_hand→opshop/pawn). 3. **Pin its golden**: run `node web/js/citygen/selfcheck.js`. The unpinned town prints a ready-to-paste line — `⚠ osm/: UNPINNED — add to OSM_GOLDENS → : 0x…,` — paste it into `OSM_GOLDENS` in `selfcheck.js`, re-run → green. (The full parity suite already ran on the town; only the golden needed pinning.) 4. **Tell F** the new (seed, plansrc, town) hash for the determinism gate. Done — zero network, the full parity suite covers the town automatically. *Dry-run (round 9): verified end-to-end with a throwaway Newtown (42 shops) — the parity suite auto-ran (1931/1932, only the golden unpinned) and selfcheck printed `newtown: 0x9045e577,`. Reverted; no 3rd town shipped (didn't fall out "for free"). The recipe is mechanical as written.* ## Round 6 (2026-07-14) ### → Lane F (F2): the `?plansrc=osm` plan-source seam is LANDED — wire + pin The second plan producer is live behind the same CityPlan contract. Full seam, invariants green (not a partial). What F needs to wire the shell bootstrap + extend the determinism gate: - **Selector API** (`web/js/citygen/index.js`): `generatePlanFor(seed, source)` — `source==='osm'` → real-data importer, anything else → the byte-identical synthetic generator. Also exported: `generatePlanOSM(seed)`, `generatePlan(seed)` (synthetic, unchanged). Unknown source falls back to synthetic. - **Shell wiring** (in `web/index.html`, F-owned — one line): read `?plansrc`, then `const src = new URLSearchParams(location.search).get('plansrc')==='osm' ? 'osm' : 'synthetic';` and call `citygen.generatePlanFor(seed, src)` where it currently calls `generatePlan(seed)`. `map.html` already does exactly this (reference impl). - **Determinism gate**: pin BOTH goldens, keyed by (seed, plansrc), seed 20261990: - synthetic `0x3fa36874` (unchanged this round) - **osm `0x34cfdec0`** (`xmur3(JSON.stringify(generatePlanOSM(20261990)))`) `selfcheck.js` already asserts both; the smoke can just hash-compare. - **Zero network**: the fixture is `web/js/citygen/osm_fixture.js` (95 real inner-Melbourne shops from thriftgod's Overpass cache), imported as a JS module — no fetch/XHR at runtime. **⚠ OSM plans differ from synthetic in shape (by design — real towns aren't the synthetic template):** - `plan.source === 'osm'` is set; `plan.size` is the real bounding box (~362×486), **not** 1024². - **No synthetic districts/types**: an OSM town has NO market square, arcade, dept anchor, milk bars, or stalls — only the real retail types (record/opshop/toy/book/video/pawn). Any code that ASSUMES a dept/market/milkbar exists must guard on `plan.source !== 'osm'` (or just not assume). - Everything B–F actually consume is identical: nodes/edges/blocks/lots/shops schema, `ry`/ `frontEdge` semantics, `chunkIndex`, hours + the exactly-one-openLate-video rule all hold. - Shop `name`s are the REAL OSM names (e.g. "Central Catholic Bookshop"); `sign` is derived. Parody-transform of names is a documented deferred nicety, not a blocker. ## Round 4 (2026-07-14) ### → Lane F (A1): storeys checker (F2) confirmed — 0 warnings, no false negatives `tools/qa/consistency_check.mjs` `permittedMax` scoping (your F2) is **correct**. Verified this round: - `node tools/qa/consistency_check.mjs` across 7 seeds → **0 storeys warnings**; `qa.sh --strict` GREEN (4/4). - **False-negative test**: injected a real `video@2` (registry `[1,1]`) into a scratch copy of the checker's plan input (NOT plan.js) across 5 seeds → the storeys gate still fired every time. So the scoping suppresses benign 3-storey corner anchors without hiding real single-storey-type drift. ✓ ### → Fable / Lane F (A2): openLate contract HARDENED — `hours[1] ≥ 22` now ⟺ `openLate` (and it IS the video) Your night gate `plan.shops.some(s => s.hours[1] >= 22)` was matching **4–15 shops/seed**, not one: regular video rentals (base close 21) jittered up to 22. That contradicted the shell's own contract (`web/index.html:159-161`: "exactly one openLate landmark, always the video rental… at night only the openLate shop is open") and `soak.py:68`. Rather than ask you to change the gate, I made reality match it: - **plan.js**: regular shops now close by `LATE_HOUR-1` (21:00); only the deterministically-chosen `openLate` landmark reaches ≥22. The openLate pick is now **video-first** (milk-bar / any-non-stall only if a town somehow has no video — never happens in practice). - **Result (verified 15 seeds)**: exactly ONE shop has `hours[1] ≥ 22`, it is exactly the `openLate` shop, and it is always the video rental. `{s: s.hours[1] >= 22}` and `{s: s.openLate}` are now the same singleton — **gate on either**, they agree. - selfcheck.js asserts all of this now (exactly-one, field⟺threshold, is-video). **⚠ GOLDEN.hash changed: `0x098eec2b` → `0x3fa36874`.** This is the *intended* consequence of the A2 plan.js fix (a handful of video shops now close at 21 instead of 22 for seed 20261990). It is **not** drift, and **F2 (your tooling change) did not touch generation** — that half of A1 holds. selfcheck.js carries the new golden and is GREEN. Nothing else in a plan changed (same lots/shops/types/names/skins). ## Round 3 (2026-07-14) ### → Lane F: the `qa.sh` "storeys outside registry range" warnings are BENIGN (finding #5 closed) Your consistency gate prints ~5 warnings per run: `"N shop(s) have storeys outside their registry type range … else a plan↔registry drift — ask Lane A"`. **Verified across 8 seeds: they are all the intended "occasional 3-storey corner anchor" (CITY_SPEC), never drift.** Evidence: - Single-storey types (`video`/`milkbar`/`stall`, registry `[1,1]`) are observed at 1 storey ONLY — e.g. 412 video shops, none at 2. So the specific "video at 2" example does **not** occur. - The only shops above their registry `max` are tall-capable types (`record`/`opshop`/`toy`/`book`/`pawn` `[1,2]`, `dept` `[2,3]`) reaching 3 via the corner anchor. Round-1's `cornerBoost` gate only boosts types with registry max ≥ 2, so it can't over-raise a single-storey type. **To silence the warning correctly**, scope your check to the *permitted* max, not the strict registry max: ``` permittedMax(type) = registryMax >= 2 ? Math.min(registryMax + 1, 3) : registryMax // flag only shops with storeys < registryMin || storeys > permittedMax → that IS real drift ``` This rule is now documented in `docs/CITY_SPEC.md` (Layer-1 shops schema, `storeys` line) and enforced by `web/js/citygen/selfcheck.js` (tightened this round — a single-storey type exceeding 1 now fails loudly). ### → Lane F: `hours` / `openLate` contract is ready for your §3.5 wiring (no changes) `hours = [open, close]`, 24h integers, `0 ≤ open < close ≤ 23`. **Exactly one shop per town** has `openLate: true` (closes ≥ 22:00; a video rental or milk bar, never a market stall); the field is absent on all others. Gate on the field — `plan.shops.find(s => s.openLate)` — not a magic hours threshold. Your ROUND3_INSTRUCTIONS §Lane F states it correctly, so consume as-is.