#!/usr/bin/env node /* * measure-route-cpu.mjs — the A*-CPU measurement gate (A1, the phase's top risk). * ------------------------------------------------------------------------------ * A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src, * web/src, or routing/src imports it. The free plan gives a Worker 10 ms of * CPU per request (Error 1102 beyond it), so the A*-over-the-real-graph cost * is MEASURED AND PRINTED, never hoped for ("budget gates print their * measurement" — the BA9 precedent). * * What it measures: routeAstar() over the REAL routing/data/kl-roadgraph.v1.bin * (decodeGraph → typed arrays, exactly the Worker's data plane), timed with * process.hrtime.bigint() around the search call only. * * The 25-route sample mixes both realistic classes: * - the fixture-corpus endpoints (golden-pairs origins + gazetteer * destinations), snapped through snapToGraph — the demo-script routes * - quadrant-sampled node pairs spread across the graph bbox (deterministic * seeded PRNG — re-runs are comparable) — the cross-city worst cases * One UNMEASURED warm-up route runs first so the numbers reflect the * steady-state isolate an /api/route request actually lands on (the warm-up * is printed, never hidden). * * The GATE (A1's mitigation margin): p95 > P95_BUDGET_MS (8 ms — 20% under * the 10 ms free-plan ceiling) exits 1. The mitigation ladder, IN ORDER: * (a) tighten DRIVABLE_CLASSES (drop tertiary/residential) and rebuild * (b) bidirectional A* variant * (c) Workers Paid as an explicit fork decision (breaks $0 — user sign-off) * * LADDER HISTORY (2026-10-24, the real KL graph — see the plan summary): * full 13-class graph, unidirectional A*: p95 38.4 ms GATE FAIL * ladder (a) drop tertiary+residential: p95 17.8 ms GATE FAIL + 12/25 * pairs unreachable (the dropped classes are the fine mesh tying * landmarks to the arterials) — REJECTED on correctness, REVERTED. * ladder (b) bidirectional A* (BH): p95 ~38 ms GATE FAIL — the * potential split halves each side's steering, cancelling the halved * radius; kept (sound, no worse, better on short product routes). * (c) NOT APPLIED — breaks the $0 law; needs maintainer sign-off. The * honest alternatives on the table: Workers Paid, or a layout-v2 ALT * landmark graph (build-time preprocessing, still $0 at runtime). * * Usage: * node routing/scripts/measure-route-cpu.mjs # the 25-route CI gate * node routing/scripts/measure-route-cpu.mjs --extended # D-04 extended verdict * # (opt-in, maintainer-time: ~100 coordinate-pinned cross-metro * # pairs — the SAME pair set as the 08-01 optimality baseline — * # with its own p95 + gate verdict; the default mode above is * # byte-compatible and untouched) * * Exit codes: 0 p95 inside budget; 1 p95 over budget or absent binary. */ import { existsSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { decodeGraph } from "../src/graph.ts"; import { snapToGraph as snap } from "../src/snap.ts"; import { routeAstar as astar } from "../src/astar.ts"; import { getEntries } from "../../search/src/gazetteer.ts"; import { haversineM } from "../src/geo.ts"; import { generateCrossMetroPairs } from "./measure-optimality-baseline.mjs"; const here = dirname(fileURLToPath(import.meta.url)); // routing/scripts const GRAPH_PATH = resolve(here, "..", "data", "kl-roadgraph.v1.bin"); const CORPUS_PATH = resolve(here, "..", "test", "fixtures", "golden-pairs.json"); /** The gate: p95 budget, 20% margin under the 10 ms free-plan ceiling. */ const P95_BUDGET_MS = 8; /** The sample size (the plan's number). */ const ROUTE_COUNT = 25; /** The PRNG seed — fixed so re-runs sample identical pairs. */ const SAMPLE_SEED = 0x06d4; /** D-04 extended verdict: ~100 cross-metro coordinate-pinned pairs * (maintainer-time, opt-in via --extended; the default mode above — the * CI-size snapshot that budget-gates.mjs re-runs with ZERO args — is * byte-compatible and untouched). */ const EXTENDED_PAIR_COUNT = 100; /** Shared pair-set seed — identical to the 08-01 baseline recorder's * (the baseline and this verdict measure the SAME pairs; one definition). */ const EXTENDED_SEED = 0x08a1; /** Cross-metro floor: straight-line endpoint distance below this is NOT a * worst-case pair. */ const EXTENDED_MIN_PAIR_KM = 12; function fail(message) { console.error(`measure-route-cpu: ${message}`); process.exit(1); } if (!existsSync(GRAPH_PATH)) { fail(`no graph at ${GRAPH_PATH} — build it first: node routing/scripts/build-road-graph.mjs`); } function toArrayBuffer(buf) { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } const graph = decodeGraph(toArrayBuffer(readFileSync(GRAPH_PATH))); if (graph === null) fail(`${GRAPH_PATH} does not decode (fail-honest null) — rebuild the binary`); console.log( `graph: ${graph.nodeCount} nodes, ${graph.edgeCount} edges, ` + `bbox lat ${graph.bbox.minLat.toFixed(3)}..${graph.bbox.maxLat.toFixed(3)} lng ${graph.bbox.minLng.toFixed(3)}..${graph.bbox.maxLng.toFixed(3)}`, ); /* ————— the D-04 EXTENDED verdict (opt-in; the default mode below is untouched) ————— */ const extended = process.argv.includes("--extended"); if (extended) { console.log(`extended sample (D-04): ~${EXTENDED_PAIR_COUNT} coordinate-pinned cross-metro pairs (seed ${EXTENDED_SEED}, min ${EXTENDED_MIN_PAIR_KM} km straight-line — the SAME pair set as the 08-01 optimality baseline)`); const crossPairs = generateCrossMetroPairs(graph.bbox, { count: EXTENDED_PAIR_COUNT, minPairKm: EXTENDED_MIN_PAIR_KM, seed: EXTENDED_SEED, haversineM, }); // One unmeasured warm-up (printed, never hidden) — same warm-isolate law as // the default mode: the reverse-CSR build must not land inside a sample. const extWarm = cpuMs(() => astar(graph, 0, Math.min(1, graph.nodeCount - 1))); console.log(`warm-up (unmeasured): ${extWarm.ms.toFixed(3)} ms`); const extendedSamples = []; let extendedMisses = 0; let extendedMissMs = 0; for (const pair of crossPairs) { const origin = snap(graph, pair.origin); const goal = snap(graph, pair.destination); if (origin === null || goal === null) { // An off-road random coordinate: the honest snap miss, reported // separately — never inside the p95 sample (the same law as the // default mode's unreachable probes). extendedMisses += 1; console.log(` ${pair.pairId}: unsnappable (honest null)`); continue; } const { ms, result } = cpuMs(() => astar(graph, origin.node, goal.node)); if (result === null) { extendedMisses += 1; extendedMissMs += ms; console.log(` ${pair.pairId}: ${ms.toFixed(3)} ms (unreachable — honest null, D-03)`); continue; } const km = `${(result.distanceM / 1000).toFixed(1)} km / ${(result.seconds / 60).toFixed(1)} min`; console.log(` ${pair.pairId}: ${ms.toFixed(3)} ms (${km})`); extendedSamples.push(ms); } if (extendedSamples.length === 0) fail("no routable pairs in the extended sample — the graph may be fragmented; rebuild"); extendedSamples.sort((a, b) => a - b); const extIdx = Math.min(extendedSamples.length - 1, Math.ceil(0.95 * extendedSamples.length) - 1); const extP95 = extendedSamples[extIdx]; const extMax = extendedSamples[extendedSamples.length - 1]; const extMean = extendedSamples.reduce((acc, v) => acc + v, 0) / extendedSamples.length; console.log( `routed pairs: ${extendedSamples.length}/${crossPairs.length}` + (extendedMisses > 0 ? `; snap-miss/unreachable probes: ${extendedMisses} (mean ${(extendedMissMs / extendedMisses).toFixed(3)} ms, reported separately)` : ""), ); console.log(`p95: ${extP95.toFixed(3)} ms (max ${extMax.toFixed(3)} ms, mean ${extMean.toFixed(3)} ms, n=${extendedSamples.length})`); if (extP95 > P95_BUDGET_MS) { console.error(`GATE FAIL: p95 ${extP95.toFixed(3)} ms exceeds the ${P95_BUDGET_MS} ms budget — D-04 extended verdict`); process.exit(1); } console.log(`GATE PASS: p95 ${extP95.toFixed(3)} ms <= ${P95_BUDGET_MS} ms budget — D-04 extended verdict`); process.exit(0); } /** mulberry32 — tiny deterministic PRNG (fixed seed => comparable runs). */ 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; }; } /* ————— pair sampling: corpus endpoints + quadrant spread ————— */ const corpus = JSON.parse(readFileSync(CORPUS_PATH, "utf8")); const gazetteerById = new Map(getEntries().map((e) => [e.id, e])); const pairs = []; let corpusRoutes = 0; for (const pair of corpus.pairs) { const dest = gazetteerById.get(pair.destinationId); if (dest === undefined) continue; const origin = snap(graph, { lat: pair.origin.lat, lng: pair.origin.lng }); const goal = snap(graph, { lat: dest.lat, lng: dest.lng }); if (origin === null || goal === null) continue; pairs.push({ label: `corpus ${pair.pairId}`, from: origin.node, to: goal.node }); corpusRoutes += 1; } const midLat = (graph.bbox.minLat + graph.bbox.maxLat) / 2; const midLng = (graph.bbox.minLng + graph.bbox.maxLng) / 2; const quadrants = [[], [], [], []]; // 0=NW 1=NE 2=SW 3=SE for (let n = 0; n < graph.nodeCount; n++) { const q = (graph.lat[n] >= midLat ? 0 : 2) + (graph.lng[n] >= midLng ? 1 : 0); quadrants[q].push(n); } const rand = mulberry32(SAMPLE_SEED); const pick = (q) => quadrants[q][Math.floor(rand() * quadrants[q].length)]; while (pairs.length < ROUTE_COUNT) { const a = Math.floor(rand() * 4); const b = (a + 1 + Math.floor(rand() * 3)) % 4; // always a DIFFERENT quadrant — cross-city const from = pick(a); const to = pick(b); if (from === to) continue; pairs.push({ label: `quadrant q${a}->q${b}`, from, to }); } console.log(`sample: ${corpusRoutes} corpus-snapped + ${pairs.length - corpusRoutes} quadrant pairs = ${pairs.length} routes`); /* ————— warm-up (unmeasured, printed) + the measured runs ————— */ function cpuMs(fn) { const t0 = process.hrtime.bigint(); const result = fn(); const t1 = process.hrtime.bigint(); return { ms: Number(t1 - t0) / 1e6, result }; } const warm = cpuMs(() => astar(graph, pairs[0].from, pairs[0].to)); console.log(`warm-up (unmeasured): ${warm.ms.toFixed(3)} ms`); const samples = []; let unreachableProbes = 0; let unreachableMs = 0; for (const pair of pairs) { const { ms, result } = cpuMs(() => astar(graph, pair.from, pair.to)); if (result === null) { // A directed dead-end probe (a snap onto a one-way sink or a small // island): the honest-null degradation path. Reported SEPARATELY — it // is not a routed trip, and mixing it into the route p95 would // misdescribe the budget the /api/route hot path actually needs. unreachableProbes += 1; unreachableMs += ms; console.log(` ${pair.label}: ${ms.toFixed(3)} ms (unreachable — honest null, D-03)`); continue; } const km = `${(result.distanceM / 1000).toFixed(1)} km / ${(result.seconds / 60).toFixed(1)} min`; console.log(` ${pair.label}: ${ms.toFixed(3)} ms (${km})`); samples.push(ms); } console.log( `routed pairs: ${samples.length}/${pairs.length}` + (unreachableProbes > 0 ? `; unreachable probes: ${unreachableProbes} (mean ${(unreachableMs / unreachableProbes).toFixed(3)} ms, reported separately)` : ""), ); if (samples.length === 0) fail("no routable pairs in the sample — the graph may be fragmented; rebuild"); samples.sort((a, b) => a - b); const idx = Math.min(samples.length - 1, Math.ceil(0.95 * samples.length) - 1); const p95 = samples[idx]; const max = samples[samples.length - 1]; const mean = samples.reduce((acc, v) => acc + v, 0) / samples.length; console.log(`p95: ${p95.toFixed(3)} ms (max ${max.toFixed(3)} ms, mean ${mean.toFixed(3)} ms, n=${samples.length})`); if (p95 > P95_BUDGET_MS) { console.error(`GATE FAIL: p95 ${p95.toFixed(3)} ms exceeds the ${P95_BUDGET_MS} ms budget (A1) — apply the mitigation ladder: (a) tighten DRIVABLE_CLASSES + rebuild, (b) bidirectional A*, (c) Workers Paid as an explicit fork decision`); process.exit(1); } console.log(`GATE PASS: p95 ${p95.toFixed(3)} ms <= ${P95_BUDGET_MS} ms budget — A1 retired for this graph`); process.exit(0);