266 lines
12 KiB
JavaScript
266 lines
12 KiB
JavaScript
#!/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);
|