Files
arah2/routing/scripts/measure-optimality-baseline.mjs
T

218 lines
9.8 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
/*
* measure-optimality-baseline.mjs — the pre-ALT optimality baseline recorder
* (Phase 8 plan 08-01; D-03's comparison anchor).
* ------------------------------------------------------------------------------
* A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src,
* web/src, or routing/src imports it. It records the PRE-ALT route seconds for
* 100 coordinate-pinned cross-metro pairs into a COMMITTED JSON record — the
* anchor the D-03 optimality gate (Plan 08-05's measure-optimality-gate.mjs)
* compares against after the ALT preprocessing lands.
*
* RUN THIS BEFORE THE ARG2 CODEC LANDS (research Pitfall 1): once graph.ts
* versions to 2, decodeGraph rejects the v1 binary and "old code + old binary"
* is unreproducible from the working tree. The recorder enforces this
* mechanically — it reads the binary's version field and REFUSES anything
* that is not ARG1, so a post-codec run can never silently fake a pre-ALT
* baseline.
*
* THE RECORD IS IMMUTABLE: the script refuses to overwrite an existing record
* ("delete by explicit git revert only"). Pairs are pinned by COORDINATES
* (lat/lng drawn from the graph's order-independent bbox by a seeded PRNG) —
* never node indices (Pitfall 2: a rebuild reorders nodes; coordinates do not
* move). Route seconds are stored as JSON numbers — routeAstar returns exact
* f64 accumulations of f32 edgeSeconds and JSON round-trips IEEE doubles
* losslessly (research A8).
*
* Echo law: pair ids + verdict classes + seconds ONLY — coordinates in the
* record are public bbox-derived values, never user data (the corpus's D-12
* posture, applied to a maintainer tool).
*
* Usage:
* node routing/scripts/measure-optimality-baseline.mjs
*
* Exit codes: 0 recorded; 1 already recorded, graph absent/not ARG1, or
* decode failure. A run that measures but fails a gate writes NOTHING.
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url)); // routing/scripts
const GRAPH_PATH = resolve(here, "..", "data", "kl-roadgraph.v1.bin");
const RECORD_PATH = resolve(here, "..", "test", "fixtures", "alt-baseline.json");
/** D-04's sample size for the optimality record (~100 cross-metro pairs). */
const BASELINE_PAIR_COUNT = 100;
/** Shared pair-set seed — the SAME seed measure-route-cpu.mjs --extended
* reuses, so the baseline and the CPU verdict measure identical pairs. */
const BASELINE_SEED = 0x08a1;
/** Cross-metro floor: straight-line endpoint distance below this is NOT a
* worst-case pair (the 20–30 km class is where the budget breaks — §1.9). */
const MIN_PAIR_KM = 12;
/** The binary version the baseline MUST be recorded on (Pitfall 1's guard). */
const REQUIRED_ARG_VERSION = 1;
/** The epsilon the D-03 gate will assert against (documentary here; the gate
* mirrors this value — a record edit alone cannot fake equality). */
const OPTIMALITY_EPSILON_PCT = 0.1;
/** Honest-termination guard for the rejection sampler. */
const MAX_SAMPLER_ATTEMPTS = 50_000;
function fail(message) {
console.error(`measure-optimality-baseline: ${message}`);
process.exit(1);
}
function toArrayBuffer(buf) {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
}
/** mulberry32 — tiny deterministic PRNG (fixed seed => comparable runs).
* Copied verbatim from measure-route-cpu.mjs (one PRNG shape in the repo). */
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Exact-decimal rounding to 1e-6 (the codec's coordinate grid law). */
const round6 = (v) => Math.round(v * 1e6) / 1e6;
/**
* The pair generator — exported so the extended CPU sample (Plan 08-01 task 3)
* measures the SAME pairs from ONE definition (no drift between reporters).
* Deterministic: same bbox + seed ⇒ identical pairs, always.
*
* @param bbox {{ minLat: number, minLng: number, maxLat: number, maxLng: number }}
* @param opts {{ count: number, minPairKm: number, seed: number, haversineM: (a, b) => number }}
* @returns Array<{ pairId, origin: {lat, lng}, destination: {lat, lng}, straightLineKm: number }>
*/
export function generateCrossMetroPairs(bbox, opts) {
const { count, minPairKm, seed, haversineM } = opts;
const rand = mulberry32(seed);
const draw = (axis) =>
axis.min + rand() * (axis.max - axis.min);
const pairs = [];
let attempts = 0;
while (pairs.length < count) {
if (++attempts > MAX_SAMPLER_ATTEMPTS) {
fail(`sampler gave up after ${MAX_SAMPLER_ATTEMPTS} attempts — bbox too small for the ${minPairKm} km floor`);
}
const origin = { lat: round6(draw({ min: bbox.minLat, max: bbox.maxLat })), lng: round6(draw({ min: bbox.minLng, max: bbox.maxLng })) };
const destination = { lat: round6(draw({ min: bbox.minLat, max: bbox.maxLat })), lng: round6(draw({ min: bbox.minLng, max: bbox.maxLng })) };
const straightLineKm = haversineM(origin, destination) / 1000;
if (straightLineKm < minPairKm) continue; // not a cross-metro worst case
pairs.push({
pairId: `x${String(pairs.length).padStart(3, "0")}`,
origin,
destination,
straightLineKm: Math.round(straightLineKm * 1000) / 1000,
});
}
return pairs;
}
/**
* The measurement core — exported for in-process mirrors of the same numbers.
* Honest nulls are MEASUREMENTS: a snap miss records verdict "unsnappable",
* a disconnected pair records "unreachable" — neither is skipped silently.
*
* @returns Array<{ pairId, origin, destination, straightLineKm, seconds: number|null, verdict: "reachable"|"unreachable"|"unsnappable" }>
*/
export function measureBaselinePairs(graph, pairs, opts) {
const { snap, astar } = opts;
return pairs.map((pair) => {
const originSnap = snap(graph, pair.origin);
const goalSnap = snap(graph, pair.destination);
if (originSnap === null || goalSnap === null) {
return { ...pair, seconds: null, verdict: "unsnappable" };
}
const result = astar(graph, originSnap.node, goalSnap.node);
if (result === null) {
return { ...pair, seconds: null, verdict: "unreachable" };
}
return { ...pair, seconds: result.seconds, verdict: "reachable" };
});
}
async function main() {
if (!existsSync(GRAPH_PATH)) {
fail(`no graph at ${GRAPH_PATH} — build it first: node routing/scripts/build-road-graph.mjs`);
}
if (existsSync(RECORD_PATH)) {
fail(`baseline already recorded at ${RECORD_PATH} — the record is immutable; delete by explicit git revert only`);
}
const buffer = readFileSync(GRAPH_PATH);
if (buffer.byteLength < 64) fail(`${GRAPH_PATH} is not a road-graph binary`);
const version = new DataView(toArrayBuffer(buffer)).getUint32(4, true);
if (version !== REQUIRED_ARG_VERSION) {
fail(`the on-disk binary is ARG${version} — the baseline MUST be recorded on the ARG1 binary, BEFORE the ARG2 codec lands (Pitfall 1)`);
}
const { decodeGraph } = await import("../src/graph.ts");
const { snapToGraph } = await import("../src/snap.ts");
const { routeAstar } = await import("../src/astar.ts");
const { haversineM } = await import("../src/geo.ts");
const graph = decodeGraph(toArrayBuffer(buffer));
if (graph === null) fail(`${GRAPH_PATH} does not decode (fail-honest null) — rebuild the binary`);
const pairs = generateCrossMetroPairs(graph.bbox, {
count: BASELINE_PAIR_COUNT,
minPairKm: MIN_PAIR_KM,
seed: BASELINE_SEED,
haversineM,
});
const rows = measureBaselinePairs(graph, pairs, { snap: snapToGraph, astar: routeAstar });
// Print the measurement (every pair, then the summary) — print-the-measurement law.
for (const row of rows) {
const value = row.seconds === null ? row.verdict : `${row.seconds.toFixed(1)}s ${row.verdict}`;
console.log(` ${row.pairId}: ${value}`);
}
const routed = rows.filter((r) => r.verdict === "reachable");
const unreachable = rows.filter((r) => r.verdict === "unreachable");
const unsnappable = rows.filter((r) => r.verdict === "unsnappable");
const seconds = routed.map((r) => r.seconds);
const mean = seconds.reduce((acc, v) => acc + v, 0) / (seconds.length || 1);
const max = seconds.length > 0 ? Math.max(...seconds) : 0;
console.log(
`baseline: ${routed.length}/${rows.length} routed` +
(unreachable.length > 0 ? `, ${unreachable.length} unreachable` : "") +
(unsnappable.length > 0 ? `, ${unsnappable.length} unsnappable` : "") +
` — mean ${mean.toFixed(1)}s, max ${max.toFixed(1)}s`,
);
// Collect-then-write: the record is written ONLY after every pair measured.
const record = {
recordedAt: new Date().toISOString(),
tool: "routing/scripts/measure-optimality-baseline.mjs",
purpose:
"pre-ALT baseline for the D-03 optimality gate (Phase 8); measured BEFORE the ARG2 codec landed (Pitfall 1) — the record is immutable, delete by explicit git revert only",
epsilon: { name: "OPTIMALITY_EPSILON_PCT", pct: OPTIMALITY_EPSILON_PCT },
graph: {
path: "routing/data/kl-roadgraph.v1.bin",
byteLength: buffer.byteLength,
nodeCount: graph.nodeCount,
edgeCount: graph.edgeCount,
argVersion: REQUIRED_ARG_VERSION,
},
pairRules: { seed: `0x${BASELINE_SEED.toString(16)}`, count: BASELINE_PAIR_COUNT, minPairKm: MIN_PAIR_KM },
pairs: rows,
};
writeFileSync(RECORD_PATH, JSON.stringify(record, null, 2) + "\n");
console.log(`wrote ${RECORD_PATH} — ${rows.length} pairs, epsilon ±${OPTIMALITY_EPSILON_PCT}% (the D-03 gate mirrors this constant)`);
process.exit(0);
}
const invoked = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
if (invoked === import.meta.url) {
await main();
}