Files
arah2/.planning/phases/05-routing-route-display/05-02-PLAN.md

29 KiB
Raw Permalink Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
05-routing-route-display 02 execute 2
01
package.json
routing/package.json
routing/test/fixtures/golden-pairs.json
routing/scripts/fetch-route-fixtures.mjs
routing/scripts/route-corpus.mjs
routing/src/data/route-fixtures.json
routing/test/corpus.test.mjs
routing/test/loader-env.test.mjs
true
RTNG-01
RTNG-02
decisions_implemented truths artifacts key_links
D-02: fixture routes for the 10–20 golden KL pairs, geometries precomputed at BUILD time by a repo script hitting the public OSRM demo server once, committed as compact encoded polylines, each pair pinning distance/duration consistency gates
D-12: fixture geometries are public-data routes between public places — the corpus gate mechanically proves every pinned origin equals a gazetteer landmark coordinate; the script header documents the maintainer-machine boundary
D-11: the fixture payload stays inside the bundle budget — a fixture-level gzipped-size gate (≤ 100 KB) gives early warning under the 200 KB BA9 ceiling
RTNG-02: the alternatives floor — at least 4 pairs carry ≥ 2 routes so the chips/redraw surface has real data
15 golden pairs are committed in routing/src/data/route-fixtures.json, each with 1–3 best-first routes stored as verbatim polyline6 strings
Every committed pair passes the OFFLINE tolerance gate: geometry↔summary haversine consistency (0.85–1.10 × distanceM), duration sanity (1.0–30 m/s average speed), and exact codec round-trip — no test ever touches a network
Every pair's pinned origin coordinate equals SOME gazetteer entry's lat/lng within 1e-4 degrees — no user coordinate can have entered a fixture (mechanical D-12 proof)
Every pair's destinationId exists in the shared gazetteer; exactly zero pairs target the pending-probe destination 'national-museum' (the 05-05 capture's pending-state carrier)
The fetch script is a repo tool on the overpass-topup.mjs pattern: descriptive User-Agent, inter-request delay, per-pair hard-fail diagnostics, never imported at runtime, never run in CI or at web-build time
The corpus gate is both a CLI (npm run routing:corpus) and an in-suite Nyquist gate (npm run routing:test) — one measurement core, two reporters, no drift
path provides contains
routing/test/fixtures/golden-pairs.json the hand-authored pinned corpus: 15 pairs (origin landmark + destinationId + coordinates), the KL bbox, the size budget, the pending-probe id "pairs"
path provides contains
routing/scripts/fetch-route-fixtures.mjs the build-time OSRM-demo fetch tool (maintainer machine; D-12 boundary documented in header) router.project-osrm.org
path provides contains
routing/scripts/route-corpus.mjs measurePairs() — the corpus measurement core the CLI prints and the suite asserts export function measurePairs
path provides contains
routing/src/data/route-fixtures.json the COMMITTED fixture dataset — 15 pairs populated from the OSRM demo (this plan replaces 05-01's empty seed) "pairId"
path provides contains
routing/test/corpus.test.mjs the RT1–RT10 offline tolerance gate suite (in-process, deterministic) RT1
from to via pattern
routing/scripts/fetch-route-fixtures.mjs routing/test/fixtures/golden-pairs.json the script reads the pinned corpus and fetches each pair from the OSRM demo golden-pairs.json
from to via pattern
routing/scripts/fetch-route-fixtures.mjs routing/src/data/route-fixtures.json collect-then-write: the committed dataset is regenerated atomically per run route-fixtures.json
from to via pattern
routing/scripts/route-corpus.mjs routing/src/data/route-fixtures.json the measurement core validates the committed dataset (never the script output directly) route-fixtures.json
from to via pattern
routing/test/corpus.test.mjs routing/scripts/route-corpus.mjs the suite imports measurePairs and asserts the SAME numbers the CLI prints (gazetteer-corpus.mjs precedent) measurePairs
Populate the committed fixture dataset with 10–20 real KL origin/destination pairs precomputed from the public OSRM demo server (build-time, public coordinates only), and pin them behind the offline corpus tolerance gate — the phase's primary acceptance instrument (the amended ROADMAP SC3: "the golden-route corpus routes cleanly through the fixture RoutingSource backend with bundle-size and type gates holding").

Purpose: D-02 makes fixture geometry the product's actual route data for v1's golden pairs; D-12 makes "public data between public places" a mechanically provable property (every pinned origin must equal a gazetteer landmark coordinate); RTNG-02 needs real alternatives (≥ 4 pairs with ≥ 2 routes) for the chips/redraw surface. This plan delivers the hand-authored pair list, the maintainer fetch script (overpass-topup.mjs pattern), the populated committed dataset, and the RT1–RT10 offline gate — both as a CLI and as an in-suite Nyquist gate.

Output: routing/test/fixtures/golden-pairs.json (15 pinned pairs), routing/scripts/fetch-route-fixtures.mjs (repo tool), a committed routing/src/data/route-fixtures.json with 15 real pairs, routing/scripts/route-corpus.mjs + routing/test/corpus.test.mjs (the gate), and the routing:fixtures root delegate.

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@AGENTS.md @.planning/phases/05-routing-route-display/05-CONTEXT.md @.planning/phases/05-routing-route-display/05-RESEARCH.md @.planning/phases/05-routing-route-display/05-PATTERNS.md @.planning/phases/05-routing-route-display/05-01-PLAN.md @search/scripts/overpass-topup.mjs @search/scripts/gazetteer-corpus.mjs @search/test/corpus.test.mjs @search/src/data/kl-gazetteer.json @routing/src/types.ts @routing/src/index.ts @routing/src/data/route-fixtures.json

<pinned_corpus> The 15 golden pairs — hand-pinned NOW (the executor does not invent pairs; the executor verifies each pair routes via the script and records the outcome). All origins AND destinations are gazetteer entries from search/src/data/kl-gazetteer.json (public landmark coordinates; the D-12 proof keys off exactly this). Coordinates below are the gazetteer's own values — the corpus file repeats them verbatim so the file is self-contained, and gate RT2b re-proves they match the gazetteer.

# pairId origin (id) origin lat,lng destinationId
1 kl-tower-to-kl-sentral kl-tower 3.1528, 101.7038 kl-sentral
2 kl-sentral-to-suria-klcc kl-sentral 3.1343, 101.6869 suria-klcc
3 suria-klcc-to-bangsar suria-klcc 3.1578, 101.7117 bangsar
4 mid-valley-to-bukit-bintang mid-valley 3.118, 101.6779 bukit-bintang
5 bukit-bintang-to-mid-valley bukit-bintang 3.1466, 101.7106 mid-valley
6 pwtc-to-sunway-velocity pwtc 3.1768, 101.698 sunway-velocity
7 masjid-jamek-to-lake-gardens masjid-jamek 3.1484, 101.6953 lake-gardens
8 titiwangsa-to-petaling-street titiwangsa 3.1714, 101.7016 petaling-street
9 sunway-pyramid-to-pavilion-kl sunway-pyramid 3.0726, 101.6069 pavilion-kl
10 central-market-to-merdeka-118 central-market 3.1454, 101.6953 merdeka-118
11 little-india-brickfields-to-aquaria-klcc little-india-brickfields 3.1306, 101.6869 aquaria-klcc
12 national-museum-to-pudu-raya national-museum 3.1378, 101.687 pudu-raya
13 mid-valley-to-ioi-city-mall mid-valley 3.118, 101.6779 ioi-city-mall
14 kl-sentral-to-sunway-pyramid kl-sentral 3.1343, 101.6869 sunway-pyramid
15 berjaya-times-square-to-titiwangsa berjaya-times-square 3.1421, 101.7101 titiwangsa

Notes:

  • The pair ids follow <origin-id>-to-<dest-id> (slug charset; the ?fixpair= knob value in plan 05-05).
  • national-museum appears ONLY as an ORIGIN (pair 12) — it is NEVER a destinationId (gate RT4 pins this; plan 05-05's pending capture uses dest=national-museum as the supported-but-out-of-corpus carrier).
  • Distances span ~1 km (pair 10) to ~20 km (pair 13) — short/mid/long spread so the UI-SPEC's card/chips/camera behaviors are exercised across zoom ranges.
  • Pairs 4/5 and 2/3 give direction-reversed coverage (one-way streets and alternatives differ by direction — the demo script walks these). </pinned_corpus>

<osrm_contract> The OSRM demo request shape (VERIFIED LIVE 2026-10-24 — see 05-RESEARCH.md Finding 2):

GET https://router.project-osrm.org/route/v1/driving/{originLng},{originLat};{destLng},{destLat}?alternatives=2&overview=full&geometries=polyline6
  • alternatives=2 → up to 2 alternates (≤ 3 total; matches RTNG-02's "up to 3").
  • overview=full → complete geometry; geometries=polyline6 → the string stored VERBATIM.
  • Response: code: "Ok", routes[] best-first with distance (m), duration (s), geometry (string).
  • Per-pair HARD FAIL when code !== "Ok" or routes.length === 0 — name the pair in the diagnostic, exit 1, write NOTHING (collect-then-write).
  • Do NOT request steps (turn instructions are Phase 6; fixture steps stay empty — D-10).
  • Do NOT retry on failure beyond ONE immediate retry per pair (transient 5xx); a second failure is a hard fail for that pair. </osrm_contract>

<environment_facts> Verified in this session — do not re-derive:

  • The OSRM demo server answered a KL route request with code: Ok, 2 routes, polyline6 geometry (~1200 chars for ~6.9 km) on 2026-10-24 — the endpoint, parameter set, and response shape are current, not assumed.
  • 15 pairs × 1 request each ≈ 15 requests + up to 1 retry each — light, fair use of a free demo server; the 1200 ms inter-request delay keeps it polite (~20 s total runtime).
  • search/scripts/overpass-topup.mjs is the repo-tool precedent: mandatory descriptive User-Agent, inter-request delay, per-item soft diagnostics, writes committed JSON, imported by NOTHING at runtime.
  • search/scripts/gazetteer-corpus.mjs exports measureCorpus() precisely so search/test/corpus.test.mjs can assert the same numbers in-process — "one measurement, two reporters, no drift" is the established corpus-gate shape.
  • The gazetteer's 30 entries and exact coordinates are in search/src/data/kl-gazetteer.json — the corpus file's origin coordinates above are copied from there.
  • The search workspace imports (import … from "../src/index.ts") work in .mjs scripts under Node 24 type-stripping (gazetteer-corpus.mjs does exactly this). </environment_facts>

<threat_model>

Trust Boundaries

Boundary Description
OSRM demo server → fetch script → committed dataset A third-party free service supplies the geometry. Drift (data updates) is benign for a committed snapshot; POISONING (wrong/malformed geometry) must be caught offline by the tolerance gate before it can ship.
Pinned corpus file → fetch script The hand-authored pairs file decides which coordinates leave the machine. A user coordinate or a private address entering this file would be shipped to a third party — the D-12 red line.
Committed dataset → bundle → users Fixture bytes ride the web bundle (public data, but size is an availability budget — D-11).

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-05-05 Tampering / Information Disclosure (a) OSRM-supplied geometry inconsistent with its own summary silently shipping; (b) a non-public coordinate entering the pinned corpus and being sent to the demo server mitigate (a) RT5 recomputes each route's haversine polyline length OFFLINE and demands 0.85–1.10 × distanceM, RT6 bounds average speed to 1.0–30 m/s, RT3 re-decodes and re-encodes every geometry (exact round-trip) — a poisoned or drifting dataset fails npm run routing:test, not just the script. (b) RT2b mechanically proves every pinned origin equals SOME gazetteer entry coordinate within 1e-4 deg (≈11 m) — the corpus can only contain public landmark points, so the maintainer machine sends nothing private (D-12's "no user coordinate ever enters a fixture" made checkable).
T-05-12 Denial of Service (availability budget) Fixture payload growth pushing the web bundle over the D-11 budget mitigate RT9 gzips the committed dataset and fails above 100 KB (expected ≈ 20 KB at 15 pairs × ~1.2–2.5 KB) — early warning under BA9's 200 KB all-in ceiling (plan 05-05 owns the final gate; the two tiers share this threat ID); the gate prints the measured size so growth is visible, never discovered.
T-05-06 Repudiation Derivative OSM geometry shipped without licence/attribution mitigate The script writes the ODbL header fields (_license/_attribution/_notice/_source) into every regenerated file (they are part of the file template, not an optional add-on); RT8 asserts them post-write — the notice cannot be lost by a regeneration.
</threat_model>
task 1: pinned golden-pairs corpus + fetch script + populated committed dataset package.json, routing/package.json, routing/test/fixtures/golden-pairs.json, routing/scripts/fetch-route-fixtures.mjs, routing/src/data/route-fixtures.json - `search/scripts/overpass-topup.mjs` (the repo-tool pattern: UA, delay, diagnostics, committed-JSON write) - `search/src/data/kl-gazetteer.json` (the origin/destination coordinates and ids — the corpus file must agree with them) - `routing/src/types.ts` (FixturePair/FixtureRoute — the written dataset must typecheck against these via the corpus runner's import) - `routing/src/data/route-fixtures.json` (05-01's seed — this task REPLACES its `pairs: []` with real data, preserving the header fields) - `.planning/phases/05-routing-route-display/05-RESEARCH.md` (Finding 2 — the verified request shape) **`routing/test/fixtures/golden-pairs.json`** — hand-author the pinned corpus, exactly the `` table:
{
  "_comment": "Pinned golden-pair corpus (D-02/D-12). Origins AND destinations are gazetteer landmarks (public data). RT2b proves every origin equals a gazetteer coordinate. national-museum is never a destinationId (RT4) — it is the 05-05 pending-probe carrier.",
  "klBbox": { "minLat": 2.9, "maxLat": 3.4, "minLng": 101.4, "maxLng": 102.0 },
  "fixtureGzipBudgetBytes": 102400,
  "pendingProbeDestinationId": "national-museum",
  "alternativesFloorPairs": 4,
  "pairs": [
    { "pairId": "kl-tower-to-kl-sentral", "originId": "kl-tower", "origin": { "lat": 3.1528, "lng": 101.7038, "label": "Kuala Lumpur Tower" }, "destinationId": "kl-sentral" },
    { "pairId": "kl-sentral-to-suria-klcc", "originId": "kl-sentral", "origin": { "lat": 3.1343, "lng": 101.6869, "label": "KL Sentral" }, "destinationId": "suria-klcc" },
    { "pairId": "suria-klcc-to-bangsar", "originId": "suria-klcc", "origin": { "lat": 3.1578, "lng": 101.7117, "label": "Suria KLCC" }, "destinationId": "bangsar" },
    { "pairId": "mid-valley-to-bukit-bintang", "originId": "mid-valley", "origin": { "lat": 3.118, "lng": 101.6779, "label": "Mid Valley Megamall" }, "destinationId": "bukit-bintang" },
    { "pairId": "bukit-bintang-to-mid-valley", "originId": "bukit-bintang", "origin": { "lat": 3.1466, "lng": 101.7106, "label": "Bukit Bintang" }, "destinationId": "mid-valley" },
    { "pairId": "pwtc-to-sunway-velocity", "originId": "pwtc", "origin": { "lat": 3.1768, "lng": 101.698, "label": "PWTC" }, "destinationId": "sunway-velocity" },
    { "pairId": "masjid-jamek-to-lake-gardens", "originId": "masjid-jamek", "origin": { "lat": 3.1484, "lng": 101.6953, "label": "Masjid Jamek" }, "destinationId": "lake-gardens" },
    { "pairId": "titiwangsa-to-petaling-street", "originId": "titiwangsa", "origin": { "lat": 3.1714, "lng": 101.7016, "label": "Taman Tasik Titiwangsa" }, "destinationId": "petaling-street" },
    { "pairId": "sunway-pyramid-to-pavilion-kl", "originId": "sunway-pyramid", "origin": { "lat": 3.0726, "lng": 101.6069, "label": "Sunway Pyramid" }, "destinationId": "pavilion-kl" },
    { "pairId": "central-market-to-merdeka-118", "originId": "central-market", "origin": { "lat": 3.1454, "lng": 101.6953, "label": "Central Market" }, "destinationId": "merdeka-118" },
    { "pairId": "little-india-brickfields-to-aquaria-klcc", "originId": "little-india-brickfields", "origin": { "lat": 3.1306, "lng": 101.6869, "label": "Brickfields" }, "destinationId": "aquaria-klcc" },
    { "pairId": "national-museum-to-pudu-raya", "originId": "national-museum", "origin": { "lat": 3.1378, "lng": 101.687, "label": "National Museum" }, "destinationId": "pudu-raya" },
    { "pairId": "mid-valley-to-ioi-city-mall", "originId": "mid-valley", "origin": { "lat": 3.118, "lng": 101.6779, "label": "Mid Valley Megamall" }, "destinationId": "ioi-city-mall" },
    { "pairId": "kl-sentral-to-sunway-pyramid", "originId": "kl-sentral", "origin": { "lat": 3.1343, "lng": 101.6869, "label": "KL Sentral" }, "destinationId": "sunway-pyramid" },
    { "pairId": "berjaya-times-square-to-titiwangsa", "originId": "berjaya-times-square", "origin": { "lat": 3.1421, "lng": 101.7101, "label": "Berjaya Times Square" }, "destinationId": "titiwangsa" }
  ]
}

routing/scripts/fetch-route-fixtures.mjs — the maintainer build tool (overpass-topup.mjs pattern). Header comment MUST contain this D-12 boundary statement verbatim: 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. Behavior:

  • Read routing/test/fixtures/golden-pairs.json + the gazetteer (../node_modules/@araharah/search/src/data/kl-gazetteer.json? NO — resolve the workspace package by RELATIVE path ../../search/src/data/kl-gazetteer.json, the gazetteer-corpus.mjs cross-workspace import precedent — verify the path exists at runtime and hard-fail with a diagnostic if not).
  • For each pair: validate the origin coordinate matches the gazetteer's originId entry within 1e-4 deg AND the destinationId exists (refuse to even fetch a non-public or unknown coordinate — fail fast, name the pair).
  • Fetch the <osrm_contract> URL with header User-Agent: araharah-fixtures/1.0 (build-time fixture precompute; repo: araharah), 1200 ms delay between pairs, ONE retry on network/5xx failure.
  • Per-pair hard fail on code !== "Ok" or routes.length === 0 (collect-then-write: on ANY pair failure, exit 1 with the failing pair named and the committed file UNTOUCHED).
  • Map each OSRM route → { geometry: r.geometry, distanceM: r.distance, durationS: r.duration } (order preserved, cap 3).
  • Write routing/src/data/route-fixtures.json preserving 05-01's header fields (_license, _attribution, _notice, _source) with version: 1, originToleranceM: 200, and the populated pairs — 2-space JSON, trailing newline.
  • Support --only=<pairId> to regenerate one pair (merging into the existing committed file — a single-pair repair must not churn the other 14).

Run the script NOW (this is the plan's build step): node routing/scripts/fetch-route-fixtures.mjs from the repo root. The committed routing/src/data/route-fixtures.json MUST end this task with 15 real pairs. If the OSRM demo server is unreachable at execution time: retry the fetch up to 3 times over ~10 minutes; if still unreachable, STOP this plan with an explicit CHECKPOINT note in the summary (fixtures are the phase's data product — shipping empty fixtures fails the corpus gate by design).

Root package.json — add "routing:fixtures": "node routing/scripts/fetch-route-fixtures.mjs" (delegate next to routing:corpus).

routing/package.json — add "fixtures": "node scripts/fetch-route-fixtures.mjs" and "corpus": "node scripts/route-corpus.mjs" scripts.

Acceptance: the committed dataset carries 15 pairs; npm run routing:test still exits 0 (05-01's suites pass against REAL data — F1's empty-seed case L3 now asserts the default source serves kl-tower→kl-sentraL… NOTE: loader-env L3 asserted the seed serves null; with real data it must now assert a KNOWN pair SERVES — update L3 in routing/test/loader-env.test.mjs accordingly: the default source serves pair 1 for a request at its pinned origin, still returns null for an origin 300 m away). cd /home/C-Fu/araharah && node -e "const f=require('./routing/src/data/route-fixtures.json'); if(f.pairs.length<10||f.pairs.length>20) throw new Error('pair count '+f.pairs.length); for(const p of f.pairs){if(p.routes.length<1||p.routes.length>3) throw new Error(p.pairId+' route count');} console.log('pairs:', f.pairs.length)" ; grep -q '"routing:fixtures"' package.json && echo "delegate OK" <acceptance_criteria> - node -e "const f=require('./routing/src/data/route-fixtures.json'); if(f.pairs.length!==15) throw new Error(1); for(const p of f.pairs){ if(!/^[a-z0-9-]{1,64}$/.test(p.pairId)) throw new Error(2); if(p.routes.length<1||p.routes.length>3) throw new Error(3);} console.log('OK')" exits 0 - grep -q 'D-12 BOUNDARY: this script runs on the MAINTAINER' routing/scripts/fetch-route-fixtures.mjs exits 0 - grep -q 'router.project-osrm.org' routing/scripts/fetch-route-fixtures.mjs exits 0 - grep -q 'User-Agent' routing/scripts/fetch-route-fixtures.mjs exits 0 AND grep -q '1200' routing/scripts/fetch-route-fixtures.mjs exits 0 (mandatory UA + inter-request delay) - grep -q '"routing:fixtures": "node routing/scripts/fetch-route-fixtures.mjs"' package.json exits 0 - test -f routing/test/fixtures/golden-pairs.json exits 0 AND node -e "const c=require('./routing/test/fixtures/golden-pairs.json'); if(c.pairs.length!==15) throw new Error(1); if(c.pendingProbeDestinationId!=='national-museum') throw new Error(2); console.log('OK')" exits 0 - node -e "const f=require('./routing/src/data/route-fixtures.json'); const bad=f.pairs.filter(p=>p.destinationId==='national-museum'); if(bad.length) throw new Error('probe id used as destination'); console.log('RT4 OK')" exits 0 </acceptance_criteria> The pinned 15-pair corpus exists, the maintainer fetch tool exists with the D-12 boundary documented, and the committed fixture dataset carries 15 real OSRM-demo-precomputed pairs (1–3 routes each, verbatim polyline6).

task 2: the offline corpus tolerance gate (CLI + in-suite Nyquist gate) routing/scripts/route-corpus.mjs, routing/test/corpus.test.mjs - `search/scripts/gazetteer-corpus.mjs` (the "one measurement, two reporters" shape: exported measure core + CLI report + fixed escalation ladder on failure) - `search/test/corpus.test.mjs` (the in-process assertion suite that imports the measurement core — NEVER spawns the script) - `routing/src/index.ts` (the public API the measurement core imports: decodePolyline6, haversineM, getPairs) - `routing/test/fixtures/golden-pairs.json` (task 1's thresholds: bbox, gzip budget, alternatives floor, probe id) **`routing/scripts/route-corpus.mjs`** — the corpus measurement core + CLI reporter. Header contract (gazetteer-corpus.mjs style): a LOCAL DEV TOOL over committed files; the WORKER/WEBVIEW never prints coordinates or pair data (D-15: this script prints pair ids and measurements only — never geometry strings). Exports:
export function measurePairs(corpus, fixtures, gazetteer): MeasurementReport;

MeasurementReport carries per-check counts + violations arrays. The measurement core performs RT1–RT10 (ALL OFFLINE — zero network in the measurement path):

  • RT1 corpus size — 10 <= pairs.length <= 20.
  • RT2 schema + public origin — every pair: pairId unique + slug charset; origin label non-empty; origin inside klBbox; destinationId exists in the gazetteer; AND RT2b: the gazetteer entry whose id === originId satisfies |entry.lat − origin.lat| <= 1e-4 AND |entry.lng − origin.lng| <= 1e-4 — i.e. the pinned origin EQUALS the named gazetteer landmark's committed coordinate (the mechanical no-user-coordinate proof, D-12).
  • RT3 route shape — every pair has 1..3 routes; every route: finite distanceM > 0, finite durationS > 0, non-empty geometry string.
  • RT4 probe exclusion — NO pair's destinationId equals pendingProbeDestinationId (national-museum).
  • RT5 geometry↔summary consistency — decode each geometry; haversineLengthM(points) (sum of segment distances) within [0.85, 1.10] × distanceM; decoded points all finite and inside the KL bbox (with 0.05 deg slack for OSRM's road snapping).
  • RT6 duration sanity — distanceM / durationS within [1.0, 30] m/s (≈ 3.6–108 km/h — KL congestion floor to DUKE highway ceiling).
  • RT7 codec round-trip — encodePolyline6(decodePolyline6(geometry)) === geometry for every route (the 1e-6-grid law, verbatim storage proof).
  • RT8 licence header — _license === "ODbL-1.0", _attribution contains OpenStreetMap, _notice contains ODbL, _source contains router.project-osrm.org (T-05-06).
  • RT9 size budget — gzipSync(readFileSync(fixturesPath)).length <= corpus.fixtureGzipBudgetBytes (102400); the report PRINTS the measured bytes (T-05-11 early warning).
  • RT10 alternatives floor — pairs.filter(p => p.routes.length >= 2).length >= corpus.alternativesFloorPairs (4) — RTNG-02's real-data requirement.

CLI: prints a per-check table (RT1..RT10 with counts + violation details: pairId + reason — never geometry text), exits 0 ONLY when every check passes; on failure prints the fixed escalation ladder (inspect routing/scripts/fetch-route-fixtures.mjs --only=<pairId> for the failing pair; re-run the fetch; re-run the gate).

routing/test/corpus.test.mjs — RED-first-then-GREEN (the fixtures already exist from task 1, so the suite goes straight to green; a future regression re-reddens it). Import measurePairs from the script + the three JSON inputs via readFileSync/import … with { type: "json" }. One test() per check: RT1…RT10, each asserting violations.length === 0 with a named message. Mirror the gazetteer corpus suite's in-process shape exactly (never spawns the CLI).

Acceptance: npm run routing:corpus exits 0 printing RT1–RT10 green with the measured gz size; npm run routing:test exits 0 including PASS RT1…PASS RT10. cd /home/C-Fu/araharah && npm run routing:corpus 2>&1 | tail -15 ; npm run routing:test 2>&1 | tail -15 <acceptance_criteria> - npm run routing:corpus exits 0 and its output contains RT1 through RT10 and a gz-size line - npm run routing:test exits 0 and the output contains PASS RT1 through PASS RT10 - grep -q 'export function measurePairs' routing/scripts/route-corpus.mjs exits 0 (the shared measurement core) - grep -q 'measurePairs' routing/test/corpus.test.mjs exits 0 (in-process import — the two-reporters law) - node -e "const f=require('./routing/src/data/route-fixtures.json'); const multi=f.pairs.filter(p=>p.routes.length>=2).length; if(multi<4) throw new Error('alternatives floor: '+multi); console.log('RT10 OK:', multi)" exits 0 - node -e "const{gzipSync}=require('node:zlib');const{readFileSync}=require('node:fs');const s=gzipSync(readFileSync('./routing/src/data/route-fixtures.json')).length; if(s>102400) throw new Error('gz '+s); console.log('gz bytes:', s)" exits 0 - grep -c 'fetch(' routing/scripts/route-corpus.mjs | head -1 returns 0 (the measurement core is OFFLINE — T-05-05a) </acceptance_criteria> The committed 15-pair dataset is pinned behind ten offline checks — corpus size, public-origin proof, schema, probe exclusion, geometry↔summary consistency, duration sanity, codec round-trip, licence header, gz size budget, and the alternatives floor — enforced identically by the CLI and the in-suite gate.

1. `npm run routing:corpus` — RT1–RT10 green, measured gz size printed (expect ≈ 15–25 KB, budget 100 KB). 2. `npm run routing:test` — 05-01's C/P/F/L suites PLUS RT1–RT10, all green (the loader-env L3 update: default source now SERVES pair 1 at its pinned origin and returns null 300 m away). 3. `npm run routing:typecheck` — exit 0. 4. The committed dataset: 15 pairs, ODbL header intact, `national-museum` never a destination. 5. Network isolation: `grep -rn "fetch(" routing/scripts/route-corpus.mjs routing/test/corpus.test.mjs` → no matches (the gate never touches the network; only the maintainer fetch script does).

<success_criteria>

  • 15 golden KL pairs committed with real OSRM-demo geometry, pinned origins proven public (RT2b), 1–3 best-first routes each.
  • The offline tolerance gate holds in both reporters (CLI + suite) with zero network access.
  • At least 4 pairs carry ≥ 2 routes (RTNG-02's alternatives floor).
  • The fetch tool is a documented repo-tool with the D-12 boundary in its header. </success_criteria>