PROCITY/tools/qa/door_footpath_check.mjs
m3ultra 3e1ff312a5 Lane D R40 §40.5: the peds come in the front door — 100% on every real town
Verification first, and B's R39 heading fix did NOT clear the gate alone. Measured today
(tools/qa/door_footpath_check.mjs, new): synthetic 447/493 foot (90.7%) · katoomba 71/72 ·
fitzroy 138/139 · bowral 28/30 (93.3%) — from R39's 0/493, 0/72, 0/139, 0/30.

Two residue species, handled differently ON PURPOSE:
1. A 0.85m kerb miss on real towns — plan_osm's setback puts the facade past the corridor
   edge, so the shell's d/2+0.6 door point lands just off the verge. Fixed by new D-owned
   door_snap.js, applied in sim.setShops: clamps each door into the registry vergeBand of
   its nearest footpath-bearing edge, 6m cap. Corpus: fixes 4, breaks 0. Live-shell verified
   (493 points, 2 moved, max 5.41m — matches node exactly). Real towns now 72/72, 139/139, 30/30.
2. The synthetic's 36 strict misses are market stalls and arcade shops WHOSE FRONT IS THE
   MARKET SQUARE. An uncapped snap would 'pass' them by teleporting a door 59.85m to a street
   they do not front — passing the gate by breaking the truth it measures. The gate instead
   scores walkable front ground (footpath ∪ market/arcade block ground) = 473/493 (95.9%) and
   reports the strict number alongside.

The check ships its own falsifiability CONTROL (the R8-R39 back point must score under the
floor: reads 0.4-13.3%) and a fix-only arm (clamp breaks 0, asserted forever). Deterministic.
Remaining 20 synthetic misses are artefacts of the fixed nudge at index.html:433-437 — not
D's file; measured, exact fix documented for F. Goldens untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:28:25 +10:00

166 lines
9.0 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.

#!/usr/bin/env node
// PROCITY Lane D — R40 §40.5: THE DOOR POINTS AND THE FOOTPATH (reusable gate).
//
// R39 found the shell's patronage door points read `(-sin ry, -cos ry)` — the BACK of the building —
// and measured 0 door points on a footpath corpus-wide (0/493, 0/72, 0/139, 0/30): peds ducking into
// the back fence since R8. Lane B landed the two-character `+sin/+cos` fix (web/index.html:433-437)
// in the same round; Lane D added the kerb clamp (web/js/citizens/door_snap.js, applied in
// sim.setShops) in R40. This gate makes the front-door truth un-regressable.
//
// WHAT IS MEASURED — each shop's door point, derived EXACTLY as the shell derives it (lot centre +
// front-normal · (d/2 + 0.6)), then passed through the SHIPPED snap (the same door_snap.js the sim
// imports — the gate tests the code path, not a copy). Classified against the plan's own geometry:
//
// ON FOOTPATH inside `vergeBand(e)` of SOME street edge (registry.js's published corridor
// law), on NO carriageway, inside NO lot. The R39 measure — reported for
// continuity.
// ON WALKABLE FRONT the gate's measure: ON FOOTPATH, or on the open ground of a pedestrian
// block (`market`/`arcade` block poly, outside every lot, off every
// carriageway). A market stall's front IS the market square — it can never be
// in a street's verge band and dragging its door 50 m to a street it does not
// front would re-break the truth this gate holds. "Duck in through the FRONT"
// means front ground a ped can stand on, which is what this classifies.
//
// GATE ARMS (all must hold):
// 1. walkable-front rate >= FLOOR_PCT (95) on all four R39 towns (synthetic seed 20261990 +
// katoomba_real + fitzroy_real + bowral_real);
// 2. CONTROL — the BACK point (the exact point shipped R8→R39) scores BELOW the floor on every
// town, or the classifier has gone vacuous and arm 1 means nothing;
// 3. FIX-ONLY — the snap breaks 0 points corpus-wide (every moved point classifies >= its raw).
//
// Run: node tools/qa/door_footpath_check.mjs [--json OUT]
// Exit: 0 green, 1 red. Plain node, zero deps, no browser; reads the plan generators + shipped town
// caches only — touches no game state, moves no goldens.
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { generatePlan, generatePlanOSM, roadWidth, vergeBand } from '../../web/js/citygen/index.js';
import { snapDoorToFootpath } from '../../web/js/citizens/door_snap.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const TOWNS = join(ROOT, 'web', 'assets', 'towns');
const SEED = 20261990;
const FLOOR_PCT = 95; // §40.5's gate
const DOOR_STEP = 0.6; // the shell's kerbward nudge past the facade (index.html:434)
const EPS = 1e-6;
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}`);
function dSeg(px, pz, ax, az, bx, bz) {
const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz;
if (L2 < EPS) return Math.hypot(px - ax, pz - az);
let t = ((px - ax) * dx + (pz - az) * dz) / L2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (ax + t * dx), pz - (az + t * dz));
}
// strictly inside a lot's rotated rectangle (the boundary is not "in someone's yard")
function inLot(px, pz, l) {
const c = Math.cos(l.ry || 0), s = Math.sin(l.ry || 0);
const dx = px - l.x, dz = pz - l.z;
const lx = c * dx - s * dz, lz = s * dx + c * dz; // world→local (inverse of rot-by-ry)
return Math.abs(lx) < l.w / 2 - EPS && Math.abs(lz) < l.d / 2 - EPS;
}
function inPoly(px, pz, poly) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, zi] = poly[i], [xj, zj] = poly[j];
if ((zi > pz) !== (zj > pz) && px < (xj - xi) * (pz - zi) / (zj - zi) + xi) inside = !inside;
}
return inside;
}
function measure(plan, label) {
const nodeById = new Map(plan.streets.nodes.map((n) => [n.id, n]));
const lotById = new Map(plan.lots.map((l) => [l.id, l]));
const pedBlocks = (plan.blocks || []).filter((b) => b.kind === 'market' || b.kind === 'arcade');
// sim-style edges — the exact shape _setGraph hands door_snap (A/B resolved, width/kind kept)
const simEdges = plan.streets.edges.map((e) => ({ ...e, A: nodeById.get(e.a), B: nodeById.get(e.b) }));
const classify = (px, pz) => {
let onVerge = false, onCarr = false, minCentre = Infinity;
for (const e of plan.streets.edges) {
const a = nodeById.get(e.a), b = nodeById.get(e.b);
if (!a || !b) continue;
const d = dSeg(px, pz, a.x, a.z, b.x, b.z);
if (d < minCentre) minCentre = d;
const inner = roadWidth(e) / 2, [vi, vo] = vergeBand(e);
if (d < inner - EPS) onCarr = true;
if (d >= vi - EPS && d <= vo + EPS && vo > vi + EPS) onVerge = true;
}
let insideLot = false;
for (const l of plan.lots) if (inLot(px, pz, l)) { insideLot = true; break; }
const foot = onVerge && !onCarr && !insideLot;
const plaza = !foot && !insideLot && !onCarr && pedBlocks.some((b) => inPoly(px, pz, b.poly));
const walk = foot || plaza;
const why = walk ? (foot ? 'footpath' : 'plaza') : insideLot ? 'in-a-lot' : onCarr ? 'carriageway' : 'off-verge';
return { foot, walk, why, minCentre };
};
const out = { town: label, shops: 0, rawFoot: 0, rawWalk: 0, foot: 0, walk: 0, back: 0,
moved: 0, maxMove: 0, brokeBydSnap: 0, fixedBySnap: 0, why: {}, centre: [] };
for (const s of plan.shops) {
const l = lotById.get(s.lot); if (!l) continue;
out.shops++;
const ry = l.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry), off = l.d / 2 + DOOR_STEP;
const rx = l.x + fx * off, rz = l.z + fz * off; // raw — the shell's derivation
const R = classify(rx, rz);
if (R.foot) out.rawFoot++;
if (R.walk) out.rawWalk++;
const p = snapDoorToFootpath(rx, rz, simEdges); // shipped — what sim.setShops stores
const S = p.moved ? classify(p.x, p.z) : R;
if (p.moved) {
out.moved++; out.maxMove = Math.max(out.maxMove, Math.hypot(p.x - rx, p.z - rz));
if (R.walk && !S.walk) out.brokeBydSnap++;
if (!R.walk && S.walk) out.fixedBySnap++;
}
if (S.foot) out.foot++;
if (S.walk) out.walk++; else out.why[`${l.use || 'shop'}:${S.why}`] = (out.why[`${l.use || 'shop'}:${S.why}`] || 0) + 1;
const B = classify(l.x - fx * off, l.z - fz * off); // control — the R8R39 defect point
if (B.walk) out.back++;
out.centre.push(S.minCentre);
}
out.centre.sort((a, b) => a - b);
out.median = out.centre.length ? +out.centre[(out.centre.length / 2) | 0].toFixed(2) : null;
delete out.centre;
const pct = (n) => out.shops ? +(100 * n / out.shops).toFixed(1) : 0;
out.rawFootPct = pct(out.rawFoot); out.rawWalkPct = pct(out.rawWalk);
out.footPct = pct(out.foot); out.walkPct = pct(out.walk); out.backPct = pct(out.back);
out.maxMove = +out.maxMove.toFixed(2);
return out;
}
head(`DOOR POINT → FOOTPATH (floor ${FLOOR_PCT}% · door step ${DOOR_STEP} m · seed ${SEED})`);
const results = [];
results.push(measure(generatePlan(SEED), `synthetic@${SEED}`));
for (const town of ['katoomba_real', 'fitzroy_real', 'bowral_real']) {
const cache = JSON.parse(readFileSync(join(TOWNS, `${town}.json`), 'utf8'));
results.push(measure(generatePlanOSM(SEED, town, { cache }), town));
}
for (const r of results) {
const whyStr = Object.entries(r.why).map(([k, v]) => `${k}:${v}`).join(' ') || '—';
note(`${r.town}: raw foot ${r.rawFoot}/${r.shops} (${r.rawFootPct}%) walk ${r.rawWalk} (${r.rawWalkPct}%) → shipped foot ${r.foot} (${r.footPct}%) walk ${r.walk} (${r.walkPct}%)`);
note(` residue [${whyStr}] · snap moved ${r.moved} pt(s) max ${r.maxMove} m (fixed ${r.fixedBySnap}, broke ${r.brokeBydSnap}) · back arm ${r.back}/${r.shops} (${r.backPct}%) · median centreline dist ${r.median} m`);
}
head('GATE');
for (const r of results) {
ok(r.walkPct >= FLOOR_PCT, `${r.town}: door point on walkable front ground ${r.walk}/${r.shops} = ${r.walkPct}% (floor ${FLOOR_PCT}%)`);
ok(r.backPct < FLOOR_PCT, `${r.town}: CONTROL — back point scores ${r.backPct}% (must be < ${FLOOR_PCT}% or the classifier is vacuous)`);
ok(r.brokeBydSnap === 0, `${r.town}: FIX-ONLY — snap broke ${r.brokeBydSnap} previously-passing point(s) (must be 0)`);
}
const ji = process.argv.indexOf('--json');
if (ji > -1 && process.argv[ji + 1]) {
const { writeFileSync } = await import('node:fs');
writeFileSync(process.argv[ji + 1], JSON.stringify({ seed: SEED, floorPct: FLOOR_PCT, doorStep: DOOR_STEP, results }, null, 2));
note(`json → ${process.argv[ji + 1]}`);
}
console.log(fails ? `\n\x1b[31mRED — ${fails} gate arm(s) failed\x1b[0m` : '\n\x1b[32mGREEN\x1b[0m');
process.exit(fails ? 1 : 0);