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>
256 lines
16 KiB
JavaScript
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');
|