From 6fffd19043e2ff48d4df9c041c0b3e07fd221473 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 14:32:14 +1000 Subject: [PATCH] Lane A round 17: v3.2 the town-cache contract (published first) + plan_osm hardened for real data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real-map scout (ledger #6), A's half. Scope fence held: NO new plan features, NO golden moved (classic 0x3fa36874, gig 0xb1d48ea1, all three osm fixtures frozen). The scout proves the EXISTING ?plansrc=osm contract eats real geography. - The town-cache contract (published FIRST — E builds build_towns.py to it). plan_osm.js gains validateTownCache() (authoritative {ok,errors,warnings}), registerTownCache(), a generatePlanOSM(.., {cache}) seam, and TOWN_CACHE_SCHEMA/MIN_TOWN_SHOPS(6)/MAX_TOWN_SPAN_M(5km). Shape: {schema,key,town, source,license,attribution,center{lat,lon},shops[{id,name,type,lat,lon,suburb?}]}. Home declared: web/assets/towns/.json (+ README.md full spec). Documented in CITY_SPEC v3.2 + LANE_A_NOTES R17. - plan_osm hardened: blank name -> registry label; the validator gates finite center/coords + a >=6-shop floor and warns on unknown-type(->opshop)/dup-id/span>5km. Proven vs thriftgod's real 2,828-shop city_source.json: boots a valid plan, 0 bad coords, 0 'undefined' names. - selfcheck: a committed synthetic-adversarial contract suite (valid-messy caches boot + pass structural/ gig; invalid reject; wide-spread warns; registry seam) + an auto-loader for web/assets/towns/*.json (validate -> suites -> pin per-town golden), empty and green until E lands caches. ALL GREEN 15277/15277. - v4 charter risk list (LANE_A_NOTES R17): the MEGA-STRIP (unbounded bbox -> 11.6 km strip; town selection is E's real lever, warned >5km); sparse compact towns (2 km inner-Canberra held 6 shops); osm facades clear (rows 54 m apart) so R16's bias is a no-op on real towns. E->A handshake: E drops a cache in web/assets/towns/, the selfcheck prints its golden, I pin it. Co-Authored-By: Claude Opus 4.8 --- docs/A-progress.md | 34 +++++++++++++-- docs/CITY_SPEC.md | 22 ++++++++++ docs/LANES/LANE_A_NOTES.md | 42 +++++++++++++++++++ web/assets/towns/README.md | 56 +++++++++++++++++++++++++ web/js/citygen/plan_osm.js | 76 ++++++++++++++++++++++++++++++++-- web/js/citygen/selfcheck.js | 82 ++++++++++++++++++++++++++++++++++++- 6 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 web/assets/towns/README.md diff --git a/docs/A-progress.md b/docs/A-progress.md index 2dae22c..098b643 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,10 +1,38 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed; self-check all-green (14769/14769); rounds 2–16 closed** (see below). -v3.1 (THE FLIP): corner-poster fix via `pickVenues` +Z-clearance bias (ledger #6, gig golden -`0x4f4a549d → 0xb1d48ea1`) + a no-spine osm fixture (silverton, #7). Classic/synthetic `0x3fa36874` frozen.* +*Status: **all deliverables landed; self-check all-green (15277/15277); rounds 2–17 closed** (see below). +v3.2 scout: town-cache contract published (E's handshake) + `plan_osm` hardened for real data (proven vs +2,828 real Canberra shops). No new plan features; no golden moved (classic `0x3fa36874`, gig `0xb1d48ea1`).* *Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)* +## 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/.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 diff --git a/docs/CITY_SPEC.md b/docs/CITY_SPEC.md index a69c314..e80dbe8 100644 --- a/docs/CITY_SPEC.md +++ b/docs/CITY_SPEC.md @@ -346,6 +346,28 @@ alpha). Asserted by `selfcheck.js` alongside the frozen v2/classic golden; chang The gig sweep now also runs a **no-spine osm fixture** (`silverton`, single-row) proving `pickVenues` places venues with no main spine (ROUND16 ledger #7). +## Town caches — the real-map scout (v3.2, ROUND17 ledger #6) + +> Bounded groundwork for **v4 THE REAL MAP** (John's pick). The `?plansrc=osm` producer already eats +> processed OSM data (three fixtures in `osm_fixture.js`); v3.2 lets it eat **real town caches** E scrapes +> from Overpass — **no new plan features**, the existing contract proven against real geography. + +**The town-cache contract** (Lane-A-owned; authoritative implementation is `validateTownCache()` in +`web/js/citygen/plan_osm.js`, home `web/assets/towns/.json`, full spec in that dir's `README.md`): +a processed town is `{ 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`). +E's `build_towns.py` produces it (raw Overpass → dedupe/suburb/parody-name/seeded-hours); `plan_osm` marches +it identically to the fixtures. **Hard requirements:** finite `center.{lat,lon}`; `shops` ≥ `MIN_TOWN_SHOPS` +(6 = up to 4 venues + the openLate landmark + a spare); finite shop `lat`/`lon`. **Absorbed (warnings):** +blank name → type label, unknown `type` → `opshop`, dup ids, and **span > 5 km → "likely more than one +town"** (a cache is ONE compact town — an over-broad bbox marches into an unusable multi-km strip). + +**Wiring:** `generatePlanOSM(seed, key, { cache })` marches a cache; `registerTownCache(key, cache)` adds it +to the registry (`osmTownKeys()` + `generatePlanFor(seed,'osm',{town:key})` then see it). `selfcheck.js` +auto-loads `web/assets/towns/*.json` — validates, runs the structural + gig suites, pins a per-town golden. +**Provenance:** OSM is **ODbL**; attribution ships with the data (E owns `SOURCES.md` + the `license`/ +`attribution` fields; raw Overpass responses cached so re-runs never re-fetch). + ## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read) > **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index 0e35464..b51c587 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,48 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## Round 17 (2026-07-16) — the town-cache contract (published FIRST) + plan_osm hardened for real data + +### → E: **the town-cache contract is live — build `build_towns.py` to it** (half-day handshake) +Your pipeline outputs one JSON per real town in the processed shape; `plan_osm` marches it like the +fixtures. **Home: `web/assets/towns/.json`** (full spec in that dir's `README.md`). Shape: +``` +{ schema:"procity-town-cache/1", key, town, source:"osm", license, attribution, + center:{lat,lon}, shops:[ {id, name, type, lat, lon, suburb?} ] } // + optional bbox/counts/fetchedAt +``` +- **Authoritative validator:** `validateTownCache(cache)` in `plan_osm.js` — `{ok, errors[], warnings[]}`. + Import it; don't hand-roll the check. **Hard errors:** finite `center.{lat,lon}`, `shops` ≥ **6** + (`MIN_TOWN_SHOPS`), finite shop `lat`/`lon`. **Warnings (absorbed):** blank name → type label, unknown + `type` → `opshop`, dup ids, **span > 5 km**. +- `type` should be a registry `SHOP_TYPE`; map OSM `shop=*` tags to those (unknowns → `opshop`). +- **Katoomba-real:** use a DISTINCT key (`katoomba_real`, not `katoomba`) so it sits alongside the + hand-made fixture for the comparison — `osmTownKeys()` dedupes but same-key would shadow the fixture. +- Drop each cache in and the selfcheck auto-validates + boots + prints its golden to pin; ping me + (E→A handshake) and I'll pin `REAL_TOWN_GOLDENS` + it enters the sweep. +- **ODbL:** ship `SOURCES.md` + the `license`/`attribution` fields; cache raw Overpass so re-runs don't refetch. + +### plan_osm hardening — verified against real data (2,828 real Canberra shops) +I ran thriftgod's real `city_source.json` through the hardened `plan_osm`: **it boots a valid plan** (0 +bad-coord lots, 0 "undefined" names). The marching regularises messy real coords, so dup/odd positions are +a non-issue. Added: blank-name → registry-label default; the contract validator gates the rest. + +### → Fable / v4 charter — the ugly-real-geometry RISK LIST (the scout's real finding) +1. **The mega-strip (the big one).** An unbounded Overpass bbox = a whole region. All 2,828 Canberra shops + marched into an **11.6 km** single strip (350 shops/avenue) — a valid plan, an unusable "town". **Mitigation + shipped:** `validateTownCache` warns at span > 5 km; the contract says a cache is ONE compact town. **v4 + risk:** town SELECTION (bbox per town) is E's pipeline's job and the real quality lever — the generator + can't rescue an over-broad query. Charter needs a per-town bbox/centre discipline. +2. **Sparse compact towns.** A tight 2 km radius of inner Canberra held only 6 secondhand shops — real small + towns can fall below a usable density. `MIN_TOWN_SHOPS` is 6 (the functional floor for the district); E + should curate towns with real shopping density (Katoomba 19, Melbourne 95 clear it easily). +3. **Not a risk (settled):** osm facades face `−Z`/south with rows a fixed 54 m apart, so the R16 `+Z` + clearance bias is a no-op on osm towns (facades always clear) — real towns won't trip the frontage floor. + +### Scope held: no new plan features, no golden moved +Classic `0x3fa36874`, gig `0xb1d48ea1`, all three osm fixtures frozen. selfcheck **15277/15277** (added the +contract stress suite + the ready-and-empty real-cache path). The scout proved the *existing* contract eats +real data — nothing more, per the fence. + ## Round 16 (2026-07-16) — corner-poster fix via pickVenues clearance bias (ledger #6) + no-spine osm fixture (#7) ### → B + F: **new gig golden `0x4f4a549d → 0xb1d48ea1`** (classic/synthetic `0x3fa36874` unchanged) diff --git a/web/assets/towns/README.md b/web/assets/towns/README.md new file mode 100644 index 0000000..29b0918 --- /dev/null +++ b/web/assets/towns/README.md @@ -0,0 +1,56 @@ +# Real town caches — the home (`web/assets/towns/`) + +**Lane A owns the contract; Lane E fills this directory** (ROUND17 ledger #6, the real-map scout). + +Each real Australian town is one file, `.json`, in the **town-cache contract** shape — the processed +output of `pipeline/build_towns.py` (Overpass → dedupe/suburb/parody-name/seeded-hours). `plan_osm.js` +marches it exactly like the checked-in fixtures in `web/js/citygen/osm_fixture.js`; the scout proves the +**existing** CityPlan contract eats real data — no new plan fields, no new geometry. + +## The contract (authoritative: `validateTownCache()` in `web/js/citygen/plan_osm.js`) + +```jsonc +{ + "schema": "procity-town-cache/1", + "key": "katoomba_real", // matches the filename; distinct from the hand-made fixture key + "town": "Katoomba", // display name + "source": "osm", + "license": "ODbL 1.0", // REQUIRED — OSM is ODbL + "attribution": "© OpenStreetMap contributors", + "generator": "pipeline/build_towns.py", // optional provenance + "fetchedAt": "2026-07-16", // optional + "center": { "lat": -33.71, "lon": 150.31 },// REQUIRED — the equirectangular projection origin + "bbox": { "minLat": …, "minLon": …, "maxLat": …, "maxLon": … }, // optional metadata + "counts": { "raw": 512, "shops": 96 }, // optional metadata + "shops": [ + { "id": 318855426, "name": "Canty's Bookshop", "type": "book", "lat": -35.32, "lon": 149.18, "suburb": "Fyshwick" } + // … + ] +} +``` + +**Hard requirements** (else the cache is rejected and does not boot): +- `center.{lat,lon}` finite numbers. +- `shops` an array of **≥ `MIN_TOWN_SHOPS` (6)** entries (up to 4 venues + the one openLate landmark + a spare). +- every shop has finite `lat`/`lon`. + +**Absorbed as warnings** (still boots): a blank `name` defaults to the type label; an unknown `type` remaps +to `opshop`; duplicate ids; and — importantly — **a cache spanning > 5 km warns "likely more than one +town"**. A cache MUST be ONE compact town: an over-broad Overpass bbox marches into an unusable multi-km +strip (the R17 mega-strip risk — a whole region of 2,918 shops became an 11.6 km avenue). Bound the query. + +`type` should be a registry `SHOP_TYPE` (`record`/`opshop`/`toy`/`book`/`video`/`pawn`/`milkbar`/`dept`/`stall`); +`build_towns.py` maps OSM `shop=*` tags to these (unknowns land on `opshop`). + +## Provenance (Lane E) + +OSM data is **ODbL**. Ship attribution with the data: this `README`, a `SOURCES.md` (E's licence table), +and the `license`/`attribution` fields on every cache. Cache the **raw** Overpass responses too so re-runs +never re-fetch. + +## Wiring + +- `plan_osm.generatePlanOSM(seed, key, { cache })` marches a cache directly; `registerTownCache(key, cache)` + adds it to the runtime registry so `osmTownKeys()` and `generatePlanFor(seed, 'osm', { town: key })` see it. +- `web/js/citygen/selfcheck.js` auto-loads every `*.json` here: validates it, runs the full structural + + gig suites across the hero seeds, and pins a per-town golden (unpinned → prints the value to paste). diff --git a/web/js/citygen/plan_osm.js b/web/js/citygen/plan_osm.js index 3106543..2091d6e 100644 --- a/web/js/citygen/plan_osm.js +++ b/web/js/citygen/plan_osm.js @@ -35,9 +35,75 @@ function signOf(name) { return (s || String(name).slice(0, 15)).toUpperCase(); } +// ── the TOWN-CACHE CONTRACT (ROUND17 ledger #6, Lane A owns it) ───────────────────────────────────── +// What `plan_osm` accepts. E's pipeline (build_towns.py) produces one JSON per real town under +// web/assets/towns/.json in THIS shape; plan_osm marches it exactly like the checked-in fixtures — +// the scout proves the EXISTING contract eats real data, no new plan fields, no new geometry. +// +// { schema:"procity-town-cache/1", key, town, source:"osm", +// license, attribution, // ODbL — REQUIRED on shipped real caches (+ SOURCES.md, E) +// center:{lat,lon}, // REQUIRED — equirectangular projection origin +// bbox?, counts?, fetchedAt?, // optional provenance/metadata (geometry ignores these) +// shops:[ { id, name, type, lat, lon, suburb? } ] } // REQUIRED, >= MIN_TOWN_SHOPS +// +// `type` SHOULD be a registry SHOP_TYPE; an unknown OSM kind remaps to 'opshop' (a warning, absorbed — +// same as the fixtures). A blank name defaults to the type label. `suburb` is optional. +export const TOWN_CACHE_SCHEMA = 'procity-town-cache/1'; +export const MIN_TOWN_SHOPS = 6; // functional floor: up to 4 venues + the one openLate landmark + a spare +export const MAX_TOWN_SPAN_M = 5000; // a cache should be ONE compact town, not a region (see R17 risk note) +const isNum = v => typeof v === 'number' && Number.isFinite(v); + +// Validate a cache against the contract. { ok, errors[], warnings[] } — errors mean plan_osm would not +// boot a valid district; warnings are absorbed (remapped type, defaulted name, missing licence). +export function validateTownCache(cache) { + const errors = [], warnings = []; + if (!cache || typeof cache !== 'object') return { ok: false, errors: ['cache is not an object'], warnings }; + if (cache.schema && cache.schema !== TOWN_CACHE_SCHEMA) warnings.push(`schema '${cache.schema}' != '${TOWN_CACHE_SCHEMA}'`); + const c = cache.center; + if (!c || !isNum(c.lat) || !isNum(c.lon)) errors.push('center.{lat,lon} must be finite numbers'); + if (!Array.isArray(cache.shops)) errors.push('shops must be an array'); + else { + if (cache.shops.length < MIN_TOWN_SHOPS) errors.push(`>= ${MIN_TOWN_SHOPS} shops required (got ${cache.shops.length})`); + let badCoord = 0, blankName = 0, unknownType = 0, dupId = 0; const ids = new Set(); + for (const s of cache.shops) { + if (!s || !isNum(s.lat) || !isNum(s.lon)) badCoord++; + if (!s || !(typeof s.name === 'string' && s.name.trim())) blankName++; + if (!s || !SHOP_TYPES[s.type]) unknownType++; + if (s) { if (ids.has(s.id)) dupId++; else ids.add(s.id); } + } + if (badCoord) errors.push(`${badCoord} shop(s) with non-finite lat/lon`); + if (blankName) warnings.push(`${blankName} blank shop name(s) → defaulted to type label`); + if (unknownType) warnings.push(`${unknownType} unknown shop type(s) → remapped to opshop`); + if (dupId) warnings.push(`${dupId} duplicate shop id(s)`); + // wide-spread warning: an over-broad Overpass bbox marches into an unusable multi-km strip (the R17 + // real-data risk). Metadata only — plan_osm still boots a valid plan; it just isn't a coherent town. + let mnLat = Infinity, mxLat = -Infinity, mnLon = Infinity, mxLon = -Infinity; + for (const s of cache.shops) if (s && isNum(s.lat) && isNum(s.lon)) { + mnLat = Math.min(mnLat, s.lat); mxLat = Math.max(mxLat, s.lat); + mnLon = Math.min(mnLon, s.lon); mxLon = Math.max(mxLon, s.lon); + } + if (mxLat > mnLat) { + const latM = (mxLat - mnLat) * 111320, lonM = (mxLon - mnLon) * 111320 * Math.cos((c && c.lat || 0) * Math.PI / 180); + if (Math.max(latM, lonM) > MAX_TOWN_SPAN_M) warnings.push(`shops span ${(Math.max(latM, lonM) / 1000).toFixed(1)} km — likely more than one town (bound the query tighter)`); + } + } + if (!cache.license || !cache.attribution) warnings.push('missing license/attribution (ODbL required for shipped real caches)'); + return { ok: errors.length === 0, errors, warnings }; +} + +// A runtime registry of validated town caches (E's real caches; the selfcheck + shell register them). +// generatePlanOSM resolves a town from opts.cache, then this registry, then the checked-in fixtures. +const TOWN_CACHES = {}; +export function registerTownCache(key, cache) { + const v = validateTownCache(cache); + if (!v.ok) throw new Error(`town cache '${key}' invalid: ${v.errors.join('; ')}`); + TOWN_CACHES[key] = cache; + return v; +} + export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) { citySeed = (citySeed >>> 0); - const fx = OSM_TOWNS[town] || OSM_TOWNS[DEFAULT_TOWN]; + const fx = opts.cache || TOWN_CACHES[town] || OSM_TOWNS[town] || OSM_TOWNS[DEFAULT_TOWN]; // Normalization log: what the importer had to bend to fit the CityPlan contracts (real OSM data // "doesn't bend the contract"). Surfaced via opts.report (selfcheck prints it) — never on the plan. const norm = { town: fx.town, source: 'osm', shops: fx.shops.length, typesRemapped: 0, hoursClamped: 0, openLate: 'video' }; @@ -70,7 +136,11 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) { if (close <= open) close = clampHour(open + 2); if (close > LATE_HOUR - 1) norm.hoursClamped++; close = Math.min(close, LATE_HOUR - 1); // only the openLate landmark reaches ≥ LATE_HOUR - shops.push({ id, lot: lotId, type, name: fixtureShop.name, sign: signOf(fixtureShop.name), + // real OSM data has the odd unnamed shop — default a blank name to the registry label so the sign + // never renders "undefined" (build_towns.py filters most, but plan_osm must not trust its input). + const nm = (typeof fixtureShop.name === 'string' && fixtureShop.name.trim()) ? fixtureShop.name : (reg.label || type); + if (nm !== fixtureShop.name) norm.namesDefaulted = (norm.namesDefaulted || 0) + 1; + shops.push({ id, lot: lotId, type, name: nm, sign: signOf(nm), seed, facadeSkin, storeys, hours: [open, close] }); return id; } @@ -167,4 +237,4 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) { } // The catalogue of available OSM town keys (for the shell/map selector + selfcheck iteration). -export function osmTownKeys() { return Object.keys(OSM_TOWNS); } +export function osmTownKeys() { return [...new Set([...Object.keys(OSM_TOWNS), ...Object.keys(TOWN_CACHES)])]; } diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index fbddf9e..eed2b37 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -4,11 +4,11 @@ // The overlap/facing helpers are IMPORTED from plan.js (lotCorners/obbOverlap) so the harness and // the generator can never disagree about what "overlap" or "facing" means. -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js'; -import { generatePlanOSM, osmTownKeys } from './plan_osm.js'; +import { generatePlanOSM, osmTownKeys, validateTownCache, registerTownCache, MIN_TOWN_SHOPS } from './plan_osm.js'; import { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.js'; import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js'; import { xmur3 } from '../core/prng.js'; @@ -352,6 +352,84 @@ for (const seed of GIG_SEEDS) { districtInvariants(sv, `sweep osm/silverton ${seed}`); } +// ── 3e. town-cache contract (ROUND17 ledger #6): validator + real-data hardening + registry seam ── +// A publishes the contract E's build_towns.py builds to; plan_osm must eat real data (dead ends, no +// spine, blank names, unknown types, odd densities) without new plan features. These are synthetic +// adversarial caches — the committed proof of the hardening (real caches load in §3f below). +section('town-cache contract (ROUND17: real data through plan_osm)'); +const mkCache = (shops, extra = {}) => ({ + schema: 'procity-town-cache/1', key: 'stress', town: 'Stress', source: 'osm', + license: 'ODbL 1.0', attribution: '© OpenStreetMap contributors', center: { lat: -33.7, lon: 150.3 }, shops, ...extra, +}); +const cluster = (n, opt = {}) => Array.from({ length: n }, (_, i) => ({ + id: 500 + i, + name: opt.blank && i % 3 === 0 ? '' : `Real Shop ${i}`, + type: opt.unknown && i % 4 === 0 ? 'op_shop_weird' : ['book', 'record', 'opshop', 'toy', 'pawn', 'video', 'milkbar'][i % 7], + lat: -33.7 + (opt.samePoint ? 0 : (i % 5) * 0.0008), + lon: 150.3 + (opt.samePoint ? 0 : Math.floor(i / 5) * 0.0008), + suburb: 'Testville', +})); +// (a) VALID-but-messy caches validate ok AND boot a full valid district (structural + gig invariants) +for (const [label, cache] of [ + ['compact 14-shop town', mkCache(cluster(14))], + ['blank names → defaulted', mkCache(cluster(14, { blank: true }))], + ['unknown types → opshop', mkCache(cluster(14, { unknown: true }))], + ['all shops at one point', mkCache(cluster(14, { samePoint: true }))], + ['minimum shop count', mkCache(cluster(MIN_TOWN_SHOPS))], +]) { + const v = validateTownCache(cache); + ok(v.ok, `cache "${label}": validates ok` + (v.ok ? '' : ` — ${v.errors.join('; ')}`)); + if (!v.ok) continue; + for (const s of [1, 20261990]) structuralSuite(generatePlanOSM(s, 'stress', { cache }), `stress/${label} ${s}`); + districtInvariants(generatePlanFor(20261990, 'osm', { gigs: true, town: 'stress', cache }), `stress/${label}`); +} +// (b) INVALID caches are REJECTED (they would not boot a valid district) +for (const [label, cache, needle] of [ + ['too few shops', mkCache(cluster(MIN_TOWN_SHOPS - 1)), 'shops required'], + ['non-finite coords', mkCache(cluster(12).map((s, i) => i === 2 ? { ...s, lat: NaN } : s)), 'non-finite'], + ['missing center', { schema: 'procity-town-cache/1', shops: cluster(12) }, 'center'], + ['shops not an array', { ...mkCache(cluster(12)), shops: 'nope' }, 'array'], + ['not an object', 'nope', 'not an object'], +]) { + const v = validateTownCache(cache); + ok(!v.ok && v.errors.some(e => e.includes(needle)), `cache "${label}": rejected` + (v.ok ? ' (WRONGLY ACCEPTED)' : ` — "${v.errors[0]}"`)); +} +// (c) a region-wide cache still boots but WARNS (the R17 mega-strip risk: bound the query to one town) +{ + const wide = mkCache(cluster(20).map((s, i) => ({ ...s, lat: -33.7 + i * 0.05 }))); + const v = validateTownCache(wide); + ok(v.ok && v.warnings.some(w => /span|town/.test(w)), `wide-spread cache: valid but warns (${v.warnings.find(w => /span/.test(w)) || 'NO WARN'})`); +} +// (d) the registry seam: register → osmTownKeys() sees it → generatePlanOSM resolves it +{ + registerTownCache('regtest', mkCache(cluster(14))); + ok(osmTownKeys().includes('regtest'), `registerTownCache: osmTownKeys() includes 'regtest'`); + ok(generatePlanOSM(1, 'regtest').shops.length === 14, `registerTownCache: generatePlanOSM resolves the registered cache`); +} + +// ── 3f. real town caches — E's build_towns.py output under web/assets/towns/ (ROUND17 ledger #6) ── +// Empty until E's caches land; each is validated, run through the full structural + gig suites, and +// pinned. Guarded so the gate is green before E's first cache — A pins goldens as caches arrive. +section('real town caches (web/assets/towns/*.json)'); +const TOWNS_DIR = join(HERE, '..', '..', 'assets', 'towns'); +const REAL_TOWN_GOLDENS = { /* E's caches pinned here as they land: : 0x… */ }; +const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json')); +if (!townFiles.length) console.log(" (none yet — the contract + hardening are live, ready for E's build_towns.py)"); +for (const f of townFiles) { + const key = f.replace(/\.json$/, ''); + let cache; try { cache = JSON.parse(readFileSync(join(TOWNS_DIR, f), 'utf8')); } catch (e) { ok(false, `real/${key}: parses as JSON — ${e.message}`); continue; } + const v = validateTownCache(cache); + ok(v.ok, `real/${key}: valid cache` + (v.ok ? '' : ` — ${v.errors.join('; ')}`)); + if (v.warnings.length) console.log(` ⚠ real/${key}: ${v.warnings.join(' · ')}`); + if (!v.ok) continue; + for (const s of OSM_SEEDS) structuralSuite(generatePlanOSM(s, key, { cache }), `real/${key} ${s}`); + for (const s of GIG_SEEDS) districtInvariants(generatePlanFor(s, 'osm', { gigs: true, town: key, cache }), `real/${key} ${s}`); + const hash = xmur3(JSON.stringify(generatePlanOSM(20261990, key, { cache })))() >>> 0; + const want = REAL_TOWN_GOLDENS[key]; + if (want === undefined) console.log(` ⚠ real/${key}: UNPINNED — add REAL_TOWN_GOLDENS['${key}'] = 0x${hash.toString(16).padStart(8, '0')}`); + else ok(hash === (want >>> 0), `real/${key}: golden 0x${hash.toString(16)} matches pinned 0x${(want >>> 0).toString(16)}`); +} + // ── 4. every facade skin referenced by the registry exists on disk ────────────────── section('assets on disk'); for (const f of allFacadeSkins()) ok(existsSync(join(ASSETS, f)), `registry facade exists: ${f}`);