#!/usr/bin/env node // PROCITY Lane F — R39 §39.5 gate A: THE ADDRESS LAYER, gated on CORRECTNESS, not on coverage. // // Fable's brief: prove the layer resolves >=97% of corpus shops and FAILS LOUDLY on a town with no // roads cache. Lane A's binding warning, which sets the whole shape of this gate: // // > COVERAGE IS NOT A PROXY FOR CORRECTNESS. A 3 m shift error RAISES adelaide from 89 to 98 // > resolved shops while every one of those names is wrong. // // So this harness measures coverage (because the brief asks for a number) and then spends the rest of // its arms trying to make a HIGH-coverage build fail — by perturbing the one input that can rename a // whole town, and checking two independent things fire: // (a) `stats().shiftCheck` — address.js's recovered shift vs plan_osm's published `norm.shift` // (two derivations that share no code), which trips on 0.01 m; // (b) POINT MEMBERSHIP — a plan edge is two consecutive points of ONE simplified way snapped on a // 3 m lattice, so the way it came from must CONTAIN both endpoints' snap keys. Implemented here // independently of both address.js and selfcheck.js, over the shop-bearing edges only. // // The red arm is the deliverable: under a +3 m shift adelaide's coverage goes UP and membership // disagreement goes from 0 to nearly every edge. A gate that reads coverage would have gone green. // // Run: node tools/qa/r39_address.mjs [--json OUT] import { readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { generatePlanOSM, generatePlan, createAddresses, STREET_TOLERANCE_M } from '../../web/js/citygen/index.js'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const TOWNS = join(ROOT, 'web', 'assets', 'towns'); const SEED = 20261990; const CORPUS_FLOOR_PCT = 97; // Fable's brief const SNAP = 3; // plan_osm.js's lattice — replicated on purpose; see below let fails = 0; const ok = (c, m) => { console.log(c ? ` \x1b[32m✓\x1b[0m ${m}` : ` \x1b[31m✗ FAIL\x1b[0m ${m}`); if (!c) fails++; }; const head = (m) => console.log(`\n\x1b[1m${m}\x1b[0m`); const note = (m) => console.log(` \x1b[33m·\x1b[0m ${m}`); // ── the independent control: which named way CONTAINS both endpoints of this edge? ─────────────── // Deliberately the same coupling Lane A's own control declares (it replicates plan_osm's SNAP): if // the lift's snapping changes, this must be re-derived rather than silently trusted. It is a // different QUESTION from address.js's ("which named way is nearest at five samples along it?"), // which is what makes the two comparable rather than circular. function membership(plan, cache, shift) { const cosLat = Math.cos(cache.center.lat * Math.PI / 180); const px = (lon) => (lon - cache.center.lon) * 111320 * cosLat; const pz = (lat) => (lat - cache.center.lat) * 111320; const k = (x, z) => `${Math.round(x / SNAP)},${Math.round(z / SNAP)}`; const at = new Map(), name = []; (cache.roads || []).forEach((rd, i) => { if (!rd || !Array.isArray(rd.pts) || rd.pts.length < 2) { name.push(null); return; } name.push(typeof rd.name === 'string' && rd.name.trim() ? rd.name.trim() : null); for (const p of rd.pts) { if (!Array.isArray(p)) continue; const key = k(px(p[1]), pz(p[0])); const s = at.get(key); if (s) s.add(i); else at.set(key, new Set([i])); } }); const nodeById = new Map(plan.streets.nodes.map((n) => [n.id, n])); const out = new Map(); for (const e of plan.streets.edges) { const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue; const A = at.get(k(a.x - shift.shx, a.z - shift.shz)), B = at.get(k(b.x - shift.shx, b.z - shift.shz)); if (!A || !B) continue; const nms = [...new Set([...A].filter((w) => B.has(w)).map((w) => name[w]).filter(Boolean))]; if (nms.length) out.set(e.id, nms); } return out; } function shopEdgeVerdict(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; for (const [eid, nms] of ctrl) { const got = addr.streetOf(eid); if (!got || !fronts.has(eid)) continue; checked++; if (nms.includes(got)) agree++; else disagree++; } return { checked, agree, disagree }; } const keys = existsSync(TOWNS) ? readdirSync(TOWNS).filter((f) => f.endsWith('.json') && f !== 'index.json').map((f) => f.replace(/\.json$/, '')).sort() : []; console.log(`\x1b[1mGATE R39 §39.5-A: THE ADDRESS LAYER\x1b[0m (${keys.length} caches · tolerance ${STREET_TOLERANCE_M} m · seed ${SEED})`); if (!keys.length) { console.log(' no town caches on disk — nothing to gate'); process.exit(1); } // ── 1. the corpus roll-up, measured here rather than quoted ────────────────────────────────────── head('1. corpus resolution — my own count, town by town'); const roll = { towns: 0, shops: 0, street: 0, labelled: 0, edges: 0, edgesNamed: 0, checked: 0, agree: 0, disagree: 0, shiftAgree: 0, ways: 0 }; const perTown = {}; for (const key of keys) { const cache = JSON.parse(readFileSync(join(TOWNS, `${key}.json`), 'utf8')); const report = {}; const plan = generatePlanOSM(SEED, key, { cache, report }); const addr = createAddresses(plan, cache); const st = addr.stats(); const ctrl = membership(plan, cache, report.shift); const v = shopEdgeVerdict(plan, addr, ctrl); const pct = st.shops ? (100 * st.shopsWithStreet) / st.shops : 0; perTown[key] = { shops: st.shops, street: st.shopsWithStreet, pct: +pct.toFixed(1), supplier: st.supplier, edges: st.edges, edgesNamed: st.edgesNamed, names: st.distinctStreets, ...v, shiftCheck: createAddresses(plan, cache, { shift: report.shift }).stats().shiftCheck }; roll.towns++; roll.shops += st.shops; roll.street += st.shopsWithStreet; roll.labelled += st.shopsLabelled; roll.edges += st.edges; roll.edgesNamed += st.edgesNamed; roll.checked += v.checked; roll.agree += v.agree; roll.disagree += v.disagree; if (perTown[key].shiftCheck === 'agree') roll.shiftAgree++; if (st.supplier === 'ways') roll.ways++; } const pctCorpus = (100 * roll.street) / roll.shops; const worst = Object.entries(perTown).sort((a, b) => a[1].pct - b[1].pct)[0]; const full = Object.values(perTown).filter((t) => t.pct === 100).length; ok(pctCorpus >= CORPUS_FLOOR_PCT, `corpus ${roll.street}/${roll.shops} shops resolve to a real street name (${pctCorpus.toFixed(1)}%) — floor ${CORPUS_FLOOR_PCT}%`); note(`${roll.edgesNamed}/${roll.edges} edges named (${((100 * roll.edgesNamed) / roll.edges).toFixed(1)}%) · ` + `100% on ${full}/${roll.towns} towns · worst ${worst[0]} ${worst[1].pct}%`); ok(roll.ways === roll.towns, `all ${roll.ways}/${roll.towns} towns resolve through the 'ways' supplier`); ok(roll.shiftAgree === roll.towns, `the shift recovered by address.js agrees with plan_osm's published norm.shift on ${roll.shiftAgree}/${roll.towns} towns (0.01 m tolerance)`); // ── 2. the correctness arm: zero wrong names, and the control is NOT vacuous ────────────────────── head('2. wrong names — the independent point-membership control'); ok(roll.disagree === 0, `${roll.agree}/${roll.checked} shop-bearing edges agree with the membership control, ${roll.disagree} disagree`); ok(roll.checked > 800, `the control actually checked ${roll.checked} edges — a control that quietly checks nothing is how a gate goes vacuous (R37)`); // ── 3. FAILS LOUDLY WITH NO CACHE — degrade to district labels, never to a wrong name ──────────── head('3. no roads cache — degrade to district labels, never to a wrong name'); { const key = 'katoomba_real'; const cache = JSON.parse(readFileSync(join(TOWNS, `${key}.json`), 'utf8')); const plan = generatePlanOSM(SEED, key, { cache }); for (const [what, arg] of [['null', null], ['undefined', undefined], ['names stripped (the pre-R39 state)', { ...cache, roads: (cache.roads || []).map((r) => ({ kind: r.kind, pts: r.pts })) }]]) { const st = createAddresses(plan, arg).stats(); const a = createAddresses(plan, arg); const wrong = a.cohort((l) => l.street !== null).length; ok(st.supplier === 'district' && st.edgesNamed === 0 && wrong === 0, `${key} with cache=${what}: supplier '${st.supplier}', ${st.edgesNamed} named edges, ${wrong} street names — visible in stats(), never a silent wrong name`); } // …and the degradation is USABLE: labels still come out, from the other supplier. const a = createAddresses(plan, null); const lab = a.cohort((l) => l.label !== null).length; ok(lab > 0, `…and it still labels ${lab}/${plan.shops.length} shops from district+block (e.g. "${a.localityOf(plan.shops[0].id).label}") — a degrade, not an outage`); // the synthetic is the same code path with no cache in sight, and must label EVERY shop const syn = generatePlan(SEED); const sa = createAddresses(syn, null), ss = sa.stats(); ok(ss.supplier === 'district' && sa.cohort((l) => l.label === null).length === 0, `synthetic: supplier '${ss.supplier}', ${syn.shops.length}/${syn.shops.length} shops labelled, 0 nulls — one consumer contract, two suppliers`); } // ── 4. THE COVERAGE TRAP, swept rather than assumed. This is the arm the brief demands. ─────────── // A wrong shift is the one input that can rename an entire town, so it is the perturbation the gate // is built around. Eight directions x three magnitudes on three towns, and for each: how many shops // still resolve (COVERAGE), how many carry a name different from the truth (WRONGNESS), and whether // the two independent alarms fire. The pairing is the finding: coverage and correctness move apart. head('4. COVERAGE IS NOT CORRECTNESS — the wrong-shift sweep'); const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.707, 0.707], [-0.707, 0.707], [0.707, -0.707], [-0.707, -0.707]]; const MAGS = [3, 8, 30]; const trap = {}; for (const key of ['adelaide_real', 'castlemaine_real', 'katoomba_real']) { const cache = JSON.parse(readFileSync(join(TOWNS, `${key}.json`), 'utf8')); const report = {}; const plan = generatePlanOSM(SEED, key, { cache, report }); const truth = createAddresses(plan, cache, { shift: report.shift }); const tSt = truth.stats(); const ctrl = membership(plan, cache, report.shift); const rows = []; for (const m of MAGS) for (const [dx, dz] of DIRS) { const bad = { shx: report.shift.shx + dx * m, shz: report.shift.shz + dz * m }; const a = createAddresses(plan, cache, { shift: bad }); const st = a.stats(); let changed = 0, invented = 0; for (const s of plan.shops) { const t = truth.localityOf(s.id).street, g = a.localityOf(s.id).street; if (!g) continue; if (t && t !== g) changed++; // the truth had a name and this build says another one else if (!t) invented++; // the truth honestly said null and this build names it } rows.push({ m, dx, dz, street: st.shopsWithStreet, changed, invented, renamed: changed + invented, shiftCheck: st.shiftCheck, ...shopEdgeVerdict(plan, a, ctrl) }); } trap[key] = { true: tSt.shopsWithStreet, shops: tSt.shops, rows }; const byMag = MAGS.map((m) => { const r = rows.filter((x) => x.m === m); const best = r.reduce((p, q) => (q.street > p.street ? q : p)); const wrongest = r.reduce((p, q) => (q.disagree > p.disagree ? q : p)); return { m, maxStreet: best.street, maxChanged: Math.max(...r.map((x) => x.changed)), maxInvented: Math.max(...r.map((x) => x.invented)), maxDisagree: wrongest.disagree, checked: wrongest.checked }; }); trap[key].byMag = byMag; console.log(` ${key}: truth ${tSt.shopsWithStreet}/${tSt.shops} shops resolved, 0 wrong`); for (const b of byMag) { const up = b.maxStreet - tSt.shopsWithStreet; console.log(` off by ${String(b.m).padStart(2)} m (best of 8 directions): ${b.maxStreet} shops resolved ` + `(${up > 0 ? `\x1b[31m+${up} — MORE than the truth\x1b[0m` : up}), up to ${b.maxChanged} shops given a DIFFERENT ` + `name, ${b.maxInvented} given a name the truth honestly refused, ` + `${b.maxDisagree}/${b.checked} shop-edges disagree with the membership control`); } } { const anyUp = Object.entries(trap).filter(([, t]) => t.byMag.some((b) => b.maxStreet > t.true)); ok(anyUp.length > 0, `RED ARM: a wrong shift RAISES coverage on ${anyUp.length} of 3 towns — ` + anyUp.map(([k, t]) => `${k} ${t.true} → ${Math.max(...t.byMag.map((b) => b.maxStreet))}`).join(' · ') + ` — a coverage gate goes GREENER on a broken build`); const ade = trap['adelaide_real'].byMag.find((b) => b.m === 3); ok(ade.maxInvented > 0 || ade.maxChanged > 0, `…and the extra coverage is fabricated: ${ade.maxInvented} adelaide shops get a street name that the correctly-shifted build honestly refused ` + `(${ade.maxChanged} get a different name outright). Coverage 89 → 98 is 9 borrowed names, not 9 corrections.`); const allFire = Object.values(trap).every((t) => t.rows.every((r) => r.shiftCheck === 'DISAGREE')); ok(allFire, `stats().shiftCheck fires DISAGREE on all ${Object.values(trap)[0].rows.length * 3} perturbations (8 directions x 3 magnitudes x 3 towns) — the cross-derivation arm is what this gate stands on`); const memFires = Object.values(trap).some((t) => t.rows.some((r) => r.disagree > 0)); ok(memFires, `the membership control also discriminates: 0 wrong on the shipped tree, up to ` + `${Math.max(...Object.values(trap).flatMap((t) => t.rows.map((r) => r.disagree)))} wrong under perturbation`); // …and the honest limit of that second alarm, which matters for what this gate leans on. const ade3 = trap['adelaide_real'].rows.filter((r) => r.m === 3); note(`BUT the membership control does NOT catch the 3 m case: at 3 m adelaide reads ${Math.max(...ade3.map((r) => r.disagree))} disagreements ` + `while inventing ${ade.maxInvented} names — because those shops front ways OSM leaves UNNAMED, so membership has no answer there and stays silent. ` + `The only alarm that fires at 3 m is stats().shiftCheck. That is why this gate asserts the shift cross-derivation on all 23 towns and never the coverage.`); // A's note says a 30 m error returns ZERO names on these three towns. It does not — measured below. const at30 = Object.entries(trap).map(([k, t]) => [k, t.byMag.find((b) => b.m === 30)]); note(`CORRECTION to LANE_A_NOTES §39 ("feed it a shift wrong by 30 m and castlemaine/katoomba/adelaide return 0 street names"): ` + `measured here, 30 m keeps ${at30.map(([k, b]) => `${k} ${b.maxStreet}`).join(' · ')} names, and they are wrong ones ` + `(up to ${at30.map(([, b]) => b.maxDisagree).join('/')} membership disagreements). A big shift does NOT degrade to null on a dense grid — ` + `it lands you on the next street. Which is why the shift arm is exact-or-nothing and this gate never reads coverage.`); } // ── 5. the layer never writes to a plan (the reason it costs nothing) ───────────────────────────── head('5. purity — createAddresses does not mutate the plan'); { let dirty = 0; for (const key of keys) { const cache = JSON.parse(readFileSync(join(TOWNS, `${key}.json`), 'utf8')); const plan = generatePlanOSM(SEED, key, { cache }); const before = JSON.stringify(plan); createAddresses(plan, cache); if (JSON.stringify(plan) !== before) dirty++; } ok(dirty === 0, `${keys.length}/${keys.length} towns byte-identical after the call (${dirty} mutated)`); } const out = { corpusPct: +pctCorpus.toFixed(2), roll, perTown, trap, fails }; if (process.argv.includes('--json')) writeFileSync(process.argv[process.argv.indexOf('--json') + 1], JSON.stringify(out, null, 1)); console.log(); if (fails) { console.log(`\x1b[31m${fails} FAIL\x1b[0m`); process.exit(1); } console.log('\x1b[32mR39 §39.5-A GREEN\x1b[0m');