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
This commit is contained in:
araharah-executor
2026-10-24 05:19:55 +08:00
parent 63ef408197
commit 6c0880b4db
+334
View File
@@ -0,0 +1,334 @@
#!/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();
}