PROCITY/tools/qa/r39_address.mjs
m3ultra 076b972f71 Lane F R39 (v9 WAVE 1, CLOSE): QA GREEN 10/0/0/0 — and the interior margin is 162, not 21 and never 6
FIVE GATES, FIVE CONTROLS, each demonstrated in the same run. New F-owned tools wired into qa.sh as
Gate 8: r39_address.mjs · r39_transmission.py · r39_runtime.py · r39_shots.py (--no-r39 skips the
browser half). Lane A's selfcheck reproduces exactly: 157,647/157,647 ALL GREEN, fingerprint
0x5f76e76 — ZERO GOLDENS MOVED, in a round that added an address layer, a fog ledger, a spawn move
and a new interior fitting.

THE ROUND'S BIGGEST NUMBER IS NOT THE ONE THE LEDGER ASKED FOR. Two port-isolated no-store roots,
the same inode for every byte except one file — the shipped bookshelf GLB vs a copy with
transmissionFactor:1 re-inserted (BIN sha1 IDENTICAL, only the JSON chunk moved) — both through
interior_test.html's own drawSweep:

  GLB-on worst   329 (opshop/hall)  ->  188 (dept/auto)     -141
  GLB-off worst  162 (opshop/hall)  ->  162 (opshop/hall)   0 — the control's own control
  margin vs <=350            21     ->  162                 +141

opshop 329->174 (-47%) · book 247->125 (-49%) · every other type 0, exactly the shape Lane C
predicted from an isolated room, at sweep scale, by a harness C did not write. The worst interior
room in the game is now dept/auto — a room with no bookshelf in it. The default boot fetches these
GLBs from the public depot, so all four R39 GLBs were fetched off digalot.fyi and scanned: sha1
identical to the local tree, 0 transmissive materials, 4/4. Binding fact #3's third and final value.

39.1 ADDRESS — GREEN. 1,192/1,219 shops (97.8%), 23/23 'ways', 857/857 membership agree / 0 wrong
(and the control is asserted to have CHECKED 857 edges). No cache -> supplier 'district', 0 names,
still labels 72/72 from district+block. RED ARM: a 3 m shift error RAISES adelaide 89 -> 98 resolved
shops (Lane A's number to the digit) while INVENTING 9 names; stats().shiftCheck reads DISAGREE on
all 72 perturbations. And the honest limit: at 3 m the membership control reads 0 disagreements,
because those shops front ways OSM leaves unnamed — only the shift cross-derivation catches it.

39.2 FOG — GREEN, both arms on one boot. HIDES 4/72 at spawn, 0 of 61 distant shops known. REVEALS
2,125 m -> 58 shops. FRONT TEST: probe from 5 m BEHIND each of 59 unknown shopfronts, 0 learned.
+0 DRAWS AND +0 TRIS, four freshly launched browsers, both orders, drained, 3 reads each, with the
arms asserted distinguishable first. 8/8 corrupt saves rejected, state untouched. Null store on
classic/game=0/fog=0; game=0 and fog=0 pixel-identical, default differs.

39.3 THE SIGN — GREEN. Shops in front of the player at spawn: 4/4 · 4/4 · 4/4 · 1/1, and ZERO on all
four with the pre-R39 sign re-derived on the same tree. The live spawn is asserted equal to the +sin
derivation, so the shell is what is scored. Re-baselined WITHOUT falsifying a release record: the
v7 tour and v7-beta frames are kept as history; docs/shots/laneF_r39/ ships the new pair with its
own BEFORE. The R35 gate itself is pose-independent and was green either side.

39.4 RUMMAGE BIN — GREEN. 120 rooms: tub 60/60 both arms, kind:'bin' 0/60 default vs 60/60 armed,
0 carves, 60/60 deterministic, reachable 0.80-1.03 m by a flood fill written here. THE CONTENTS DID
NOT SHIP: on the default street boot the tub is kind:'prop', 0 dig targets, E opens nothing.

39.5 TRANSMISSION VALIDATOR — GREEN and it FIRES: hard-fails rc 1 on the fabricated glass asset AND
passes on the shipped tree.

THREE CORRECTIONS, ALL MEASURED. A's "a 30 m shift returns 0 street names" — it returns 40/12/47
wrong ones; a big shift lands you on the next street, it does not degrade to null. B's "a fresh
export has no `known` key" — true of ?fog=0, no longer of a default boot, because B's own +sin fix
means you spawn in front of four shopfronts. And my own: I first aimed the transmission control at
web/assets/models/ when validate_manifest.py reads pipeline/_normalized/, and nearly filed a defect
against Lane E on the strength of it. Aim the control at the code path, not the plausible path.

HELD -> LANE E: check_transmission() skips any GLB with no local _normalized copy and never scans
web/assets/models/. Demonstrated with a live control — remove the local copy and the same glass
asset passes rc 0. A depot-only transmissive GLB (the exact shape of longbench and streetlight)
would ship unseen.

No tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:47:03 +10:00

256 lines
16 KiB
JavaScript

#!/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');