Lane A round 17: v3.2 the town-cache contract (published first) + plan_osm hardened for real data

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/<key>.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 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-16 14:32:14 +10:00
parent ee16545941
commit 6fffd19043
6 changed files with 304 additions and 8 deletions

View File

@ -1,10 +1,38 @@
# LANE A — CITYGEN · progress (PROCITY-A) # LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (14769/14769); rounds 216 closed** (see below). *Status: **all deliverables landed; self-check all-green (15277/15277); rounds 217 closed** (see below).
v3.1 (THE FLIP): corner-poster fix via `pickVenues` +Z-clearance bias (ledger #6, gig golden v3.2 scout: town-cache contract published (E's handshake) + `plan_osm` hardened for real data (proven vs
`0x4f4a549d → 0xb1d48ea1`) + a no-spine osm fixture (silverton, #7). Classic/synthetic `0x3fa36874` frozen.* 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)* *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/<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) ## 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 Lane A ran **first** among the parallel lanes (F bookends with the flip; A's golden feeds F's default-boot

View File

@ -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` 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). 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/<key>.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) ## 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 > **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for

View File

@ -2,6 +2,48 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. 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/<key>.json`** (full spec in that dir's `README.md`). Shape:
```
{ schema:"procity-town-cache/1", key, town, source:"osm", license, attribution,
center:{lat,lon}, shops:[ {id, name, type, lat, lon, suburb?} ] } // + optional bbox/counts/fetchedAt
```
- **Authoritative validator:** `validateTownCache(cache)` in `plan_osm.js``{ok, errors[], warnings[]}`.
Import it; don't hand-roll the check. **Hard errors:** finite `center.{lat,lon}`, `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) ## 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) ### → B + F: **new gig golden `0x4f4a549d → 0xb1d48ea1`** (classic/synthetic `0x3fa36874` unchanged)

View File

@ -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, `<key>.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).

View File

@ -35,9 +35,75 @@ function signOf(name) {
return (s || String(name).slice(0, 15)).toUpperCase(); 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/<key>.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 = {}) { export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
citySeed = (citySeed >>> 0); 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 // 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. // "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' }; 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 <= open) close = clampHour(open + 2);
if (close > LATE_HOUR - 1) norm.hoursClamped++; if (close > LATE_HOUR - 1) norm.hoursClamped++;
close = Math.min(close, LATE_HOUR - 1); // only the openLate landmark reaches ≥ LATE_HOUR 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] }); seed, facadeSkin, storeys, hours: [open, close] });
return id; 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). // 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)])]; }

View File

@ -4,11 +4,11 @@
// The overlap/facing helpers are IMPORTED from plan.js (lotCorners/obbOverlap) so the harness and // 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. // 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 { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js'; 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 { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.js';
import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js'; import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js';
import { xmur3 } from '../core/prng.js'; import { xmur3 } from '../core/prng.js';
@ -352,6 +352,84 @@ for (const seed of GIG_SEEDS) {
districtInvariants(sv, `sweep osm/silverton ${seed}`); 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: <key>: 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 ────────────────── // ── 4. every facade skin referenced by the registry exists on disk ──────────────────
section('assets on disk'); section('assets on disk');
for (const f of allFacadeSkins()) ok(existsSync(join(ASSETS, f)), `registry facade exists: ${f}`); for (const f of allFacadeSkins()) ok(existsSync(join(ASSETS, f)), `registry facade exists: ${f}`);