feat(routing): 08-01 task 2 — pre-ALT optimality baseline: recorder + committed 100-pair record (D-03 anchor, measured before ARG2 lands, immutable)
This commit is contained in:
@@ -0,0 +1,217 @@
|
|||||||
|
#!/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();
|
||||||
|
}
|
||||||
+1423
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user