Files
arah2/routing/scripts/fetch-route-fixtures.mjs
T
araharah-executor 4e89cff552 feat(05-02): pinned golden-pair corpus + OSRM-demo fetch tool + populated committed dataset (GREEN)
- routing/test/fixtures/golden-pairs.json: the hand-authored 15-pair pinned
  corpus (D-02/D-12) — origins AND destinations are gazetteer landmarks,
  klBbox + 100 KB gz budget + probe id + alternatives floor pinned
- routing/scripts/fetch-route-fixtures.mjs: the maintainer build tool
  (overpass-topup.mjs pattern) — D-12 boundary in the header, pre-flight
  public-coordinate proof against the gazetteer (1e-4 deg), mandatory
  User-Agent, 1200 ms inter-request delay, one retry on network/5xx,
  collect-then-write, --only=<pairId> single-pair repair merge
- routing/src/data/route-fixtures.json: 15 real OSRM-demo pairs committed
  (1-3 verbatim polyline6 routes each; 8 pairs carry >= 2 alternates),
  ODbL header fields preserved (T-05-06)
- loader-env L3 armed (RED commit 01b0ed6) + C6 re-pinned from the empty-seed
  state to the populated corpus (05-01 documented both as 05-02 handoffs)
- npm delegates: root routing:fixtures; routing fixtures + corpus scripts
2026-10-24 05:17:01 +08:00

250 lines
11 KiB
JavaScript

#!/usr/bin/env node
/*
* fetch-route-fixtures.mjs — build-time fixture precompute from the OSRM demo (D-02).
* ------------------------------------------------------------------------------------
* A MAINTAINER-TIME REPO TOOL in the overpass-topup.mjs pattern: it is NOT a
* runtime dependency. Nothing in bot/src/, web/src/, or routing/src/ imports
* it; no deploy, test, CI run, or vite build ever runs it. The app only ever
* reads the COMMITTED routing/src/data/route-fixtures.json.
*
* D-12 BOUNDARY: this script runs on the MAINTAINER'S machine against FIXED
* PUBLIC coordinates (gazetteer landmarks). No user coordinate can ever enter
* a fixture. The app itself gains NO runtime dependency on this server or any
* network.
*
* Mechanically: before ANY request leaves the machine, every corpus pair is
* validated against the committed gazetteer — the pinned origin must EQUAL
* the named landmark's coordinate (within 1e-4 deg) and the destinationId
* must exist. A non-public or unknown coordinate is refused pre-flight, with
* the pair named.
*
* Request shape (verified live 2026-10-24, 05-RESEARCH.md Finding 2):
* GET https://router.project-osrm.org/route/v1/driving/{lng},{lat};{lng},{lat}
* ?alternatives=2&overview=full&geometries=polyline6
* - alternatives=2 -> up to 2 alternates (<= 3 total; RTNG-02's "up to 3")
* - overview=full + geometries=polyline6 -> the geometry string is stored
* VERBATIM (no float rounding, no re-serialization)
* - steps are NOT requested (turn instructions are Phase 6; D-10)
* - identifying header is MANDATORY on every request:
* User-Agent: araharah-fixtures/1.0 (build-time fixture precompute; repo: araharah)
* - 1200 ms delay between pair requests; ONE immediate retry per pair on
* network error or 5xx (a second failure is a hard fail for that pair)
*
* COLLECT-THEN-WRITE: results accumulate in memory; the committed file is
* written ONLY after every pair succeeds. Any per-pair hard failure
* (code !== "Ok", zero routes, exhausted retry) exits 1 with the failing
* pair named and the committed file UNTOUCHED.
*
* Usage:
* npm run routing:fixtures # full regeneration (15 pairs)
* node routing/scripts/fetch-route-fixtures.mjs --only=<pairId>
* # single-pair repair, merged
* # into the committed file —
* # the other pairs never churn
*
* Exit codes: 0 all pairs fetched and the file written; 1 pre-flight
* validation failure, per-pair hard failure, or usage error.
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url)); // routing/scripts
const CORPUS_PATH = resolve(here, "..", "test", "fixtures", "golden-pairs.json");
const GAZETTEER_PATH = resolve(here, "..", "..", "search", "src", "data", "kl-gazetteer.json");
const FIXTURES_PATH = resolve(here, "..", "src", "data", "route-fixtures.json");
const USER_AGENT = "araharah-fixtures/1.0 (build-time fixture precompute; repo: araharah)";
const OSRM_ROUTE_BASE = "https://router.project-osrm.org/route/v1/driving";
const INTER_REQUEST_DELAY_MS = 1200;
const REQUEST_TIMEOUT_MS = 30_000;
/** Max coordinate drift between a pinned corpus origin and its gazetteer
* landmark — 1e-4 deg is ~11 m; anything larger is not the same point. */
const ORIGIN_MATCH_TOLERANCE_DEG = 1e-4;
const MAX_ROUTES = 3;
/** The file-template header (T-05-06): the ODbL attribution is part of the
* file shape, not an optional add-on. An existing committed file's own
* header fields are preserved verbatim on top of these defaults. */
const HEADER_TEMPLATE = {
_license: "ODbL-1.0",
_attribution: "© OpenStreetMap contributors",
_notice:
"Route geometry © OpenStreetMap contributors, precomputed from the public OSRM demo server (router.project-osrm.org) at build time between fixed public landmark coordinates. ODbL v1.0 — https://opendatacommons.org/licenses/odbl/1-0/",
_source:
"OSRM demo server (router.project-osrm.org) — build-time fetch, never a runtime dependency",
};
function fail(message) {
console.error(`fetch-route-fixtures: ${message}`);
process.exit(1);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/* ————— inputs ————— */
const onlyPairId = (() => {
const arg = process.argv.find((a) => a.startsWith("--only="));
return arg === undefined ? null : arg.slice("--only=".length);
})();
const corpus = JSON.parse(readFileSync(CORPUS_PATH, "utf8"));
if (!Array.isArray(corpus.pairs) || corpus.pairs.length === 0) {
fail(`corpus has no pairs: ${CORPUS_PATH}`);
}
if (!existsSync(GAZETTEER_PATH)) {
fail(`gazetteer not found at ${GAZETTEER_PATH} — the cross-workspace relative path is load-bearing (gazetteer-corpus.mjs precedent)`);
}
const gazetteer = JSON.parse(readFileSync(GAZETTEER_PATH, "utf8"));
const gazetteerById = new Map(gazetteer.entries.map((e) => [e.id, e]));
/** The committed dataset as it exists right now (null on first run). */
const existingFile = existsSync(FIXTURES_PATH)
? JSON.parse(readFileSync(FIXTURES_PATH, "utf8"))
: null;
const existingPairsById = new Map(
(existingFile?.pairs ?? []).map((p) => [p.pairId, p]),
);
/* ————— PRE-FLIGHT validation (D-12): nothing is fetched until every pair is
* proven to be a public landmark pair ————— */
const seenPairIds = new Set();
for (const pair of corpus.pairs) {
const name = pair.pairId ?? "(missing pairId)";
if (seenPairIds.has(pair.pairId)) fail(`duplicate pairId in corpus: ${name}`);
seenPairIds.add(pair.pairId);
if (onlyPairId !== null && pair.pairId !== onlyPairId) continue;
const landmark = gazetteerById.get(pair.originId);
if (landmark === undefined) {
fail(`pair ${name}: originId "${pair.originId}" is not a gazetteer landmark — refusing to send a non-public coordinate`);
}
const dLat = Math.abs(landmark.lat - pair.origin.lat);
const dLng = Math.abs(landmark.lng - pair.origin.lng);
if (dLat > ORIGIN_MATCH_TOLERANCE_DEG || dLng > ORIGIN_MATCH_TOLERANCE_DEG) {
fail(
`pair ${name}: pinned origin (${pair.origin.lat}, ${pair.origin.lng}) does not equal ` +
`gazetteer landmark "${pair.originId}" (${landmark.lat}, ${landmark.lng}) within ` +
`${ORIGIN_MATCH_TOLERANCE_DEG} deg — refusing to send (D-12)`,
);
}
if (!gazetteerById.has(pair.destinationId)) {
fail(`pair ${name}: destinationId "${pair.destinationId}" is not a gazetteer entry — refusing`);
}
}
if (onlyPairId !== null && !seenPairIds.has(onlyPairId)) {
fail(`--only=${onlyPairId}: no such pairId in the corpus`);
}
console.log(
`pre-flight OK — ${onlyPairId ?? `all ${corpus.pairs.length}`} pair(s) proven public against the committed gazetteer`,
);
/* ————— the OSRM demo request (one per pair, 1200 ms apart, one retry) ————— */
async function requestRoutes(pair) {
const coordinates =
`${pair.origin.lng},${pair.origin.lat};` +
`${gazetteerById.get(pair.destinationId).lng},${gazetteerById.get(pair.destinationId).lat}`;
const url = `${OSRM_ROUTE_BASE}/${coordinates}?alternatives=2&overview=full&geometries=polyline6`;
const attempts = 2; // the initial request + ONE immediate retry
for (let attempt = 1; attempt <= attempts; attempt++) {
let payload = null;
let networkError = null;
let status = 0;
try {
const res = await fetch(url, {
headers: { "User-Agent": USER_AGENT },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
status = res.status;
if (res.ok) payload = await res.json();
} catch (err) {
networkError = err;
}
if (payload !== null && payload.code === "Ok" && Array.isArray(payload.routes) && payload.routes.length > 0) {
return payload.routes.slice(0, MAX_ROUTES);
}
const retryable = networkError !== null || status >= 500;
const detail =
networkError !== null
? `network error: ${networkError.message}`
: payload !== null
? `code=${payload.code} routes=${Array.isArray(payload.routes) ? payload.routes.length : "none"}`
: `HTTP ${status}`;
if (attempt < attempts && retryable) {
console.log(` retry ${pair.pairId} — ${detail}`);
continue;
}
fail(`pair ${pair.pairId}: OSRM demo did not deliver a usable route (${detail})`);
}
fail(`pair ${pair.pairId}: unreachable`); // structurally unreachable
}
/* ————— collect (ALL pairs) then write (only on total success) ————— */
const fetchedRoutes = new Map(); // pairId -> FixtureRoute[]
let first = true;
for (const pair of corpus.pairs) {
const isTarget = onlyPairId === null || pair.pairId === onlyPairId;
if (!isTarget) continue;
if (!first) await sleep(INTER_REQUEST_DELAY_MS);
first = false;
const routes = await requestRoutes(pair);
fetchedRoutes.set(
pair.pairId,
routes.map((r) => ({ geometry: r.geometry, distanceM: r.distance, durationS: r.duration })),
);
const best = routes[0];
console.log(
` ok ${pair.pairId} — ${routes.length} route(s); best ${(best.distance / 1000).toFixed(1)} km / ${(best.duration / 60).toFixed(1)} min`,
);
}
/** Output pairs in corpus order: the --only target (or every pair in a full
* run) carries freshly fetched routes; every other pair keeps its committed
* routes UNTOUCHED (a single-pair repair never churns the other fourteen). */
function mergedPairs() {
const out = [];
for (const pair of corpus.pairs) {
const fresh = fetchedRoutes.get(pair.pairId);
if (fresh !== undefined) {
out.push({
pairId: pair.pairId,
origin: pair.origin,
destinationId: pair.destinationId,
routes: fresh,
});
continue;
}
const committed = existingPairsById.get(pair.pairId);
if (committed === undefined) {
fail(`pair ${pair.pairId}: no committed routes to keep and none fetched this run — run a full regeneration`);
}
out.push(committed);
}
return out;
}
const pairs = mergedPairs();
if (pairs.length !== corpus.pairs.length) {
fail(`corpus/dataset drift: ${pairs.length} output pairs vs ${corpus.pairs.length} corpus pairs`);
}
const file = {
...(existingFile ?? HEADER_TEMPLATE),
...HEADER_TEMPLATE,
version: 1,
originToleranceM: 200,
pairs,
};
writeFileSync(FIXTURES_PATH, `${JSON.stringify(file, null, 2)}\n`);
const multi = pairs.filter((p) => p.routes.length >= 2).length;
console.log(
`wrote ${FIXTURES_PATH} — ${pairs.length} pairs, ${multi} with >= 2 routes (alternatives floor: ${corpus.alternativesFloorPairs})`,
);
console.log("next: npm run routing:corpus # the offline gate over the committed dataset");
process.exit(0);