- 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
250 lines
11 KiB
JavaScript
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);
|