Files
arah2/routing/scripts/route-corpus.mjs
araharah-executor 6c0880b4db feat(05-02): offline corpus tolerance gate — measurePairs core + CLI + in-suite RT1-RT10 (GREEN)
- routing/scripts/route-corpus.mjs: the shared measurement core (exported
  measurePairs) + CLI reporter in the gazetteer-corpus.mjs shape — one
  measurement, two reporters, no drift
- RT1 corpus size 10..20; RT2 schema + RT2b mechanical D-12 proof (every
  committed origin equals its gazetteer landmark within 1e-4 deg); RT3 route
  shape; RT4 probe exclusion (national-museum never a destination); RT5
  haversine-vs-summary band [0.85, 1.10] + bbox+0.05; RT6 speed [1.0, 30] m/s;
  RT7 codec round-trip per route; RT8 ODbL header surfaces; RT9 gz budget
  (measured 16576 B / 102400 B); RT10 alternatives floor (8 pairs / floor 4)
- fully offline: zero network in the measurement path (T-05-05); pair ids and
  measurements printed, never geometry strings (D-15); fixed escalation
  ladder on failure (--only repair -> regenerate -> re-gate)
- suite: 35 passed, 0 failed; npm run routing:corpus exits 0; typecheck clean
2026-10-24 05:19:55 +08:00

335 lines
14 KiB
JavaScript
Raw Permalink 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
/*
* route-corpus.mjs — the offline tolerance gate over the committed fixture
* corpus (RT1–RT10) — the phase's primary acceptance instrument (amended
* ROADMAP SC3).
* -------------------------------------------------------------------------
* Header contract (gazetteer-corpus.mjs style):
* Purpose: measure the COMMITTED dataset (routing/src/data/route-fixtures.json)
* against the SAME shared routing module the webview imports — the measured
* corpus IS the shipped corpus.
* Inputs: routing/test/fixtures/golden-pairs.json (thresholds + pinned
* corpus), routing/src/data/route-fixtures.json (the measured
* dataset), search/src/data/kl-gazetteer.json (the public-origin
* proof ground truth) — all readFileSync over COMMITTED files.
* Echoes: pair ids and measurements ONLY — never geometry strings
* (D-15: this is a LOCAL DEV TOOL over committed files; the
* Worker/webview never print pair data).
* Exit: 0 ONLY when every check passes; 1 otherwise, with the fixed
* escalation ladder printed. Nothing in the repo depends on this
* script succeeding beyond its own suite (corpus.test.mjs asserts
* the same measurements in-process).
*
* FULLY OFFLINE (T-05-05): zero network in the measurement path. Geometry-
* ↔-summary consistency is recomputed by haversine, the codec round-trip is
* re-proven per route, and the no-user-coordinate law (D-12) is proven
* mechanically against the committed gazetteer. The OSRM demo server is
* exercised ONLY by the maintainer regeneration tool, never here.
*
* Reproduce the gate with: npm run routing:corpus
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { gzipSync } from "node:zlib";
import { decodePolyline6, encodePolyline6, haversineM } from "../src/index.ts";
const DEFAULT_CORPUS_PATH = new URL("../test/fixtures/golden-pairs.json", import.meta.url);
const DEFAULT_FIXTURES_PATH = new URL("../src/data/route-fixtures.json", import.meta.url);
const DEFAULT_GAZETTEER_PATH = new URL("../../search/src/data/kl-gazetteer.json", import.meta.url);
const SLUG_PATTERN = /^[a-z0-9-]{1,64}$/;
/** RT5: the haversine polyline length may sit within this band around the
* route's own distanceM (OSRM measures the same road shape). */
const LENGTH_RATIO_BAND = [0.85, 1.10];
/** RT6: average speed bounds in m/s — KL congestion floor to DUKE ceiling. */
const SPEED_BAND = [1.0, 30];
/** RT5: decoded points must sit inside the KL bbox with this much slack
* (OSRM's road snapping around landmark coordinates). */
const BBOX_SLACK_DEG = 0.05;
/** RT2b: a committed origin must equal its gazetteer landmark within this
* many degrees on BOTH axes (~11 m) — the mechanical no-user-coordinate
* proof (D-12). */
const ORIGIN_MATCH_TOLERANCE_DEG = 1e-4;
/**
* The corpus measurement core — exported so routing/test/corpus.test.mjs
* asserts the SAME numbers this CLI prints (one measurement, two reporters,
* no drift). ALL OFFLINE: the only I/O is one readFileSync of the committed
* dataset for the RT9 gzip measurement.
*
* @param corpus parsed golden-pairs.json (thresholds + pinned origins)
* @param fixtures parsed route-fixtures.json (the measured dataset)
* @param gazetteer parsed kl-gazetteer.json (the public-coordinate ground truth)
* @param fixturesPath where the committed dataset lives (RT9 gzips THIS file;
* defaults to the committed dataset URL relative to this module)
* @returns {{ checks: Record<string, {id: string, label: string, violations: Array<{pairId: ?string, reason: string}>}>, gzipBytes: number, multiRoutePairs: number, pass: boolean }}
*/
export function measurePairs(
corpus,
fixtures,
gazetteer,
fixturesPath = DEFAULT_FIXTURES_PATH,
) {
const checks = {};
const check = (id, label) => {
checks[id] = { id, label, violations: [] };
};
const violation = (id, pairId, reason) => {
checks[id].violations.push({ pairId, reason });
};
const pairs = Array.isArray(fixtures.pairs) ? fixtures.pairs : [];
const bbox = corpus.klBbox;
const gazById = new Map((gazetteer.entries ?? []).map((e) => [e.id, e]));
const corpusById = new Map((corpus.pairs ?? []).map((p) => [p.pairId, p]));
/* ————— RT1 — corpus size ————— */
check("RT1", "corpus size");
if (pairs.length < 10 || pairs.length > 20) {
violation("RT1", null, `pair count ${pairs.length} outside D-02's [10, 20] window`);
}
/* ————— RT2 — schema + public origin (incl. RT2b, the D-12 proof) ————— */
check("RT2", "schema + public origin");
const seenPairIds = new Set();
for (const pair of pairs) {
if (typeof pair.pairId !== "string" || !SLUG_PATTERN.test(pair.pairId)) {
violation("RT2", String(pair.pairId), "pairId missing or outside the slug charset");
continue;
}
if (seenPairIds.has(pair.pairId)) violation("RT2", pair.pairId, "duplicate pairId");
seenPairIds.add(pair.pairId);
if (typeof pair.origin?.label !== "string" || pair.origin.label.length === 0) {
violation("RT2", pair.pairId, "origin label empty or missing");
}
const { lat, lng } = pair.origin ?? {};
const inBbox =
bbox.minLat <= lat && lat <= bbox.maxLat && bbox.minLng <= lng && lng <= bbox.maxLng;
if (!inBbox) violation("RT2", pair.pairId, "origin outside the KL bbox");
if (typeof pair.destinationId !== "string" || !gazById.has(pair.destinationId)) {
violation("RT2", pair.pairId, `destinationId ${JSON.stringify(pair.destinationId ?? null)} is not a gazetteer entry`);
}
// RT2b — the committed origin EQUALS the named gazetteer landmark's
// committed coordinate: no user coordinate can have entered a fixture.
const corpusPair = corpusById.get(pair.pairId);
if (corpusPair === undefined) {
violation("RT2", pair.pairId, "no pinned corpus entry for this pairId — RT2b cannot run");
continue;
}
const landmark = gazById.get(corpusPair.originId);
if (landmark === undefined) {
violation("RT2", pair.pairId, `corpus originId ${corpusPair.originId} is not a gazetteer entry`);
continue;
}
const dLat = Math.abs(landmark.lat - lat);
const dLng = Math.abs(landmark.lng - lng);
if (dLat > ORIGIN_MATCH_TOLERANCE_DEG || dLng > ORIGIN_MATCH_TOLERANCE_DEG) {
violation(
"RT2",
pair.pairId,
`committed origin does not equal gazetteer landmark "${corpusPair.originId}" within ${ORIGIN_MATCH_TOLERANCE_DEG} deg (D-12 red line)`,
);
}
}
/* ————— RT3 — route shape ————— */
check("RT3", "route shape");
for (const pair of pairs) {
const routes = pair.routes;
if (!Array.isArray(routes) || routes.length < 1 || routes.length > 3) {
violation("RT3", pair.pairId, `route count ${Array.isArray(routes) ? routes.length : "none"} outside 1..3`);
continue;
}
routes.forEach((route, i) => {
if (typeof route.distanceM !== "number" || !Number.isFinite(route.distanceM) || route.distanceM <= 0) {
violation("RT3", pair.pairId, `route ${i}: distanceM is not finite-positive`);
}
if (typeof route.durationS !== "number" || !Number.isFinite(route.durationS) || route.durationS <= 0) {
violation("RT3", pair.pairId, `route ${i}: durationS is not finite-positive`);
}
if (typeof route.geometry !== "string" || route.geometry.length === 0) {
violation("RT3", pair.pairId, `route ${i}: geometry is empty or missing`);
}
});
}
/* ————— RT4 — probe exclusion ————— */
check("RT4", "probe exclusion");
for (const pair of pairs) {
if (pair.destinationId === corpus.pendingProbeDestinationId) {
violation("RT4", pair.pairId, `destinationId is the pending-probe carrier "${corpus.pendingProbeDestinationId}"`);
}
}
/* ————— RT5 — geometry↔summary consistency ————— */
check("RT5", "geometry↔summary consistency");
const slackBbox = {
minLat: bbox.minLat - BBOX_SLACK_DEG,
maxLat: bbox.maxLat + BBOX_SLACK_DEG,
minLng: bbox.minLng - BBOX_SLACK_DEG,
maxLng: bbox.maxLng + BBOX_SLACK_DEG,
};
for (const pair of pairs) {
(pair.routes ?? []).forEach((route, i) => {
if (typeof route.geometry !== "string") return; // RT3 already flagged
let points;
try {
points = decodePolyline6(route.geometry);
} catch (err) {
violation("RT5", pair.pairId, `route ${i}: geometry does not decode (${err.message})`);
return;
}
for (const p of points) {
if (!Number.isFinite(p.lat) || !Number.isFinite(p.lng)) {
violation("RT5", pair.pairId, `route ${i}: non-finite decoded point`);
return;
}
const inSlack =
slackBbox.minLat <= p.lat && p.lat <= slackBbox.maxLat &&
slackBbox.minLng <= p.lng && p.lng <= slackBbox.maxLng;
if (!inSlack) {
violation("RT5", pair.pairId, `route ${i}: decoded point outside the KL bbox +${BBOX_SLACK_DEG} deg`);
return;
}
}
let haversineLengthM = 0;
for (let k = 1; k < points.length; k++) {
haversineLengthM += haversineM(points[k - 1], points[k]);
}
const ratio = route.distanceM > 0 ? haversineLengthM / route.distanceM : 0;
const [lo, hi] = LENGTH_RATIO_BAND;
if (!(ratio >= lo && ratio <= hi)) {
violation(
"RT5",
pair.pairId,
`route ${i}: haversine/route length ratio ${ratio.toFixed(3)} outside [${lo}, ${hi}]`,
);
}
});
}
/* ————— RT6 — duration sanity ————— */
check("RT6", "duration sanity");
for (const pair of pairs) {
(pair.routes ?? []).forEach((route, i) => {
if (typeof route.distanceM !== "number" || typeof route.durationS !== "number") return;
const speed = route.distanceM / route.durationS;
const [lo, hi] = SPEED_BAND;
if (!(speed >= lo && speed <= hi)) {
violation("RT6", pair.pairId, `route ${i}: average speed ${speed.toFixed(1)} m/s outside [${lo}, ${hi}]`);
}
});
}
/* ————— RT7 — codec round-trip ————— */
check("RT7", "codec round-trip");
for (const pair of pairs) {
(pair.routes ?? []).forEach((route, i) => {
if (typeof route.geometry !== "string") return; // RT3 already flagged
let points;
try {
points = decodePolyline6(route.geometry);
} catch (err) {
violation("RT7", pair.pairId, `route ${i}: geometry does not decode (${err.message})`);
return;
}
if (encodePolyline6(points) !== route.geometry) {
violation("RT7", pair.pairId, `route ${i}: round-trip mismatch — not a verbatim 1e-6-grid polyline6 string`);
}
});
}
/* ————— RT8 — licence header (T-05-06) ————— */
check("RT8", "licence header");
const header = (key) => String(fixtures[key] ?? "");
if (header("_license") !== "ODbL-1.0") {
violation("RT8", null, `_license must be exactly "ODbL-1.0"`);
}
if (!header("_attribution").includes("OpenStreetMap")) {
violation("RT8", null, "_attribution must credit OpenStreetMap contributors");
}
if (!header("_notice").includes("ODbL")) {
violation("RT8", null, "_notice must name ODbL");
}
if (!header("_source").includes("router.project-osrm.org")) {
violation("RT8", null, "_source must name router.project-osrm.org");
}
/* ————— RT9 — size budget (T-05-12 early warning) ————— */
check("RT9", "size budget");
const gzipBytes = gzipSync(readFileSync(fixturesPath)).length;
if (gzipBytes > corpus.fixtureGzipBudgetBytes) {
violation(
"RT9",
null,
`gzipped dataset ${gzipBytes} B exceeds the budget ${corpus.fixtureGzipBudgetBytes} B`,
);
}
/* ————— RT10 — alternatives floor (RTNG-02) ————— */
check("RT10", "alternatives floor");
const multiRoutePairs = pairs.filter(
(p) => Array.isArray(p.routes) && p.routes.length >= 2,
).length;
if (multiRoutePairs < corpus.alternativesFloorPairs) {
violation(
"RT10",
null,
`${multiRoutePairs} multi-route pairs is below the floor ${corpus.alternativesFloorPairs}`,
);
}
const allChecks = Object.values(checks);
const pass = allChecks.every((c) => c.violations.length === 0);
return { checks, gzipBytes, multiRoutePairs, pass };
}
/** The fixed escalation ladder — printed VERBATIM on failure. */
function printEscalationLadder() {
console.log("Corpus gate failed. Escalation order is FIXED:");
console.log(" (1) inspect the failing pair: node routing/scripts/fetch-route-fixtures.mjs --only=<pairId>");
console.log(" (2) regenerate the dataset: npm run routing:fixtures");
console.log(" (3) re-run this gate: npm run routing:corpus");
}
function report(corpus, fixtures, results) {
const routeCount = fixtures.pairs.reduce((n, p) => n + (p.routes?.length ?? 0), 0);
console.log(
`fixture corpus gate — ${fixtures.pairs.length} pairs / ${routeCount} routes`,
);
for (const checkRow of Object.values(results.checks)) {
const status = checkRow.violations.length === 0 ? "ok" : "FAIL";
console.log(` ${checkRow.id} ${checkRow.label.padEnd(34)} ${status}`);
for (const v of checkRow.violations) {
console.log(` ${v.pairId ?? "(dataset)"}: ${v.reason}`);
}
}
console.log(
` gz size ${results.gzipBytes} B / budget ${corpus.fixtureGzipBudgetBytes} B — ` +
`multi-route pairs ${results.multiRoutePairs} / floor ${corpus.alternativesFloorPairs}`,
);
console.log(`corpus gate ${results.pass ? "PASS" : "FAIL"}`);
return results.pass;
}
function main() {
const corpus = JSON.parse(readFileSync(DEFAULT_CORPUS_PATH, "utf8"));
const fixtures = JSON.parse(readFileSync(DEFAULT_FIXTURES_PATH, "utf8"));
const gazetteer = JSON.parse(readFileSync(DEFAULT_GAZETTEER_PATH, "utf8"));
const results = measurePairs(corpus, fixtures, gazetteer);
const pass = report(corpus, fixtures, results);
if (!pass) {
printEscalationLadder();
process.exit(1);
}
process.exit(0);
}
// Run only when invoked directly — importing this module (the corpus suite
// does, for in-process measurement) must never execute the CLI.
const invoked = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
if (invoked === import.meta.url) {
main();
}