Lane A R39 39.1: THE ADDRESS LAYER — 17,835 street names stop being dropped on the floor

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 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-03 20:32:43 +10:00
parent 07328b7e9f
commit f628bf581a
5 changed files with 758 additions and 2 deletions

View File

@ -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 **65319 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.030.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

354
web/js/citygen/address.js Normal file
View File

@ -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.030.90 m across the 23 caches (worst katoomba). The real
// bound the tolerance has to clear is DouglasPeucker 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,
}),
};
}

View File

@ -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.

View File

@ -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 {

View File

@ -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}`);