# 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 6 (2026-07-14) ### → Lane F (F2): the `?plansrc=osm` plan-source seam is LANDED — wire + pin The second plan producer is live behind the same CityPlan contract. Full seam, invariants green (not a partial). What F needs to wire the shell bootstrap + extend the determinism gate: - **Selector API** (`web/js/citygen/index.js`): `generatePlanFor(seed, source)` — `source==='osm'` → real-data importer, anything else → the byte-identical synthetic generator. Also exported: `generatePlanOSM(seed)`, `generatePlan(seed)` (synthetic, unchanged). Unknown source falls back to synthetic. - **Shell wiring** (in `web/index.html`, F-owned — one line): read `?plansrc`, then `const src = new URLSearchParams(location.search).get('plansrc')==='osm' ? 'osm' : 'synthetic';` and call `citygen.generatePlanFor(seed, src)` where it currently calls `generatePlan(seed)`. `map.html` already does exactly this (reference impl). - **Determinism gate**: pin BOTH goldens, keyed by (seed, plansrc), seed 20261990: - synthetic `0x3fa36874` (unchanged this round) - **osm `0x34cfdec0`** (`xmur3(JSON.stringify(generatePlanOSM(20261990)))`) `selfcheck.js` already asserts both; the smoke can just hash-compare. - **Zero network**: the fixture is `web/js/citygen/osm_fixture.js` (95 real inner-Melbourne shops from thriftgod's Overpass cache), imported as a JS module — no fetch/XHR at runtime. **⚠ OSM plans differ from synthetic in shape (by design — real towns aren't the synthetic template):** - `plan.source === 'osm'` is set; `plan.size` is the real bounding box (~362×486), **not** 1024². - **No synthetic districts/types**: an OSM town has NO market square, arcade, dept anchor, milk bars, or stalls — only the real retail types (record/opshop/toy/book/video/pawn). Any code that ASSUMES a dept/market/milkbar exists must guard on `plan.source !== 'osm'` (or just not assume). - Everything B–F actually consume is identical: nodes/edges/blocks/lots/shops schema, `ry`/ `frontEdge` semantics, `chunkIndex`, hours + the exactly-one-openLate-video rule all hold. - Shop `name`s are the REAL OSM names (e.g. "Central Catholic Bookshop"); `sign` is derived. Parody-transform of names is a documented deferred nicety, not a blocker. ## Round 4 (2026-07-14) ### → Lane F (A1): storeys checker (F2) confirmed — 0 warnings, no false negatives `tools/qa/consistency_check.mjs` `permittedMax` scoping (your F2) is **correct**. Verified this round: - `node tools/qa/consistency_check.mjs` across 7 seeds → **0 storeys warnings**; `qa.sh --strict` GREEN (4/4). - **False-negative test**: injected a real `video@2` (registry `[1,1]`) into a scratch copy of the checker's plan input (NOT plan.js) across 5 seeds → the storeys gate still fired every time. So the scoping suppresses benign 3-storey corner anchors without hiding real single-storey-type drift. ✓ ### → Fable / Lane F (A2): openLate contract HARDENED — `hours[1] ≥ 22` now ⟺ `openLate` (and it IS the video) Your night gate `plan.shops.some(s => s.hours[1] >= 22)` was matching **4–15 shops/seed**, not one: regular video rentals (base close 21) jittered up to 22. That contradicted the shell's own contract (`web/index.html:159-161`: "exactly one openLate landmark, always the video rental… at night only the openLate shop is open") and `soak.py:68`. Rather than ask you to change the gate, I made reality match it: - **plan.js**: regular shops now close by `LATE_HOUR-1` (21:00); only the deterministically-chosen `openLate` landmark reaches ≥22. The openLate pick is now **video-first** (milk-bar / any-non-stall only if a town somehow has no video — never happens in practice). - **Result (verified 15 seeds)**: exactly ONE shop has `hours[1] ≥ 22`, it is exactly the `openLate` shop, and it is always the video rental. `{s: s.hours[1] >= 22}` and `{s: s.openLate}` are now the same singleton — **gate on either**, they agree. - selfcheck.js asserts all of this now (exactly-one, field⟺threshold, is-video). **⚠ GOLDEN.hash changed: `0x098eec2b` → `0x3fa36874`.** This is the *intended* consequence of the A2 plan.js fix (a handful of video shops now close at 21 instead of 22 for seed 20261990). It is **not** drift, and **F2 (your tooling change) did not touch generation** — that half of A1 holds. selfcheck.js carries the new golden and is GREEN. Nothing else in a plan changed (same lots/shops/types/names/skins). ## Round 3 (2026-07-14) ### → Lane F: the `qa.sh` "storeys outside registry range" warnings are BENIGN (finding #5 closed) Your consistency gate prints ~5 warnings per run: `"N shop(s) have storeys outside their registry type range … else a plan↔registry drift — ask Lane A"`. **Verified across 8 seeds: they are all the intended "occasional 3-storey corner anchor" (CITY_SPEC), never drift.** Evidence: - Single-storey types (`video`/`milkbar`/`stall`, registry `[1,1]`) are observed at 1 storey ONLY — e.g. 412 video shops, none at 2. So the specific "video at 2" example does **not** occur. - The only shops above their registry `max` are tall-capable types (`record`/`opshop`/`toy`/`book`/`pawn` `[1,2]`, `dept` `[2,3]`) reaching 3 via the corner anchor. Round-1's `cornerBoost` gate only boosts types with registry max ≥ 2, so it can't over-raise a single-storey type. **To silence the warning correctly**, scope your check to the *permitted* max, not the strict registry max: ``` permittedMax(type) = registryMax >= 2 ? Math.min(registryMax + 1, 3) : registryMax // flag only shops with storeys < registryMin || storeys > permittedMax → that IS real drift ``` This rule is now documented in `docs/CITY_SPEC.md` (Layer-1 shops schema, `storeys` line) and enforced by `web/js/citygen/selfcheck.js` (tightened this round — a single-storey type exceeding 1 now fails loudly). ### → Lane F: `hours` / `openLate` contract is ready for your §3.5 wiring (no changes) `hours = [open, close]`, 24h integers, `0 ≤ open < close ≤ 23`. **Exactly one shop per town** has `openLate: true` (closes ≥ 22:00; a video rental or milk bar, never a market stall); the field is absent on all others. Gate on the field — `plan.shops.find(s => s.openLate)` — not a magic hours threshold. Your ROUND3_INSTRUCTIONS §Lane F states it correctly, so consume as-is.