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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user