diff --git a/docs/A-progress.md b/docs/A-progress.md index 3c5ac9e..cbce5fc 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,10 +1,50 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed; self-check all-green (161,372/161,372); rounds 2–23 closed** (see below). -R23 (v5.0-alpha): D's thin-tail finding is law — `validateTownCache` warns above **150 m median shop -spacing**; flags 2 of 23 towns (toowoomba 396 m, **ballarat 255 m — beyond the brief**). Warn, never error. -R22: the pack absorbed — 23 towns, 36 goldens in one commit. classic `0x3fa36874` / gig `0xb1d48ea1` frozen.* -*Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)* +*Status: **all deliverables landed; self-check all-green (152,030/152,030); rounds 2–24 closed** (see below). +R24 (v5.0-alpha): the **`godverseShopId`** identity field — schema v2 (optional), carried through the lift +onto `plan.shops[]`, **no golden moved**. R23: the spacing warn (150 m). classic `0x3fa36874` / +gig `0xb1d48ea1` frozen. (Pack is 21 real + 1 godverse — E retired toowoomba in R23.)* +*Date: 2026-07-17 · owner: PROCITY-A (Opus 4.8)* + +## Round 24 (2026-07-17) — v5.0-alpha: the identity field (ledger #2) + +`shop.godverseShopId` — the optional key resolving `stock_godverse//`. Schema **stays v2**; absent +everywhere except godverse caches, so the 21 osm caches are untouched by construction. Published to +`web/assets/towns/README.md` (the doc G/E build to) + CITY_SPEC. selfcheck **ALL GREEN 152,030/152,030** +(+15), synthetic `0x3fa36874` frozen, **no golden moved** — by design (see below). + +- **The brief's "the POS shop id IS the id" is false for the one shop that matters.** G's own docstring: + Monster Robot Party is **not in thriftgod's census**, so its id is its **dealgod store id 3962749**, while + census shops use thriftgod ids (max 2992). Two disjoint spaces on one field ⇒ built it as an **opaque + key**: no derivation from `id`, no equality assert, no mapping table (the R12 gigKey law). +- **The field had to survive the lift, or it was decoration.** `plan_osm` builds a *fresh* shop object and + copies nothing unknown, so a cache-only field would never reach the runtime — and the runtime reads + `plan.shops[]`, never the cache. It's now carried through, **conditionally**: absent ⇒ the key isn't + written ⇒ `JSON.stringify` byte-identical ⇒ **every pinned golden holds until G re-emits** (that re-emit + is the sanctioned move). +- **Arms split by hazard:** malformed → **error** (it's a path segment; `"3962749"` vs `3962749` is the + same coercion drift that broke the atlas contract), duplicate → **error** (two shops, one atlas = the + mis-stock charter risk #3 forbids), missing on a godverse cache → **warn + fail-soft to tier 0**. +- **Recorded departure:** the ledger said missing should be an *error*. It would invert the charter's + determinism boundary (the **town** failing to load because **stock** identity is absent), contradict risk + #3's own remedy ("fail-soft to tier 0"), and red the tree for C and E through all of wave 1 — G re-emits + in wave 2 and it's their namespace, not mine. Teeth kept where R23 says to put them: selfcheck **fails on + partial keying**, prints an explicit **SKIP + count** at zero. Flagged for Fable to overrule. +- **Filed for G before they start (measured, wave 1):** `redhill_godverse` can't contain Monster Robot from + **either** source. `godverse_town.py` takes roads from the OSM donor and shops from the census; the shop is + in neither — not in thriftgod's census (G's R23 finding), and **not in E's `redhill_real`** (I checked it + as it landed: 36 shops, **zero of type `record`**, nothing matching /monster|robot/). Needs explicit + injection, or F's #7b id-equality gate fails at the end of the round. Also flagged: the census bbox must + still clear `MIN_TOWN_SHOPS` (6) or the cache is rejected outright. +- **Pins deliberately NOT taken this round yet.** `redhill_real` is untracked/in-flight (E is still editing + `build_towns.py`), and `redhill_godverse` + the re-keyed `newtown_godverse` don't exist until wave 2. + Pinning a moving file buys a false green or a spurious red. The round's law says A pins these **batched** — + that batch lands once E commits and G's caches are in. `redhill_real` reads clean meanwhile: valid, no + warns, 73.3 m spacing. +- **Housekeeping:** removed toowoomba's two stale pins (E retired the cache in R23 — a golden for a town + that can't load can never fire). E's attempt also proved my R23 spacing metric is **gameable**: the + re-bbox fixed the median (395.7 → 89.4 m) while hub density fell 3/12 → 2/12. Necessary, not sufficient — + D's hub test is the complement. **Ballarat (255 m) still flagged, still E's call; I pin whatever moves.** ## Round 23 (2026-07-16) — v5.0-alpha: the spacing warn (ledger #5) diff --git a/docs/CITY_SPEC.md b/docs/CITY_SPEC.md index 57490dc..1166ea1 100644 --- a/docs/CITY_SPEC.md +++ b/docs/CITY_SPEC.md @@ -426,6 +426,33 @@ auto-loads `web/assets/towns/*.json` — validates, runs the structural + gig su **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). +### `godverseShopId` — the stock identity (v5.0-alpha, ROUND24 ledger #2; A + G) + +The optional shop key that resolves **`stock_godverse//`**. **Schema stays v2** — optional +field, absent everywhere except godverse caches, so every shipped cache stays valid *by construction*. + +**It is NOT `shop.id` and NOT "the census id"** — the round doc's "the POS shop id IS the id" holds for +census shops but breaks on the shop the alpha is about. Two disjoint id spaces ride one opaque field: +census shops use thriftgod `shop.id` (max **2992**), while **Monster Robot Party is not in thriftgod's +census at all** and uses its **dealgod store id 3962749**. So: never derive it from `id`, never assert +equality, never build a mapping table (the R12 gigKey law — one key, zero mapping). **G emits, all read.** + +**`validateTownCache` arms** (split by hazard, not tidiness): a **malformed** id (not a positive integer) +is an **error** — it names a path segment, and `"3962749"` vs `3962749` is exactly the coercion drift that +broke the atlas contract; a **duplicate** id is an **error** — two shops sharing one atlas *is* mis-stocking +(charter risk #3: "never mis-stocked"); a **missing** id on a `source: "godverse+osm"` cache is a **warn**, +and those shops fall soft to **tier 0**. Missing is deliberately *not* an error: the charter's determinism +boundary says *world = seeded and gated, stock = data tiers*, so a town must never fail to load because its +**stock** identity is absent — tier 0 is the designed ladder, not a defect. The teeth live in `selfcheck`: +**partial keying FAILS** (a half-keyed town silently mis-stocks the rest) and zero keying prints an explicit +**SKIP** naming the town and count, per the round's vacuous-gate law. *(Recorded departure from the ledger's +"requires" — reasons above; Fable's to overrule.)* + +**It survives the lift:** `plan_osm` copies it onto `plan.shops[]` (the runtime reads the plan, never the +cache — otherwise the field is decoration and F's per-shop `base` can never resolve). Absent ⇒ the key is +**not written**, so `JSON.stringify` is byte-identical and **every pinned golden holds until G re-emits**; +that re-emit is the sanctioned golden move. + ### Schema v2 — real roads (v4.0-alpha, ROUND18 REAL ROADS) > The gap the scout left: the marched fallback lays real shops onto **synthetic** parallel avenues. diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index ba8b5bd..73785dd 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,73 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## Round 24 (2026-07-17) — v5.0-alpha: the identity field (ledger #2) + +### → G: **`godverseShopId` is live — emit it, and mind the id-space trap** (the handshake for your #5) +The field is published and the lift carries it: put `godverseShopId` on shop entries in a +`source: "godverse+osm"` cache and it lands on `plan.shops[]`, which is what F's per-shop `base` reads. +Contract in `web/assets/towns/README.md`; `validateTownCache` enforces it; selfcheck gates it. + +**The round doc says "the POS shop id IS the id — no mapping table". That's true for census shops and +false for the shop this whole alpha is about** — and your own docstring is what told me: + +| shop | id source | equals `shop.id`? | +|---|---|---| +| census shops (`newtown_godverse`) | thriftgod `shop.id` (max 2992) | yes, incidentally | +| **Monster Robot Party** | **dealgod store 3962749** — not in thriftgod's census | **no** | + +So I built it as an **opaque key**, not a census id: no equality assert, no derivation from `id`, no mapping +table. Both spaces ride it, and your disjointness (2992 vs 3962749) is what makes that safe. **Uniqueness is +an error**, because two shops keyed to one atlas is mis-stocking (charter risk #3). + +**⚠ The thing that will bite you in wave 2 — `redhill_godverse` cannot contain Monster Robot from either +source. Measured, not inferred.** `godverse_town.py` takes **roads from the OSM donor** and **shops from +thriftgod's census**. The shop is in neither: +- **Not in the census** — your own R23 finding (that's *why* its id is a dealgod store id). +- **Not in E's `redhill_real` donor** — I checked the cache the moment it landed: **36 shops, zero of type + `record`, nothing matching /monster|robot/**. OSM's Red Hill doesn't know the shop exists. + +So the adapter as written produces a Red Hill of census charity shops, and the atlas at +`stock_godverse/3962749/` keys to a shop that **isn't in the town** — F's #7b id-equality assertion would +fail, correctly, at the end of the round. **Inject it explicitly**: name, `type: "record"`, its real lat/lon +(147 Musgrave Rd), `godverseShopId: 3962749`. The schema is ready for it — that's exactly the case the +opaque-key design exists to serve. + +**Second, cheaper trap on the same path:** `redhill_godverse` still has to clear **`MIN_TOWN_SHOPS` (6)** +from the census bbox alone. Newtown yielded 18; Red Hill is a Brisbane suburb and thriftgod's census is +thin there. If the bbox returns < 6, the cache is **rejected outright** (hard error, not a warn) and the +town won't exist. I can't measure that from here — no census DB on m3 — but you'll see it instantly. If it +bites, the count includes any shop you inject. + +**E's donor is otherwise good for you:** valid, no warnings, **73.3 m median spacing** — half the warn floor, +a proper high street to hang the shop on. + +### → F: the seam you need for #6a +`plan.shops[].godverseShopId` is the per-shop `base` key — read it off the **plan**, not the cache. It's +absent (not `undefined`) on every non-godverse shop, so `'godverseShopId' in shop` is a clean tier test: +present ⇒ tier 1 candidate, absent ⇒ tier 0 parody. That absence is also why **no golden moved this round**. + +### → Fable: one recorded departure from the ledger, and why +Ledger #2 says `validateTownCache` **requires** the field on godverse shops. I implemented **missing → warn** +(malformed and duplicate → hard errors). Three reasons, and it's yours to overrule: +1. **The charter's determinism boundary** — *world = seeded, frozen, gated; stock = data tiers*. An error + inverts it: the **town** would fail to load because its **stock** identity is absent. Tier 0 must boot. +2. **Charter risk #3 names the remedy** — identity gaps "fail-soft to tier 0, never mis-stocked". Missing + *is* the soft case; duplicate is the hard one. They're different failures and now get different arms. +3. **Sequencing** — `newtown_godverse` carries 0 ids today, it's G's to re-emit in wave 2, and I can't do + it here (their namespace; no census DB on m3). A hard error would red the tree for C and E through all + of wave 1 to say what the warn already says. +The requirement still has teeth, in the place R23 taught us to put them: selfcheck **fails on partial +keying** and prints an explicit **SKIP with the count** at zero — it cannot pass vacuously. + +### Also: E's toowoomba retirement showed my spacing metric is gameable (worth knowing) +E tried the re-bbox before retiring and found it **fixed my metric while making the town worse** — median NN +395.7 → 89.4 m ("passes") but hub density 3/12 → 2/12, the pack's worst. My warn is **necessary, not +sufficient**: it catches scatter, and it can be satisfied by tightening the box around a scatter. D's hub +test is the complement, and E used both. Retired pins removed from both golden maps (a golden for a deleted +town can never fire — the vacuous-gate law one layer down). **Ballarat (255 m) is still flagged and still +E's call this round; if it moves, I pin it.** + ## Round 23 (2026-07-16) — v5.0-alpha: the spacing warn (D's thin-tail finding as law) ### → E: **the warn is live — and it flags TWO towns, not one** (the handshake for your #4) diff --git a/web/assets/towns/README.md b/web/assets/towns/README.md index b781168..c393362 100644 --- a/web/assets/towns/README.md +++ b/web/assets/towns/README.md @@ -42,6 +42,49 @@ strip (the R17 mega-strip risk — a whole region of 2,918 shops became an 11.6 `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`). +## `godverseShopId` — the stock identity (ROUND24, v5.0-alpha; Lane A + Lane G) + +The optional key that says **"this shop's real stock lives at `web/assets/stock_godverse//`"**. +The schema stays **v2** — it's an optional field, so every shipped cache stays valid and the 21 osm caches +are untouched *by construction*. + +```jsonc +"shops": [ + { "id": 158, "name": "Egg Records", "type": "record", "lat": -33.897, "lon": 151.179, + "suburb": "Newtown", "godverseShopId": 158 }, // census shop: happens to equal `id` + { "id": 9001, "name": "Monster Robot Party", "type": "record", "lat": -27.455, "lon": 153.006, + "suburb": "Red Hill", "godverseShopId": 3962749 } // NOT in the census: a dealgod store id +] +``` + +**It is NOT `shop.id`, and it is NOT "the census id".** That shortcut breaks on the one shop the v5 alpha +is about. Two id spaces ride this field, and G verified they're disjoint: + +| shop | source of the id | equals `shop.id`? | +|---|---|---| +| census shops (`newtown_godverse`) | thriftgod `shop.id` — max **2992** | yes, incidentally | +| **Monster Robot Party** | **dealgod store 3962749** — *not in thriftgod's census at all* | **no** | + +So treat it as an **opaque key**: never derive it from `id`, never assume equality, never build a mapping +table (the R12 gigKey lesson — one key, zero mapping; **G emits it, everyone else reads it**). + +**Rules** — split by what is actually a hazard, not by tidiness: +- **Malformed → ERROR.** Must be a **positive integer**. It names a path segment, so `"3962749"` and + `3962749` are the same folder but different JSON; that coercion drift is the species that produced the + `licence`/`license` atlas break. One truth. +- **Duplicate within a cache → ERROR.** Two shops keyed to one atlas *is* mis-stocking (charter risk #3: + "fail-soft to tier 0, **never mis-stocked**"). +- **Missing on a `source: "godverse+osm"` cache → WARN, and those shops fall soft to tier 0 (parody).** + Not an error: the charter's determinism boundary is *world = seeded and gated, stock = data tiers*, so a + town must never fail to load because its **stock** identity is absent. The teeth are in `selfcheck`, + where **partial keying FAILS** (a half-keyed town silently mis-stocks the rest) and zero keying prints an + explicit **SKIP** with the count, per the vacuous-gate law. + +**It survives the lift.** `plan_osm` copies it onto `plan.shops[]`, because the runtime reads the *plan*, +never the cache — without that the field would be decoration and F's per-shop `base` could never resolve. +Absent ⇒ the key is **not written** (not `undefined`), so `JSON.stringify` is byte-identical and every +pinned town golden holds until G re-emits. + ## Schema v2 — `roads[]` (ROUND18, v4.0-alpha REAL ROADS) A **v2** cache adds an optional `roads[]` — the town's real OSM street geometry. When present, `plan_osm` diff --git a/web/js/citygen/plan_osm.js b/web/js/citygen/plan_osm.js index 01d8cd4..788069d 100644 --- a/web/js/citygen/plan_osm.js +++ b/web/js/citygen/plan_osm.js @@ -94,6 +94,20 @@ export function medianShopSpacing(cache) { return nn.length % 2 ? nn[m] : (nn[m - 1] + nn[m]) / 2; } +// ── the identity field (ROUND24 ledger #2, with Lane G) ────────────────────────────────────────── +// `shop.godverseShopId` — the OPTIONAL external key that says "this lot's stock lives at +// `stock_godverse//`". Absent everywhere except godverse caches; schema stays v2 +// (an optional field breaks no shipped cache, and the 22 osm caches are untouched by construction). +// +// IT IS NOT `shop.id`, AND IT IS NOT "THE CENSUS ID" — that assumption breaks on the one shop the +// v5 alpha is about. Two id spaces ride this field, and G verified they are disjoint: +// census shops → thriftgod `shop.id` (max 2992) — here godverseShopId does equal `id` +// Monster Robot → dealgod store 3962749 — NOT in thriftgod's census at all +// So it's an OPAQUE key: never derive it from `id`, never assume equality, never build a mapping +// table (the R12 gigKey lesson — one key, zero mapping; G emits it, everyone else reads it). +const GODVERSE_SOURCE = 'godverse+osm'; +const isGodverseCache = cache => cache && cache.source === GODVERSE_SOURCE; + // ── roads[] (schema v2, ROUND18) ───────────────────────────────────────────────────────────────── // OSM `highway=*` → CityPlan edge kind. The graph lift refines this with shop density (a `side` road // dense with shops becomes the `main` spine). Anything unmapped defaults to 'side'. @@ -147,6 +161,38 @@ export function validateTownCache(cache) { 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)`); + // godverseShopId (ROUND24 ledger #2) — the stock identity. Split by what is actually a hazard: + // · MALFORMED → error. It names a path segment (`stock_godverse//`); a string "3962749" and + // the number 3962749 are the same folder but different JSON, and that coercion drift is exactly + // the species F caught in the atlas contract (licence/license). One truth: a positive integer. + // · DUPLICATE → error. Two shops keyed to one atlas IS mis-stocking — charter risk #3 says + // mismatches "fail-soft to tier 0, NEVER mis-stocked". This is the arm that earns its keep. + // · MISSING on a godverse cache → WARN, not error. See the note below — this is a deliberate + // departure from the round doc's "requires", and it is Fable's to overrule. + let badGid = 0, dupGid = 0, withGid = 0; const gids = new Set(); + for (const s of cache.shops) { + if (!s || s.godverseShopId === undefined || s.godverseShopId === null) continue; + withGid++; + if (!Number.isSafeInteger(s.godverseShopId) || s.godverseShopId <= 0) { badGid++; continue; } + if (gids.has(s.godverseShopId)) dupGid++; else gids.add(s.godverseShopId); + } + if (badGid) errors.push(`${badGid} shop(s) with a malformed godverseShopId (want a positive integer — it is a path segment, not a label)`); + if (dupGid) errors.push(`${dupGid} duplicate godverseShopId(s) — two shops cannot share one stock atlas (mis-stock)`); + // WHY MISSING IS A WARN AND NOT AN ERROR (ROUND24 ledger #2 says "requires"; recorded as a + // departure, with reasons, per measurement-over-brief): + // 1. The charter's determinism boundary: "world = seeded, frozen, gated. Stock = data tiers." + // An error here inverts it — the TOWN would fail to load because its STOCK identity is absent. + // Tier 0 must always boot; a shop with no id is a parody shop, which is the designed ladder. + // 2. Charter risk #3 names the remedy for identity gaps: "fail-soft to tier 0, never mis-stocked." + // Missing = tier 0 (soft). Duplicate = mis-stocked (hard). They are not the same failure. + // 3. Sequencing: `newtown_godverse` carries 0 ids today and is G's to re-emit in wave 2 (their + // namespace, and the census DB isn't on this machine). An error would red the tree for C and E + // through all of wave 1 to convey what this warn already says. + // The teeth live in selfcheck: PARTIAL coverage fails, and zero coverage prints an explicit SKIP + // naming the town and the count (the vacuous-gate law) — so this cannot pass unnoticed. + if (isGodverseCache(cache) && withGid < cache.shops.length) { + warnings.push(`${cache.shops.length - withGid}/${cache.shops.length} shop(s) missing godverseShopId on a '${GODVERSE_SOURCE}' cache → those shops fall soft to tier 0 (parody); G re-emits`); + } // 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; @@ -237,8 +283,17 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) { // 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] }); + const shop = { id, lot: lotId, type, name: nm, sign: signOf(nm), + seed, facadeSkin, storeys, hours: [open, close] }; + // ROUND24 ledger #2: carry the stock identity THROUGH the lift. Without this the field would be a + // cache-only decoration — the runtime reads `plan.shops[]`, never the cache, so F's per-shop `base` + // (`stock_godverse//`) could never resolve. Conditional by design: absent ⇒ the key + // is not written ⇒ `JSON.stringify` is byte-identical ⇒ every pinned town golden holds today, and + // the field starts flowing the moment G re-emits (that re-emit IS the sanctioned golden move). + if (Number.isSafeInteger(fixtureShop.godverseShopId) && fixtureShop.godverseShopId > 0) { + shop.godverseShopId = fixtureShop.godverseShopId; + } + shops.push(shop); return id; } diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index bb4ead3..ba494cb 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -435,6 +435,57 @@ for (const [label, cache, needle] of [ ok(Math.abs(got - 100) < 0.5, `medianShopSpacing: 100 m pair measures ${got.toFixed(1)} m (projection sane)`); } } +// (c3) the IDENTITY FIELD (ROUND24 ledger #2, with G) — `shop.godverseShopId`, the key that resolves +// `stock_godverse//`. It is NOT `shop.id` and NOT "the census id": Monster Robot Party isn't in +// thriftgod's census at all, so its id is its dealgod store id (3962749), while census shops use +// thriftgod ids (max 2992). Two disjoint spaces, one opaque field — so these assert the FIELD's +// properties, never a relationship to `id`. +{ + const gv = shops => ({ ...mkCache(shops), source: 'godverse+osm' }); + const withIds = cluster(8).map((s, i) => ({ ...s, godverseShopId: 3962749 + i })); + + // a well-formed godverse cache validates clean and carries no spurious warn + const good = validateTownCache(gv(withIds)); + ok(good.ok, `godverse cache: valid with ids` + (good.ok ? '' : ` — ${good.errors.join('; ')}`)); + ok(!good.warnings.some(w => /godverseShopId/.test(w)), `godverse cache: fully-keyed cache raises no identity warn`); + + // MALFORMED → error. A path segment, not a label: the string "3962749" is the same folder but + // different JSON, and that coercion drift is the species F caught in the atlas contract. + for (const [label, bad] of [ + ['string id', '3962749'], ['float id', 3962749.5], ['zero', 0], ['negative', -5], ['NaN', NaN], + ]) { + const v = validateTownCache(gv(withIds.map((s, i) => i === 1 ? { ...s, godverseShopId: bad } : s))); + ok(!v.ok && v.errors.some(e => /malformed godverseShopId/.test(e)), `godverse cache: ${label} REJECTED`); + } + // DUPLICATE → error. Two shops keyed to one atlas is mis-stocking (charter risk #3: "never mis-stocked"). + { + const v = validateTownCache(gv(withIds.map((s, i) => i === 1 ? { ...s, godverseShopId: withIds[0].godverseShopId } : s))); + ok(!v.ok && v.errors.some(e => /duplicate godverseShopId/.test(e)), `godverse cache: duplicate id REJECTED (mis-stock)`); + } + // MISSING → warn + fail-soft, NOT an error (the charter's determinism boundary: the world must not + // fail to load because stock identity is absent). Partial coverage still warns and still boots. + { + const v = validateTownCache(gv(withIds.map((s, i) => i < 2 ? { ...s, godverseShopId: undefined } : s))); + ok(v.ok, `godverse cache: missing ids stay VALID (fail-soft to tier 0, never a boot failure)`); + ok(v.warnings.some(w => /2\/8 shop\(s\) missing godverseShopId/.test(w)), `godverse cache: missing ids WARN with a count (${v.warnings.find(w => /godverseShopId/.test(w)) || 'NO WARN'})`); + } + // a plain osm cache must never be asked for ids + ok(!validateTownCache(mkCache(cluster(8))).warnings.some(w => /godverseShopId/.test(w)), `osm cache: no identity warn (godverse-only requirement)`); + + // THE SEAM THAT MATTERS: the id survives the lift onto plan.shops. Without this the field is a + // cache-only decoration and F's per-shop base can never resolve — the runtime reads the PLAN. + { + const cache = gv(withIds); + const p = generatePlanOSM(20261990, 'gvtest', { cache }); + const keyed = p.shops.filter(s => s.godverseShopId !== undefined); + ok(keyed.length === p.shops.length, `godverse lift: every seated shop carries godverseShopId (${keyed.length}/${p.shops.length})`); + ok(keyed.every(s => Number.isSafeInteger(s.godverseShopId) && s.godverseShopId > 0), `godverse lift: ids survive as positive integers`); + ok(new Set(keyed.map(s => s.godverseShopId)).size === keyed.length, `godverse lift: ids stay unique through the lift (no atlas collision)`); + // and an unkeyed cache writes NO key — that absence is what keeps all 22 pinned goldens frozen today + const plain = generatePlanOSM(20261990, 'plaintest', { cache: mkCache(cluster(8)) }); + ok(plain.shops.every(s => !('godverseShopId' in s)), `plain lift: the key is ABSENT, not undefined (byte-identical JSON ⇒ goldens frozen)`); + } +} // (d) the registry seam: register → osmTownKeys() sees it → generatePlanOSM resolves it { registerTownCache('regtest', mkCache(cluster(14))); @@ -511,7 +562,10 @@ const REAL_TOWN_GOLDENS = { newtown_godverse: 0x24e63feb, newtown_real: 0x741c18ec, northbridge_real: 0x1a645848, - toowoomba_real: 0x8b635722, + // toowoomba_real RETIRED by E in R23 (cache deleted) — pin removed with it. A golden for a town that + // cannot load is dead config: it can never fire, so it can never fail. Same species as the round's + // vacuous-gate law, one layer down. (E tried the re-bbox first and it GAMED my spacing metric — median + // NN 395.7 → 89.4 m "passes" while hub density fell 3/12 → 2/12, the pack's worst. Right call.) westend_real: 0x5b1e5ffe, }; // ROUND20: the GIG-layer golden per real town (district + capped posters) — the base golden above is @@ -541,7 +595,7 @@ const REAL_TOWN_GIG_GOLDENS = { newtown_godverse: 0xc5ed0b92, newtown_real: 0xacdb3eee, northbridge_real: 0x83a1d5be, - toowoomba_real: 0x9d34ccfe, + // toowoomba_real RETIRED by E in R23 — see the note on the base map above. westend_real: 0xb2c7ddb6, }; // `index.json` is E's towns INDEX (key/town/state/shops/roads for B's selector), not a town cache — skip it. @@ -564,6 +618,24 @@ for (const f of townFiles) { const gwant = REAL_TOWN_GIG_GOLDENS[key]; if (gwant === undefined) console.log(` ⚠ real/${key}: gig UNPINNED — add REAL_TOWN_GIG_GOLDENS['${key}'] = 0x${ghash.toString(16).padStart(8, '0')}`); else ok(ghash === (gwant >>> 0), `real/${key}: gig golden 0x${ghash.toString(16)} matches pinned 0x${(gwant >>> 0).toString(16)}`); + // ROUND24 ledger #2 — the identity gate on the REAL godverse caches, written to the round's own + // vacuous-gate law: it names its subject, it proves it touched it (a count), and it CANNOT pass while + // its subject is absent. Zero ids ⇒ explicit SKIP with the reason and the count. Partial ⇒ FAIL (a + // half-keyed town silently mis-stocks half its shops). Full ⇒ assert the ids reach plan.shops. + if (cache.source === 'godverse+osm') { + const keyed = cache.shops.filter(s => Number.isSafeInteger(s && s.godverseShopId) && s.godverseShopId > 0).length; + if (keyed === 0) { + console.log(` ⊘ SKIP real/${key}: godverse identity — 0/${cache.shops.length} shops carry godverseShopId. ` + + `Subject absent: G re-emits with ids (R24 ledger #2). This gate binds the moment one id lands.`); + } else { + ok(keyed === cache.shops.length, + `real/${key}: godverse identity — ${keyed}/${cache.shops.length} shops keyed (partial keying mis-stocks the rest)`); + const seated = generatePlanOSM(20261990, key, { cache }).shops; + const lifted = seated.filter(s => s.godverseShopId !== undefined).length; + ok(lifted === seated.length, `real/${key}: godverse identity survives the lift (${lifted}/${seated.length} seated shops keyed)`); + ok(new Set(seated.map(s => s.godverseShopId)).size === seated.length, `real/${key}: godverse ids unique on the plan (no shared atlas)`); + } + } } // ── 4. every facade skin referenced by the registry exists on disk ──────────────────