#!/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}>, 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="); 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(); }