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
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
#!/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);
|
||||
Reference in New Issue
Block a user