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:
parent
ee16545941
commit
6fffd19043
@ -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 2–16 closed** (see below).
|
*Status: **all deliverables landed; self-check all-green (15277/15277); rounds 2–17 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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
56
web/assets/towns/README.md
Normal file
56
web/assets/towns/README.md
Normal 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).
|
||||||
@ -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)])]; }
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user