diff --git a/routing/scripts/route-corpus.mjs b/routing/scripts/route-corpus.mjs new file mode 100644 index 0000000..ee1d030 --- /dev/null +++ b/routing/scripts/route-corpus.mjs @@ -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}>, 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(); +}