PROCITY/docs/LANES/LANE_A_NOTES.md
m3ultra f628bf581a Lane A R39 39.1: THE ADDRESS LAYER — 17,835 street names stop being dropped on the floor
The contract, published for B and C in LANE_A_NOTES §39 (first commit, per the half-day rule).

  createAddresses(plan, cache|null) → streetOf(edgeId) · localityOf(shopId) · cohort(pred)
                                     · streets() · stats()

New pure module web/js/citygen/address.js: ZERO imports, no THREE, no fetch, no DOM, no module
state, no plan mutation. Two suppliers, ONE consumer contract — a real town fills `label` from the
cache's named ways, the synthetic fills it from district.kind + block. Consumers must never branch
on town type; that constraint is the design.

MEASURED (Fable's binding facts re-derived independently, 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 resolve to a street name (97.8%); 29,865/30,986 edges named (96.4%)
· all 27 unresolved shops front a way OSM genuinely leaves unnamed (adelaide 21 arcade) — null is
  the honest answer and the module returns it rather than borrowing a neighbour's name

TWO DEPARTURES FROM THE SYNTHESIS, both with numbers:
· the shift is recovered EXACTLY (node↔waypoint lattice intersection), not voted for. The proposed
  centroid-align + nearest-waypoint vote returns the WRONG shift on 5 of 23 towns (braddon,
  fremantle, hobart, northbridge, westend) — island culling drags the plan centroid off the
  cache's. A wrong shift renames every street in the town. Exact on 23/23, nothing to tune.
· the resolver samples FIVE points along an edge, not the midpoint — a midpoint cannot tell a
  street from the street that crosses it.

THE ONE-LINE UPGRADE: TAKEN. plan_osm.js writes norm.shift = {shx,shz} into the normalization log,
reaching the caller only via the existing opts.report sink — never onto the plan. It is a
cross-check, not a dependency: selfcheck compares plan_osm's published shift against address.js's
independently recovered one and fires on a 0.01 m disagreement.

GATES (+240 checks), each proven to fire on a broken world:
· 0 wrong street names on 884 shop-bearing edges, against an INDEPENDENT way-membership resolver
  (857 agree, 0 disagree). Corpus-wide 4/30,986 disagree — all 2D-stacked ways, none carrying a
  shop — PINNED at 4, not asserted > 0.
· CONTROL: with roads[].name stripped (exactly the pre-change state) the layer resolves 0 streets.
· CONTROL: no cache ⇒ supplier 'district', visible in stats(), never a silent wrong name.
· createAddresses does not mutate the plan — asserted byte-for-byte, per town.
· tolerance 8 m, and the finding that sets it: correctness SATURATES AT 6 m (857 agreeing frontage
  names at 6, 8, 12, 16 and 24 m). Every metre past 6 buys only a name borrowed from an unnamed
  way; it can never buy a correction.

Additive: getTownCache(key) — index.html registers the cache and drops its reference, so roads[]
was unreachable downstream. Read-only, no new state, no shell change.

GOLDENS: NOTHING MOVED, ZERO RE-PINS. selfcheck 157,407 → 157,647 ALL GREEN, fingerprint 0x5f76e76.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:32:43 +10:00

99 KiB
Raw Blame History

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

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:

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 65319 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.030.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_CLEAR0.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 12 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 24 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 bothtier: 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/<id>/ 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 boundaryworld = 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. Sequencingnewtown_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 allballarat, 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 01), 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 36× 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 DouglasPeucker (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 94100% 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 (DouglasPeucker ε≈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/<key>.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}, shops6 (MIN_TOWN_SHOPS), finite shop lat/lon. Warnings (absorbed): blank name → type label, unknown typeopshop, 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 24-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 1400 synthetic + the large edge seeds (2e9, 8675309) asserting the full district contract each: 24 venues, ≥1 pub, never the openLate shop, no band twice in one night town-wide, per-venue 46 nights, every poster off the carriageway. Also asserts the seeded count spans {2,3,4} across the range (measured 146/121/133) — proves the 24 range is genuinely exercised, not pinned.
  • osm graceful placement: katoomba (smallest fixture, 19 shops, no warehouse district) still lands 24 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 271272) 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 → 24; 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: 0xa6ae5a5e0x1f636349 (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: 24 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 24; 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-<genreKey>. No mapping table anywhere:

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 46 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/$210, RSL 45% free/$25, band_room 70% free/$26. 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):

import { roadWidth, vergeBand, poleOffset } from './citygen/index.js';
roadWidth(edge)   // carriageway half-corridor (main 10m, side 68, 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)

// 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 (startSeg1 = 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 520s 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.jsALL 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 15150 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/<key>: UNPINNED — add to OSM_GOLDENS → <key>: 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 BF actually consume is identical: nodes/edges/blocks/lots/shops schema, ry/ frontEdge semantics, chunkIndex, hours + the exactly-one-openLate-video rule all hold.
  • Shop names 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 415 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: 0x098eec2b0x3fa36874. 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.