PROCITY/docs/A-progress.md

67 KiB
Raw Permalink Blame History

LANE A — CITYGEN · progress (PROCITY-A)

Status: all deliverables landed; self-check ALL GREEN (156,352/156,352); rounds 230 closed. R30 (v7.0-alpha ledger #6): the rotation boundary VERIFIED — F's day salt rides runtime streams only, plan generation structurally cannot receive it; all goldens frozen, zero re-pins.

Round 30 (2026-07-18) — the rotation boundary verify (ledger #6, small round as briefed)

One job: prove F's day-salted stock rotation (207bcff) never reaches plan generation. It holds. Three measured legs (full detail in LANE_A_NOTES §30): (1) grep-zero for day identifiers over the proven citygen/** glob (8 files / 2,546 lines, census printed; pattern proven non-vacuous against interior_mode.js); (2) structural absence — generatePlanFor consumes no day-shaped opt and neither shell call site passes one; (3) day-inertness byte-measured — plan hash identical with {day, stockDay} hostilely injected as 1 and 37 vs absent, 3 seeds × synthetic+osm, 6/6. The salt's actual reach: stk-* stock-pick stream labels + dig binSeed + setWeekNight night-selection over the plan-time 7-night week (day picks a night, never generates one). Selfcheck ALL GREEN 156,352/156,352, fingerprint 0x5f76e76 frozen, every town pin green. No files of mine changed except these two notes.

Round 27 wave 5 (2026-07-17) — THE FACADE FIX (the epoch's biggest catch)

The ruling asked me to land B's one-liner in the real-roads seating and to measure the marched path rather than assume it was clean. I measured all three paths, and the ruling's premise was wrong in the half that mattered: synthetic is broken too. Not "every real town since R18" — every town, every path, since v1. Confirmed numerically (0 toward / 681 away, dot 1.0000) and then with my own eyes: ?classic=1, synthetic main street = three blank boxes; walk into the block and NUMBAT PLAYTHINGS is there, facing the dirt. 637 of 681 lots pointed their facade at no street at all.

  • Four sites, one sign. plan.js:122 (synthetic strip), plan.js:244 (market row), plan_osm:330 (marched avenue), plan_osm:443 (real roads — B's proven one-liner). All atan2(outward) where the intent was atan2(outward), and every one says so in its own comment. The code never did what it said; CITY_SPEC:71 blessed the result by writing the SIM convention into the LOTS contract. After: synthetic 681/681, marched + all 3 fixtures 100%, real-roads katoomba 0/72 → 72/72 (B's number).
  • The covenant moved, on John's ruling. Fixing synthetic necessarily moves 0x3fa36874, frozen "forever" since R16 — not Lane A's call, so I stopped and asked. Ruling: amend and fix all paths (the covenant is anti-drift machinery, not a defect preservative). 51 goldens re-pinned in one commit; amendment recorded in CITY_SPEC as a one-time, ruling-gated precedent.
  • A second bug the fix exposed, fixed with it: buildRealRoads seated blocks at KERB = 4 m from the centreline, but a main carriageway is 10 m wide (kerb at 5 m) — every main-street building has stood 1 m inside the road since R18. Invisible until the shopfront turned around and its poster landed in the traffic lane. Blocks now seat behind the kerb per A's own corridor law. Drops 6.4% → 6.6%, counted.
  • The cross-convention gate (the lesson made law). It does not restate a spec line — that is the trap F proved (72/72 "facing" over blank walls). It replicates Lane B's own buildings.js maths and asks whether the quad the renderer draws lands nearer the street than the lot centre. Fires if A's ry or B's zf drifts. It would have caught this on day one.
  • → F (blocking their re-gate): scaffold_check is RED until F re-pins the covenant hashes duplicated in F's own files (flags_check.py GOLDEN_HASH/GIG_GOLDEN, scaffold_check.mjs TOWN_GOLDENS, two tour captions). Exact values handed over in LANE_A_NOTES §R27w5; I can't touch F's files.

Previous status: 156,212/156,212; rounds 227 closed (see below). R27 (v5.0 close): CITY_SPEC v5 section (tier ladder · sourcing vs tier · the three id-space fences · the manifest · Ruling 2) + the charter risk list judged line by line — risk #1 deliberately CARRIED, not pre-retired, because F's kill-the-server gate is what retires it. classic 0x3fa36874 / gig 0xb1d48ea1 frozen; no golden moved this epoch. Date: 2026-07-17 · owner: PROCITY-A (Opus 4.8)

Round 27 (2026-07-17) — v5.0 THE LIVE CRATE: the docs freeze (ledger #6)

Lane A's half of the epoch close. Docs only; no code, no golden moved — correct, since tier 2 touches no plan, no cache, no atlas bytes. selfcheck ALL GREEN 156,212/156,212.

  • CITY_SPEC v5 section, written from the artifacts rather than the briefs (the epoch's own lesson): the tier ladder (0 parody / 1 static real / 2 live, each degrading softly); sourcing is not tier — both ship in every index, tier: 1 is the rung and a mint atlas is still rung 1, while sourcing: "real"|"mint" is provenance (15 keyed atlases = 1 real + 14 mint); the three id-space fences; the manifest (existence declared, never probed — the root fix for fail-soft-404 vs the zero-console-errors law; 15 entries, 15 dirs); Ruling 2; and A's single seam, godverseShopId.
  • The id fences, verified not restated. The plan-id/godverse-id trap is still live in the data and I reproduced it: in redhill_godverse, bare 31 is "Wholefood Cafe" as a plan id and "Silky Oak Furnature" as a godverse id — the exact ambiguity that made F's enterShop(31) assert vacuously on the wrong shop. It's a standing rule, not a fixed bug, and the spec says so.
  • The risk list judged, without pre-retiring anyone's gate. #1 (tier-2 latency) CARRIED — written in wave 2, F's reader doesn't exist yet, and nothing retires that line except the gate going green; retiring it from a doc while the code is unwritten is the precise species this epoch spent four holds catching. #2 retired (measured: max 2 atlases/shop vs C's cap of 2; mint at 16 items/1024², only the real POS shop at 2048²; the mecca case served by crate rotation — the remedy the risk itself named). #3 retired — designed out, not solved: it assumed fuzzy name-matching, and there is no matcher; Monster Robot is in neither census nor OSM, so no matcher could ever have found it. #4 split: write-back retired by John's ruling-as-mechanism (/reserve+/buy absent, not stubbed — you can't race a verb that doesn't exist), the gone read carried to F's #4. Two new carried: #6 orphaned atlas, #7 "real" is one shop wide (14/15 are mint — honest and marked, but the headline rests on one shop).
  • The gate that armed itself. My R25 orphan gate SKIPped on newtown_godverse while no atlas keyed there; G's mint atlases landed and it began binding unattended — 156,211 → 156,212, and that +1 is the gate arming itself. Near-miss carried: redhill's lift drops keyed shop 2286 "Empire Revival"; G stocked neighbour 2287. Stocking 2286 today would orphan it.

Round 25 (2026-07-17) — v5.0-alpha: the sweep vs Ruling 2 + the six pins (ledger #1)

All 8 tree reds cleared — 6 were my own gate asserting Ruling 2 away, 2 were goldens awaiting my pin. selfcheck ALL GREEN 156,211/156,211, scaffold + consistency green, classic frozen.

  • My R24 gate encoded an assumption that Ruling 2 falsified. I built the identity arm believing a godverse cache is all census shops. It's mixed by design — a keyed GODVERSE layer + an inherited OSM texture layer that G measured to be load-bearing (census-only Red Hill: 306 m spacing warn + a poster inside a kerb). So newtown_godverse (18 keyed + 54 texture) and redhill_godverse (10 + 27) were healthy towns my gate called broken. Keyed asserts now run per-layer over the keyed subset only; the "N/M missing" warn is gone. The one honest requirement left: a godverse+osm cache must have a non-empty godverse layer (else it's an osm cache with the wrong source) — still a warn.
  • The uniqueness check was broken in both directions. new Set(all ids).size === shops.length collapses every undefined into one entry, so it failed structurally on mixed caches and could hide a real duplicate behind the same collapse. Now over defined ids only — and, per the vacuous-gate law, proven able to fail: the validator rejects dups at the front door, so the test feeds one straight to the lift (generatePlanOSM doesn't validate) and asserts the predicate catches it.
  • New: the orphaned-atlas gate — my own R24 finding, enforced rather than filed. A committed stock_godverse/<id>/ whose shop the lift dropped = real stock keyed to a shop that isn't in the town. Node-side, subject-aware (SKIPs by name when a town has no atlas), so it catches in seconds what F's #7b could only catch in a browser at the end of a round. It already found one, benign today: redhill drops keyed shop 2286 "Empire Revival" (unstocked ⇒ no orphan ⇒ gate passes). Filed for G: stocking it at the beta would orphan. Monster Robot Party is seated (lot 8).
  • The six pins, cross-checked two ways before landing — byte-identical across two fresh processes, and matching the values G stated independently in 86e2985: newtown_godverse 0xcf69b387/0x1e266f60, redhill_real 0x6046700f/0x673785ee, redhill_godverse 0xb2224939/0xc6f80e18.

Round 24 (2026-07-17) — v5.0-alpha: the identity field (ledger #2)

shop.godverseShopId — the optional key resolving stock_godverse/<id>/. Schema stays v2; absent everywhere except godverse caches, so the 21 osm caches are untouched by construction. Published to web/assets/towns/README.md (the doc G/E build to) + CITY_SPEC. selfcheck ALL GREEN 152,030/152,030 (+15), synthetic 0x3fa36874 frozen, no golden moved — by design (see below).

  • The brief's "the POS shop id IS the id" is false for the one shop that matters. G's own docstring: Monster Robot Party is not in thriftgod's census, so its id is its dealgod store id 3962749, while census shops use thriftgod ids (max 2992). Two disjoint spaces on one field ⇒ built it as an opaque key: no derivation from id, no equality assert, no mapping table (the R12 gigKey law).
  • The field had to survive the lift, or it was decoration. plan_osm builds a fresh shop object and copies nothing unknown, so a cache-only field would never reach the runtime — and the runtime reads plan.shops[], never the cache. It's now carried through, conditionally: absent ⇒ the key isn't written ⇒ JSON.stringify byte-identical ⇒ every pinned golden holds until G re-emits (that re-emit is the sanctioned move).
  • Arms split by hazard: malformed → error (it's a path segment; "3962749" vs 3962749 is the same coercion drift that broke the atlas contract), duplicate → error (two shops, one atlas = the mis-stock charter risk #3 forbids), missing on a godverse cache → warn + fail-soft to tier 0.
  • Recorded departure: the ledger said missing should be an error. It would invert the charter's determinism boundary (the town failing to load because stock identity is absent), contradict risk #3's own remedy ("fail-soft to tier 0"), and red the tree for C and E through all of wave 1 — G re-emits in wave 2 and it's their namespace, not mine. Teeth kept where R23 says to put them: selfcheck fails on partial keying, prints an explicit SKIP + count at zero. Flagged for Fable to overrule.
  • Filed for G before they start (measured, wave 1): redhill_godverse can't contain Monster Robot from either source. godverse_town.py takes roads from the OSM donor and shops from the census; the shop is in neither — not in thriftgod's census (G's R23 finding), and not in E's redhill_real (I checked it as it landed: 36 shops, zero of type record, nothing matching /monster|robot/). Needs explicit injection, or F's #7b id-equality gate fails at the end of the round. Also flagged: the census bbox must still clear MIN_TOWN_SHOPS (6) or the cache is rejected outright.
  • Pins deliberately NOT taken this round yet. redhill_real is untracked/in-flight (E is still editing build_towns.py), and redhill_godverse + the re-keyed newtown_godverse don't exist until wave 2. Pinning a moving file buys a false green or a spurious red. The round's law says A pins these batched — that batch lands once E commits and G's caches are in. redhill_real reads clean meanwhile: valid, no warns, 73.3 m spacing.
  • Housekeeping: removed toowoomba's two stale pins (E retired the cache in R23 — a golden for a town that can't load can never fire). E's attempt also proved my R23 spacing metric is gameable: the re-bbox fixed the median (395.7 → 89.4 m) while hub density fell 3/12 → 2/12. Necessary, not sufficient — D's hub test is the complement. Ballarat (255 m) still flagged, still E's call; I pin whatever moves.

Round 23 (2026-07-16) — v5.0-alpha: the spacing warn (ledger #5)

D's R22 finding made law: MIN_TOWN_SHOPS counts shops, but what makes a town read alive is clustering. validateTownCache now warns when medianShopSpacing(cache) > MAX_MEDIAN_SPACING_M (150 m) — the metric and threshold both exported from the barrel, so no lane re-derives them. Warn, never error: a flagged cache still boots a valid district (toowoomba shipped green through the whole v4.0 pack), E curates, the validator flags. selfcheck ALL GREEN 161,372/161,372 (+72), synthetic 0x3fa36874 frozen, no golden moved.

  • The threshold is measured, not picked. Fable's brief offered darwin 27 m / toowoomba 388 m as "plenty of daylight for a line". Measuring all 23 towns showed the pack is a near-continuum, and the line is forced into (119.3, 254.9] — braddon (119.3 m) is alive by D's own hub test (7 shops within 160 m, identical to darwin's 7), ballarat (254.9 m) is not. 150 m sits in the pack's widest gap (×2.14).
  • Two towns flag, not one. The brief names toowoomba; the measurement adds ballarat (255 m), which nobody had looked at — and which is independently one of the three towns my R22 cluster fallback fired on. Filed for E as a curation call, counted either way.
  • The brief's anchors were in the wrong space, and it matters. D's 27 m is plan space (post-lift); validateTownCache is cache space (raw lat/lon — the only thing it has at ingest). Darwin is 68.3 m in cache space, not 27. Coding a threshold interpolated between D's figures would have mixed spaces silently. Cache space is right here, and provably safe: the lift compresses dense towns (darwin plan/cache 0.40) but not sparse ones (toowoomba 0.98, ballarat 1.01), so the error lives at the dense end and the warn only fires at the sparse end. Verified all 23 in both spaces: one town disagrees (newtown_godverse, 94.4 cache → 158.3 plan) — filed for G/Fable, not acted on, since no cache-space line catches it without false-positiving braddon, and its sparsity is census coverage, which re-bboxing can't fix.
  • Cost: ~1 ms/town at registration (25.6 ms for all 23 including fitzroy's 160 shops), against a <100 ms plan budget. Pending: re-pin whatever E's toowoomba re-bbox moves — only that.

Round 22 (2026-07-16) — v4.0 THE EPOCH CLOSE: the pack absorbed + 36 goldens pinned (ledger #2)

E's 17 new towns + Lane G's newtown_godverse fed through the lift and pinned in ONE golden commit (REAL_TOWN_GOLDENS + REAL_TOWN_GIG_GOLDENS: 5 → 23 towns each, zero unpinned).

The headline: everything built this epoch held at 23-town scale, first try. 1,210 shops → 1,133 seated (77 dropped, 6.4%, counted by cause); 1 connected component and 3 venues on every one of the 23 towns. fitzroy — 160 shops, 2× anything absorbed before — landed 139 seated, 1 component, 3 venues, 212 posters, all invariants green. The junction-protected DP, fragmentation ruling, cluster bias, poster cap and capacity fence needed no new hardening: the pack needed absorbing, not fixing.

  • Graded cluster fallback (the one refinement). Three towns have no candidate at the floor at all (ballarat/toowoomba/newtown_godverse top out at 2 shops within 160 m); R21's bias switched off there, stranding venues at 0. It now falls back to the densest available — ballarat 0,2,0 → 2,2,2, godverse 0,1,1 → 2,2,2, toowoomba 1,0,0 → 1,2,1. Fires only where the floor is unreachable ⇒ no already-pinned town moved (verified). Acceptance at pack scale: 20 of 23 towns have every venue ≥3; the three above sit at their town's genuine ceiling — the carved-out case, stated explicitly.
  • Drop outliers, counted not hidden: daylesford 19% (32 shops on a 171-road graph — the thinnest in the pack, all overflow), fitzroy 13%. RGAP is already 0.5 m and NODE_CLEAR 3 m; the only lever left is spilling onto a different street, which buys a few percent by putting a shop on a road it isn't on. Same ruling as R20: drop and count beats teleport.
  • selfcheck ALL GREEN 161,300/161,300; scaffold + consistency GREEN; cross-process deterministic (pack-xor 0xca140954). classic / gig / marched fixtures frozen.

Handshake fired → C + D + F: the pack is in, 23 towns pinned, start your verifies.

Ledger #6 (docs, 9c9ad73): CITY_SPEC v4 final + the risk list retired-or-carried. Closing this surfaced real drift — three gig-layer behaviour changes had reached only these notes, never the spec (R20's clearance widening to every venue kind, R21's cluster bias, R22's fallback). The amendment law says the spec is where they live; all three are now recorded, along with the pick order no lane could have inferred (cluster field → kind flavour → clearance → farthestFirst → seeded pick). Risk list judged against the pack, not the 5-town sample it was written for: #1 retired (widest town geelong 3,829 m = 77% of the 5 km law), #2 retired (thinnest darwin 12 shops = 2× MIN_TOWN_SHOPS), #3 retired plan-side / carried for the runtime lanes (a green plan is not a green town — C/D/F's verifies close it), #4 retired (DP knob measured; 1 component on all 23), #6 NEW carried (thin-graph seating capacity).

Ledger #1 (tri diet): not A's — B's numbers decided it. The driver is E's four furniture GLBs (91.5%); posters are 222 tris, buildings 348. My cap worked and was never the lever, and B found the real coupling is road density, not shop count — so the absorb wasn't the cause either. My guess (buildings/LOD) was wrong at 0.2%; the measurement won, as it should.

Round 21 (2026-07-16) — v4.0-beta: the venue cluster-adjacency bias (ledger #1, the round's spine)

D filed it: katoomba's pub sat 1,000 m from the retail cluster — a gig with no passing crowd. Measuring all five towns first showed it was systemic: 11 of 15 venues had <3 shops within 160 m (newtown's were 0/0/1). After the bias: 0 of 15. Katoomba's pub went 1 → 9; castlemaine cleared without needing the floor exception Fable anticipated.

  • pickVenues now builds a cluster-adjacent field (≥CLUSTER_MIN 3 shops within CLUSTER_R 160 m), then the kind flavors (pub corner / band_room fringe / rsl off-spine) bias within it rather than overriding it (per the brief: biases, not absolutes). No cluster at all → falls back to the pool.
  • Synthetic frozen BY CONSTRUCTION, not by luck. The brief assumed a blanket filter was a no-op because "the synthetic town is one dense cluster anyway" — measurement says otherwise: 4 synthetic candidates per seed sit under the threshold (min cluster 01), so a blanket filter would have moved the frozen golden. Gated on plan.source === 'osm' (undefined on synthetic). Marched fixtures are 'osm' but genuinely dense (min 3/11/13) ⇒ verified no-op.
  • Only the GIG goldens moved (venue selection is gig-layer): katoomba 0xbf586b77, newtown 0xacdb3eee, fremantle 0xcf3426fc, bendigo 0x646b2c23, castlemaine 0xf9713a69. Base goldens untouched. classic/gig/marched fixtures frozen. selfcheck ALL GREEN 66419/66419; scaffold + consistency GREEN; cross-process deterministic.
  • FYI: E's v4.0 pack (glebe, marrickville, newcastle) already boots clean through my loader (validates
    • full suites pass), listed UNPINNED — I pin the pack in R22; it does not gate the beta tag.

Handshake fired → C + D: cluster bias landed, goldens re-pinned, start your verifies.

Round 20 (2026-07-16) — v4.0-beta THE FIVE TOWNS: the poster cap (density absorb pends E)

Two Lane-A deliverables; the first (independent) landed this commit, the second waits on E's widened caches.

  1. Poster cap (ledger #1) — LANDED. My R19 finding (real katoomba: 438 spine posters, driving F's one budget edge, venue_night 200,965 tris) is fixed. gigs.js buildPosters caps the spine pole run at POSTER_PER_SHOP × shops (1.5/shop, floor 12) — a seeded subsample of the run. Scaled by shop count, not edge count (the whole point: a sparse real town has few shops but a big graph). Result: katoomba 438 → 31 posters (~2× the synthetic feel), fremantle 32, castlemaine 14. No-op on the synthetic town (493 shops ⇒ cap ~740 ≫ its ~15 posters), so the synthetic gig golden 0xb1d48ea1 is frozen. Posters are gig-layer, so the real-town BASE goldens are unchanged — I added REAL_TOWN_GIG_GOLDENS (the full district+poster plan per town: katoomba 0xbd332e83, …) to regression-guard the capped output byte-exact. selfcheck ALL GREEN 51675/51675; scaffold + consistency GREEN; cross-process deterministic. → F: re-measure venue_night, the +965 edge should close.
  2. Density absorb (ledger #2) — LANDED. E's five caches at 36× shops (292 total: katoomba 20→80, fremantle 21→80, newtown 18→72, bendigo 9→36, castlemaine 6→24) absorbed; all five place 3 venues; the poster cap auto-scaled off the live shop count as designed. Capacity widened — at 4× the dominant drop cause was overflow (edge-side full), so NODE_CLEAR 6→3 + a roads-only RGAP 2→0.5 (shared marched GAP untouched ⇒ fixtures frozen): drops 7.9% → 5.5% (16/292), residual counted by cause (dropped_overflow/_overlap/_stranded). Stopped there deliberately — the next lever (spilling overflow onto a different street) buys ~1% by putting a shop on a road it isn't on. Venue facade bias extended to pub (R16 exempted it; on a real graph at density the pub's frontage poster overhung a cross street — newtown 0.91 m); still a no-op on synthetic. Goldens re-pinned once (base + gig, all five). Fixed: the real-cache loader now skips E's new index.json (a selector index, not a cache). selfcheck ALL GREEN 51866/51866; scaffold + consistency GREEN; cross-process deterministic.

Handshake fired → all: densities absorbed, goldens pinned. → F: re-measure venue_night (poster cap) and gate the five-town matrix.

Round 19 (2026-07-16) — v4.0-alpha CLOSE: fragmentation ruling + findings hardened + goldens pinned

A was the round's spine (the A→E→A finalization deferred from R18). Every filed finding resolved; the tag is F's to land.

  1. Fragmentation (D's finding — Fable ruled, A owns details). The naïve real graph fragmented (katoomba 105 components, 58% street-metres on islands). Fixed in plan_osm.buildRealRoads: (a) junction-protected DouglasPeucker — a point shared by ≥2 ways is a junction; simplify only between junctions, never through one (the collinear tee-off drop). Alone → 94100% main-net. (b) cull/bridge — main component + near-fragments joined (JOIN_TOL 12 m; shop-island bridge ≤200 m); far shopless islands culled. Result: 1 connected component on all five towns (katoomba 100% main-net, 2 islands culled). Every dropped shop counted (norm.dropped) — katoomba 19/20 (1 crowd drop, the R18 overflow prediction measured), the other four kept all.
  2. E's poster-clearance finding fixed (gigs.js): a spine poster overhanging a real crossing carriageway is skipped — measured exactly as the selfcheck kerb floor, a no-op on synthetic (gig golden frozen). The 23 real-town clearance failures → 0.
  3. Goldens pinned (the finalization): all 5 real towns on their real-roads output (katoomba 0xb7ffbca2, newtown 0x6f984c81, fremantle 0xfb197000, bendigo 0x21179b3b, castlemaine 0x7c47f639). synthetic/marched-fixtures/classic/default frozen — the alpha changed nothing outside the cache-schema path. selfcheck ALL GREEN 51670/51670; scaffold + consistency GREEN.
  4. New finding filed (beta, non-blocking): spine-poster density scales with edge count (katoomba 438 posters vs ~14 synthetic; draws stay low via instancing) — a beta posters-per-town cap.

Handshake fired: katoomba_real boots real roads, golden pinned, findings resolved → B/C/D/F. D re-measures (1 component / 100% / 0 stranded); F applies B's BIG_CITY one-liner + gates + tags v4.0-alpha.

Round 18 (2026-07-16) — v4.0-alpha REAL ROADS: cache schema v2 + the real-graph lift (the epoch headline)

Lane A is the round's long pole (serial spine A→E→A). Two commits:

Commit 1 — cache schema v2 (roads[]), published FIRST (4dc80c2) so E's build_towns.py can fetch road geometry. A v2 cache adds roads:[{kind,pts:[[lat,lon],…]}] (kind = OSM highway=*); validateTownCache accepts schema …/1 AND …/2 (roads absent ⇒ marched fallback, nothing regresses); ROAD_KIND maps the class to a CityPlan edge kind. Full contract in web/assets/towns/README.md.

Commit 2 — the real-graph lift (plan_osm.buildRealRoads): real OSM ways → the CityPlan street graph, same schema out (B/C/D/F consume it unchanged). 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/side), marched + overlap-resolved. Passes the full structuralSuite + gig district + determinism on a committed synthetic irregular graph (main + two cross streets + intersection + acute angle + dead-end lane + curve). Robust on messy inputs (single road, coincident dups, disconnected ways, all-roads-too-short → marched fallback).

Frozen (the alpha changes only the roads path): classic 0x3fa36874, gig 0xb1d48ea1, osm melbourne/ katoomba/silverton, + E's 4 trailing real caches pinned on v1 (bendigo/castlemaine/fremantle/newtown). katoomba_real stays UNPINNED — its golden re-pins to the real-ROADS output when E lands its roads (the A→E→A finalization). selfcheck ALL GREEN 16810/16810; scaffold + consistency GREEN.

Fidelity decision (charter risk #4): real edge + real order + real side + regularised spacing — NOT pixel-exact (real shops overlap; no-overlap is load-bearing). Alpha failure list (LANE_A_NOTES §R18): (1) overflow drop on short edges (norm.dropped); (2) intersections = shared coordinates only (crossings without a shared OSM node → disconnected edges — a D sim-graph risk); (3) blocks are per-edge frontage strips not planar faces (a B ground-render audit item); (4) crowded-intersection overlap-resolve drop.

Handshake: schema v2 → E (done). "katoomba_real boots with real roads, golden pinned" → B/C/D fires when E's roads land (katoomba is v1/marched until then). B/C/D audit prep isn't blocked on me.

Round 17 (2026-07-16) — v3.2 THE SCOUT: the town-cache contract + plan_osm hardened for real data

Lane A ran first (E's build_towns.py builds to the contract). Scope fence held: no new plan features, no golden moved — the scout proves the existing ?plansrc=osm contract eats real geography.

Delivered (one commit):

  1. The town-cache contract (published FIRST, ledger #6). plan_osm.js gains validateTownCache() (the authoritative {ok, errors[], warnings[]} check), registerTownCache(), a generatePlanOSM(…, {cache}) seam, and TOWN_CACHE_SCHEMA/MIN_TOWN_SHOPS/MAX_TOWN_SPAN_M. Shape: {schema, key, town, source, license, attribution, center{lat,lon}, shops[{id,name,type,lat,lon,suburb?}]}. Home declared: web/assets/towns/<key>.json (+ a README.md with the full spec). Documented in CITY_SPEC (v3.2 section)
    • LANE_A_NOTES §R17 (the E handshake).
  2. plan_osm hardened — blank name → registry label; the validator gates finite center/coords, a ≥6-shop floor, and warns on unknown types (→opshop), dup ids, and span > 5 km (the mega-strip). Proven against thriftgod's real 2,828-shop city_source.json: boots a valid plan, 0 bad coords, 0 "undefined" names. Marching regularises messy real coords, so dup/odd positions are a non-issue.
  3. Selfcheck: contract stress suite + real-cache path. A committed synthetic-adversarial suite (valid messy caches boot + pass structural/gig; invalid ones reject; wide-spread warns; the registry seam works), plus an auto-loader for web/assets/towns/*.json — validates, runs the suites, pins per-town goldens. Empty until E lands caches (guarded, green). ALL GREEN 15277/15277; scaffold + consistency GREEN.
  4. The v4 risk list (LANE_A_NOTES §R17, for Fable's charter): (a) the mega-strip — an unbounded Overpass bbox marches a whole region (2,828 shops) into an 11.6 km strip; town SELECTION is E's real quality lever, warned at >5 km, the generator can't rescue an over-broad query; (b) sparse compact towns (a 2 km inner-Canberra radius held only 6 shops); (c) settled — osm facades clear (rows 54 m apart), so the R16 clearance bias is a no-op on real towns.

Handshake open: E builds build_towns.py to the contract; E→A each cache → I pin its golden.

Round 16 (2026-07-16) — v3.1 THE FLIP: corner-poster clearance bias (#6) + no-spine osm fixture (#7)

Lane A ran first among the parallel lanes (F bookends with the flip; A's golden feeds F's default-boot gate). The R15 finding was that a corner venue's frontage poster can sit near a crossing kerb.

Delivered (one commit):

  1. Corner-poster fix — via pickVenues, not a nudge. I proved the brief's proposed fix (nudge along the facade) is geometrically impossible: the crossing edge is parallel to the facade and spans it (0/187 along-facade slides help). Root cause: band_room/rsl land on through-lots on narrow warehouse blocks, so the +Z wall (B's door side) faces the block's other street, overlapping its carriageway by up to ~3 m. Surfaced to Fable; ruling: bias pickVenues. It now prefers a band_room/rsl candidate whose +Z facade clears every carriageway by ≥ POSTER_CLEAR (0.5 m), broadening off-spine when the fringe is uniformly narrow. Result: 0/851 frontage posters below floor across 400 seeds (worst 0.51 m); pub untouched (hero shot unmoved). Feasibility pre-checked: a clear lot always exists.
  2. Gig golden re-pinned 0x4f4a549d → 0xb1d48ea1 — the sanctioned move (posters moved); synthetic/ classic 0x3fa36874 frozen. §v3 FROZEN marker records the amendment.
  3. Selfcheck: exemption → floor. R15's blanket frontage exemption is now a real invariant — frontage posters must clear their kerb by ≥ POSTER_CLEAR (exported from the barrel), spine posters keep ≥ 0.
  4. No-spine osm fixture silverton (ledger #7): a constructed single-row town (all shops one latitude ⇒ 0 main edges). Closes the R14 gap (both real fixtures had a spine); proves pickVenues' no-spine branch + buildPosters' no-main fallback. Base golden pinned 0xd4b351c9; sweep asserts 0 main edges + graceful 24-venue placement. Selfcheck ALL GREEN 14769/14769; scaffold + consistency GREEN.
  5. CITY_SPEC §v3 amended (golden line, poster clearance-bias prose, no-spine note, recorded amendment). Left the prime-flag-law blockquote for F (the v3.1 classic-law + flags-table is F's §v3 amendment).

For F: default-boot golden = 0xb1d48ea1; ?classic=1 regression target = 0x3fa36874.

Round 15 (2026-07-16) — the frontage-poster +Z flip (ledger #1) + one sanctioned golden move

Lane A ran first (B's hero re-shoot + F's tour hang off it). The R14 review found every venue's own-frontage poster had been on the building's Z back since R13 — ~16 m behind the street face (B measured 15.9 m), invisible from the street, which is why John's queue_night framed no poster.

Delivered (one commit):

  1. Frontage-poster fix (gigs.js buildPosters item 1): flipped to the +Z street facade (B's canon) AND ry = lot.ry + π so the printed face points +Z at the queue-side camera (else DoubleSide renders a mirrored band name). Proven: pub + rsl frontage posters now sit 0.06 m from B's door, printed-face·(+Z street) = 1.000. Spine posters untouched.
  2. Gig golden re-pinned 0x1f636349 → 0x4f4a549d — the round's ONE sanctioned move (posters moved); synthetic 0x3fa36874 frozen. §v3 FROZEN marker records the amendment (amendment law).
  3. Selfcheck poster check refined: the brief expected the 400-seed off-carriageway assert to stay green, but the +Z facade is the block-interior side in plan coords, so corner venues (a band_room/ rsl fronting one street with another behind — seed 1 lot#481, 1.58 m from a crossing centreline) put the frontage poster near that street. That's B's building geometry (the door is there too), not a mid-road defect — so the no-bitumen rule now exempts building-bound frontage posters and keeps it for free-standing spine posters. Sweep green again: 14264/14264.
  4. CITY_SPEC §v3 amended (poster prose → +Z facade + ry+π; golden line; frontage/spine carriageway split; recorded amendment in the FROZEN marker). LANE_A_NOTES §R15 hands B/F the new golden + a corner-venue eyeball note.

Convention settled: the visible venue face is +Z (buildings.js facade zf=+d/2; venue.js door/queue +Z). The selfcheck's facing=(sin,cos) "faces frontEdge" is the sim/GLB convention, 180° off the visual front — the same π-ambiguity D fixed for the fleet in R13.

Round 14 (2026-07-16) — v3.0 RELEASE: invariant sweep + CITY_SPEC §v3 freeze

Release round, Lane A scope = harden + freeze (no new systems). No golden moved (synthetic 0x3fa36874, gig 0x1f636349 both re-verified frozen); the only code touched is selfcheck.js (test-only), so no seeded stream gained a draw — the release-round law holds.

Delivered:

  1. 400-seed district invariant sweep (selfcheck.js, ALL GREEN 14264/14264 in ~0.7s). Factored the district checks out of gigSuite into districtInvariants(plan,label) (behaviour-preserving — the 4 hero-seed gigSuite runs are byte-identical), then swept seeds 1400 + the large edge seeds asserting the whole contract each: 24 venues, ≥1 pub, never the openLate shop, no band twice per night town-wide, per-venue 46 nights, every poster off the carriageway. Added a distribution assert that the seeded count spans {2,3,4} across 400 seeds (146/121/133) — proves the range is exercised, not pinned. structuralSuite still runs on the 6 hero SEEDS + osm parity (unchanged); the sweep runs the light district checks only, so 400 gig-on generations stay ~0.5s.
  2. osm graceful placement in the sweep: katoomba (19 shops, no warehouse district) still lands 24 valid venues incl. a pub for every hero seed — proving pickVenues' off-spine fallback. Honest scope noted: both osm fixtures keep a main spine, so this proves the no-warehouse branch (not no-spine).
  3. Poster-check hardening: kept the roadWidth/2 nearest-edge formulation (a literal poleOffset compare would false-fail corner posters); 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).
  4. CITY_SPEC §v3 frozen at v3.0 — §v2-style FROZEN marker, final wording pass over venues table, genre map, gigKey contract, corridor law, gig golden. Genres final (pubrock/grunge/covers, Fable's ruling).

Coordination: the R12 single-venue alias line stays in §v3 for F to delete atomically with the gig_state.js getters (debt #1) — deleting it now would be doc-ahead-of-code (alias still live) and would race F on the same lines; the freeze marker carves it out explicitly. Flagged to F that the runtime block omits nightOf(id), and to B that venue.js:228 is a second bare-string edit site. Not touched: web/assets/manifest.json shows a concurrent Lane E glb_law edit in the shared tree — left for E.

Round 13 (2026-07-15) — v3.0-beta THE DISTRICT: 24 venues + the week + town-wide posters + verge fix

Lane A ran first again (C/D/B/F all hang off the district plan.gigs). Same flag, same prime-flag law: flags-off byte-identical — all v2 goldens frozen (synthetic 0x3fa36874, osm unchanged). Self-check ALL GREEN 3273/3273; scaffold_check + consistency GREEN. The gig entry shapes are unchanged — the alpha's one-venue readers now iterate 24. Interfaces published in LANE_A_NOTES.md (§Round 13) and CITY_SPEC.md (§v3.0-beta) in this same commit.

Delivered (all behind ?gigs=1):

  1. 24 venues, three kindspickVenues converts a seeded 24 plain shops in place: pub (spine end), band_room (warehouse fringe), rsl (off-spine), 4th = a 2nd pub at the far end. Each carries venueKind + genreKey (pubrock/grunge/covers). Registry gains weight-0 band_room+rsl types (facades, hours, footprints). Placement degrades gracefully so osm towns still get venues. Never the openLate landmark; venues occupy distinct lots.
  2. The gigKey contract (debt #1)gigKeyFor(genreKey) => 'gig-'+genreKey, the single exported genre→bed mapping (no table anywhere). C/E/B/F all route through it; F deletes the GIG_BED bridge.
  3. plan.gigs goes district — 7 nights × every venue, each playing a seeded 46 (dark the rest, reads true). gigId unique town-wide; cover skewed by kind (pub 50%/$210, RSL 45%/$25, band_room 70%/$26). No band plays two venues on one night; custom names spread night-major across the week.
  4. plan.posters goes town-wide — tonight's playing venues each get a frontage poster + a seeded spine run (2/main edge, either verge). Different venues share pole runs — the district reading.
  5. Verge offset (debt #5 PAID) — the R12 bug (spine posters at node.x+2 = mid-bitumen on a 28 m corridor) is fixed by the new corridor law: roadWidth/vergeBand/poleOffset(edge) in registry.js, re-exported from the barrel — the road-vs-verge split of edge.width, published as functions (no plan fields ⇒ base goldens frozen). Selfcheck now asserts no poster stands on a carriageway.
  6. Registry pub.interior corrected 'band_room''pub' (was an unread field; now avoids a name-collision with the new band_room venue type — C keys recipes off shop.type, not this field).

Golden re-pin: gig golden 0xa6ae5a5e0x1f636349 (multi-venue changed gig-plan output; re-pinned in-commit per CITY_SPEC amendment law). New selfcheck coverage: 24 venues + known kinds + type≡venueKind

  • genre map + gigKey ≡ gig-+genreKey; 46 gigs/venue, ≤1 gig/night/venue, no same-night band clash town-wide; posters advertise night-0 and clear every carriageway. Sample seed 20261990 → 3 venues: The Exchange Hotel (pub), Wangaratta RSL, The Vinyl Shed (band room, dark tonight); 14 gigs across the week.

Handshake note for Lane C: RECIPES.band_room/RECIPES.rsl don't exist in theme.js yet — until C adds them those venues fall back to the op-shop interior. That's the order (A→C). Not run by A: the browser flags/scale gates + depot (Gate 3, John-blocked) — F's end-of-round acceptance, and they'd test R12 runtime that hasn't adapted to the new venue count yet.

Round 12 (2026-07-15) — v3.0-alpha GIG LAYER (?gigs=1): venue + gigs + band names + closing-time debt

Lane A ran first this round (the whole round hangs off the plan.gigs handshake). Everything is behind ?gigs=1 (prime flag law): flags-off is byte-identical — all v2 goldens frozen (synthetic 0x3fa36874, osm melbourne 0x34cfdec0, katoomba 0x0f652510). Self-check ALL GREEN 3074/3074 (+ the whole gig suite ×2 sources), qa.sh --strict GREEN (6/6). Verified in-browser both modes.

Architecture — a post-hoc augmentation layer, not a generator change. web/js/citygen/gigs.js withGigs(plan, seed, opts) takes a finished plan and adds the venue + gigs + posters; the selector (index.js generatePlanFor) applies it only when opts.gigs. So generatePlan is untouched (goldens can't move) and the layer is source-agnostic (synthetic + osm both get gigs).

Delivered (all behind the flag):

  1. Venue — one pub per town: a spine-END plain shop converted in place to type:'pub' (keeps lot id/geometry ⇒ enterable, no overlap), shop.venue=true, hours [17,23]. Never the openLate shop. Registry gains a weight-0 pub type (facades djsim-pub+federation, interior:'band_room' for Lane C, fittings). Weight-0 ⇒ never auto-placed ⇒ base plan unchanged.
  2. plan.gigs — 7 nightly entries {gigId, venueShopId, bandName, genreKey:'pubrock', night, startSeg:5, endSeg:5, cover}; cover seeded ~half free / half $2$10. F picks tonight's + charges at the door.
  3. Band namesbandName(seed) generator (90s Aussie pub-rock wordlists) + the drop-in web/assets/custom_bands.json ({bands:[…]}, ships with 10 examples incl. "The Feral Galahs"): present → priority into the pool (up to 7 surface); absent → pure generator, zero fetch (?noassets). Deterministic given (seed, file). The shell (F) does the async load and passes customBands in.
  4. plan.posters — ~6 seeded poster placements {id, gigId, x, z, ry} (venue + spine) for Lane B.
  5. Closing-time debt PAIDisOpen(shopOrHours, hour) exported from the barrel: the canonical half-open law (open ≤ h < close) as code, for D's gig-night patronage surge (was prose in round 9).
  6. Debug mapmap.html?gigs=1 shows the venue ring + "♪ PUB" label, poster pins, and a panel gig line ("♪ The Thornbury Hotel · tonight: Screaming Utes · $10 cover · 7 nights · 6 posters"). Loads the band drop-in for the view. Flags-off map is unchanged (adds a benign pub (0) legend row).

New self-check coverage: gigSuite runs the full structural suite (venue-aware — the pub is exempt from the single-late-shop rule via !sh.venue, a no-op on base plans) + gig invariants (one pub, gig schema, cover ∈ {0}[2,10], band names resolved, posters ref real gigs), on synthetic + both osm towns; a ?gigs-off≡base byte-identity check per seed; a custom-band priority + determinism check; and a gig golden 0xa6ae5a5e. CITY_SPEC gained a "CityPlan v3 — gig layer" section; handshake interfaces for C/D/B/F are in LANE_A_NOTES.md. Sample seed 20261990 → venue "The Thornbury Hotel" (a converted spine-end pawn shop), openLate video preserved.

Round 9 (2026-07-15) — freeze the v2 contract + closing-time ruling (ship round)

Small round; the most "done" lane makes it official.

  1. CITY_SPEC v2 contract FROZEN — added a "CityPlan v2 — frozen producer contract" section: the producer API (generatePlanFor/generatePlan/generatePlanOSM/osmTownKeys/chunkIndex), the goldens table (synthetic 0x3fa36874, osm/melbourne 0x34cfdec0, osm/katoomba 0x0f652510, keyed (seed,plansrc,town)), the town-key mechanism, osm normalization rules, and the hours/openLate/storeys laws — all matching code as shipped (F diffs claims vs code in review). Refreshed the stale round-6 "Plan sources" blockquote to the multi-town reality. Layer-1 changes now require a CITY_SPEC amendment + golden re-pin in the same commit.
  2. Recipe sanity — dry-ran end-to-end (not just eyeballed): appended a throwaway Newtown (42 shops) to OSM_TOWNS, ran selfcheck → the full parity suite auto-covered it (1931/1932, only the golden unpinned) and printed a ready-to-paste newtown: 0x9045e577, line. Reverted; no 3rd town shipped (didn't fall out free). Improved selfcheck to print that ⚠ UNPINNED — add to OSM_GOLDENS line so the recipe is genuinely mechanical; fixed the recipe wording to match.
  3. Closing-time occupant ruling (D's on-call question) — ratifies what D's sim.js already does and freezes it: half-open hours, no new entry when closed, occupants drain (finish their bounded dwell) — never popped, player not force-ejected, openLate video is the night destination. In CITY_SPEC (contract law) + LANE_A_NOTES.md (for D). No code change asked of D.

Self-check ALL GREEN 1727/1727; qa.sh --strict GREEN. Only doc + one selfcheck log-line touched; no generator change ⇒ all three goldens unchanged.

Round 8 (2026-07-15) — OSM to parity + a second town (carried R7 brief, FULLY LANDED)

Round 8 (2026-07-15) — OSM to parity + a second town (carried R7 brief, FULLY LANDED)

The carried brief (slipped R7) is done — all four items, not a partial.

  1. Selfcheck parity: extracted the full invariant suite into structuralSuite(plan, label) and run it on every (source, town) — synthetic + both OSM towns now get identical coverage (storeys, one-shop-per-lot, solid-use, facing, within/cross-block overlap, corridor coverage, determinism, exactly-one-openLate). ALL GREEN 1727/1727 (was 1362 — OSM went from a subset to the full suite ×2 towns).
  2. Second town: Katoomba (Blue Mountains, 19 shops, op-shop/book heavy) — a small-regional contrast to inner-Melbourne (95, book heavy). ?plansrc=osm&town=katoomba. osm_fixture.js is now OSM_TOWNS { melbourne, katoomba } + DEFAULT_TOWN; generatePlanFor(seed, source, { town }). Golden pinned 0x0f652510. Melbourne golden 0x34cfdec0 and synthetic 0x3fa36874 unchanged.
  3. "Add a town" recipe documented in LANE_A_NOTES.md (R8) — extraction → OSM_TOWNS entry → pin golden → tell F. Mechanical.
  4. Normalization + logging: importer normalizes real data to the contracts (unknown kind→opshop, hours clamp so only one shop is openLate, video-preferred landmark w/ fallback) and logs what it changed via { report } — selfcheck prints e.g. melbourne: 95 shops · normalized {typesRemapped:0, hoursClamped:5, openLate:video}. The contract never bends; the data does.

Extraction one-liner (Katoomba, reusable — see recipe): cell 0.05 around (-33.714, 150.312), same KIND map + dedupe + sort-by-id as Melbourne (cell 0.06 around -37.803, 144.967). Screenshots: docs/shots/laneA/osm-{melbourne,katoomba}.png. qa.sh --strict GREEN.

Round 6 (2026-07-14) — the second plan source (?plansrc=osm), FULLY LANDED

The carried-over OSM plan-source seam (no-showed rounds 45) is done — not the escape-hatch partial, the whole thing, invariants green. A second CityPlan producer behind the same contract, so Lanes BF consume it unchanged.

  • osm_fixture.js — 95 REAL inner-Melbourne secondhand/charity/music/book/toy/antique shops (book 36, toy 14, opshop 13, record 12, video 11, pawn 9), extracted deterministically from thriftgod's city_source.json Overpass cache. Checked in, imported as a module → zero network.
  • plan_osm.jsgeneratePlanOSM(seed): projects real lat/lon → metres, bins shops into latitude-band avenues off a spine (real row + real eastwest order preserved), marches them into a valid, non-overlapping CityPlan, centres on origin, reports its true 362×486 bbox. Real shop names kept ("Central Catholic Bookshop"). Exactly-one-openLate-video rule matches synthetic.
  • index.jsgeneratePlanFor(seed, source) selector; default 'synthetic' is byte-identical (golden 0x3fa36874 untouched). generatePlanOSM exported for F.
  • selfcheck.js — parameterized by source: OSM runs the full structural invariant suite (frontEdge/facing/no-overlap/chunk-coverage/finite/JSON/determinism/openLate) + its own golden 0x34cfdec0; synthetic-only brief checks (market/arcade/dept/milkbar) deliberately skipped.
  • map.html?plansrc=osm renders it; screenshot docs/shots/laneA/osm-melbourne.png.
  • Seam + goldens + shape-differences documented for F's determinism gate in LANE_A_NOTES.md (R6).

Verified: 95/95 shops placed, 0 bad frontEdges / 0 overlaps / 0 backwards / full chunk coverage, deterministic, synthetic path byte-identical. Self-check ALL GREEN 1362/1362; qa.sh --strict GREEN. Honest cut (documented): street geometry is regularized (real row+order drive it, exact projected metres do not) and shop-name parody is deferred.

Round 4 (2026-07-14) — v1-tag round: 2 confirmations, 1 hardening fix

A1 — storeys checker (post-F2): CONFIRMED. tools/qa/consistency_check.mjs permittedMax scoping (Lane F's F2, matching my R3 spec) gives 0 storeys warnings across 7 seeds; qa.sh --strict GREEN (4/4). False-negative test: injected a real video@2 into a scratch copy of the checker's input → caught on every seed. No benign-anchor false positives, no real-drift false negatives.

A2 — openLate contract: AUDITED + HARDENED. F's night gate hours[1] >= 22 was matching 415 shops/seed (regular videos jittered from base-21 to 22), not the single landmark it assumes. Fixed in plan.js so hours[1] >= 22openLate (exactly one, always the video rental): regular shops close by 21:00; only the deterministically-chosen video landmark reaches ≥22 (LATE_HOUR). This also makes the generator finally match what web/index.html:159-161 and soak.py:68 already assumed ("at night only the openLate shop is open"). Added selfcheck assertions; verified across 15 seeds.

⚠ GOLDEN.hash: 0x098eec2b0x3fa36874 — intended output change from the A2 fix (not drift; F2 is tooling-only and didn't touch generation). Same lots/shops/types/names/skins; only a few videos' close hour moved 22→21. Details for Fable in LANE_A_NOTES.md (round 4).

Self-check: ✓ ALL GREEN — 1301/1301 (was 1283; +18 are the new openLate exactly-one / field⟺threshold / is-video assertions). Seed 20261990 → "Boolarra Heads": 681 lots, 493 shops, unchanged.

⚠ Fable — commit-label mixup (no action needed on the code): my round-2 changes are committed and correct, but a shared-working-tree race landed them under Lane C's message — commit 4235060 "Lane C: keeper stand pose…" actually contains only my 5 Lane A files (a concurrent git commit grabbed my staged index between my add and my commit). Lane C's real keeper-pose changes are still uncommitted & intact. Nothing is pushed, so relabel at your leisure when the tree is quiet (git commit --amend). Systemic: lanes should commit atomically with git commit -- <paths> -m … to avoid this. Self-check green 1283/1283; content is safe.

TL;DR

generatePlan(citySeed) → CityPlan is done: a deterministic, JSON-serializable, <1ms pure-data town generator matching CITY_SPEC schema v1, plus chunkIndex, the shared shop-type registry, a seeded 90s-AU name generator, a Canvas-2D map debugger, and a node self-check that asserts the whole acceptance contract. node web/js/citygen/selfcheck.js prints ALL GREEN (1082/1082). Five seeds render as recognisable towns (screenshots in docs/shots/laneA/).

Deliverables (all present)

# file what
1 web/js/citygen/plan.js generatePlan(citySeed), chunkIndex(plan), CHUNK, chunkKey
2 web/js/citygen/names.js + wordlists.js seeded shop+town names, 50+ patterns, short signboard forms
3 web/js/core/registry.js SHOP_TYPES (all 9), district weights, enums, pure helpers
4 web/map.html standalone Canvas-2D plan viewer (pan/zoom/hover, layer toggles, seed box, regen, export JSON, chunk grid)
5 web/js/citygen/selfcheck.js node web/js/citygen/selfcheck.js — full acceptance harness
+ web/package.json {"type":"module"} so node runs the self-check as ESM (see Treaty notes)
+ docs/shots/laneA/seed-*.png 5 seeds screenshotted (20261990, 1, 42, 777, 8675309)

What the generator produces (matches the CITY_SPEC design brief)

  • Main-street spine — 7 stations S→N through the origin, x-jitter ±30m, 28m corridor, kind:'main'. Continuous narrow-frontage (69m) retail both sides.
  • Cross streets at 4 stations; the two central ones are wider "second high streets", the outer two are grittier backstreets. All kind:'side', ±8° jitter.
  • Market square west of the origin — a tidy 5×8 grid of stall lots + the dept anchor fronting the spine.
  • Arcade — a kind:'arcade' (width 5) pedestrian lane cutting east through a mid-spine block, lined both sides with tiny 35m record-heavy shops.
  • Warehouse fringe — sparse big lots beyond one spine end (N or S, seeded), mostly infill with the odd pawn shop.
  • Residential collar — a loose ring road of house lots with 24 corner milk bars embedded.
  • Laneways (kind:'lane') behind the central blocks, with yard lots for back-door flavour.

Sample (seed 20261990 → "Boolarra Heads"): 27 nodes, 22 edges, 35 blocks, 711 lots, 523 shops, 152 chunks.

Self-check coverage (node web/js/citygen/selfcheck.js)

Determinism (two runs byte-identical) · <100ms (actually <1ms) · every edge→real nodes · every block→real district · every lot→real block + valid frontEdge + positive size · every shop→real lot + known type + facade skin exists on disk + sane hours + named · one-shop-per-lot · no overlapping lots within a block (rotated-rect OBB / SAT test) · chunkIndex covers every lot & buckets reference real ids · JSON round-trip lossless · design-brief presence (spine, arcade, market, stalls, dept anchor, 24 milkbars) · all registry + used facades exist in web/assets/gen/. Run over 6 seeds → 1082/1082.

House-law compliance

  • Zero Math.random and zero THREE imports in js/citygen/* and registry.js (grep-clean). All randomness flows through web/js/core/prng.js. (map.html uses Math.random only to pick a random seed to view — the generation it then runs is fully seeded; the map is pure Canvas 2D, no THREE.)
  • JSON-serializable, deterministic, <100ms — verified.
  • Registry is flat data every lane imports; facade pools are real files in web/assets/gen/.

Treaty / cross-lane notes for Fable

  1. web/package.json added. Needed so node web/js/citygen/selfcheck.js runs the ES modules (browsers use <script type=module>; node needs "type":"module"). It's additive infra — python http.server and browsers ignore it, no deps, no build. Not in the ownership table; flagging for your blessing. Other lanes can now node-test their pure modules too.
  2. Schema extensions (non-breaking). Added kind to each block (its district-kind string, alongside the spec's district id) so the map & Lane B can theme a block without a district lookup. block.district is the district id; districts[id].kind is the kind. Everything else matches schema v1 exactly.
  3. ry convention (Lane B please read): a lot's ry is the Y-rotation so a GLB modelled facing Z ends up with its facade's outward normal pointing at its frontEdge street. Derivation + a worked example are in plan.js (marchStrip). frontEdge is a valid edge id for every lot (stalls/dept front the nearest spine edge).
  4. pipeline/gen_names.py intentionally NOT shipped. The Lane A brief offered an optional Ollama word-expansion script, but pipeline/* is Lane E's owned dir per the CITY_SPEC ownership table. Wordlists are hand-authored and checked in; runtime does zero network calls. If you want the Ollama expansion, it should live in Lane E's pipeline or be re-assigned.

How to eyeball it

cd web && python3 -m http.server 8130
# http://localhost:8130/map.html            (default seed)
# http://localhost:8130/map.html?seed=42    (any uint32 seed; ?seed= drives the screenshot harness)
node web/js/citygen/selfcheck.js             # acceptance harness, prints ALL GREEN

Open questions for Fable

  • Retail density: a core seed yields ~500 shops (continuous frontage, as briefed). Happy with that, or dial down for Lane B's draw-call budget? (It's one constant per band; trivial to tune.)
  • Want the residential collar to be a true closed ring vs the current 4-edge loop? (Current reads fine on the map; a fuller street graph is a later enhancement.)

Adversarial review + fixes (round 1)

Ran a 6-dimension multi-agent review of the whole lane (27 agents: determinism · schema/spec · geometry · chunk/JSON/perf · cross-lane contract · code quality), each finding independently verified by a skeptic agent that ran the real generator to confirm or refute. 21 findings raised, 15 confirmed real, 6 refuted. All confirmed defects are now fixed and re-verified; the self-check grew new assertions so none of these classes can regress silently.

# defect (confirmed) sev fix
D1 market stalls + dept anchor faced backwards (ryEast sign inverted → facades pointed west, away from the spine they front) HIGH ryEast = atan2(-1,0); self-check now asserts every lot faces its frontEdge
D2 cross-block lot overlaps at spine×cross-street corners (≈45 building-lot pairs/seed, ≤8.7m) — self-check only tested within a block HIGH reserve corner (rung near-spine inset 14→34m) + a deterministic overlap-resolution pass (demote later lot to infill); self-check now runs global cross-block OBB/SAT over building lots
D3 chunkIndex skipped chunks (32m point-sampling on a 64m grid); the origin chunk under the spine was omitted; road width ignored HIGH AmanatidesWoo grid supercover + kerb rails (centreline ± half-width); self-check now asserts edge coverage vs a dense supercover
D4 market stalls' frontEdge pointed at the wrong spine segment (z-band mismatch, up to 122m off) MED stalls → spineEdges[2], dept → spineEdges[3]
D5 name generator emitted doubled possessives (Maccass) — {First}*s fired both the * and the literal s HIGH dropped the redundant *; simplified fill(); self-check rejects unresolved tokens
D6 map.html drew the main road with var(--roadmain), which canvas can't parse → the spine rendered near-invisible MED pass the bare custom-property name to getcss() (now a proper brown spine)
D7 registry facades/fittings drifted from the CITY_SPEC table LOW amended CITY_SPEC's shop-types table (in this commit, flagged) to match the registry lanes import
+ _sbl Map cache was memoized onto the plan → polluted exported JSON with "_sbl":{} LOW moved the cache to a module-level var, reset per regen
+ cornerBoost could push single-storey types (video/milkbar) above their registry max LOW gated to types with registry max ≥ 2
+ no golden-fingerprint / cross-revision drift guard; lot coords never checked finite added a committed golden hash for seed 20261990 + finiteness assertions
+ undocumented block.kind / block.district-is-id; dead stall guard documented the schema in CITY_SPEC; removed dead code

Refuted (6) — investigated and dismissed with reasons, e.g. "the determinism test is a tautology" (true that it only catches in-process nondeterminism — so I added the golden fingerprint for real drift detection) and "package.json isn't in the ownership table" (correct, but it's required infra — now added to the table).

Post-fix self-check: ✓ ALL GREEN — 1271/1271 (was 1082; +189 checks are the new facing/cross-block-overlap/edge-coverage/finiteness/fingerprint assertions). Seed 20261990 → "Boolarra Heads": 27 nodes, 22 edges, 35 blocks, 681 lots, 493 shops, 174 chunks. All 5 seed screenshots re-captured. Golden fingerprint 0xb5d5cc13.

Round 2 — cross-lane integration responses (2026-07-14)

Picked the lane back up after BF integrated against it. Worked through the notes those lanes left for Lane A (docs/LANES/LANE_F_NOTES.md §8/§3.5/§7, B-progress.md, E-progress.md) and closed every request that lands in my files. Self-check green at 1283/1283; golden fingerprint refreshed 0xb5d5cc130x098eec2b; map.html re-verified in-browser (renders a town, chunk-grid overlay works, zero console errors). No screenshot refresh needed — the changes don't alter the plan's 2D appearance (the map colours by type/use, not by facade or hours).

# request (who asked) what I changed files
R1 "Which field marks the open-late shop, and what's the hours encoding?" (Lane F §8); "exactly one late-night shop per town" (F §3.5) The old sr()<0.06 per-shop dice yielded 0…many late shops and — because its formula was max(close,19)+0..2 — usually none past 22:00, so F's hours[1]>=22 gate was flaky/false. Replaced it with exactly one deterministic openLate:true landmark per town, chosen after de-confliction from a naturally-late type (video/milk bar, never a stall), closing 22:00 or 23:00. Encoding documented: hours=[open,close], 24h ints, 0≤open<close≤23. plan.js, CITY_SPEC.md
R2 Skin-name double-prefix trap — registry stores full filenames, skins.js expected bare keys → facade-facade-…jpg.jpg 404s (Lane F §7); "pick one and say so" on field types (Lane B) Added a Field-type contract to CITY_SPEC: all ids + id-refs are integers; facadeSkin/registry facades entries are full filenames under web/assets/gen/, loaded verbatim (extended to E's ground/awning/sky/wall/interior skins). Lane A is the source of truth; consumer fixtures conform to it. CITY_SPEC.md
R3 chunkIndex furniture-drop — verge props at a perpendicular offset bucketed into a chunk that omitted their edge (Lane B) Rewrote edge bucketing from a 3-rail supercover to a gap-free corridor rasterisation: each edge is split into ≤64m pieces and every cell of each piece's ±width/2 corridor-rect AABB is bucketed. Now provably covers the full road+verge band. Self-check samples 9 offsets across the band (incl. between-rail) to prove it. plan.js, selfcheck.js
R4 stall pool has only 1 facade (Lane E validator wants ≥2) Added facade-corrugated.jpg (market-shed backdrop) to the stall pool — matches Lane E's manifest facade→type map, which already tags corrugated for stall. Every type now has ≥2 facades. registry.js, CITY_SPEC.md

New self-check assertions (regression guards): exactly one openLate shop per seed; the late shop closes ≥22:00 and isn't a stall; chunkIndex covers the full road corridor sampled densely across the verge band (not just the centreline).

Empirical result (5 seeds): every town gets one late-night video store — e.g. seed 20261990 → "Yass VIDEO" open till 23:00, seed 42 → "MOVIES" till 23:00, seed 1 → "RENTALS" till 22:00. Both stall facades now appear in the market square.

Notes for Fable / other lanes

  • Lane B can drop its private furniture-rasterisation workaround if it likes — chunkIndex.edges now covers the whole corridor at the source, so any consumer (incl. Lane D footpath NPCs) can trust it.
  • Orientation: CITY_SPEC (§Units/axes + Layer-1 schema) already specifies GLB front = Z at ry=0, world facing (sin ry, cos ry) toward frontEdge; the self-check enforces it and B renders it correctly. Lane B's note prose says "+Z" informally — flagging only so the Z spec stays the single reference for Lane C/E when modelling facades. No code change; it works.
  • Still-open design questions from round 1 (retail density ~493/core vs Lane B's draw budget; true closed residential ring) remain parked for your call — both are one-constant tunes, not rework.

Round 3 — verification round (2026-07-14, per ROUND3_INSTRUCTIONS "Lane A — on-call + one verification")

Small round as briefed. All three tasks done; bash tools/qa.sh --strict GREEN (4/4 gates, selfcheck 1283/1283, manifest 0 errors).

1. F finding #5 — "shops with storeys outside the registry type range" → verified STALE / CLOSED. Scanned every shop across 8 seeds against the correct permitted range. Result: zero violations. video/milkbar/stall (registry [1,1]) are observed at 1 storey only (e.g. 412 video shops, none at 2 — so F's specific "video at 2" case does not occur). The only shops above their registry max are the tall-capable types (record/opshop/toy/book/pawn [1,2], dept [2,3]) reaching 3 via the intentional corner anchor — exactly the CITY_SPEC "occasional 3-storey corner anchor". My round-1 cornerBoost gate (only boosts registry max ≥ 2) already prevents the reported bug.

  • This is the source of qa.sh's 5 consistency warnings ("N shops have storeys outside their registry range … ask Lane A"). They are benign — all intended corner anchors, not plan↔registry drift. Lane F: you can silence them by scoping the check to permittedMax = max≥2 ? min(max+1,3) : max instead of the strict registry max.
  • Hardened anyway (the finding's real lesson): the selfcheck's storeys assertion was too lax (<= Math.max(mx,3) would have passed a video at 3). Tightened to the exact permitted range, so a single-storey type exceeding 1 (or any type past min(max+1,3)) now fails loudly. Proved non-vacuous (rejects video@2, record@4, dept@1; allows record@3). Documented the storeys rule in the CITY_SPEC schema so the intended-vs-drift question can't recur.

2. Golden fingerprint confirmed. Committed GOLDEN.hash = 0x098eec2b matches the current selfcheck fingerprint on main. No output drift. (selfcheck.js assertion tightening doesn't touch the plan, so the hash is unchanged.)

3. Hours / openLate contract — ready for its first real consumer (Lane F §3.5). No changes needed: hours=[open,close] (24h ints, 0≤open<close≤23), exactly one openLate:true landmark per town (closes ≥22:00, video/milkbar, never a stall). ROUND3_INSTRUCTIONS §Lane F restates it correctly, so F's night-gate wiring can consume it as-is. Full contract in CITY_SPEC schema + the round-2 notes above.