PROCITY/docs/LANES/LANE_A_NOTES.md
m3ultra f952f513d0 Lane A round 8: OSM to parity + a second town (Katoomba) — carried R7 brief, full
The carried brief (slipped R7) lands complete — all four items, qa.sh --strict GREEN.

1. Selfcheck PARITY: extracted structuralSuite(plan,label) and run the FULL invariant suite
   on every (source, town) — synthetic + both OSM towns get identical coverage (storeys,
   one-shop-per-lot, facing, within/cross-block overlap, corridor coverage, determinism,
   exactly-one-openLate). ALL GREEN 1727/1727 (was 1362; OSM went subset → full ×2 towns).
2. SECOND TOWN: Katoomba (Blue Mountains, 19 shops, op-shop/book heavy) — regional contrast
   to inner-Melbourne. osm_fixture.js → OSM_TOWNS{melbourne,katoomba}+DEFAULT_TOWN;
   generatePlanFor(seed, source, {town}); ?plansrc=osm&town=katoomba. Golden 0x0f652510.
   Melbourne 0x34cfdec0 + synthetic 0x3fa36874 UNCHANGED (town key kept off the plan).
3. "Add a town" recipe in LANE_A_NOTES (R8): extract → OSM_TOWNS → pin golden → tell F.
4. Normalization + LOG: importer bends real data to the contracts (unknown kind→opshop,
   hours-clamp so only one shop is openLate, video-preferred landmark w/ fallback) and logs
   it via {report}; selfcheck prints per-town (e.g. melbourne hoursClamped:5, openLate:video).

For F: three goldens to pin, gate keyed (seed, plansrc, town); selector gained {town} — see
LANE_A_NOTES R8. Screenshots docs/shots/laneA/osm-{melbourne,katoomba}.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 04:15:12 +10:00

9.5 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 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, read the printed osm/<key>: fingerprint 0x…, add <key>: 0x… to OSM_GOLDENS in selfcheck.js. Re-run → green.
  4. Tell F the new (seed, plansrc, town) hash for the determinism gate. Done — zero network, the full parity suite covers it automatically.

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.