#!/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= * # 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);