From f628bf581a8ff722d49f68a730481365b65b6288 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Mon, 3 Aug 2026 20:32:43 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20A=20R39=2039.1:=20THE=20ADDRESS=20LAYER?= =?UTF-8?q?=20=E2=80=94=2017,835=20street=20names=20stop=20being=20dropped?= =?UTF-8?q?=20on=20the=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract, published for B and C in LANE_A_NOTES §39 (first commit, per the half-day rule). createAddresses(plan, cache|null) → streetOf(edgeId) · localityOf(shopId) · cohort(pred) · streets() · stats() New pure module web/js/citygen/address.js: ZERO imports, no THREE, no fetch, no DOM, no module state, no plan mutation. Two suppliers, ONE consumer contract — a real town fills `label` from the cache's named ways, the synthetic fills it from district.kind + block. Consumers must never branch on town type; that constraint is the design. MEASURED (Fable's binding facts re-derived independently, all three land exactly): · 19,132 road ways, 17,835 named (93.2%) · median plan-edge-sample → nearest named way 0.03–0.90 m (worst katoomba) · 1,192 / 1,219 corpus shops resolve to a street name (97.8%); 29,865/30,986 edges named (96.4%) · all 27 unresolved shops front a way OSM genuinely leaves unnamed (adelaide 21 arcade) — null is the honest answer and the module returns it rather than borrowing a neighbour's name TWO DEPARTURES FROM THE SYNTHESIS, both with numbers: · the shift is recovered EXACTLY (node↔waypoint lattice intersection), not voted for. The proposed centroid-align + nearest-waypoint vote returns the WRONG shift on 5 of 23 towns (braddon, fremantle, hobart, northbridge, westend) — island culling drags the plan centroid off the cache's. A wrong shift renames every street in the town. Exact on 23/23, nothing to tune. · the resolver samples FIVE points along an edge, not the midpoint — a midpoint cannot tell a street from the street that crosses it. THE ONE-LINE UPGRADE: TAKEN. plan_osm.js writes norm.shift = {shx,shz} into the normalization log, reaching the caller only via the existing opts.report sink — never onto the plan. It is a cross-check, not a dependency: selfcheck compares plan_osm's published shift against address.js's independently recovered one and fires on a 0.01 m disagreement. GATES (+240 checks), each proven to fire on a broken world: · 0 wrong street names on 884 shop-bearing edges, against an INDEPENDENT way-membership resolver (857 agree, 0 disagree). Corpus-wide 4/30,986 disagree — all 2D-stacked ways, none carrying a shop — PINNED at 4, not asserted > 0. · CONTROL: with roads[].name stripped (exactly the pre-change state) the layer resolves 0 streets. · CONTROL: no cache ⇒ supplier 'district', visible in stats(), never a silent wrong name. · createAddresses does not mutate the plan — asserted byte-for-byte, per town. · tolerance 8 m, and the finding that sets it: correctness SATURATES AT 6 m (857 agreeing frontage names at 6, 8, 12, 16 and 24 m). Every metre past 6 buys only a name borrowed from an unnamed way; it can never buy a correction. Additive: getTownCache(key) — index.html registers the cache and drops its reference, so roads[] was unreachable downstream. Read-only, no new state, no shell change. GOLDENS: NOTHING MOVED, ZERO RE-PINS. selfcheck 157,407 → 157,647 ALL GREEN, fingerprint 0x5f76e76. Co-Authored-By: Claude Opus 5 --- docs/LANES/LANE_A_NOTES.md | 196 ++++++++++++++++++++ web/js/citygen/address.js | 354 ++++++++++++++++++++++++++++++++++++ web/js/citygen/index.js | 7 +- web/js/citygen/plan_osm.js | 18 ++ web/js/citygen/selfcheck.js | 185 ++++++++++++++++++- 5 files changed, 758 insertions(+), 2 deletions(-) create mode 100644 web/js/citygen/address.js diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index beb55c2..3c2870c 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,202 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## Round 39 (2026-08-03) — v9 item 39.1: THE ADDRESS LAYER — the contract, published + +**`web/js/citygen/address.js` is landed, green, and yours to consume.** Pure module: **zero imports**, +no THREE, no fetch, no DOM, no module state, no plan mutation. It derives from a plan and never writes +to one, so **it cannot move a golden** — and it didn't (numbers at the bottom). + +### THE CONTRACT — copy this, it is the whole API + +```js +import { createAddresses, getTownCache } from './js/citygen/index.js'; + +const cache = citygen.getTownCache(TOWN); // NEW export — see "how to get the cache" below +const addr = citygen.createAddresses(plan, cache /* or null */); + +addr.streetOf(edgeId) // → "Templeton Street" | null — a real street name, or null. NEVER a guess. +addr.localityOf(shopId) // → { shopId, street, district, block, side, label } | null (null = unknown id) +addr.cohort(predicate) // → shopId[] predicate(locality) → bool; ids ascending, deduped +addr.streets() // → [{ name, edges:[edgeId], shops:[shopId] }] sorted by name +addr.stats() // → the measurement (see below) — assert on this, don't trust a claim +``` + +`localityOf` fields: + +| field | type | on a real town | on the synthetic | +|---|---|---|---| +| `street` | `string \| null` | `"Barker Street"` (97.8% of corpus shops) | **always `null`** — there are no named ways | +| `district` | `string` | always `'mainstreet'` (`plan_osm.js:305` adds exactly one) | one of the six `DISTRICT_KINDS` | +| `block` | `int` | `lot.block` | `lot.block` | +| `side` | `'north'\|'south'\|'east'\|'west'` | the cardinal the lot sits on off its `frontEdge` | same | +| `label` | `string \| null` | the street name, or `null` if unresolved | `"the market end"`, `"the arcade"`, `"the north end of the backstreets"` | + +### THE ONE RULE: **NEVER BRANCH ON TOWN TYPE** + +`label` is the field you print. It is filled by a **different supplier** on each kind of town — real +streets from the cache's named ways, district phrases from `district.kind` + block — and that is the +entire point of the module. Do not test `plan.source`, do not test for a cache, do not special-case +the synthetic. **Print `label`. Group by `block`. Ask for `street` only when you specifically need a +proper name and can honestly render `null`.** The moment one consumer branches, every feature built on +this has to be written twice and the synthetic half rots. + +`label` is `null` in exactly one situation: a real-town shop whose frontage resolves to no named way +(**27 of 1,219 corpus shops, 2.2%**). Handle null once, on both town types, and you are done. + +### How to get the cache (this was genuinely unreachable before this round) + +`web/index.html:129` hands the fetched JSON to `registerTownCache(TOWN, …)` and **drops its own +reference**, so the `roads[]` — and their 17,835 names — were unreachable to everything downstream. +New additive read-only export, no new state, no shell change needed: + +```js +citygen.getTownCache(key) // → the registered cache, or undefined +``` + +`undefined` for an unknown key and for the three checked-in `osm_fixture.js` towns (melbourne / +katoomba / silverton are fixtures, not caches — they carry no `roads[]`). **`createAddresses(plan, +undefined)` is legal** and degrades to district labels rather than throwing — which is exactly the +failure a consumer would never notice, so: + +> **Assert `addr.stats().supplier === 'ways'` wherever you require real street names.** Selfcheck does +> this for all 23 caches. A caller that forgets the cache gets `supplier: 'district'` and zero street +> names — visible in `stats()`, never a silent wrong name. + +### → LANE B (39.2, the fog): what you can consume today + +- **Map street labels.** `addr.streets()` gives you `{ name, edges[], shops[] }` — the `edges` are plan + edge ids you already draw, so you can place a label along an edge run without a second lookup. + **Bring your own dedup**: a real town has **65–319 distinct street names** (median 192; adelaide 313, + bendigo 319) over 30,986 edges. Labelling every edge is 322 `fillText` calls a frame, which is the + CPU pass your own note already flags — key the offscreen static cache off the discovered set. +- **HUD tooltip.** `addr.localityOf(shop.id).label` is the template literal. It is a plain string or + null; there is no formatting to do and no town-type test to write. +- **`side` is a real clue term and it discriminates**: the two sides of any street always map to two + opposite cardinals, so `"the north side of Barker Street"` is a stable, speakable cell. +- **Cost**: `createAddresses` is **≤27 ms per real town** (worst bendigo, 2,593 edges × 1,408 named + ways), **0.3 ms on the synthetic**, once, at plan time. Zero draws, zero triangles, zero plan fields. + +### → LANE C (39.3): the shop you are digging in now has an address + +`localityOf(shop.id)` works for any shop id on the plan, interiors included — it reads `lot.frontEdge`, +which every shop already has. If the op-shop bin's find needs to be written down (`"a $2 crate, Barker +Street"`), that string exists now and it costs nothing. **Do not import `address.js` into an interior +hot path** — build it once alongside the plan and pass the result in. + +### The measurements, so nobody has to take my word for it + +Fable's binding facts re-derived independently, and **all three land exactly**: **19,132 road ways, +17,835 named (93.2%)**; median plan-edge-sample → nearest named way **0.03–0.90 m** (worst katoomba); +**1,192 / 1,219 corpus shops (97.8%) resolve to a street name.** + +| | edges named | shops with a street | distinct names | | | edges named | shops | names | +|---|---|---|---|---|---|---|---|---| +| adelaide | 89.9% | **80.9%** (89/110) | 313 | | marrickville | 97.5% | 100% (26/26) | 236 | +| bendigo | 98.9% | 100% (35/35) | 319 | | newcastle | 98.7% | 100% (34/34) | 158 | +| bowral | 95.4% | 100% (30/30) | 71 | | newtown_godverse | 93.1% | 100% (67/67) | 306 | +| braddon | 97.1% | 100% (19/19) | 142 | | newtown | 93.1% | 100% (67/67) | 306 | +| brunswick | 98.8% | 100% (92/92) | 273 | | northbridge | 96.7% | 98.3% (57/58) | 166 | +| castlemaine | 98.0% | 100% (22/22) | 141 | | redhill_godverse | 99.1% | 100% (35/35) | 291 | +| darwin | 93.1% | 100% (12/12) | 90 | | redhill | 99.1% | 100% (34/34) | 291 | +| daylesford | 97.7% | 100% (26/26) | 65 | | westend | 92.4% | 100% (41/41) | 159 | +| fitzroy | 92.8% | 99.3% (138/139) | 185 | | fremantle | 97.5% | 100% (79/79) | 134 | +| geelong | 98.4% | 98.4% (61/62) | 226 | | glebe | 97.8% | 100% (51/51) | 225 | +| hobart | 98.9% | 100% (78/78) | 215 | | katoomba | 99.0% | 100% (72/72) | 165 | +| launceston | 96.0% | 90.0% (27/30) | 192 | | **CORPUS** | **96.4%** | **97.8%** (1192/1219) | — | + +**The failure modes, named.** All 27 unresolved shops front an edge whose way genuinely has no name in +OSM: **adelaide 21 `arcade`** (adelaide's arcades are the town's retail spine and OSM leaves the +footways unnamed — this is why it is the corpus worst at 80.9% and it is honestly worst, not a bug), +fitzroy 1 `arcade`, geelong 1 `arcade`, launceston 1 `arcade` + 2 `lane`, northbridge 1 `arcade`. +**`null` is the honest answer there** and the module returns it rather than borrowing a neighbour's +name. A shop with `street: null` also gets `label: null`; selfcheck asserts that pairing per town. + +### Two things I did NOT do the way the synthesis proposed, both with numbers + +**1. The shift is recovered EXACTLY, not voted for.** `plan_osm.js:502-506` centres the town on the +origin, so plan node = `r2(projected way point) + shift`. The synthesis recovers that by centroid-align ++ nearest-waypoint vote. **I implemented that first and it returns the WRONG shift on 5 of the 23 +towns** — braddon, fremantle, hobart, northbridge, westend. The cull of shopless islands +(`plan_osm.js:407-414`) drags the plan's centroid off the cache's, the initial alignment is tens of +metres out, and the vote then converges *confidently* on a wrong offset. **A wrong shift is not a +degraded answer — it renames every street in the town.** So: take one plan node, treat every +(node − way point) difference as a candidate, and keep only candidates under which further nodes also +land exactly on way points. Two or three nodes collapse ~25,000 candidates to one. **Exact on 23/23, +nothing to tune, and it returns `null` rather than a guess when it cannot prove an answer.** + +**2. The resolver samples five points along an edge, not the midpoint.** "Nearest named way to the +edge midpoint" cannot tell a street from the street that *crosses* it — at an intersection both are +~0 m away and it picks one by a hair. A name must now be within tolerance at **all five** samples +(t ∈ 0.15…0.85, endpoints excluded because they *are* the junctions). A crossing street is close at +one sample and far at the rest. + +### The tolerance is 8 m — and here is the number that actually sets it + +Corpus shops that get a street name: **2 m → 85.2% · 4 m → 94.8% · 6 m → 97.4% · 8 m → 97.8% · +12 m → 99.1% · 16 m → 99.2%**. But coverage is the wrong quantity to tune on. Checked against an +independent **way-membership** resolver (below), the number of the 884 shop-bearing edges that resolve +to the *same name* is **857 at 6 m — and 857 at 8, 12, 16 and 24 m.** + +> **Correctness saturates at 6 m.** Every metre past 6 buys exactly one thing: a name attached to a way +> OSM left unnamed (0 such edges at 6 m, 4 at 8 m, 12 at 12 m, 14 at 24 m). **It can never buy a +> correction.** Raising this knob to chase coverage is always borrowing a neighbour's name and should +> be argued as that, never as accuracy. + +8 m keeps the four borrowings, all arcade footways inside a named mall (bowral's *Corbett Plaza* ×2, +fremantle's *High Street* ×2), where the borrowed name is what a person would actually say. +`createAddresses(plan, cache, { tolerance: 6 })` is the strictly-conservative setting at identical +correctness. + +### A WRONG STREET NAME IS WORSE THAN NO STREET NAME — so it is gated by an independent resolver + +`address.js` asks "which named way is this edge ON?" by **distance**. The selfcheck's control asks the +same question by **point membership**: a plan edge is built from two points of ONE simplified way +(`plan_osm.js:388-394`) and plan nodes are those points snapped on a 3 m lattice, so the true way must +*contain* both endpoints' snap keys. Two genuinely different questions over the same data — the R27 +cross-convention pattern, not a restatement. + +> **Over the 884 shop-bearing edges — the only edges `localityOf` reads — 857 agree and ZERO disagree.** +> Corpus-wide, 4 of 30,986 named edges disagree, all four are 2D-stacked ways where the question has no +> single answer (geelong's two adjacent malls; a motorway crossing *over* a street on glebe and redhill +> ×2), and **none of the four carries a shop.** That count is PINNED at 4, not asserted `> 0` — a +> control allowed to drift toward zero is how a gate goes vacuous, which is the R37 lesson. + +**And the resolver degrades to null, not to a wrong name.** Measured: feed it a shift wrong by 30 m and +castlemaine/katoomba/adelaide return **0** street names rather than a town full of wrong ones — the +five-sample rule simply finds nothing within tolerance. Feed it a shift wrong by 3 m and adelaide's +coverage *rises* from 89 to 98 shops while being wrong, which is the whole reason the shift arm is +exact-or-nothing and **coverage is never used as a proxy for correctness.** + +### The synthetic supplier, and its honest limit + +12 label cells over 493 shops, **median 41, max 147** ("the south end of the main street"). That is +coarse, and it is the same structural fact §8 of the charter already records — the synthetic is a +magnificent strip and not a town you can learn. **`block` is the finer cell: 32 shop-bearing blocks, +median 14, max 41.** Consumers that need precision should `cohort(l => l.block === n)`; consumers that +need prose should print `label`. Both work on both town types, which is the point. + +### GOLDENS: NOTHING MOVED. ZERO RE-PINS. + +`address.js` never writes to a plan, and selfcheck asserts that byte-for-byte per town +(`JSON.stringify(plan)` before and after the call). Every pinned hash in `selfcheck.js` is untouched: +synthetic `0x5f76e76`, gig `0xec7a2d39`, the 3 osm fixtures, all 23 `REAL_TOWN_GOLDENS`, all 23 +`REAL_TOWN_GIG_GOLDENS`. **Selfcheck 157,407 → 157,647 (+240 checks), ALL GREEN, fingerprint unchanged.** + +### The one-line upgrade: TAKEN, and it is genuinely that clean + +`plan_osm.js` now writes `norm.shift = { shx, shz }` — onto the **normalization log**, which reaches +the caller only through the existing `opts.report` sink at the next line. **Never onto the plan +object.** `JSON.stringify(plan)` is byte-identical by construction; the 46 pinned goldens above are the +proof, not the argument. + +**But it is not a dependency, and it should not become one.** `createAddresses(plan, cache)` recovers +the shift itself, exactly, on 23/23. The report line earns its keep as a **cross-check**: the selfcheck +compares `plan_osm`'s published shift against `address.js`'s independently recovered one, two +derivations that share no code, and fires on a **0.01 m** disagreement. Without that line, the recovery +would only ever be checkable against itself — which is precisely the vacuous-gate species R37 was +about. `opts.shift` is accepted as a fast path and `stats().shiftCheck` reports `'agree'` / `'DISAGREE'`. + ## Round 37 (2026-08-03) — v8 WAVE 0 §0.1: THE KERB, and the gate that was green over a wrong street ### → Lane B (URGENT, you are consuming these RIGHT NOW): the corridor law, stated plainly diff --git a/web/js/citygen/address.js b/web/js/citygen/address.js new file mode 100644 index 0000000..da2588a --- /dev/null +++ b/web/js/citygen/address.js @@ -0,0 +1,354 @@ +// PROCITY CityGen — THE ADDRESS LAYER (Lane A, ROUND39 item 39.1). `createAddresses(plan, cache)`. +// +// PURE. No THREE, no fetch, no DOM, no plan mutation, no module state. In: a CityPlan (+ the town +// cache it was lifted from, or null). Out: a small JSON-safe query object. Same laws as the rest of +// citygen — deterministic, and it CANNOT move a golden because it never writes to `plan`. +// +// ── WHY THIS EXISTS ───────────────────────────────────────────────────────────────────────────── +// 17,835 of the 19,132 road ways in the shipped caches carry a `name` (93.2% — re-measured this round, +// exact to the digit). `plan_osm.js:365` builds each way as `rawWays.push({ kind, pl })` and DISCARDS +// `rd.name`. Every real street in 23 Australian towns has been sitting in this repo with its name on +// it and the game has never said one out loud. This module says them, without changing plan output. +// +// ── THE ONE CONSUMER CONTRACT (this is the point of the module — read this before consuming) ───── +// +// createAddresses(plan, cache|null, opts?) → { +// streetOf(edgeId) → "Templeton Street" | null // real street name, or null. Never a guess. +// localityOf(shopId) → { shopId, street, district, block, side, label } | null +// cohort(predicate) → shopId[] // predicate(locality) → bool; ids ascending +// streets() → [{ name, edges:[edgeId], shops:[shopId] }] // sorted by name +// stats() → { … } // the measurement, so a gate reads numbers instead of a claim +// } +// +// TWO SUPPLIERS, ONE FIELD. `label` is the string a consumer PRINTS, and it is filled by a different +// supplier on each kind of town: +// · a real town (a cache with named ways) → the street name: "Templeton Street" +// · the synthetic town (no cache, 22 edges) → the district phrase: "the market end", "the arcade", +// "the backstreets" — from `district.kind` + the block's sector inside that district. +// **CONSUMERS MUST NEVER BRANCH ON TOWN TYPE.** Do not test `plan.source`, do not test for a cache, +// do not special-case the synthetic. Print `label`; group by `block`; ask `street` only when you +// specifically need a *name* and can honestly handle null. That constraint is the whole design: the +// moment a consumer branches, every feature built on this has to be written twice and the synthetic +// half rots. (`street` is null on the synthetic town by construction, and null on the ~2% of real-town +// shops whose frontage resolves to no named way. `label` is null exactly when nothing honest can be +// said — a real-town shop with no resolvable street. Handle null once, on both town types.) +// +// A WRONG STREET NAME IS WORSE THAN NO STREET NAME. The player navigates by this. So the resolver is +// deliberately conservative: see THE RESOLUTION RULE below. Unresolved is `null`, loudly, every time. + +const isNum = v => typeof v === 'number' && Number.isFinite(v); +const EARTH_M = 111320; // metres per degree latitude — plan_osm.js:24's equirectangular convention +const cents = v => Math.round(v * 100); // plan_osm rounds every coord to 2dp (r2); work on that integer lattice + +// ── THE TOLERANCE ─────────────────────────────────────────────────────────────────────────────── +// 8 m, as briefed — but the number that decided it is not the coverage curve, it is the CONTROL. +// Plan nodes ARE snapped projected way points (plan_osm.js:361-372, SNAP=3), so a plan edge lies ON +// its way: median sample→way distance is 0.03–0.90 m across the 23 caches (worst katoomba). The real +// bound the tolerance has to clear is Douglas–Peucker chord error (EPS = 6 m, plan_osm.js:353), not +// the median. +// +// Measured, whole corpus, corpus shops that get a street name: +// 2 m → 85.2% 4 m → 94.8% 6 m → 97.4% 8 m → 97.8% 12 m → 99.1% 16 m → 99.2% +// and the share of resolved edges where TWO different names both qualified at all five samples: +// 2 m → 0.23% 4 m → 3.21% 6 m → 7.27% 8 m → 10.96% 12 m → 16.59% 16 m → 20.68% +// +// THE FINDING THAT ACTUALLY SETS THE KNOB — checked against the independent way-membership control +// (see LANE_A_NOTES §39): over the 884 shop-bearing edges, the number that resolve to the SAME name +// the control derives is **857 at 6 m and 857 at 8, 12, 16 and 24 m**. Correctness SATURATES AT 6 m. +// Every metre past 6 buys exactly one thing: a name attached to a way OSM left unnamed (0 such edges +// at 6 m, 4 at 8 m, 12 at 12 m, 14 at 24 m). It can never buy a correction. So raising this knob to +// chase coverage is always borrowing a neighbour's name, and should be argued as that, never as +// accuracy. 8 m keeps the four borrowings — all arcade footways inside a named mall (bowral's +// "Corbett Plaza" ×2, fremantle's "High Street" ×2), where the borrowed name is what a person would +// actually say. `opts.tolerance: 6` is the strictly-conservative setting at identical correctness. +export const STREET_TOLERANCE_M = 8; + +// ── THE RESOLUTION RULE (the reason this is not "nearest way to the midpoint") ────────────────── +// The brief says "nearest named way to the edge midpoint". A midpoint alone cannot tell a street from +// the street that CROSSES it: at an intersection both are ~0 m away, and picking either by a hair is +// exactly the wrong-name failure the player would navigate by. So a name must be within tolerance at +// FIVE samples spread along the edge, not at one point. A crossing street is close at one sample and +// far at the rest; the edge's own street is ~0 m at all five. Endpoints are excluded (t ∈ [0.15,0.85]) +// because they ARE the junctions. Among names that qualify at every sample, the smallest worst-case +// distance wins; a tie inside 0.25 m (genuinely coincident ways) is resolved by name, deterministically. +const SAMPLE_TS = [0.15, 0.3, 0.5, 0.7, 0.85]; + +const DISTRICT_PHRASE = { + mainstreet: 'the main street', + market: 'the market end', + arcade: 'the arcade', + backstreets: 'the backstreets', + warehouse: 'the warehouse fringe', + residential: 'the residential streets', +}; +const SECTOR = ['east', 'north', 'west', 'south']; // index = quadrant of atan2, see sectorOf + +// Nearest point on segment AB to P, squared distance. (Same maths as selfcheck's nearestOnSeg.) +function segDist2(px, pz, ax, az, bx, bz) { + const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz; + let t = L2 ? ((px - ax) * dx + (pz - az) * dz) / L2 : 0; + t = t < 0 ? 0 : t > 1 ? 1 : t; + const qx = px - (ax + dx * t), qz = pz - (az + dz * t); + return qx * qx + qz * qz; +} + +// Cardinal sector of a vector, as a word. Two opposite vectors always map to two opposite words, so +// the two sides of any street get two distinct, stable tokens whatever its bearing. +function sectorOf(dx, dz) { + return Math.abs(dx) >= Math.abs(dz) ? (dx >= 0 ? 'east' : 'west') : (dz >= 0 ? 'north' : 'south'); +} + +// ── THE SHIFT ─────────────────────────────────────────────────────────────────────────────────── +// plan_osm.js:502-506 centres the imported town on the origin with a rigid translation +// (`shx`,`shz`) applied AFTER the graph is built, so: plan node = r2(projected way point) + shift, +// exactly, on the 2dp lattice. To compare a plan edge against a cache way we must undo it. +// +// It is recovered EXACTLY, not estimated. Take one plan node; every (node − way point) difference is +// a candidate; keep only candidates under which further plan nodes ALSO land exactly on way points. +// Two or three nodes collapse ~25,000 candidates to one, because a real road network has no +// centimetre-exact translational symmetry. Measured: **exact on 23 of 23 caches**, no tuning, and it +// returns null rather than a guess when it cannot prove an answer. +// +// This is deliberately NOT the centroid-align + nearest-waypoint vote the synthesis describes. I +// implemented that first and it landed on the WRONG shift on 5 of the 23 towns (braddon, fremantle, +// hobart, northbridge, westend) — the cull of shopless islands (plan_osm.js:407-414) drags the plan's +// centroid off the cache's, the initial alignment is then tens of metres out, and the vote converges +// confidently onto a wrong offset. A wrong shift is not a degraded answer: it renames EVERY street in +// the town. A tuned vote can no doubt be made to work; an exact recovery has nothing to tune, so it +// is the one I shipped. `opts.shift` (e.g. `report.shift` from `generatePlanOSM(seed, town, {report})`) +// short-circuits it and is CROSS-CHECKED against it — `stats().shiftCheck` reports 'agree'/'DISAGREE'. +function recoverShift(plan, projPointKeys, sampleNodes) { + if (!sampleNodes.length || !projPointKeys.size) return null; + for (let anchor = 0; anchor < Math.min(4, sampleNodes.length); anchor++) { + const n0 = sampleNodes[anchor]; + let cands = []; + for (const k of projPointKeys) { + const c = k.indexOf(','); + cands.push([cents(n0.x) - +k.slice(0, c), cents(n0.z) - +k.slice(c + 1)]); + } + for (let i = 0; i < sampleNodes.length && cands.length > 1; i++) { + if (i === anchor) continue; + const n = sampleNodes[i], nx = cents(n.x), nz = cents(n.z); + const kept = cands.filter(([sx, sz]) => projPointKeys.has(`${nx - sx},${nz - sz}`)); + if (!kept.length) break; // this anchor was not itself a way point + cands = kept; + } + if (cands.length === 1) return { shx: cands[0][0] / 100, shz: cands[0][1] / 100, source: 'recovered' }; + } + return null; +} + +export function createAddresses(plan, cache = null, opts = {}) { + const TOL = isNum(opts.tolerance) && opts.tolerance > 0 ? opts.tolerance : STREET_TOLERANCE_M; + const TOL2 = TOL * TOL; + const edges = (plan && plan.streets && Array.isArray(plan.streets.edges)) ? plan.streets.edges : []; + const nodes = (plan && plan.streets && Array.isArray(plan.streets.nodes)) ? plan.streets.nodes : []; + const lots = (plan && Array.isArray(plan.lots)) ? plan.lots : []; + const shops = (plan && Array.isArray(plan.shops)) ? plan.shops : []; + const blocks = (plan && Array.isArray(plan.blocks)) ? plan.blocks : []; + const districts = (plan && Array.isArray(plan.districts)) ? plan.districts : []; + + const nodeById = new Map(nodes.map(n => [n.id, n])); + const edgeById = new Map(edges.map(e => [e.id, e])); + const lotById = new Map(lots.map(l => [l.id, l])); + const blockById = new Map(blocks.map(b => [b.id, b])); + const districtById = new Map(districts.map(d => [d.id, d])); + + const roads = (cache && Array.isArray(cache.roads)) ? cache.roads : []; + const hasCentre = !!(cache && cache.center && isNum(cache.center.lat) && isNum(cache.center.lon)); + const namedRoads = hasCentre ? roads.filter(r => r && Array.isArray(r.pts) && r.pts.length >= 2 && + typeof r.name === 'string' && r.name.trim()) : []; + + // ── SUPPLIER A: the real streets, from the cache's named ways ────────────────────────────────── + const streetByEdge = new Map(); // edgeId → name + let shift = null, shiftSource = 'none', shiftCheck = null; + let resolveCandidatesSeen = 0, ambiguousEdges = 0; + + if (namedRoads.length) { + const cosLat = Math.cos(cache.center.lat * Math.PI / 180); + const projX = lon => (lon - cache.center.lon) * EARTH_M * cosLat; + const projZ = lat => (lat - cache.center.lat) * EARTH_M; + + // every way point (named or not — a plan node may come from an unnamed way) on the 2dp lattice + const projPointKeys = new Set(); + for (const rd of roads) { + if (!rd || !Array.isArray(rd.pts)) continue; + for (const p of rd.pts) if (Array.isArray(p) && isNum(p[0]) && isNum(p[1])) { + projPointKeys.add(`${cents(projX(p[1]))},${cents(projZ(p[0]))}`); + } + } + // spread the sample across the node list so one bad island can't own the anchor set + const step = Math.max(1, Math.floor(nodes.length / 12)); + const sampleNodes = []; + for (let i = 0; i < nodes.length && sampleNodes.length < 12; i += step) sampleNodes.push(nodes[i]); + + const given = opts.shift && isNum(opts.shift.shx) && isNum(opts.shift.shz) ? opts.shift : null; + const rec = recoverShift(plan, projPointKeys, sampleNodes); + if (given && rec) shiftCheck = (cents(given.shx) === cents(rec.shx) && cents(given.shz) === cents(rec.shz)) ? 'agree' : 'DISAGREE'; + if (given) { shift = { shx: given.shx, shz: given.shz }; shiftSource = 'given'; } + else if (rec) { shift = { shx: rec.shx, shz: rec.shz }; shiftSource = 'recovered'; } + + if (shift) { + // segment grid over the named ways, in CACHE (projected) space + const CELL = 32; + const segs = []; // [ax, az, bx, bz, nameIdx] + const names = [], nameIdx = new Map(); + const grid = new Map(); + for (const rd of namedRoads) { + const nm = rd.name.trim(); + let ni = nameIdx.get(nm); + if (ni === undefined) { ni = names.length; names.push(nm); nameIdx.set(nm, ni); } + const pl = rd.pts.filter(p => Array.isArray(p) && isNum(p[0]) && isNum(p[1])) + .map(p => [projX(p[1]), projZ(p[0])]); + for (let i = 0; i + 1 < pl.length; i++) { + const si = segs.length; + segs.push([pl[i][0], pl[i][1], pl[i + 1][0], pl[i + 1][1], ni]); + const x0 = Math.floor(Math.min(pl[i][0], pl[i + 1][0]) / CELL), x1 = Math.floor(Math.max(pl[i][0], pl[i + 1][0]) / CELL); + const z0 = Math.floor(Math.min(pl[i][1], pl[i + 1][1]) / CELL), z1 = Math.floor(Math.max(pl[i][1], pl[i + 1][1]) / CELL); + for (let cx = x0; cx <= x1; cx++) for (let cz = z0; cz <= z1; cz++) { + const k = `${cx},${cz}`; const b = grid.get(k); if (b) b.push(si); else grid.set(k, [si]); + } + } + } + // name → smallest squared distance within TOL of (x,z); absent = farther than TOL + const nearNames = (x, z) => { + const out = new Map(); + const x0 = Math.floor((x - TOL) / CELL), x1 = Math.floor((x + TOL) / CELL); + const z0 = Math.floor((z - TOL) / CELL), z1 = Math.floor((z + TOL) / CELL); + for (let cx = x0; cx <= x1; cx++) for (let cz = z0; cz <= z1; cz++) { + const b = grid.get(`${cx},${cz}`); if (!b) continue; + for (const si of b) { + const s = segs[si]; + const d2 = segDist2(x, z, s[0], s[1], s[2], s[3]); + if (d2 > TOL2) continue; + const cur = out.get(s[4]); + if (cur === undefined || d2 < cur) out.set(s[4], d2); + } + } + return out; + }; + + for (const e of edges) { + const a = nodeById.get(e.a), b = nodeById.get(e.b); + if (!a || !b) continue; + const ax = a.x - shift.shx, az = a.z - shift.shz, bx = b.x - shift.shx, bz = b.z - shift.shz; + // intersect the qualifying-name sets across all five samples, carrying the worst distance + let worst = null; + for (let k = 0; k < SAMPLE_TS.length; k++) { + const t = SAMPLE_TS[k]; + const hit = nearNames(ax + (bx - ax) * t, az + (bz - az) * t); + if (!hit.size) { worst = null; break; } + if (worst === null) { worst = hit; continue; } + const next = new Map(); + for (const [ni, d2] of worst) { const d = hit.get(ni); if (d !== undefined) next.set(ni, d > d2 ? d : d2); } + if (!next.size) { worst = null; break; } + worst = next; + } + if (!worst || !worst.size) continue; + resolveCandidatesSeen += worst.size; + if (worst.size > 1) ambiguousEdges++; + let bestNi = -1, bestD = Infinity; + for (const [ni, d2] of worst) { + const d = Math.sqrt(d2); + if (d < bestD - 0.25 || (Math.abs(d - bestD) <= 0.25 && bestNi >= 0 && names[ni] < names[bestNi])) { + bestD = Math.min(d, bestD); bestNi = ni; // ±0.25 m tie → alphabetical, deterministic + } + } + if (bestNi >= 0) streetByEdge.set(e.id, names[bestNi]); + } + } + } + + // ── SUPPLIER B: the synthetic town's districts ───────────────────────────────────────────────── + // No cache, no named ways, 22 edges. The label comes from `district.kind` + the block's sector + // inside that district, so it is a PLACE ("the market end") rather than a name. Same field. + const blockLabel = new Map(); // blockId → label + { + const perDistrict = new Map(); + for (const b of blocks) { + const cen = b.poly && b.poly.length + ? b.poly.reduce((a, p) => [a[0] + p[0] / b.poly.length, a[1] + p[1] / b.poly.length], [0, 0]) + : [0, 0]; + const arr = perDistrict.get(b.district); if (arr) arr.push([b, cen]); else perDistrict.set(b.district, [[b, cen]]); + } + for (const [did, arr] of perDistrict) { + const d = districtById.get(did); + const phrase = (d && DISTRICT_PHRASE[d.kind]) || 'the town'; + if (arr.length === 1) { blockLabel.set(arr[0][0].id, phrase); continue; } + const cx = arr.reduce((s, [, c]) => s + c[0], 0) / arr.length; + const cz = arr.reduce((s, [, c]) => s + c[1], 0) / arr.length; + for (const [b, c] of arr) blockLabel.set(b.id, `the ${sectorOf(c[0] - cx, c[1] - cz)} end of ${phrase}`); + } + } + + const supplier = streetByEdge.size ? 'ways' : 'district'; + + // ── the shop table ───────────────────────────────────────────────────────────────────────────── + const localityByShop = new Map(); + for (const sh of shops) { + const lot = lotById.get(sh.lot); + const block = lot ? blockById.get(lot.block) : null; + const district = block ? districtById.get(block.district) : null; + const street = lot && streetByEdge.has(lot.frontEdge) ? streetByEdge.get(lot.frontEdge) : null; + + // side of the street: the cardinal the lot sits on, off its frontEdge's centreline + let side = null; + if (lot) { + const e = edgeById.get(lot.frontEdge); + const a = e ? nodeById.get(e.a) : null, b = e ? nodeById.get(e.b) : null; + if (a && b) { + const dx = b.x - a.x, dz = b.z - a.z, L2 = dx * dx + dz * dz; + let t = L2 ? ((lot.x - a.x) * dx + (lot.z - a.z) * dz) / L2 : 0; + t = t < 0 ? 0 : t > 1 ? 1 : t; + side = sectorOf(lot.x - (a.x + dx * t), lot.z - (a.z + dz * t)); + } + } + localityByShop.set(sh.id, { + shopId: sh.id, + street, + district: district ? district.kind : null, + block: lot ? lot.block : null, + side, + label: supplier === 'ways' ? street : (lot ? (blockLabel.get(lot.block) || null) : null), + }); + } + + const streetIndex = new Map(); // name → { name, edges[], shops[] } + for (const [eid, nm] of streetByEdge) { + const rec = streetIndex.get(nm) || streetIndex.set(nm, { name: nm, edges: [], shops: [] }).get(nm); + rec.edges.push(eid); + } + for (const loc of localityByShop.values()) if (loc.street) streetIndex.get(loc.street).shops.push(loc.shopId); + for (const rec of streetIndex.values()) { rec.edges.sort((a, b) => a - b); rec.shops.sort((a, b) => a - b); } + + const resolvedShops = [...localityByShop.values()].filter(l => l.label !== null).length; + const streetedShops = [...localityByShop.values()].filter(l => l.street !== null).length; + + return { + streetOf: edgeId => (streetByEdge.has(edgeId) ? streetByEdge.get(edgeId) : null), + localityOf: shopId => localityByShop.get(shopId) || null, + cohort(predicate) { + const out = []; + for (const loc of localityByShop.values()) { try { if (predicate(loc)) out.push(loc.shopId); } catch { /* a throwing predicate selects nothing */ } } + return out.sort((a, b) => a - b); + }, + streets: () => [...streetIndex.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)), + stats: () => ({ + supplier, // 'ways' (a real town) | 'district' (the synthetic) + tolerance: TOL, + shift: shift ? { shx: shift.shx, shz: shift.shz } : null, + shiftSource, // 'given' | 'recovered' | 'none' + shiftCheck, // 'agree' | 'DISAGREE' | null (only one source present) + namedWays: namedRoads.length, + roads: roads.length, + edges: edges.length, + edgesNamed: streetByEdge.size, + ambiguousEdges, // ≥2 names qualified at every sample (tie-broken) + distinctStreets: streetIndex.size, + shops: shops.length, + shopsWithStreet: streetedShops, + shopsLabelled: resolvedShops, + candidatesPerResolvedEdge: streetByEdge.size ? resolveCandidatesSeen / streetByEdge.size : 0, + }), + }; +} diff --git a/web/js/citygen/index.js b/web/js/citygen/index.js index 12b32a6..0376211 100644 --- a/web/js/citygen/index.js +++ b/web/js/citygen/index.js @@ -9,9 +9,14 @@ export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js'; export { shopName, townName, bandName } from './names.js'; -export { generatePlanOSM, osmTownKeys, registerTownCache, validateTownCache, MIN_TOWN_SHOPS, +export { generatePlanOSM, osmTownKeys, registerTownCache, getTownCache, validateTownCache, MIN_TOWN_SHOPS, medianShopSpacing, MAX_MEDIAN_SPACING_M } from './plan_osm.js'; export { withGigs, gigKeyFor, POSTER_CLEAR } from './gigs.js'; +// v9 THE ADDRESS LAYER (ROUND39 item 39.1). Pure, no THREE, no fetch, no plan mutation — it derives +// from a plan and never writes to one, so it cannot move a golden. ONE consumer contract across both +// town types: print `localityOf(shopId).label`, never branch on `plan.source`. Full contract in the +// header of address.js and in LANE_A_NOTES §39. +export { createAddresses, STREET_TOLERANCE_M } from './address.js'; // The street-corridor law + venue vocabulary, re-exported so consumers get the whole Lane A contract // from one import (ROUND13). `roadWidth`/`vergeBand`/`poleOffset` are the road-vs-verge split of // `edge.width` — see the note in registry.js; `gigKeyFor` is the one and only genre→audio-key mapping. diff --git a/web/js/citygen/plan_osm.js b/web/js/citygen/plan_osm.js index 982c6ea..0a81547 100644 --- a/web/js/citygen/plan_osm.js +++ b/web/js/citygen/plan_osm.js @@ -243,6 +243,15 @@ export function registerTownCache(key, cache) { TOWN_CACHES[key] = cache; return v; } +// ROUND39 (39.1): read back a registered cache. Additive, read-only, no new state — the registry +// already held it. `web/index.html:129` hands the fetched JSON to `registerTownCache` and drops its own +// reference, so until now the roads[] (and their 17,835 names) were unreachable to anything downstream. +// `address.js` needs the cache alongside the plan; this is how a consumer gets it without re-fetching: +// const cache = citygen.getTownCache(TOWN); const addr = citygen.createAddresses(plan, cache); +// Returns undefined for an unknown key (and for the checked-in `osm_fixture.js` towns, which are not +// caches and carry no roads) — `createAddresses(plan, undefined)` is legal and degrades to district +// labels, so check `addr.stats().supplier === 'ways'` if you require real street names. +export function getTownCache(key) { return TOWN_CACHES[key]; } export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) { citySeed = (citySeed >>> 0); @@ -507,6 +516,15 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) { const M = 40; const size = { w: Math.max(256, Math.ceil((maxx - minx) + 2 * M)), d: Math.max(256, Math.ceil((maxz - minz) + 2 * M)) }; + // ROUND39 (39.1, THE ADDRESS LAYER): publish the centring translation into the NORMALIZATION LOG. + // `address.js` needs it to map a plan node back onto the cache's projected way points (plan space = + // projected space + shift). It goes on `norm` — i.e. into the CALLER-SUPPLIED `opts.report` sink at + // the line below — and NEVER onto the plan object, so `JSON.stringify(plan)` is byte-identical and + // every pinned golden (synthetic, osm, 23 real towns, gig) is frozen by construction. Recovery works + // without it (address.js votes for the shift from the node↔waypoint correspondence and gets the same + // number on all 23 caches), so this is an accelerator and a cross-check, never a dependency. + norm.shift = { shx, shz }; + if (opts.report) Object.assign(opts.report, norm); // caller-supplied log sink (selfcheck prints it) return { diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index 723857f..eba59b9 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -11,7 +11,8 @@ import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan. import { generatePlanOSM, osmTownKeys, validateTownCache, registerTownCache, MIN_TOWN_SHOPS, medianShopSpacing, MAX_MEDIAN_SPACING_M } from './plan_osm.js'; import { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.js'; -import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, vergeBand } from '../core/registry.js'; +import { createAddresses, STREET_TOLERANCE_M } from './address.js'; +import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, vergeBand, DISTRICT_KINDS } from '../core/registry.js'; import { xmur3 } from '../core/prng.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -662,6 +663,62 @@ section('v4 real-roads graph lift (schema v2)'); `roads absent: marched fallback still boots`); } +// ── THE ADDRESS LAYER's independent CONTROL RESOLVER (ROUND39 item 39.1) ──────────────────────── +// `address.js` answers "which named way is this edge ON?" by DISTANCE. This answers the same question +// by POINT MEMBERSHIP: a plan edge is built from two consecutive points of ONE simplified way +// (plan_osm.js:388-394), and plan nodes are those way points snapped on a 3 m lattice — so the way the +// edge came from must CONTAIN both endpoints' snap keys. Two different questions over the same data, +// which is the whole point: this is the R27 cross-convention pattern (see facadeQuadWorld above), not +// a restatement. If A's distance resolver drifts, or the shift recovery breaks, membership disagrees +// and the gate below fires. It deliberately replicates plan_osm's SNAP constant; that coupling IS the +// gate — change the lift's snapping and this must be re-derived rather than silently trusted. +const ADDR_SNAP = 3; // plan_osm.js:353 `SNAP` +function membershipStreets(plan, cache, shift) { + const cosLat = Math.cos(cache.center.lat * Math.PI / 180); + const projX = lon => (lon - cache.center.lon) * 111320 * cosLat; + const projZ = lat => (lat - cache.center.lat) * 111320; + const skey = (x, z) => `${Math.round(x / ADDR_SNAP)},${Math.round(z / ADDR_SNAP)}`; + const waysAtKey = new Map(), wayName = []; + (cache.roads || []).forEach((rd, wi) => { + if (!rd || !Array.isArray(rd.pts) || rd.pts.length < 2) { wayName.push(null); return; } + wayName.push(typeof rd.name === 'string' && rd.name.trim() ? rd.name.trim() : null); + for (const p of rd.pts) { + if (!Array.isArray(p) || !isFiniteNum(p[0]) || !isFiniteNum(p[1])) continue; + const k = skey(projX(p[1]), projZ(p[0])); + const s = waysAtKey.get(k); if (s) s.add(wi); else waysAtKey.set(k, new Set([wi])); + } + }); + const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n])); + const out = new Map(); // edgeId → [name…] (empty ⇒ no membership answer) + for (const e of plan.streets.edges) { + const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue; + const A = waysAtKey.get(skey(a.x - shift.shx, a.z - shift.shz)); + const B = waysAtKey.get(skey(b.x - shift.shx, b.z - shift.shz)); + if (!A || !B) continue; + const nms = [...new Set([...A].filter(w => B.has(w)).map(w => wayName[w]).filter(Boolean))]; + if (nms.length) out.set(e.id, nms); + } + return out; +} +// Compare A's shipped answer against the control over the edges `localityOf` actually reads (the ones +// a shop fronts). Returns { checked, agree, disagree, examples[] } — plus the all-edge disagreement +// count, which is pinned in the corpus roll-up rather than asserted zero (see the note there). +function addressVsControl(plan, addr, ctrl) { + const lotById = new Map(plan.lots.map(l => [l.id, l])); + const fronts = new Set(plan.shops.map(s => (lotById.get(s.lot) || {}).frontEdge)); + let checked = 0, agree = 0, disagree = 0, allDis = 0; const examples = []; + for (const [eid, nms] of ctrl) { + const got = addr.streetOf(eid); + if (!got) continue; + const same = nms.includes(got); + if (!same) allDis++; + if (!fronts.has(eid)) continue; + checked++; + if (same) agree++; else { disagree++; if (examples.length < 3) examples.push(`edge ${eid}: address "${got}" vs control "${nms.join('|')}"`); } + } + return { checked, agree, disagree, allDis, examples }; +} + // ── 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. @@ -737,6 +794,8 @@ const REAL_TOWN_GIG_GOLDENS = { // `index.json` is E's towns INDEX (key/town/state/shops/roads for B's selector), not a town cache — skip it. const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json') && f !== 'index.json'); if (!townFiles.length) console.log(" (none yet — the contract + hardening are live, ready for E's build_towns.py)"); +const ADDR = { towns: 0, shops: 0, withStreet: 0, edges: 0, edgesNamed: 0, ctrlChecked: 0, ctrlAgree: 0, + ctrlDisagree: 0, allEdgeDisagree: 0, shiftExact: 0, waysSupplier: 0, worstTown: null }; 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; } @@ -750,6 +809,63 @@ for (const f of townFiles) { const want = REAL_TOWN_GOLDENS[key]; if (want === undefined) console.log(` ⚠ real/${key}: base UNPINNED — add REAL_TOWN_GOLDENS['${key}'] = 0x${hash.toString(16).padStart(8, '0')}`); else ok(hash === (want >>> 0), `real/${key}: base golden 0x${hash.toString(16)} matches pinned 0x${(want >>> 0).toString(16)}`); + // ── ROUND39 item 39.1 — THE ADDRESS LAYER, per real town ───────────────────────────────────── + // Every arm here reads a NUMBER, not a claim, and every arm has something that can make it fail. + { + const report = {}; + const p = generatePlanOSM(20261990, key, { cache, report }); + const before = JSON.stringify(p); + const addr = createAddresses(p, cache); // recovery path: NO shift handed in + const st = addr.stats(); + // (i) NO PLAN MUTATION. The whole value of this item is that it is free; prove it locally, not + // just by the golden downstream. If this fires, every pinned hash in this file is at risk. + ok(JSON.stringify(p) === before, `real/${key}: createAddresses does not mutate the plan (byte-identical after the call)`); + // (ii) the real-ways supplier ran. A silent fall to district labels on a real town is the failure + // mode a consumer would never notice, so it is an assert, not a log. + ok(st.supplier === 'ways', `real/${key}: address supplier is 'ways' (${st.supplier}) — ${st.namedWays}/${st.roads} named ways`); + // (iii) THE CROSS-SOURCE ARM. `plan_osm` publishes its centring translation into the caller's + // report sink; `address.js` re-derives the same number from the node↔waypoint lattice with + // no knowledge of it. Two independent derivations compared against each other — if either + // drifts, this fires. Asserted through the module's own cross-check field as well. + const exact = !!(st.shift && report.shift && st.shift.shx === report.shift.shx && st.shift.shz === report.shift.shz); + ok(exact, `real/${key}: shift recovered EXACTLY without the report (${st.shift ? `${st.shift.shx},${st.shift.shz}` : 'null'} vs plan_osm's ` + + `${report.shift ? `${report.shift.shx},${report.shift.shz}` : 'NOTHING — plan_osm stopped publishing norm.shift'})`); + ok(createAddresses(p, cache, { shift: report.shift }).stats().shiftCheck === 'agree', + `real/${key}: opts.shift fast path agrees with the recovery (stats().shiftCheck)`); + // (iv) THE WRONG-NAME ARM. A wrong street name is worse than no street name, so this is measured + // against the independent membership resolver over exactly the edges a shop fronts. + const ctrl = membershipStreets(p, cache, report.shift); + const cmp = addressVsControl(p, addr, ctrl); + ok(cmp.disagree === 0, `real/${key}: 0 wrong street names on shop-bearing edges — ${cmp.agree}/${cmp.checked} agree with the independent membership control` + + (cmp.examples.length ? ` (${cmp.examples.join(' ; ')})` : '')); + // (v) coverage. Per-town floor is loose on purpose (adelaide is the corpus worst at 80.9%, and it + // is honestly worst — 21 of its 27 unresolved shops front an `arcade` edge, i.e. an OSM + // footway with no name). The tight number is the corpus roll-up after this loop. + const pctStreet = st.shops ? 100 * st.shopsWithStreet / st.shops : 0; + ok(pctStreet >= 70, `real/${key}: ${st.shopsWithStreet}/${st.shops} shops resolve to a street (${pctStreet.toFixed(1)}%) · ${st.distinctStreets} distinct names`); + // (vi) honesty of the null. Never a guess: a shop with no street must carry no label either. + ok(addr.cohort(l => l.street === null && l.label !== null).length === 0, + `real/${key}: an unresolved shop is labelled null, never guessed`); + // (vii) THE CONTROL — the arm that proves the gate DISCRIMINATES. Feed the pre-change state: the + // same cache with `roads[].name` stripped, which is exactly what `plan_osm.js:365` has been + // handing downstream for 21 rounds. The layer must resolve ZERO streets and say so. + const stripped = { ...cache, roads: (cache.roads || []).map(r => ({ kind: r.kind, pts: r.pts })) }; + const blind = createAddresses(p, stripped).stats(); + ok(blind.edgesNamed === 0 && blind.shopsWithStreet === 0 && blind.supplier === 'district', + `real/${key}: CONTROL — with roads[].name stripped (the pre-change state) the layer resolves 0 streets, not ${blind.edgesNamed}`); + // (viii) …and so does a caller who forgets the cache. Same failure, different cause, still loud. + ok(createAddresses(p, null).stats().supplier === 'district', + `real/${key}: CONTROL — no cache ⇒ district supplier (a consumer that drops the cache is visible in stats(), not silent)`); + + ADDR.towns++; ADDR.shops += st.shops; ADDR.withStreet += st.shopsWithStreet; + ADDR.edges += st.edges; ADDR.edgesNamed += st.edgesNamed; + ADDR.ctrlChecked += cmp.checked; ADDR.ctrlAgree += cmp.agree; ADDR.ctrlDisagree += cmp.disagree; + ADDR.allEdgeDisagree += cmp.allDis; + if (exact) ADDR.shiftExact++; + if (st.supplier === 'ways') ADDR.waysSupplier++; + if (!ADDR.worstTown || pctStreet < ADDR.worstTown[1]) ADDR.worstTown = [key, pctStreet]; + } + const ghash = xmur3(JSON.stringify(generatePlanFor(20261990, 'osm', { gigs: true, town: key, cache })))() >>> 0; 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')}`); @@ -799,6 +915,73 @@ for (const f of townFiles) { } } +// ── 3g. THE ADDRESS LAYER (ROUND39 item 39.1) — corpus roll-up + the synthetic supplier ───────── +// The per-town arms live inside the 3f loop (where the cache is already parsed). This is the number +// Lane F's gate quotes, plus the OTHER supplier — the one with no cache and no named way in sight. +section('the address layer (ROUND39 39.1: streetOf / localityOf / cohort)'); +if (ADDR.towns) { + const pct = 100 * ADDR.withStreet / ADDR.shops; + ok(pct >= 97, `corpus: ${ADDR.withStreet}/${ADDR.shops} shops resolve to a real street name (${pct.toFixed(1)}%) across ${ADDR.towns} caches`); + ok(ADDR.shiftExact === ADDR.towns, `corpus: the plan shift is recovered EXACTLY on ${ADDR.shiftExact}/${ADDR.towns} towns with no help from plan_osm`); + ok(ADDR.waysSupplier === ADDR.towns, `corpus: ${ADDR.waysSupplier}/${ADDR.towns} towns resolve through real named ways`); + ok(ADDR.ctrlDisagree === 0, `corpus: ${ADDR.ctrlAgree}/${ADDR.ctrlChecked} shop-bearing edges agree with the independent membership control, ${ADDR.ctrlDisagree} wrong`); + // The ALL-EDGE disagreement count is PINNED rather than asserted zero, for the R37 reason: a control + // allowed to drift is how a gate goes vacuous. Four edges in 30,986 disagree, and all four are the + // same honest thing — two named ways genuinely stacked in 2D, where "which street is this?" has no + // single answer: geelong's two adjacent malls (Little Malop Street Mall / Market Square Mall), and a + // motorway crossing OVER a street on glebe (Western Distributor / Bank Street) and redhill ×2 + // (Legacy Way / Guthrie Street). NONE of the four carries a shop, which is why the arm above is 0. + // If a cache is rebuilt this re-pins loudly, exactly like the 23 goldens above. + const PINNED_ALL_EDGE_DISAGREEMENTS = 4; + ok(ADDR.allEdgeDisagree === PINNED_ALL_EDGE_DISAGREEMENTS, + `corpus: exactly ${PINNED_ALL_EDGE_DISAGREEMENTS} of ${ADDR.edgesNamed} named edges disagree with the control (measured ${ADDR.allEdgeDisagree}) — all four are 2D-stacked ways carrying no shop`); + console.log(` address: ${ADDR.edgesNamed}/${ADDR.edges} edges named (${(100 * ADDR.edgesNamed / ADDR.edges).toFixed(1)}%) · ` + + `${ADDR.withStreet}/${ADDR.shops} shops (${pct.toFixed(1)}%) · tolerance ${STREET_TOLERANCE_M} m · worst town ${ADDR.worstTown[0]} ${ADDR.worstTown[1].toFixed(1)}%`); +} else { + console.log(' ⊘ SKIP corpus address roll-up — no town caches on disk. This gate binds the moment one lands.'); +} +// ── the SYNTHETIC supplier: no cache, no roads[], 22 edges. Same fields, same contract. ────────── +// The law this section exists to protect: a consumer must be able to read `label` on either town type +// without ever testing `plan.source`. So the synthetic must fill `label` for EVERY shop, from +// `district.kind` + block, while leaving `street` honestly null. +for (const s of [20261990, 42]) { + const plan = generatePlan(s); + const before = JSON.stringify(plan); + const addr = createAddresses(plan, null); + const st = addr.stats(); + ok(JSON.stringify(plan) === before, `addr syn ${s}: createAddresses does not mutate the plan`); + ok(st.supplier === 'district', `addr syn ${s}: the district supplier runs (no cache, no named ways)`); + ok(st.shopsWithStreet === 0 && addr.streets().length === 0, `addr syn ${s}: no street NAMES are invented (${st.shopsWithStreet} claimed)`); + ok(st.shopsLabelled === plan.shops.length, `addr syn ${s}: every one of ${plan.shops.length} shops carries a label (${st.shopsLabelled})`); + const locs = plan.shops.map(sh => addr.localityOf(sh.id)); + ok(locs.every(l => l && typeof l.label === 'string' && l.label && !/undefined|null|\[object/.test(l.label)), + `addr syn ${s}: every label is a resolved phrase (no undefined/null leaking into player-facing text)`); + ok(locs.every(l => l.district && DISTRICT_KINDS.includes(l.district)), `addr syn ${s}: every locality names a registry district kind`); + ok(locs.every(l => Number.isInteger(l.block) && ['north', 'south', 'east', 'west'].includes(l.side)), + `addr syn ${s}: every locality carries an integer block and a cardinal side`); + // the two sides of a street must be TWO tokens, or `side` adds nothing to a clue + ok(new Set(locs.map(l => l.side)).size >= 2, `addr syn ${s}: side-of-street discriminates (${[...new Set(locs.map(l => l.side))].join('/')})`); + ok(JSON.stringify(JSON.parse(JSON.stringify(locs))) === JSON.stringify(locs), `addr syn ${s}: localities are JSON round-trip lossless`); + ok(addr.localityOf(999999) === null && addr.localityOf(undefined) === null, `addr syn ${s}: an unknown shop id returns null, it does not throw`); + // cohort: ids ascending, a subset of the plan's shops, and the predicate is actually consulted + const all = addr.cohort(() => true), none = addr.cohort(() => false); + const shopIds = new Set(plan.shops.map(sh => sh.id)); + ok(all.length === plan.shops.length && none.length === 0, `addr syn ${s}: cohort spans the town (${all.length}) and can select nothing (${none.length})`); + ok(all.every((v, i) => i === 0 || all[i - 1] < v) && all.every(id => shopIds.has(id)), `addr syn ${s}: cohort returns real shop ids, ascending, no duplicates`); + const market = addr.cohort(l => /market/.test(l.label)); + ok(market.length > 0 && market.length < plan.shops.length, `addr syn ${s}: a district cohort is a real subset — "the market end" holds ${market.length} of ${plan.shops.length} shops`); + ok(JSON.stringify(createAddresses(plan, null).cohort(l => /market/.test(l.label))) === JSON.stringify(market), `addr syn ${s}: deterministic across two constructions`); +} +{ // the shape of the synthetic's cells, printed so the round can argue about it with numbers + const plan = generatePlan(GOLDEN.seed); + const addr = createAddresses(plan, null); + const byLabel = new Map(); + for (const sh of plan.shops) { const k = addr.localityOf(sh.id).label; byLabel.set(k, (byLabel.get(k) || 0) + 1); } + const cells = [...byLabel.values()].sort((a, b) => a - b); + console.log(` synthetic: ${byLabel.size} label cells over ${plan.shops.length} shops — median ${cells[cells.length >> 1]}, max ${cells[cells.length - 1]} ` + + `(e.g. "${[...byLabel.keys()].sort()[0]}"); block cells are finer — use \`block\` when a label is too coarse`); +} + // ── 4. every facade skin referenced by the registry exists on disk ────────────────── section('assets on disk'); for (const f of allFacadeSkins()) ok(existsSync(join(ASSETS, f)), `registry facade exists: ${f}`);