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

305 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 05-routing-route-display
plan: 02
type: execute
wave: 2
depends_on: ["01"]
files_modified:
- 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
autonomous: true
requirements: [RTNG-01, RTNG-02]
must_haves:
decisions_implemented:
- "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"
truths:
- "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"
artifacts:
- path: "routing/test/fixtures/golden-pairs.json"
provides: "the hand-authored pinned corpus: 15 pairs (origin landmark + destinationId + coordinates), the KL bbox, the size budget, the pending-probe id"
contains: "\"pairs\""
- path: "routing/scripts/fetch-route-fixtures.mjs"
provides: "the build-time OSRM-demo fetch tool (maintainer machine; D-12 boundary documented in header)"
contains: "router.project-osrm.org"
- path: "routing/scripts/route-corpus.mjs"
provides: "measurePairs() — the corpus measurement core the CLI prints and the suite asserts"
contains: "export function measurePairs"
- path: "routing/src/data/route-fixtures.json"
provides: "the COMMITTED fixture dataset — 15 pairs populated from the OSRM demo (this plan replaces 05-01's empty seed)"
contains: "\"pairId\""
- path: "routing/test/corpus.test.mjs"
provides: "the RT1–RT10 offline tolerance gate suite (in-process, deterministic)"
contains: "RT1"
key_links:
- from: "routing/scripts/fetch-route-fixtures.mjs"
to: "routing/test/fixtures/golden-pairs.json"
via: "the script reads the pinned corpus and fetches each pair from the OSRM demo"
pattern: "golden-pairs.json"
- from: "routing/scripts/fetch-route-fixtures.mjs"
to: "routing/src/data/route-fixtures.json"
via: "collect-then-write: the committed dataset is regenerated atomically per run"
pattern: "route-fixtures.json"
- from: "routing/scripts/route-corpus.mjs"
to: "routing/src/data/route-fixtures.json"
via: "the measurement core validates the committed dataset (never the script output directly)"
pattern: "route-fixtures.json"
- from: "routing/test/corpus.test.mjs"
to: "routing/scripts/route-corpus.mjs"
via: "the suite imports measurePairs and asserts the SAME numbers the CLI prints (gazetteer-corpus.mjs precedent)"
pattern: "measurePairs"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<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>
<tasks>
<task type="auto" tdd="true">
<name>task 1: pinned golden-pairs corpus + fetch script + populated committed dataset</name>
<files>package.json, routing/package.json, routing/test/fixtures/golden-pairs.json, routing/scripts/fetch-route-fixtures.mjs, routing/src/data/route-fixtures.json</files>
<read_first>
- `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)
</read_first>
<action>
**`routing/test/fixtures/golden-pairs.json`** — hand-author the pinned corpus, exactly the `<pinned_corpus>` table:
```json
{
"_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).
</action>
<verify>
<automated>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"</automated>
</verify>
<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>
<done>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).</done>
</task>
<task type="auto" tdd="true">
<name>task 2: the offline corpus tolerance gate (CLI + in-suite Nyquist gate)</name>
<files>routing/scripts/route-corpus.mjs, routing/test/corpus.test.mjs</files>
<read_first>
- `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)
</read_first>
<action>
**`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:
```typescript
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`.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run routing:corpus 2>&1 | tail -15 ; npm run routing:test 2>&1 | tail -15</automated>
</verify>
<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>
<done>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.</done>
</task>
</tasks>
<verification>
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).
</verification>
<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>