PROCITY/web/js/citygen/address.js
m3ultra f628bf581a 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>
2026-08-03 20:32:43 +10:00

355 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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,
}),
};
}