chore(planning): phase 8 plans 08-03..05 (ALT potentials, build stage, campaign) + validation re-map; stale 08-02 bullet fixed

This commit is contained in:
araharah-executor
2026-10-25 17:11:26 +08:00
parent f018ab1d06
commit 13c537b60f
11 changed files with 2196 additions and 10 deletions
@@ -0,0 +1,245 @@
---
phase: 08-alt-landmark-preprocessing-routing-backend-v2
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- routing/scripts/measure-optimality-baseline.mjs
- routing/test/fixtures/alt-baseline.json
- routing/test/fixtures/mini-graph-arg1.bin
- routing/scripts/measure-route-cpu.mjs
autonomous: true
requirements: [SC3, SC4]
must_haves:
truths:
- "The pre-ALT optimality baseline (100 coordinate-pinned cross-metro pairs: seconds + verdict class) is committed BEFORE the ARG2 codec lands — reproducible from the record alone, never from the old binary (Pitfall 1)"
- "The 25-route CI CPU gate runs with zero arguments exactly as today — stdout anchor lines `p95: <n> ms (...)` and `GATE (PASS|FAIL): ...` unchanged, so scripts/lib/budget-gates.mjs keeps passing"
- "An opt-in `--extended` flag measures ~100 seeded coordinate-pinned cross-metro pairs and prints its own p95 + gate verdict (D-04)"
- "The current committed ARG1 fixture bytes are preserved verbatim as `routing/test/fixtures/mini-graph-arg1.bin` — the future decode-rejection vector (Plan 02 consumes it)"
artifacts:
- path: "routing/scripts/measure-optimality-baseline.mjs"
provides: "exported pair-generation + measurement core; CLI records the baseline"
- path: "routing/test/fixtures/alt-baseline.json"
provides: "committed pre-ALT baseline record — the D-03 comparison anchor"
- path: "routing/test/fixtures/mini-graph-arg1.bin"
provides: "ARG1 bytes preserved (byte-identical to the pre-phase mini-graph.bin)"
- path: "routing/scripts/measure-route-cpu.mjs"
provides: "--extended opt-in flag; default mode byte-compatible"
key_links:
- from: "routing/scripts/measure-optimality-baseline.mjs"
to: "routing/data/kl-roadgraph.v1.bin"
via: "readFileSync → toArrayBuffer → decodeGraph → snapToGraph → routeAstar"
pattern: "decodeGraph"
- from: "routing/test/fixtures/alt-baseline.json"
to: "pairs pinned by origin/destination lat/lng"
via: "record schema — never node indices (Pitfall 2)"
pattern: "\"origin\"\\s*:\\s*\\{\\s*\"lat\""
- from: "routing/scripts/measure-route-cpu.mjs (--extended)"
to: "routing/scripts/measure-optimality-baseline.mjs"
via: "import { generateCrossMetroPairs } — ONE pair-set definition, two consumers"
pattern: "generateCrossMetroPairs"
---
<objective>
Wave-0 measurement instrumentation: record the pre-ALT optimality baseline FIRST (research Pitfall 1 — once the ARG2 codec lands, old-code+old-binary is unreproducible), preserve the ARG1 fixture bytes as the rejection vector, and add the D-04 extended-sample flag to the CPU gate with its default CLI contract untouched.
Purpose: every later plan (codec, ALT, campaign) compares against THIS committed record; measuring it after the codec change is impossible.
Output: `routing/scripts/measure-optimality-baseline.mjs`, committed `routing/test/fixtures/alt-baseline.json`, `routing/test/fixtures/mini-graph-arg1.bin`, `measure-route-cpu.mjs --extended`.
</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>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-VALIDATION.md
<interfaces>
<!-- Existing mechanics this plan extends. Extracted 2026-10-25. -->
From routing/scripts/measure-route-cpu.mjs (lines 50–64, 136–185):
```js
const GRAPH_PATH = resolve(here, "..", "data", "kl-roadgraph.v1.bin");
const P95_BUDGET_MS = 8; // 20% margin under the 10 ms ceiling
const ROUTE_COUNT = 25;
const SAMPLE_SEED = 0x06d4;
// prints per-pair ` ${label}: ${ms.toFixed(3)} ms (${km})` then
// `p95: ${p95.toFixed(3)} ms (max ..., mean ..., n=...)` then `GATE PASS|FAIL: ...`
```
From scripts/lib/budget-gates.mjs runCpuLadderGate (lines 78–119) — the CLI contract that MUST keep matching:
```js
out = execFileSync(process.execPath, [script], { ... }); // script run with ZERO args
const p95 = Number(out.match(/^p95: (\d+(?:\.\d+)?) ms/m)?.[1] ?? NaN);
const childGateLine = out.match(/^GATE (?:FAIL|PASS): .*$/m)?.[0] ?? null;
```
(The audit suite `node scripts/test/budget-audit.test.mjs` — 19/19 vectors — also regex-checks measure-route-cpu.mjs source for the `measure-route-cpu` and `P95_BUDGET_MS = 8` tokens. Keep both names/constant verbatim.)
From routing/src (the pure surfaces the recorder reuses):
```ts
export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null; // graph.ts — still ARG1 (v1) during this plan
export function snapToGraph(...); // snap.ts (mjs-wrapper in scripts)
export function routeAstar(graph, originNode, goalNode); // astar.ts — returns engineered seconds + nodes or null
```
graph.bbox: `{ minLat, minLng, maxLat, maxLng }` — the order-INDEPENDENT pair-generation source (Pitfall 2).
</interfaces>
</context>
<tasks>
<task type="auto">
<name>task 1: Preserve ARG1 fixture bytes as the rejection vector</name>
<files>routing/test/fixtures/mini-graph-arg1.bin</files>
<read_first>
- routing/test/fixtures/mini-graph.bin (the committed ARG1 bytes being preserved)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md (D-01)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md ("build-mini-graph.mjs" section — research requirement that old committed ARG1 bytes are preserved BEFORE the fixture regenerates)
</read_first>
<action>
Copy the CURRENT committed `routing/test/fixtures/mini-graph.bin` byte-for-byte to a NEW file `routing/test/fixtures/mini-graph-arg1.bin`. Do NOT touch `mini-graph.bin` itself (Plan 02 task 3 regenerates it as ARG2). Add a header comment to `routing/test/fixtures/build-mini-graph.mjs`'s docstring (one line only, no behavior change): that `mini-graph-arg1.bin` holds the frozen ARG1-era bytes preserved by Phase 8 plan 08-01 expressly as the v1 decode-rejection vector consumed by the G-suite, and must not be regenerated.
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && cmp routing/test/fixtures/mini-graph.bin routing/test/fixtures/mini-graph-arg1.bin && echo "ARG1-VECTOR-IDENTICAL"</automated>
</verify>
<acceptance_criteria>
- `routing/test/fixtures/mini-graph-arg1.bin` exists, is committed to git, and `cmp` vs `mini-graph.bin` reports byte-identical (both still ARG1 at this point in history).
- `mini-graph.bin` itself is byte-unchanged at this commit (`git diff --stat routing/test/fixtures/mini-graph.bin` empty).
- `build-mini-graph.mjs` carries the one-line provenance comment mentioning `mini-graph-arg1.bin`.
</acceptance_criteria>
<done>ARG1 bytes frozen under a new committed name, provenance documented, original fixture untouched.</done>
</task>
<task type="auto">
<name>task 2: Baseline recorder + the committed pre-ALT record (measure FIRST)</name>
<files>routing/scripts/measure-optimality-baseline.mjs, routing/test/fixtures/alt-baseline.json</files>
<read_first>
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md (Pitfall 1 — baseline cannot be re-measured after ARG2 lands; Pitfall 2 — pin by COORDINATES, never node indices; A8 — f64 JSON round-trip of seconds is lossless)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md (route-corpus.mjs exported-core/CLI-guard pattern; measure-route-cpu.mjs run mechanics)
- routing/scripts/route-corpus.mjs lines 60–92 + 320–334 (exported measurement core + `const invoked = process.argv[1] ? pathToFileURL(...)` CLI guard to copy)
- routing/scripts/measure-route-cpu.mjs lines 50–135 (GRAPH_PATH, toArrayBuffer, decodeGraph-null fail, mulberry32)
- routing/scripts/build-road-graph.mjs lines 130–133 (`fail()` print + exit 1 shape)
</read_first>
<action>
Create `routing/scripts/measure-optimality-baseline.mjs` (#!/usr/bin/env node, maintainer-time, NOTE in header: "run BEFORE the ARG2 codec lands — D-03's comparison anchor; Pitfall 1").
Architecture (copy route-corpus.mjs): exported core + CLI guard (`if (invoked === import.meta.url) main();`), named constants at top with doc-comments, `fail(message)` → console.error + exit 1, collect-then-write (record written ONLY after all pairs measured).
Constants (named, no magic):
```js
const GRAPH_PATH = resolve(here, "..", "data", "kl-roadgraph.v1.bin");
const RECORD_PATH = resolve(here, "..", "test", "fixtures", "alt-baseline.json");
const BASELINE_PAIR_COUNT = 100; // D-04: ~100 cross-metro worst-case pairs
const BASELINE_SEED = 0x08a1; // deterministic; the SAME seed --extended reuses
const MIN_PAIR_KM = 12; // straight-line endpoint floor → cross-metro class
```
Exported core:
`export function generateCrossMetroPairs(bbox, { count, minPairKm, seed })` — mulberry32(seed) PRNG (copy the exact mulberry32 from measure-route-cpu.mjs lines 100–108); per candidate pair: draw two independent uniform points inside bbox (`lat = minLat + rand()*(maxLat-minLat)` etc. via the 1e-6-safe pattern); REJECT candidates whose haversine endpoint distance < minPairKm; label `x${String(i).padStart(3,"0")}`; emit `{ pairId, origin:{lat,lng} (rounded to 1e-6 integers /1e6 exact-decimal rule), destination:{lat,lng}, fromHintKm: straight-line km }`. Deterministic: same bbox+seed ⇒ same pairs always.
`export function measureBaselinePairs(graph, pairs, { snapToGraph, routeAstar, metersToKm })` — for each pair: snap both endpoints (honest snap-miss ⇒ verdict `"unsnappable"`, recorded as `seconds: null` — a real measurement, never skipped silently); route with the CURRENT routeAstar (still haversine-potential at record time); record `seconds` as the JSON number routeAstar returns (f64 accumulation, A8-safe) or `null` for honest null; verdict = `"reachable"` if a route object returned, else `"unreachable"`. Return per-pair rows.
`main()`: existsSync(GRAPH_PATH) guard (absent graph ⇒ fail-honest, print the build hint); decodeGraph (null ⇒ fail, "rebuild the binary"); generate + measure; print per-pair one line (` xNNN: <sec>s reachable|unreachable|unsnappable`) and a summary (routed count, mean/max seconds); write RECORD only after all rows:
```json
{
"recordedAt": "<ISO-8601>",
"tool": "routing/scripts/measure-optimality-baseline.mjs",
"purpose": "pre-ALT baseline for the D-03 optimality gate (Phase 8); measured BEFORE the ARG2 codec landed (Pitfall 1)",
"epsilon": { "name": "OPTIMALITY_EPSILON_PCT", "pct": 0.1 },
"graph": { "path": "routing/data/kl-roadgraph.v1.bin", "byteLength": <n>, "nodeCount": <n>, "edgeCount": <n>, "argVersion": 1 },
"pairRules": { "seed": "0x08a1", "count": 100, "minPairKm": 12 },
"pairs": [ { "pairId": "x000", "origin": {"lat": 3.12, "lng": 101.6}, "destination": {"lat": 3.19, "lng": 101.73}, "straightLineKm": 0.0, "seconds": 0.0, "verdict": "reachable" } ]
}
```
The `epsilon` block is DOCUMENTARY here; the gate script (Plan 08-05) asserts its own constant matches this record's value. Refuse to overwrite an existing record ("baseline already recorded — the record is immutable; delete by explicit git revert only"), fail-honest.
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && test -s routing/test/fixtures/alt-baseline.json && node -e "const r=require('./routing/test/fixtures/alt-baseline.json'); console.log('pairs:', r.pairs.length, 'verdicts:', JSON.stringify([...new Set(r.pairs.map(p=>p.verdict))]), 'epsilon:', r.epsilon.pct)"</automated>
</verify>
<acceptance_criteria>
- `routing/test/fixtures/alt-baseline.json` committed; `pairs.length === 100`; every pair has BOTH endpoints as `{lat, lng}` objects (grep: `"lat"` present in the origin/destination fields; zero `nodeIndex` keys anywhere).
- `epsilon.pct === 0.1` and `graph.argVersion === 1` in the record.
- The recorder re-run refuses to overwrite (exit 1 with the immutable-record message).
- The CONTINGENCY GUARD: if `routing/data/kl-roadgraph.v1.bin` is absent OR decodes null (already ARG2 — should not happen in Wave 1), the script exits 1 with the honest build/rebuild message and NO record is written or mutated.
- Summary printed to stdout includes the routed-pair count (print-the-measurement law).
</acceptance_criteria>
<done>The committed record pins 100 coordinate-identified pairs with pre-ALT seconds + verdict classes; reproducible without the old binary.</done>
</task>
<task type="auto">
<name>task 3: --extended flag on the CPU gate (default mode byte-compatible)</name>
<files>routing/scripts/measure-route-cpu.mjs</files>
<read_first>
- routing/scripts/measure-route-cpu.mjs IN FULL (185 lines — you are editing it; preserve every existing print shape)
- scripts/lib/budget-gates.mjs lines 78–119 (the zero-arg + regex contract being preserved)
- scripts/test/budget-audit.test.mjs lines 82–130 (source-token checks: `measure-route-cpu`, `P95_BUDGET_MS = 8`)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md (D-04; the "constraint to re-verify" — budget-audit compatibility)
</read_first>
<action>
Add an opt-in flag, NOT a rewrite. Named constants joined at the top (doc-comment style matching the file):
```js
/** D-04 extended verdict: ~100 cross-metro coordinate-pinned pairs (maintainer-time). */
const EXTENDED_PAIR_COUNT = 100;
/** Shared pair-set seed — identical to the baseline recorder's (same pairs, two reporters). */
const EXTENDED_SEED = 0x08a1;
/** Cross-metro floor: straight-line endpoint distance below this is NOT a worst-case pair. */
const MIN_PAIR_KM = 12;
```
CLI dispatch at the very top of main-flow: `const extended = process.argv.includes("--extended")`. If extended: import { generateCrossMetroPairs } from "./measure-optimality-baseline.mjs"; build pairs from the decoded graph's bbox + EXTENDED_SEED/MIN_PAIR_KM/EXTENDED_PAIR_COUNT; snap + route with the EXISTING per-pair cpuMs harness — reuse the existing per-pair print line shape (` ${pairId}: ${ms} ms (${km})`) and the existing p95 statistics block VERBATIM (sort, ceil(0.95*n)-1 index, `p95: ... ms (max ...)` line) then an additional final line `GATE PASS:|GATE FAIL: p95 ${p95} ms <=|exceeds ${P95_BUDGET_MS} ms budget — D-04 extended verdict`. Unreachable/snap-miss probes reported separately exactly like the default mode does (count + mean of measured ms, never in the p95 sample). Exit codes: same 1-on-FAIL / 0-on-PASS honest law.
DEFAULT MODE (no flag): behaviour, stdout, exit codes byte-identical to today. Do not rename ROUTE_COUNT/SAMPLE_SEED/P95_BUDGET_MS; do not reorder prints; do not add any new output line to the default path. The only default-mode-visible change allowed: a comment block update in the header noting the D-04 extended flag exists (keeps the GPL line-citation in scripts/lib/source-gates.mjs from drifting semantically — keep lines 3–9's content intact; if adding header lines shifts them, VERIFY `node scripts/test/budget-audit.test.mjs` and `scripts/test/privacy-audit.test.mjs` still pass and adjust nothing else).
Pre-ALT, the --extended run EXPECTS an honest GATE FAIL (that is the c3 evidence); a FAIL exit is a valid measurement in Wave 1 — never paper over it.
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && node routing/scripts/measure-route-cpu.mjs --extended 2>&1 | grep -E "^p95: .* ms|^ x[0-9]{3}:|GATE (PASS|FAIL):" | head -6; node scripts/test/budget-audit.test.mjs 2>&1 | tail -1</automated>
</verify>
<acceptance_criteria>
- `node routing/scripts/measure-route-cpu.mjs --extended` prints ≥ 90 ` xNNN: <ms> ms` per-pair lines (snap-miss/unreachable pairs excepted, reported separately), a `p95: <n> ms (...)` line, and a `GATE (PASS|FAIL):` line mentioning "D-04 extended verdict" — exit 1 with GATE FAIL is an acceptable pre-ALT measurement.
- `node routing/scripts/measure-route-cpu.mjs` (no args) still prints the quadrant-sample `p95:` + `GATE` lines with unchanged regex shape (`^p95: (\d+(?:\.\d+)?) ms`).
- `node scripts/test/budget-audit.test.mjs` → final line `BUDGET-AUDIT SUITE: 19/19 vectors ok`.
- grep gate: `grep -c "generateCrossMetroPairs" routing/scripts/measure-route-cpu.mjs` ≥ 1 AND the extended path lives behind `--extended` (grep `-c '"--extended"\|--extended' =≥ 1`).
</acceptance_criteria>
<done>D-04's extended verdict exists behind an opt-in flag; the audited zero-arg ladder contract is untouched and its suite is green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| maintainer CLI → repo artifact | the recorder writes a committed artifact; garbage-in would poison the D-03 gate |
| graph binary → inpool measurement | the ARG1 binary is trusted-local today, but the recorder must fail-honest on absent/corrupt bytes |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-01 | Tampering/DoS | measure-optimality-baseline.mjs | mitigate | absent/corrupt/undecodable graph → `fail()` exit 1 before any measurement; decode null is never retried or papered over |
| T-08-02 | Tampering | routing/test/fixtures/alt-baseline.json | mitigate | record is immutable by construction (refuse-overwrite exit 1); committed — any tamper is visible in git history; the gate mirrors the epsilon constant so a record edit alone cannot fake equality |
| T-08-03 | Information disclosure | baseline/extended print lines | accept | prints carry route seconds + pair ids (`x000`) and km only — no coordinates beyond the committed record's public KL bbox endpoints, consistent with the category-only log law |
| T-08-04 | DoS | --extended runtime | accept | 100 routes × ~40 ms worst-case ≈ seconds, maintainer-time only; bounded by count/pairRules constants |
</threat_model>
<verification>
- `npm run routing:test` still green (no routing/src changes in this plan, but the ARG1 fixture addition must not break existing suites).
- `node scripts/test/budget-audit.test.mjs` → `BUDGET-AUDIT SUITE: 19/19 vectors ok`.
- The committed record exists with 100 coordinate-pinned pairs; `cmp` proves the ARG1 vector byte-identical to `mini-graph.bin` at this commit.
</verification>
<success_criteria>
The pre-ALT baseline is a committed, coordinate-pinned, epsilon-documented JSON artifact produced by a re-runnable tool, measured while old-code+old-binary is still in the tree (Pitfall 1 closed); the D-04 extended verdict exists behind `--extended`; budget-audit's CLI contract is proven intact by its own suite.
</success_criteria>
<output>
After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-01-SUMMARY.md`
</output>
@@ -0,0 +1,245 @@
---
phase: 08-alt-landmark-preprocessing-routing-backend-v2
plan: 02
type: execute
wave: 2
depends_on: ["08-01"]
files_modified:
- routing/src/landmark.ts
- routing/src/graph.ts
- routing/test/graph.test.mjs
- routing/test/fixtures/build-mini-graph.mjs
- routing/test/fixtures/mini-graph.bin
- bot/test/webhook.test.mjs
- bot/test/guidance-cron.test.mjs
autonomous: true
requirements: [SC3, SC4]
must_haves:
truths:
- "decodeGraph accepts EXACTLY an ARG2 graph (magic 0x41524732, version 2, landmarkCount ≥ 1, exact byte size) and returns zero-copy typed-array views including both u16 landmark-table families; everything else decodes to null (D-01 fail-honest — ARG1 fixture bytes, corrupt magic/version, landmarkCount=0, hostile oversized count)"
- "encodeGraph remains the ONE authority: GraphInput gains a required landmarks block, loud-fails on invalid input, and the ARG2 layout is its exact inverse (round-trip law)"
- "The exact-size check runs BEFORE any view allocation, extended arithmetically over the variable-size landmark suffix (T-06-03b law, T-08-01)"
- "The committed mini-graph fixture is regenerated as ARG2 through the same encode path; its former ARG1 bytes (mini-graph-arg1.bin from Plan 08-01) decode to null"
- "Landmark anchor selection + table Dijkstras exist as one pure, typed-array module (landmark.ts) shared by the fixture builder and the build script — no duplicated integer-Dijkstra implementations"
artifacts:
- path: "routing/src/landmark.ts"
provides: "farthestPointAnchors + landmarkTables — quantized ĉ-metric integer Dijkstra, 0xFFFF sentinels, ≤65,000 quantum assert"
- path: "routing/src/graph.ts"
provides: "ARG2 codec — 72-byte header, u16 suffix, version-bumped decode laws"
- path: "routing/test/graph.test.mjs"
provides: "G-suite extended: ARG2 roundtrip + tables, rejection matrix, G7/G8 re-pins"
- path: "routing/test/fixtures/mini-graph.bin"
provides: "committed ARG2 fixture with a tiny landmark set"
key_links:
- from: "routing/src/graph.ts decodeGraph"
to: "buffer.byteLength !== layoutFor(...).total"
via: "exact-size law extended to the suffix BEFORE view allocation"
pattern: "buffer\\.byteLength !== lay\\.total"
- from: "routing/test/graph.test.mjs"
to: "routing/test/fixtures/mini-graph-arg1.bin"
via: "committed ARG1 bytes decode to null (the G3-merged rejection case)"
pattern: "mini-graph-arg1"
- from: "routing/src/graph.ts RoadGraph"
to: "landmarkCount/landmarkQuantumS/landmarkTo/landmarkFrom/landmarkNodes"
via: "new typed-array fields consumed by Plan 08-03's ALT potentials"
pattern: "landmarkTo\\s*:\\s*Uint16Array"
---
<objective>
The ARG2 codec: version the pinned CSR layout per D-01 (append-only suffix, one decode path, one search path — the old v1 decode is removed, not hidden), with the landmark-table compute core as a single pure module and the committed fixture regenerated through the same encode authority.
Purpose: the binary this plan defines is what the build (08-04), the ALT potentials (08-03), and the real-graph campaign (08-05) all consume.
Output: `routing/src/landmark.ts`, ARG2 `routing/src/graph.ts`, extended G-suite, regenerated `mini-graph.bin` (+ frozen `mini-graph-arg1.bin` as the rejection vector).
</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>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md
<interfaces>
<!-- The ARG1 codec being versioned. Extracted from routing/src/graph.ts 2026-10-25 (299 lines — read it in full first). -->
Current header (64 B): magic u32 @0 = 0x41524731; version u32 @4 = 1; nodeCount @8; edgeCount @12; nameCount @16; namePoolLen @20; bbox 4×f64 @24..55; maxSpeedKmh f32 @56; pad @60..63. NOTE: the RESEARCH doc's "@52 maxSpeedKmh" is a doc drift — the CODE truth is @56 (view.getFloat32(56, true)). ARG2 places its new fields where the pad was.
Sections (relative order unchanged): lat f64[N], lng f64[N], edgeStart u32[N+1], edgeTarget u32[E], edgeFlags u8[E], edgeSeconds f32[E] (4-byte aligned start), edgeNameId u32[E], nameOffset u32[names+1], namePool u8[pool].
Existing exports/consts: `GRAPH_MAGIC = 0x41524731`, `GRAPH_VERSION = 1`, `UNNAMED_NAME_ID = 0xffffffff`, `decodeGraph(buffer): RoadGraph | null`, `encodeGraph(input: GraphInput): ArrayBuffer` (throws via `fail()` on malformed input), private `layoutFor(...): Layout { lat, lng, ..., total }`.
Decode laws carried forward: `buffer.byteLength < HEADER_SIZE → null`; magic/version mismatch → null; `buffer.byteLength !== lay.total → null` BEFORE any view allocation.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>task 1: landmark.ts — the pure landmark compute core (RED→GREEN)</name>
<files>routing/src/landmark.ts, routing/test/landmark.test.mjs</files>
<read_first>
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md (Pattern 2 — the ĉ-metric surrogate; the closed math: floor-quantize EDGE WEIGHTS once, exact integer Dijkstra, exact admissibility + Lipschitz consistency; u16/quantum range law — assert ≤65,000 quanta, sentinel 0xFFFF)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md (astar.ts A1 test-local Dijkstra shape — the traversal law: edgeFlags bit0 never relaxed)
- routing/test/astar.test.mjs lines 69–91 (the proven brute-force Dijkstra to mirror)
- routing/src/graph.ts lines 51–105 (RoadGraph/GraphEdgeInput field shapes this module consumes)
</read_first>
<behavior>
- farthestPointAnchors on a known tiny graph returns L deterministic, degree-≥2-filtered, well-spread anchors; identical bytes of input ⇒ identical anchors (no Math.random anywhere)
- landmarkTables with quantum 0.1: a two-edge path 3.05 s + 3.05 s … floor(30500/1000s units) — verify: edge seconds 2.55 → ĉ=25 quanta; table distance over 2 edges = 50 quanta exactly (integer arithmetic, no float drift)
- unreachable node pairs → 0xFFFF sentinel in the row; the landmark's own cell = 0
- any distance label > 65,000 quanta → module throws (build-time loud; message names the offending distance and the fix: raise landmarkQuantumS)
- reversed vs forward rows differ on an oneway graph (asymmetry is representable)
</behavior>
<action>
Create `routing/src/landmark.ts` — pure typed-array module (no I/O, no logging, no storage; zero deps). Exports:
```ts
export const LANDMARK_QUANTUM_S = 0.1; // the ĉ unit (header field default; doc'd fork knob)
export const LANDMARK_MAX_QUANTA = 65000; // u16 headroom below the 0xFFFF sentinel
export interface LandmarkInput {
nodeCount: number;
edges: Array<{ from: number; to: number; seconds: number; flags: number }>; // flags bit0 = oneway-reverse: relaxed by NO direction
quantumS: number; // named-callers pass LANDMARK_QUANTUM_S
}
export const LANDMARK_SENTINEL = 0xffff;
/** Deterministic farthest-point selection over the ĉ metric. */
export function farthestPointAnchors(input: LandmarkInput, count: number, opts?: { coverageFraction?: number }): Uint32Array;
/** Integer ĉ-metric Dijkstras: returns { to: Uint16Array (L·N), from: Uint16Array (L·N) } (row-major: [ℓ*N + v]). */
export function landmarkTables(input: LandmarkInput, anchors: Uint32Array): { to: Uint16Array; from: Uint16Array };
/** Per-anchor coverage audit (fraction of nodes with non-sentinel to-entry), for the build's assert. */
export function landmarkCoverage(to: Uint16Array, anchors: Uint32Array, nodeCount: number): number[];
```
Mechanics (concrete): build forward + reversed CSR internally (nodeCount+1 row pointers; reversed skips nothing — flags semantics: bit0 edges are traversed by the REVERSED direction only in the reverse set... mirror exactly the A1 test-local Dijkstra law: an edge u→v with flags bit0 CLEAR is relaxed in the forward pass FROM v in the reversed graph, and NEVER in the unreversed mirror — the keyword: oneway-reverse edges are traversable only backwards). ĉ(u,v) = `Math.floor(seconds / quantumS)` (floor keeps the surrogate ≤ the live edge). Integer Dijkstra with a binary heap over Int32 quanta labels; unvisited-finite-only relaxation; 0xFFFF frozen as unreachable. Coverage via a per-anchor reachable-count / nodeCount. Throwing path: any final label or intermediate > LANDMARK_MAX_QUANTA → `throw new Error("landmarkTables: quantized distance <d> exceeds <MAX> (raise landmarkQuantumS)”)`. Determinism: anchor greed starts at the first degree-≥2 candidate by node id; ties broken by lower node id; the greedy loop reuses each picked anchor's forward pass to maintain the per-node min-distance field (≈2L total passes, not 3L).
RED: write routing/test/landmark.test.mjs covering the <behavior> cases (hand-computed expectations on a 5-node path graph + the 5×5-grid mini graph shape from the fixture builder) BEFORE implementing; run RED; then implement; then GREEN + `npm run routing:typecheck`.
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && npm run routing:test 2>&1 | tail -3 && npm run routing:typecheck && echo "LANDMARK-CORE-GREEN"</automated>
</verify>
<acceptance_criteria>
- `routing/test/landmark.test.mjs` exists, committed, all cases passing (runner reports 0 failed).
- `grep -c "Math.random" routing/src/landmark.ts` === 0 (determinism law).
- `grep -c "0xffff" routing/src/landmark.ts` ≥ 2 (sentinel used, not invented per-callsite).
- `grep -c "Math.floor" routing/src/landmark.ts` ≥ 1 (the ĉ floor rule, exact placement documented in Pattern 2).
- typecheck clean; no imports outside node builtins/types in the module.
</acceptance_criteria>
<done>Deterministic anchors + exact integer ĉ tables + sentinel/overflow laws proven on hand-computed vectors.</done>
</task>
<task type="auto" tdd="true">
<name>task 2: ARG2 codec in graph.ts (RED→GREEN)</name>
<files>routing/src/graph.ts, routing/test/graph.test.mjs</files>
<read_first>
- routing/src/graph.ts IN FULL (299 lines — you are versioning it in place)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md ("ARG2 Binary Layout" section — but note the doc's maxSpeedKmh@52 is DRIFT: code truth is @56; use the code truth)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md (graph.ts section — layoutFor/fail/decode/encode patterns to extend mechanically)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md (D-01 — one decode path, one search path, no v1 fallback branch)
- routing/test/graph.test.mjs IN FULL (191 lines — G1–G8 cases and tatper-vector mechanics you are extending; G3 merges into the ARG1-fixture rejection, G8 re-pins)
</read_first>
<behavior>
- G2 (extended): wrong magic (0xdeadbeef) on a valid ARG2 buffer → null, never a throw
- G3 (re-pinned, merged with the version law): version field = 1 (Use the committed mini-graph-arg1.bin bytes) → null; version = 3 → null
- NEW: landmarkCount = 0 with otherwise-valid header+body → null (A5 resolution, documented choice: no silent haversine-fallback layout can exist under D-01's "no v1 fallback branch" law)
- NEW: monster header (landmarkCount = 2,000,000) on a tiny buffer → null by arithmetic BEFORE any Uint16Array allocation (exact-size law covers the suffix: total = … + 2·L·N·2 + 4·L (+pad-to-4))
- NEW: exact round-trip — encodeGraph(synthetic-with-landmarks) → decodeGraph → all 4 table properties: landmarkCount === L, tables row-major length L·N, landmarkNodes[i] matches input, landmarkQuantumS === input quantum
- NEW: encodeGraph rejects invalid landmark input via fail(): landmarkCount 0 / row length ≠ L·N / node id ≥ N / non-finite or non-positive quantum → throw named `encodeGraph:` errors (no silent clamp)
- G7 (re-pinned): the regenerated committed ARG2 fixture decodes; counts + bbox + oneway flag + speed classes intact AND landmarkCount ≥ 1
- G8 (re-pinned): GRAPH_MAGIC / GRAPH_VERSION exported constants are 0x41524732 / 2
</behavior>
<action>
Version `routing/src/graph.ts` in place (no second codec, anti-pattern "Second encodeGraph"):
1. `GRAPH_MAGIC = 0x41524732` ("ARG2"); `GRAPH_VERSION = 2`; `HEADER_SIZE = 72` (comment: 72 % 8 == 0 keeps f64 views 8-byte aligned).
2. Layout: after the ARG1 field order — landmarkCount u32 @60 (the old pad), landmarkQuantumS f32 @64, reserved u32 @68 (written 0, asserted-decode-0 OPTIONAL — recommend accepting any value there, the field is unused). Sections start @72 (ARG1 relative structure preserved, each offset shifted +8).
3. Suffix in layoutFor: `const toTable = align4(namePool + namePoolLen); const fromTable = toTable + 2*L*N; const landmarkNodes = fromTable + 2*L*N; const total = landmarkNodes + 4*L;` (u16 × dual families × L×N bytes + u32 anchors; alignment pad before toTable via `& ~3`; decode/encode use identical arithmetic — single `layoutFor` gains `landmarkCount` + `quantum` params or reads stored fields).
4. `RoadGraph` gains fields: `landmarkCount: number; landmarkQuantumS: number; landmarkTo: Uint16Array; landmarkFrom: Uint16Array; landmarkNodes: Uint32Array;`
5. `decodeGraph`: read L @60 and quantum @64; `if (L === 0) return null` (comment cites D-01/A5); extend the exact-size check to the suffix BEFORE allocation; allocate u16/u32 zero-copy views; magic/version laws unchanged in shape (v1 bytes now fail the version check — THE D-01 one-path law; comment cites the router-pending coverage).
6. `GraphInput` gains REQUIRED `landmarks: { quantumS: number; nodes: ArrayLike<number>; to: Uint16Array; from: Uint16Array }` — encode loud-validates lengths (L = nodes.length; to/from lengths === L·N; every node id < nodeCount; quantumS finite > 0; fail() otherwise). Bulk writes: follow the file's existing section-write pattern for the u16/u32 views so decode's zero-copy views read back exactly (endianness discipline identical to the existing f32/f64 sections).
7. RED: extend routing/test/graph.test.mjs first (new cases appended with the doc-index comment updated; keep the case-harness `loadGraph()` lazy-import pattern), run RED, then implement GREEN.
Doc-comment update at the module head: ARG2 layout table (72-byte header field offsets, suffix order), the D-01 fail-honest law, the sentinel (0xFFFF is DATA — never validated away).
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && npm run routing:test 2>&1 | tail -3 && npm run routing:typecheck && echo "ARG2-CODEC-GREEN"</automated>
</verify>
<acceptance_criteria>
- `npm run routing:test` green INCLUDING the new G-cases (runner output 0 failed); the G-case doc-index header lists them.
- tamper-vector grep: `grep -c "setUint32(4" routing/test/graph.test.mjs` ≥ 1 (version tamper present) and the landmarkCount=0 + oversized-L cases exist (grep `-c "landmarkCount"` ≥ 4).
- `grep -n "0x41524731" routing/src/graph.ts` returns ZERO code hits (may appear only in a comment naming the retired magic); `0x41524732` is THE constant.
- Header-72 arithmetic: grep `HEADER_SIZE = 72` ≥ 1; `grep -n "getFloat32(56" routing/src/graph.ts` still exactly 1 (the untouched ARG1 field).
- typecheck clean; RoadGraph's five new fields grep-findable.
</acceptance_criteria>
<done>One codec, one version (2): ARG2 encodes/decodes with tables; every non-ARG2 input class decodes null before allocation; G7/G8 re-pinned.</done>
</task>
<task type="auto">
<name>task 3: Regenerate the fixture as ARG2; seal the G-suite + unaffected suites</name>
<files>routing/test/fixtures/build-mini-graph.mjs, routing/test/fixtures/mini-graph.bin</files>
<read_first>
- routing/test/fixtures/build-mini-graph.mjs IN FULL (85 lines)
- routing/src/landmark.ts exports (task 1 of this plan)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md (build-mini-graph section — tiny L stage, fail-loud invariants, old-bytes-kept note)
</read_first>
<action>
Extend `build-mini-graph.mjs`: after the 80-edge assembly and BEFORE encodeGraph, add the landmark stage — `LANDMARK_QUANTUM_S` from `../../src/landmark.ts`; `const anchors = farthestPointAnchors({ nodeCount: GRID*GRID, edges, quantumS: LANDMARK_QUANTUM_S }, 2)` (tiny L = 2, deterministic); `const { to, from } = landmarkTables(...)`; hand-assert invariants fail-loud before write (style of the existing `if (edges.length !== 80) throw` — e.g. to.length === 2*25 and to[anchor0*N + anchor0] === 0). Pass `landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from }` into the SAME single encodeGraph call; write `mini-graph.bin` (unchanged path/writeshape). Print the landmark stats in the script's summary line (L=2, quantum, coverage).
Then update the ONLY remaining encodeGraph v1-shaped runtime call sites so the FULL battery is green in THIS plan (the required-landmarks law makes them fail loudly otherwise): `bot/test/webhook.test.mjs` (both corridor builders, encodeGraph calls at lines ~499 and ~509) and `bot/test/guidance-cron.test.mjs` (~252) — each computes tiny tables via `farthestPointAnchors` + `landmarkTables` from `@araharah/routing`'s `../../src/landmark.ts` and passes the `landmarks` block. (Call-site updating, NOT a second encode function — anti-pattern respected.)
Then re-run the full verification battery: the committed fixture is now ARG2 — every existing test that consumes `mini-graph.bin` must stay green (G7 re-pin, snap suite, A-suite via astar.test's fixture load, loader-env). Verify `mini-graph-arg1.bin` still decodes to null via the new G3 case (task 2 wired it).
NOTE: routeAstar still runs haversine potentials here (ALT lands in Plan 08-03) — that coexistence is deliberate; A-suite greenness at this commit proves the codec change alone is behavior-neutral to the search.
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && node routing/test/fixtures/build-mini-graph.mjs && npm run routing:test 2>&1 | tail -3 && npm test -w bot 2>&1 | tail -2 && npm run routing:typecheck && npm run bot:typecheck && echo "FULL-BATTERY-GREEN"</automated>
</verify>
<acceptance_criteria>
- `node routing/test/fixtures/build-mini-graph.mjs` exits 0 and prints the landmark stats; `mini-graph.bin` regenerated (git diff shows content change, same path).
- `git status` shows `mini-graph-arg1.bin` unchanged bytes from Plan 08-01 (cmp vs its committed version passes — it is NOT regenerated).
- `npm run routing:test` AND `npm test -w bot` AND both typechecks fully green at this commit (the corridor/cron encodeGraph call sites now pass the landmarks block).
- grep law: `grep -Lc "landmarms\|landmarks" bot/test/webhook.test.mjs bot/test/guidance-cron.test.mjs` shows both files contain `landmarks:` (grep `-c "landmarks: {"` ≥ 1 in each).
</acceptance_criteria>
<done>Committed fixture is ARG2 through the one encode authority with a tiny landmark set; the frozen ARG1 vector stays byte-identical; the full routing + bot battery is green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| R2 binary → decodeGraph | the graph binary is HOSTILE INPUT at the Worker boundary (V5); the codec is the gate |
| build input → encodeGraph | maintainer-time JSON/graph input; loud failure is the correct posture (T-06-03 shape) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-01 | Tampering/DoS | decodeGraph | mitigate | exact-size arithmetic extended over the u16 suffix BEFORE any view allocation — a monster `landmarkCount` header on a tiny buffer rejects on arithmetic (grep-able test proves it); landmarkCount=0 rejected (no silent-degradation layout) |
| T-08-02 | Tampering | decodeGraph version law | mitigate | ARG1 bytes → null → router-pending (D-01; one decode path, no v1 branch); committed mini-graph-arg1.bin is the standing rejection vector |
| T-08-03 | Information disclosure | codec module | accept | pure module law preserved (no logging/storage/network); T-06-05 unchanged |
| T-08-04 | DoS | encodeGraph | mitigate | resource-exhaustion malformed input fails loudly with named `encodeGraph:` errors before any allocation work |
</threat_model>
<verification>
- `npm run routing:test` fully green (G + landmark cases).
- `npm run routing:typecheck` clean.
- `mini-graph-arg1.bin` committed and byte-stable; `mini-graph.bin` regenerated as ARG2 (G7 pin green).
- Bot-suite encodeGraph call sites are updated in THIS plan (webhook corridor builders + guidance cron); `npm test -w bot` is green at this commit — routeAstar still runs haversine potentials here (ALT lands in Plan 08-03), so greenness proves the codec change alone is behavior-neutral to search.
</verification>
<success_criteria>
ARG2 is the single, fail-honest, exact-size-validated layout; the landmarkTables/farthestPointAnchors core exists once (pure, deterministic, integer-exact); every synthetic/future graph encodes landmark tables through the one authority; nothing else about decode or search behavior changed at this commit (A-suite green on haversine potentials over the ARG2 fixture).
</success_criteria>
<output>
After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-02-SUMMARY.md`
</output>
@@ -0,0 +1,198 @@
---
phase: 08-alt-landmark-preprocessing-routing-backend-v2
plan: 03
type: execute
wave: 3
depends_on: ["08-02"]
files_modified:
- routing/src/astar.ts
- routing/test/astar.test.mjs
- routing/test/rollout.test.mjs
autonomous: true
requirements: [SC3, SC4]
must_haves:
truths:
- "routeAstar's h_f/h_b bodies become max(ALT triangle bounds, haversine bound); the (h_f−h_b)/2 combination, reductions, balanced alternation, meet probes, BH stop rule, and real-seconds reconstruction are byte-identical (research Pattern 1 — the ONLY function that changes is potential())"
- "Sentinels are compared as RAW u16 0xFFFF BEFORE any float conversion; landmark skip is SEARCH-scoped (a sentinel constant entry drops that family for that ℓ for the whole search) with per-node sentinel skips exactly as the research sketch — never value-based per-node ℓ-masking mid-max"
- "A node whose table entries are sentinel for every remaining ℓ falls back to the existing haversine bound — correct but weaker steering (Pattern 3 containment); the haversine bound is MAX-combined everywhere (max of two consistent admissible heuristics stays consistent + admissible)"
- "A1's exhaustive 625-pair Dijkstra-equivalence sweep stays green with ALT live (the standing optimality proof now seals ALT); A8's no-Map structural pin stays green"
- "worker-router.ts and upload-road-graph.mjs are structurally unchanged (D-01 locks) — pinned by a new rollout.test.mjs so the campaign cannot silently grow format awareness"
artifacts:
- path: "routing/src/astar.ts"
provides: "ALT potentials inside the proven BH skeleton — per-search L-length constant tables, lazy per-node max-of-bounds, haversine max-fallback"
- path: "routing/test/astar.test.mjs"
provides: "A9 island + A10 search-scoped-skip + A11 structural (ALT reads the tables) cases; A1 re-pinned under ALT"
- path: "routing/test/rollout.test.mjs"
provides: "RO1 upload-format-agnostic + RO2 one-decode-path structural pins (A8 grep-shape)"
key_links:
- from: "routing/src/astar.ts potential()"
to: "graph.landmarkTo / graph.landmarkFrom / graph.landmarkQuantumS (Plan 08-02's RoadGraph fields)"
via: "per-search Float64Array(L) quanta constants (NaN = sentinel skip) + lazy per-node max over ℓ"
pattern: "graph\\.landmarkTo"
- from: "routing/test/astar.test.mjs (A9/A10)"
to: "routing/src/landmark.ts"
via: "farthestPointAnchors/landmarkTables build the islanded fixtures' tables through the ONE landmark module"
pattern: "landmarkTables"
- from: "routing/test/rollout.test.mjs"
to: "routing/scripts/upload-road-graph.mjs + bot/src/routing/worker-router.ts"
via: "structural grep pins (A8 shape): format-agnostic upload, GRAPH_KEY unchanged, no version branch in the loader"
pattern: "kl-roadgraph\\.v1\\.bin"
---
<objective>
The ALT integration itself: swap `routeAstar`'s two heuristic bodies for max(ALT triangle bounds, haversine) inside the survey-verified (πf−πr)/2 bidirectional construction, sealed by the A-suite, with the D-01 rollout locks pinned before the campaign can touch them.
Purpose: this is the entire runtime change of Phase 8 — steering sharpened on the cross-metro class, optimality carried by the already-proven skeleton (A1), and the Worker/upload seam proven untouched.
Output: ALT-live `routing/src/astar.ts`, extended `astar.test.mjs` (A9/A10/A11 + A1 re-pin), new `routing/test/rollout.test.mjs`, full battery green.
</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>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-VALIDATION.md
<interfaces>
<!-- The search being extended. Extracted from routing/src/astar.ts 2026-10-25 (470 lines — read it in full first). -->
routeAstar(graph, originNode, goalNode) @ line 219. The heuristic scaffolding (lines 225–230):
```ts
const maxSpeedMps = graph.maxSpeedKmh / 3.6;
const heuristicScale = maxSpeedMps > 0 ? 1 / maxSpeedMps : 1;
const goalLat = graph.lat[goalNode]!; /* … origin coords likewise … */
```
The lazy potential (lines 232–251) — the ONLY code that changes:
```ts
const hfCache = new Float64Array(n).fill(Number.NaN);
const hbCache = new Float64Array(n).fill(Number.NaN);
const pf = new Float64Array(n);
function potential(v: number): number {
let hf = hfCache[v]!;
if (Number.isNaN(hf)) { hf = metersTo(graph, v, goalLat, goalLng) * heuristicScale; hfCache[v] = hf; }
/* … hb likewise … */ const value = (hf - hb) / 2; pf[v] = value; return value;
}
```
Everything else is PINNED untouched: per-search init (263–268), the BH loop with reductions `du + c + pfu − pv` / `du + c − pfu + pvv` and meet probes (274–340), the stop rule (277), the real-seconds recompute (352–364), reverseFor's WeakMap memo (146–192). A8 (astar.test.mjs 277–283) greps comment-stripped source for `new Map(` — keep the hot loop typed-arrays-only.
RoadGraph gains (Plan 08-02): `landmarkCount: number; landmarkQuantumS: number; landmarkTo: Uint16Array; landmarkFrom: Uint16Array; landmarkNodes: Uint32Array` — u16 rows row-major `[ℓ*N + v]`, sentinel 0xFFFF = v cannot reach / be reached from ℓ (DATA, never validated away). decodeGraph guarantees landmarkCount ≥ 1.
landmark.ts exports (Plan 08-02 task 1) for the test fixtures: `farthestPointAnchors(input, count)`, `landmarkTables(input, anchors) → { to, from }` with `LandmarkInput = { nodeCount, edges: [{from,to,seconds,flags}], quantumS }`.
From 08-RESEARCH: Pattern 1 (the four bound families + the max-rule recommendation), Pattern 2 (why the ĉ-metric bounds are EXACTLY admissible + consistent — no epsilon), Pattern 3 (sentinel containment layers), Anti-Patterns ("Losing sentinels into Float64Array", "per-node skip rule that varies the landmark set mid-max" for VALUE-based masking).
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>task 1: ALT potentials in routeAstar + the containment A-cases (RED→GREEN)</name>
<files>routing/src/astar.ts, routing/test/astar.test.mjs</files>
<read_first>
- routing/src/astar.ts IN FULL (470 lines — you are editing potential() and its doc-comments; every other function is pinned)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md (Pattern 1 verbatim families + max-rule; Pattern 2 exactness; Pattern 3 sentinel containment; the potential() sketch in Code Examples)
- routing/test/astar.test.mjs IN FULL (283 lines — the case-index doc-comment, A1's sweep, A3's synthetic-graph mechanic you extend, A8's pin)
- routing/src/landmark.ts exports (Plan 08-02 task 1 — the fixture-table builder)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md (astar.ts section — the pinned laws)
</read_first>
<behavior>
- A11 structural (the RED case — fails before the swap): comment-stripped astar.ts code (A8's exact stripping shape) references `landmarkTo`, `landmarkFrom`, and `landmarkQuantumS` — the potential provably consults the tables; `new Map(` still absent (A8's law, unchanged)
- A9 island + per-node fallback: a two-component synthetic (e.g. a 3×3 grid + a separate 4-node path) whose landmark anchors are HAND-PINNED inside component A only (landmarkTables takes the anchors array — pin them, don't derive) so every component-B node is 0xFFFF in both families; every pair inside B routes Dijkstra-equal (haversine-fallback steering), every cross-component pair resolves null, every pair inside A routes Dijkstra-equal with real ALT bounds live
- A10 search-scoped constant skip: origin AND goal inside the anchorless component — every ℓ's goal/origin-side constant is sentinel, so all families drop for the whole search and the run is pure-haversine; assert the reachable pairs still route Dijkstra-equal (skip law: constants dropped at SEARCH scope, results still optimal)
- A1 (existing, re-pinned by execution): the 625-pair sweep on the ARG2 mini fixture stays green with ALT live — no case edits needed, the sweep IS the seal; seconds-only assertions everywhere (never node-path equality — D-03's tie law)
</behavior>
<action>
Write the A9/A10/A11 cases FIRST (they run against the current haversine implementation: A11 fails = RED; A9/A10 pass as baseline-equivalence and become the containment seal after the swap). Build each fixture via `encodeGraph({ …, landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from } })` with tables from `landmarkTables({ nodeCount, edges, quantumS }, anchors)` — keep a tiny local `buildFixture(edges, anchors)` helper; update the case-index doc-comment (A1–A8 → A1–A11).
Then swap the heuristic bodies in `routeAstar` (potential() + its init only):
1. Per-search constants (after the existing heuristic consts, before the caches): `const L = graph.landmarkCount;` `const Q = graph.landmarkQuantumS;` and four `Float64Array(L)` tables in QUANTA units — `goalToU[ℓ] = graph.landmarkTo[ℓ*n + goalNode]`, `goalFromU[ℓ] = graph.landmarkFrom[ℓ*n + goalNode]`, `origToU[ℓ] / origFromU[ℓ]` likewise for the origin — each entry stored as-is, with 0xFFFF written as NaN (the search-scoped sentinel skip; the constant tables are the ONLY place skip decisions live). L ≥ 1 is a decode guarantee (D-01) — no L===0 branch, no dead fallback path (one search path law; a comment cites the guarantee).
2. Inside potential()'s cache-miss branch: per direction, `bestTo/bestFrom` start at −Infinity; loop ℓ = 0..L−1 over RAW u16 reads: `const tv = graph.landmarkTo[ℓ*n + v]` — `if (tv === 0xffff) continue` (per-node sentinel skip, exactly the research sketch); if `goalToU[ℓ]` is not NaN → `bestTo = max(bestTo, tv − goalToU[ℓ])` (family A: d̂(v,ℓ) − d̂(t,ℓ)); `const fv = graph.landmarkFrom[ℓ*n + v]` — `if (fv === 0xffff) continue`; if `goalFromU[ℓ]` is not NaN → `bestFrom = max(bestFrom, goalFromU[ℓ] − fv)` (family B: d̂(ℓ,t) − d̂(ℓ,v)). Then `const altF = Math.max(bestTo, bestFrom)` and `hf = altF > -Infinity ? Math.max(havF, altF * Q) : havF` where `havF` is the EXISTING haversine expression (the max-rule — research Pattern 1 recommendation; sentinel-flooded nodes degrade to exactly today's behavior). Mirror for hb toward the origin. The `const value = (hf − hb) / 2; pf[v] = value; return value;` tail is BYTE-IDENTICAL to today.
3. Bounds may be negative (v closer to ℓ than t is) — legal: p_f already goes negative today when h_b > h_f; the BH meet-sum law is algebraic in p_f's sign. The max-rule with the non-negative haversine bound keeps hf/hb ≥ 0 anyway.
4. NO Map/Object allocations (A8); no new imports (graph types + geo are already imported); sentinel comparisons happen on the raw u16 BEFORE any float conversion (anti-pattern: 65535 × quantum as a bogus finite bound).
5. Doc-comments: the module header and routeAstar's BH block gain the ALT paragraph — the survey formulation (πf−πr)/2 is what p_f already implements (cite it), the ĉ-metric exactness in one sentence (floor-quantized edge weights → exact 1-Lipschitz bounds, no epsilon), the sentinel laws (search-scoped constant skip, per-node fallback, 0xFFFF is data), and that A1 re-run under ALT is the standing optimality proof. Keep the Ikeda/balanced-alternation history intact.
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && npm run routing:test 2>&1 | tail -3 && npm run routing:typecheck && echo "ALT-POTENTIALS-GREEN"</automated>
</verify>
<acceptance_criteria>
- `npm run routing:test` green INCLUDING A9, A10, A11 and the re-pinned A1 sweep (625 pairs) with ALT live; the case-index doc-comment lists A1–A11.
- A8's pin still green (no `new Map(` in comment-stripped astar.ts).
- grep law: `grep -c "landmarkTo" routing/src/astar.ts` ≥ 1 AND the A11 case (not just the grep) proves it is CODE not comments (comment-stripped source).
- grep law: `grep -c "0xffff" routing/src/astar.ts` ≥ 2 (raw sentinel comparisons on both table families) and `grep -c "65535" routing/src/astar.ts` = 0 (one spelling of the sentinel).
- Seconds-only assertions: no new case compares node paths (`grep -c "nodes.length ===" routing/test/astar.test.mjs` unchanged from its current A2 usage — A9/A10 assert seconds + nullness).
- typecheck clean; no new imports in astar.ts.
</acceptance_criteria>
<done>ALT steering is live inside the byte-identical BH skeleton; A1's exhaustive equivalence re-proves optimality under ALT; the island and skip laws have dedicated containment cases.</done>
</task>
<task type="auto">
<name>task 2: Rollout pins (RO1/RO2) + the full-battery seal with ALT live</name>
<files>routing/test/rollout.test.mjs</files>
<read_first>
- routing/scripts/upload-road-graph.mjs IN FULL (78 lines — the contract RO1 pins: KEY/BUCKET constants, wrangler shell, NO format awareness)
- bot/src/routing/worker-router.ts lines ~50–80 (GRAPH_KEY, loadGraph's ONE decode path, the null → router-pending flow)
- routing/test/astar.test.mjs A8 (the grep-pin shape to copy — comment stripping, assert on stripped code)
- bot/test/route-privacy.test.mjs RP1a (what is ALREADY pinned router-side — do not duplicate; RO2 pins the FORMAT-agnostic law, RP pins the identity/no-fetch law)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md (D-01 — upload unchanged is a roadmap LOCK)
</read_first>
<action>
Create `routing/test/rollout.test.mjs` (zero deps, harness `test()` import, case-index doc-comment, A8's readFileSync + comment-strip mechanics):
- **RO1 upload-format-agnostic**: upload-road-graph.mjs source contains `KEY = "kl-roadgraph.v1.bin"` and `BUCKET = "araharah-graphs"` (the storage contract); comment-stripped code contains NONE of `landmark`, `encodeGraph`, `decodeGraph`, `0x41524731`, `0x41524732`, `GRAPH_VERSION` — the migration is byte-shipping of the same key (D-01 lock); the script must never grow format awareness (a fork's upload path stays layout-agnostic forever).
- **RO2 one decode path at the Worker**: worker-router.ts comment-stripped code contains `GRAPH_KEY = "kl-roadgraph.v1.bin"` (storage name unchanged), exactly one `decodeGraph` reference inside the loadGraph flow, and NO version-conditional branch (no `GRAPH_VERSION`, no `=== 2` against the graph bytes) — the router never branches on format; an undecodable binary flows to the existing null → router-pending path (D-01's one-path law at the consumer).
Then run the FULL battery at this commit — `npm run routing:test`, `npm test -w bot`, `npm run routing:typecheck`, `npm run bot:typecheck`. This is the first commit where routeAstar consults REAL tables on the bot suite's ARG2 corridor graphs (built in 08-02 task 3): the RT trunks and guidance-cron cases must stay green. Any failure here is an ALT integration bug — fix the integration, never the assertion (seconds are A1-law stable; the corridors' expected costs must not move).
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && npm run routing:test 2>&1 | tail -2 && npm test -w bot 2>&1 | tail -2 && npm run routing:typecheck && npm run bot:typecheck && echo "BATTERY-ALT-GREEN"</automated>
</verify>
<acceptance_criteria>
- `routing/test/rollout.test.mjs` committed with RO1 + RO2 green (`npm run routing:test` reports 0 failed).
- Full battery green: routing suite, bot suite, both typechecks at this commit.
- grep law: `grep -c "0x41524732\|GRAPH_VERSION" ` over comment-stripped worker-router.ts = 0 (enforced by RO2 itself).
- `git status` shows worker-router.ts and upload-road-graph.mjs UNMODIFIED by this plan (the pins protect files this phase must not touch — `git diff --stat` on both empty).
</acceptance_criteria>
<done>The D-01 locks are mechanically pinned suite-side, and the whole battery is green with ALT potentials live on every fixture surface.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| graph binary (tables) → potential evaluation | the u16 tables are hostile-input-derived data at the Worker boundary; the potential is the consumer |
| measurement verdict → astar.ts | the campaign must never be able to "tune" the search into the gate (Pitfall 3) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-05 | DoS | potential() under sentinel-flooded tables | mitigate | search-scoped constant skip drops unusable ℓ wholesale; per-node sentinel skips are O(L) raw reads; worst case degrades to exactly today's haversine behavior (measured-safe), never to incorrect output (A1 seal) |
| T-08-06 | Tampering | potentials weakened to pass the CPU gate | mitigate | admissibility is STRUCTURAL (ĉ ≤ c edge-wise; no tunable scale on ALT bounds — heuristicScale touches only the haversine term); A1's 625-pair equivalence + A9/A10 re-prove optimality on every run; the campaign ladder (08-05) is rebuild-L-only |
| T-08-07 | Information disclosure | astar.ts internals | accept | pure-module law preserved (no logging/storage/network, T-06-05); no new imports |
| T-08-08 | Tampering | rollout drift (upload grows format logic / router grows a version branch) | mitigate | RO1/RO2 structural pins fail the suite on any such edit — the locks are enforced, not customary |
</threat_model>
<verification>
- `npm run routing:test` fully green (A1 seal + A9/A10/A11 + RO1/RO2).
- `npm test -w bot` green (RT trunks + cron on ARG2 corridor graphs with ALT live).
- `npm run routing:typecheck` and `npm run bot:typecheck` clean.
- `git diff --stat bot/src/routing/worker-router.ts routing/scripts/upload-road-graph.mjs` empty at this commit.
</verification>
<success_criteria>
routeAstar consults the ARG2 landmark tables through the survey-exact (πf−πr)/2 construction — max-of-bounds with sentinel-safe raw-u16 handling and a haversine max-fallback — while every other line of the search stays byte-identical; A1's 625-pair Dijkstra equivalence is the standing proof with ALT live; the island and search-scoped-skip laws have dedicated cases; the upload/router seams are pinned format-agnostic; the full battery is green.
</success_criteria>
<output>
After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-03-SUMMARY.md`
</output>
@@ -0,0 +1,200 @@
---
phase: 08-alt-landmark-preprocessing-routing-backend-v2
plan: 04
type: execute
wave: 4
depends_on: ["08-03"]
files_modified:
- routing/scripts/build-road-graph.mjs
autonomous: true
requirements: [SC3, SC4]
must_haves:
truths:
- "The build computes landmark tables at build time through the ONE landmark module (farthestPointAnchors + landmarkTables + landmarkCoverage from ../src/landmark.ts) — no second Dijkstra implementation ever exists in the script (single-authority law)"
- "L follows the D-02 scaling rule clamp(round(N/13000), 16, 32) — node-count-proportional, ~24 at KL's 311k nodes — with a maintainer --landmarks override as the measured campaign knob; anchors are road-network nodes (NOT POIs), deterministic"
- "Build fails honestly (fail() → exit 1, previous binary untouched — collect-then-write) when any anchor's coverage < LANDMARK_MIN_COVERAGE_FRACTION or landmarkTables throws the quantum-ceiling error (raise the quantum, never clamp)"
- "--check prints the landmark facts (count, quantum, coverage) decoded from the binary; the stats gate prints L/quantum/coverage/table-bytes BEFORE the write"
artifacts:
- path: "routing/scripts/build-road-graph.mjs"
provides: "the preprocessing stage between edge compaction and encode; the landmarks block into the one encodeGraph; extended stats + --check prints; the --landmarks override"
key_links:
- from: "routing/scripts/build-road-graph.mjs preprocessing stage"
to: "routing/src/landmark.ts"
via: "dynamic import of LANDMARK_QUANTUM_S + farthestPointAnchors + landmarkTables + landmarkCoverage — the only table implementation"
pattern: "farthestPointAnchors"
- from: "routing/scripts/build-road-graph.mjs encodeGraph call"
to: "routing/src/graph.ts (Plan 08-02's required landmarks block)"
via: "landmarks: { quantumS, nodes: anchors, to, from } — the one authority, no v2-only encode"
pattern: "landmarks:\\s*\\{"
- from: "routing/scripts/build-road-graph.mjs --check"
to: "graph.landmarkCount / landmarkQuantumS (decoded fields)"
via: "landmark stats in the --check print block"
pattern: "landmarkCount"
---
<objective>
The build-side half of ALT: a preprocessing stage in `build-road-graph.mjs` that quantizes nothing itself, implements nothing itself — it feeds the graph's edges to the one landmark module, gates the result honestly, and encodes ARG2 through the one authority — then rebuilds the REAL KL binary with it.
Purpose: the byte payload this plan produces is what the measurement campaign (08-05) gates on; the D-02 scaling rule and the coverage/quantum gates live here so no gate can be skipped at campaign time.
Output: staged `build-road-graph.mjs` + a real, checked-green ARG2 `routing/data/kl-roadgraph.v1.bin` on disk.
</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>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-VALIDATION.md
<interfaces>
<!-- The build tool being staged. Extracted from routing/scripts/build-road-graph.mjs 2026-10-25 (375 lines — read it in full first). -->
Structure to fit (everything is named-constant + fail-honest + collect-then-write already):
- Constants block with rationale doc-comments: lines 56–128 (MAX_BINARY_BYTES 80 MB, EXPECTED_MIN_BYTES 30 MB advisory, MIN_BINARY_BYTES 1 MB, the A1 LADDER HISTORY comment at 80–92 — ALT constants join this block and the ladder comment gains rung (c2)).
- `function fail(message)` → console.error + exit 1 (lines 130–133).
- `--check <file>` mode: lines 137–159 — decodeGraph + stats print + envelope checks, NO network. It currently prints nodeCount/edgeCount/nameCount/maxSpeedKmh/bbox/bytes.
- Edge assembly ends at line 331 (`edges` final after degree-0 compaction; shape `{ from, to, seconds, flags, nameId }`).
- Encode: line 336 `const buffer = encodeGraph({ lat: cLat, lng: cLng, edges, names, maxSpeedKmh: maxSeenKmh })`.
- Stats gate: lines 339–367 (print-every-stat, then the enforced 1–80 MB gates, then the advisory band WARNING).
- Collect-then-write: lines 369–375 — writeFileSync happens ONLY after every gate passed.
landmark.ts (Plan 08-02 task 1) exports consumed here:
```ts
LANDMARK_QUANTUM_S = 0.1;
farthestPointAnchors(input: LandmarkInput, count: number, opts?): Uint32Array; // deterministic, degree-≥2-filtered
landmarkTables(input: LandmarkInput, anchors: Uint32Array): { to: Uint16Array; from: Uint16Array };
// LandmarkInput = { nodeCount, edges: [{from,to,seconds,flags}], quantumS }
// THROWS when any quantized distance > 65,000 (build must catch → fail() with the raise-quantum hint)
landmarkCoverage(to: Uint16Array, anchors: Uint32Array, nodeCount: number): number[]; // per-anchor reachable fraction
```
encodeGraph (Plan 08-02) REQUIRES `landmarks: { quantumS: number; nodes: ArrayLike<number>; to: Uint16Array; from: Uint16Array }` — a v1-shaped call now fails loudly.
Measured real-graph facts (Phase 6 build, 2026-10-24): 311,325 nodes / 677,068 directed edges / 14.7 MB base; ONE giant SCC covering ~305k of 311k nodes (coverage headroom: 0.98 ≫ the 0.90 gate); worst distance ≈ 5,400 s ≈ 54,000 quanta < 65,000 ceiling. Scaling rule → round(311,325 / 13,000) ≈ 24 landmarks. Byte budget: ~1.19 MB/landmark → ~42.8 MB total at L=24 — inside the enforced envelope AND the 30–80 MB advisory band (the below-band WARNING should disappear).
</interfaces>
</context>
<tasks>
<task type="auto">
<name>task 1: The preprocessing stage + --check landmark facts (code)</name>
<files>routing/scripts/build-road-graph.mjs</files>
<read_first>
- routing/scripts/build-road-graph.mjs IN FULL (375 lines — you are inserting one stage and extending two print blocks; the fetch/assembly/collect-then-write laws are pinned)
- routing/src/landmark.ts IN FULL (Plan 08-02's module — the exact LandmarkInput shape and the quantum-ceiling throw message you catch)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md (D-02 scaling rule; Pattern 2 — the script never quantizes, the module does; Pitfall 5 — the quantum knob doctrine; Open Question 1 — the parameterize-L recommendation this task implements)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md (build-road-graph section — named-constant law, fail() shape, gate-then-write block)
- routing/test/fixtures/build-mini-graph.mjs (Plan 08-02 task 3 — the tiny-L precedent of the same stage shape)
</read_first>
<action>
One stage, two print extensions, one override — nothing else moves.
1. Named constants join the constants block (rationale doc-comments, measured justification, ladder-history style):
```js
/** D-02 scaling rule: node-count-proportional landmark count (NOT a fixed
* generous constant — per-landmark bytes scale with N, so over-provisioning
* explodes at Malaysia scale where ALT's edge itself narrows). */
const LANDMARK_COUNT_MIN = 16;
const LANDMARK_COUNT_MAX = 32;
const NODES_PER_LANDMARK = 13000; // 311,325 / 13,000 ≈ 24 at KL scale
/** Build-time coverage gate: every anchor's ĉ-Dijkstra must reach at least
* this fraction of nodes (the giant SCC covers ~0.98 — headroom is honest). */
const LANDMARK_MIN_COVERAGE_FRACTION = 0.9;
```
2. CLI override parsed BEFORE the `--check` dispatch: `--landmarks <n>` — a positive integer, hard-capped at 128 (fail-loud beyond; the knob exists for measured retries within/at the top of the rule, not for unbounded table bombs). Usage doc-comment gains the flag.
3. THE STAGE — after the degree-0 compaction (line 331) and BEFORE the encode (line 336):
- `const { LANDMARK_QUANTUM_S, farthestPointAnchors, landmarkTables, landmarkCoverage } = await import("../src/landmark.ts");`
- `const landmarkCount = landmarksOverride ?? Math.min(LANDMARK_COUNT_MAX, Math.max(LANDMARK_COUNT_MIN, Math.round(cLat.length / NODES_PER_LANDMARK)));` (print the rule's inputs and the chosen L)
- Project the edges ONCE for the module: `const lmEdges = edges.map((e) => ({ from: e.from, to: e.to, seconds: e.seconds, flags: e.flags }));` (the module's input shape; no mutation of the encode-side array).
- `const anchors = farthestPointAnchors({ nodeCount: cLat.length, edges: lmEdges, quantumS: LANDMARK_QUANTUM_S }, landmarkCount);`
- `const { to, from } = landmarkTables({ nodeCount: cLat.length, edges: lmEdges, quantumS: LANDMARK_QUANTUM_S }, anchors);` wrapped in try/catch → `fail("landmark tables exceeded the u16 quantum ceiling (<message>) — raise LANDMARK_QUANTUM_S in routing/src/landmark.ts and rebuild (Pitfall 5)")` on the module's throw.
- Coverage gate: `const coverage = landmarkCoverage(to, anchors, cLat.length);` → `min < LANDMARK_MIN_COVERAGE_FRACTION` ⇒ `fail(...)` naming L, the offending anchor index/id, its fraction, and the giant-SCC expectation.
- Sentinel audit print: total 0xFFFF entries per family (cheap honesty; --check mirrors it).
4. Encode passes the block: `encodeGraph({ lat: cLat, lng: cLng, edges, names, maxSpeedKmh: maxSeenKmh, landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from } })` — the ONE authority; no local fallback encode, no try-to-encode-without-landmarks branch.
5. Stats print extension (the gate-then-write block): landmark lines — count, quantum, min/mean coverage, table bytes (`2·L·N·2 + 4·L`), sentinel totals — BEFORE the size gates. The existing envelope gates are UNCHANGED (80 MB ceiling / 1 MB floor / 30 MB advisory).
6. `--check` extension: the decoded graph now carries `landmarkCount` / `landmarkQuantumS` / `landmarkNodes` — print them plus the per-anchor coverage (import landmarkCoverage; the u16 tables are on the decoded graph) and the sentinel totals. No network.
7. Header doc-comment: the A1 LADDER HISTORY block gains rung (c2) — "EXECUTED in this script's preprocessing stage (Phase 8): build-time landmark tables + ALT steering; the verdict lives in measure-route-cpu.mjs's history and docs/ROAD-GRAPH.md". Usage block documents `--landmarks`.
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && node --check routing/scripts/build-road-graph.mjs && node routing/scripts/build-road-graph.mjs --check routing/data/kl-roadgraph.v1.bin 2>&1 | tail -2; test $? -eq 1 && echo "ARG1-HONEST-REFUSE-OK"; grep -c "farthestPointAnchors" routing/scripts/build-road-graph.mjs</automated>
</verify>
<acceptance_criteria>
- grep gates: `farthestPointAnchors` ≥ 1; `landmarkTables` ≥ 1; `landmarkCoverage` ≥ 2 (stage + --check); `LANDMARK_MIN_COVERAGE_FRACTION` ≥ 2 (const + gate); `NODES_PER_LANDMARK` ≥ 1; `--landmarks` ≥ 2 (parse + usage doc).
- The encodeGraph call carries `landmarks: {` (grep ≥ 1); the script contains NO local Dijkstra/priority-queue implementation (`grep -ci "dijkstra\|priorityqueue" routing/scripts/build-road-graph.mjs` counts only doc-comment mentions of the module's Dijkstras — zero code hits).
- `--check routing/data/kl-roadgraph.v1.bin` exits 1 at this commit with the decode-null honest message — the on-disk binary is STILL ARG1 (the rebuild is task 2); the fail-honest path is proven, not assumed.
- Collect-then-write preserved: `writeFileSync` appears exactly once, after the gates (grep -c = 1).
- `node --check` (syntax) passes.
</acceptance_criteria>
<done>The build tool now produces ARG2 bytes with build-time tables through the one landmark module and the one encode authority, gated honestly on coverage and quantum ceiling.</done>
</task>
<task type="auto">
<name>task 2: The real rebuild (network, maintainer-time) — an ARG2 KL binary on disk</name>
<files></files>
<read_first>
- The staged script (task 1 of this plan) — the exact stdout shape you will quote
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md (Environment Availability — Overpass public API, REQUEST_TIMEOUT_MS 300 s, one request; A4 build-cost ≈ 2L Dijkstra passes ≈ seconds-class)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md (D-02 — the count is measured-justified; D-01 — same output path/key)
</read_first>
<action>
Run the REAL build and check the artifact. These are maintainer-time commands with a network fetch (minutes); quote the full landmark stats block in the summary (print-the-measurement law).
1. `node routing/scripts/build-road-graph.mjs` — expected stdout: the bbox line (identical bbox to the 2026-10-24 build — it is computed from the COMMITTED corpus ± margin), the fetch, the graph-stats block, the NEW landmark block (L = 24 by the rule at ~311k nodes; quantum 0.1; coverage min/mean; table bytes; sentinel totals), the envelope gates green, the write line. Expected size ≈ 43 MB (14.7 MB base + ~28 MB tables at L=24) — inside the envelope AND the advisory band; the old below-band WARNING should NOT appear.
2. `node routing/scripts/build-road-graph.mjs --check routing/data/kl-roadgraph.v1.bin` — expected exit 0 printing nodeCount/edgeCount + landmarkCount/quantum/coverage/sentinels.
3. Record in the summary: L used, per-anchor coverage min/mean, table bytes, total MB, node/edge counts. These are the campaign's build facts (08-05 compares record-vs-current counts from them).
4. HONEST FAILURE PATHS — do not paper over any of these:
- Overpass unreachable/timeout → the script exits 1, the previous binary untouched. STOP: record the failure; the campaign plans need the real binary. Do NOT fabricate, shrink the query, or retry beyond the script's own law.
- Coverage gate FAIL → the build refuses (exit 1). Do NOT lower LANDMARK_MIN_COVERAGE_FRACTION to force a pass — investigate (fragmented extract?) and report.
- Quantum ceiling throw → raise nothing silently; report (KL is measured-safe at 54,000/65,000 units — a throw means something is wrong, not tight).
5. Do NOT run the CPU gates here — every verdict belongs to the campaign (08-05). This plan ends at a checked-green artifact.
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && node routing/scripts/build-road-graph.mjs --check routing/data/kl-roadgraph.v1.bin 2>&1 | tail -8 && echo "REBUILD-CHECK-GREEN"</automated>
</verify>
<acceptance_criteria>
- `--check` exits 0 on `routing/data/kl-roadgraph.v1.bin` and prints `landmarkCount` ≥ 16 with quantum 0.1 and per-anchor coverage ≥ 0.9.
- The binary size is inside the enforced 1–80 MB envelope (expected ≈ 43 MB; the printed `binary size` line is quoted in the summary).
- The build's stdout (stats + landmark block + write line) is quoted in the summary — the measured facts exist outside the terminal.
- `git status`: routing/src/** and routing/test/** UNCHANGED by this task (a build run touches only the gitignored artifact — any source drift here is a plan violation).
</acceptance_criteria>
<done>An ARG2 KL binary exists locally, built through the staged pipeline with the D-02 rule's L, checked green, with every gate number printed and quoted.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Overpass extract → build | the fetch is remote data; a drifted/corrupt extract must not silently become a hosted binary |
| --landmarks override → table size | a fat-fingered count must not produce an envelope-busting or table-bomb binary |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-09 | Tampering | drifted/corrupt extract → subtly wrong binary | mitigate | the existing envelope gates + the NEW coverage/quantum gates run before any write; the D-03 optimality gate (08-05) then compares seconds against the frozen record — count drift prints a WARNING there; nothing hosts until the campaign passes |
| T-08-10 | DoS | build runtime / table size | mitigate | stage cost ≈ 2L Dijkstra passes (seconds-class, A4); `--landmarks` hard-capped at 128; the 80 MB envelope gate fires before any write regardless of L |
| T-08-11 | Repudiation | forcing a pass by lowering L or the coverage bar | mitigate | the coverage gate uses the same fraction for ANY L; the override cannot lower it; every build prints L + coverage — the summary quotes them, so a gamed build is visible in the record |
| T-08-12 | Information disclosure | build stdout | accept | prints counts/coverage/km-class stats of PUBLIC OSM data — no user coordinates exist in the build path |
</threat_model>
<verification>
- `node routing/scripts/build-road-graph.mjs --check routing/data/kl-roadgraph.v1.bin` exit 0 with landmark facts.
- `npm run routing:test` still green (the suite never imports the build script; this proves no accidental coupling).
- The summary quotes the full build stats + landmark block.
</verification>
<success_criteria>
build-road-graph.mjs gains the preprocessing stage with the D-02 scaling rule, the honest coverage/quantum gates, and the --landmarks measured knob — implementing nothing the landmark module doesn't already — and a real ARG2 KL binary sits on disk, checked green, with every gate number printed and quoted in the summary.
</success_criteria>
<output>
After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-04-SUMMARY.md`
</output>
@@ -0,0 +1,268 @@
---
phase: 08-alt-landmark-preprocessing-routing-backend-v2
plan: 05
type: execute
wave: 5
depends_on: ["08-04"]
files_modified:
- routing/scripts/measure-optimality-gate.mjs
- scripts/lib/budget-gates.mjs
- scripts/test/budget-audit.test.mjs
- docs/BUDGETS.md
- docs/KNOWN-LIMITATIONS.md
- docs/ROAD-GRAPH.md
autonomous: true
requirements: [SC3, SC4]
must_haves:
truths:
- "The D-03 optimality gate is a committed, re-runnable script that reads the FROZEN pre-ALT record (08-01), re-routes the SAME coordinate-pinned pairs on the current ARG2 binary, and gates on seconds-equality (± OPTIMALITY_EPSILON_PCT = 0.1) + verdict-class preservation — never on node paths (equal-cost ties are documented behavior, D-03)"
- "The gate's epsilon constant is asserted EQUAL to the record's epsilon block — a record edit alone cannot fake a pass (constant-mirror law from 08-01)"
- "The ALT verdict is MEASURED, never hoped: the 25-route CI gate AND the D-04 extended 100-pair verdict both print their p95 + GATE lines; the c3 retirement (D-05) happens ONLY on passing numbers, quoted verbatim into every record"
- "The remediation ladder is pre-committed: FAIL → rebuild at --landmarks 32 → re-measure; at L=32 still FAIL → STOP and escalate honestly (retirement never fires on failing numbers); astar's stop rule is untouchable (Pitfall 3)"
- "Every record that documented the accepted c3 degradation tells the post-ALT truth: budget-gates.mjs' route-cpu verdict law, docs/BUDGETS.md's CPU row + c3 section, docs/KNOWN-LIMITATIONS.md's c3 section + ALT pointer row, docs/ROAD-GRAPH.md's ladder table + ARG2 layout — no 'next-milestone item' wording survives (Pitfall 6, docs divorce)"
artifacts:
- path: "routing/scripts/measure-optimality-gate.mjs"
provides: "the D-03 gate — exported core + CLI guard (route-corpus pattern); D03A seconds-equality + D03B verdict-class checks with per-pair violations"
- path: "scripts/lib/budget-gates.mjs"
provides: "the route-cpu verdict law re-anchored: ≤8 ms PASS; within the shared-host variance guard → loud re-run warning; beyond → uncharted-regression FAIL; all c3-ACCEPTED wording removed"
- path: "docs/KNOWN-LIMITATIONS.md"
provides: "the c3 section replaced by the retirement record (FIXED shape, /cari precedent); ALT roadmap-pointer row flipped to built"
- path: "docs/ROAD-GRAPH.md"
provides: "ladder table's (c2) rung with measured numbers; the ARG2 layout + landmarkQuantumS knob documented; storage-name-vs-format-truth note"
key_links:
- from: "routing/scripts/measure-optimality-gate.mjs"
to: "routing/test/fixtures/alt-baseline.json"
via: "reads pairs (origin/destination lat-lng + seconds + verdict) + epsilon.pct from the frozen record — never regenerates pairs"
pattern: "alt-baseline\\.json"
- from: "routing/scripts/measure-optimality-gate.mjs OPTIMALITY_EPSILON_PCT"
to: "record.epsilon.pct"
via: "mirror assert before any measurement (mismatch → fail)"
pattern: "OPTIMALITY_EPSILON_PCT"
- from: "docs/KNOWN-LIMITATIONS.md retirement record"
to: "the campaign's printed gate lines (task 2's quoted numbers)"
via: "D-05 evidence law: the record quotes the measured p95s + date, never rounds up or hopes"
pattern: "FIXED 2026-"
---
<objective>
The measurement campaign that turns ALT from code into evidence, and the record retirement that evidence buys: the D-03 optimality gate against the frozen pre-ALT baseline, the SC-1/SC-1′ CPU verdicts (25-route CI + 100-pair extended), then every c3-accepting record — budget gate law, BUDGETS, KNOWN-LIMITATIONS, ROAD-GRAPH — re-written to the measured truth.
Purpose: this plan IS the phase's success criteria. SC-1 (p95 under the ceiling) and SC-2 (optimality unaffected) are measured here; SC-3 (the c3 class retires with evidence) is written here.
Output: green optimality gate, green CPU verdicts (or the honest-FAIL escalation), and four documents + one gate law that no longer accept a degradation that no longer exists.
</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>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-VALIDATION.md
<interfaces>
<!-- The instruments and records this plan operates on. Extracted 2026-10-25. -->
The frozen record (Plan 08-01, `routing/test/fixtures/alt-baseline.json`):
```json
{ "recordedAt": "<ISO>", "epsilon": { "name": "OPTIMALITY_EPSILON_PCT", "pct": 0.1 },
"graph": { "path": "routing/data/kl-roadgraph.v1.bin", "byteLength": 15449343, "nodeCount": N, "edgeCount": E, "argVersion": 1 },
"pairRules": { "seed": "0x08a1", "count": 100, "minPairKm": 12 },
"pairs": [ { "pairId": "x000", "origin": {"lat": …, "lng": …}, "destination": {"lat": …, "lng": …}, "straightLineKm": …, "seconds": …, "verdict": "reachable|unreachable|unsnappable" } ] }
```
The record is IMMUTABLE (the recorder refuses overwrite; delete by explicit git revert only). Pairs are pinned by COORDINATES (never node indices — Pitfall 2).
`routing/scripts/route-corpus.mjs` — the gate-script architecture to copy: exported measurement core + CLI guard (`const invoked = process.argv[1] ? pathToFileURL(...) : ""; if (invoked === import.meta.url) main();`, lines 329–334), the checks-registry + violations pattern (lines 74–92), named-constant bands (lines 41–53), the report/exit law (0 only when every check has zero violations).
`routing/scripts/measure-route-cpu.mjs` — the two verdict modes: default (zero args, 25-route CI gate, `P95_BUDGET_MS = 8`, prints `p95: <n> ms (…)` + `GATE PASS|FAIL: …`) and `--extended` (Plan 08-01: ~100 cross-metro coordinate-pinned pairs via generateCrossMetroPairs, seed 0x08a1, its own p95 + gate line naming the D-04 extended verdict). The pre-ALT extended run measured an HONEST GATE FAIL (08-01) — post-ALT PASS is the arc this plan closes.
`scripts/lib/budget-gates.mjs` — the route-cpu verdict law going stale (lines 24–33 doc, 58–61 `ROUTE_CPU_DRIFT_GUARD_MS = 200`, 121–133 verdict strings): current strings call an 8 ms overshoot "the DOCUMENTED, ACCEPTED c3 class" and call ALT "a next-milestone item". `runCpuLadderGate` (lines 78–119) runs measure-route-cpu.mjs with ZERO args and regex-parses `^p95:` / `^GATE (?:FAIL|PASS):` — that CLI contract is pinned (budget-audit 19/19 vectors; scripts/test/budget-audit.test.mjs also source-token-checks the gates). Read that test IN FULL before editing either file — vectors pinning the stale strings co-update in the same task.
docs state to rewrite: `docs/KNOWN-LIMITATIONS.md` c3 section (lines 29–41, ending "…it is a next-milestone item and is **not** in this release."), the /cari FIXED-record precedent (lines 43–57: ROOT CAUSE / what changed / verified date / what REMAINS), ALT pointer row (line 80: "Next-milestone item, not built"). `docs/ROAD-GRAPH.md` CPU-gate section (lines 77–106: ladder table + the open c1/c2/c3 decision paragraph), key-facts row "pinned CSR v1" (line 130). `docs/BUDGETS.md` route-cpu row (line 58) + "Cross-metro CPU degradation (the c3 class)" section (lines 162–186, including the 200 ms DRIFT GUARD paragraph). `docs/CONFIGURATION.md` — verify-only.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>task 1: The D-03 optimality gate script + green run against the frozen record</name>
<files>routing/scripts/measure-optimality-gate.mjs</files>
<read_first>
- routing/test/fixtures/alt-baseline.json (the committed record — its exact schema is the gate's input contract)
- routing/scripts/route-corpus.mjs lines 41–92 + 283–334 (checks/violations pattern, exported-core + CLI-guard, report/exit law — copy this architecture)
- routing/scripts/measure-route-cpu.mjs lines 47–84 (existsSync guard, toArrayBuffer, decodeGraph-null fail, graph print shape)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md (D-03 verbatim; Pitfall 1 — the record is frozen, drift escalates, never re-records; Pitfall 2 — coordinates are the only pin)
- routing/scripts/measure-optimality-baseline.mjs (Plan 08-01 — the immutable-record law and the fail() shape it established)
</read_first>
<behavior>
- epsilon mirror: the script's OPTIMALITY_EPSILON_PCT ≠ record.epsilon.pct → fail() BEFORE any measurement (constant-mirror law; a record edit alone cannot fake equality)
- record.graph.argVersion !== 1 → fail() ("the baseline must be the pre-ALT record")
- current binary decodes null (still ARG1 / corrupt) → fail() with the rebuild hint — never a partial comparison
- D03B verdict class: record reachable + post-ALT null (or the reverse) → violation naming the pairId; record unreachable + post unreachable → pass
- D03A seconds: both routed and |post − pre| > pre × 0.1/100 → violation; within → pass (equal seconds, ties allowed — node paths are NEVER compared)
- record "unsnappable" pairs (seconds null): post also unsnappable → pass; post now snaps and routes → an INFO row ("re-snapped") with its seconds printed, NOT a violation — counted and WARNING-printed in the summary if > 0 (D-03's letter gates the reachable/unreachable classes)
- a tie-path difference (different equal-cost node path, same seconds) CANNOT produce a violation — the gate structurally never looks at paths
</behavior>
<action>
Create `routing/scripts/measure-optimality-gate.mjs` (#!/usr/bin/env node; header contract doc-comment in the route-corpus style: purpose, inputs — the COMMITTED record + the LOCAL rebuilt binary — echoes pair ids + measurements only, exit law).
Constants (named, doc-commented): `OPTIMALITY_EPSILON_PCT = 0.1` (D-03; the mirror of the record's epsilon block), `RECORD_PATH = resolve(here, "..", "test", "fixtures", "alt-baseline.json")`, `GRAPH_PATH = resolve(here, "..", "data", "kl-roadgraph.v1.bin")` (the same path law as measure-route-cpu).
Exported core (the route-corpus split — one measurement, two reporters):
`export function measureOptimality(graph, record, { snap, astar })` → `{ checks: { D03A, D03B }, rows, reSnapped, pass }` — for each record pair: snap both endpoints (snap-miss on the NEW graph → row class "unsnapped", handled per the unsnappable rules above); route with `astar`; compare per the <behavior> rules; per-pair row `{ pairId, pre, post, deltaPct, verdictPre, verdictPost, class: "pass|violation|re-snapped|unsnapped" }`.
CLI main(): existsSync guards (absent record → fail "run measure-optimality-baseline first — the record is the D-03 anchor"; absent binary → fail with the build hint); parse + validate the record; the epsilon + argVersion mirror asserts; decodeGraph (null → fail honest); INFO print comparing record.graph counts vs current (nodeCount/edgeCount drift → WARNING line: "extract drift vs the frozen record — the seconds gate still applies"; NOT a violation, and NEVER a reason to re-record — Pitfall 1); run the core; print per-pair rows (` xNNN: pre 5123.4s post 5123.4s (Δ 0.000%) pass` shape), the summary (violations per check, re-snapped count, max |Δ%|, the epsilon used), and exit 1 on any violation else `GATE PASS: D-03 optimality — N/N pairs equal within ±0.1%, verdict classes preserved`.
Then RUN it — the rebuilt ARG2 binary from 08-04 is on disk: `node routing/scripts/measure-optimality-gate.mjs`. Expected: GATE PASS (ALT is steering-only; A1-class change). A FAIL here is honest and STOPS the plan: report the violating pairs, suspect extract drift first (compare the WARNING counts), and NEVER edit the record — escalation to the user is the only next step (Pitfall 1's immutable law).
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && node routing/scripts/measure-optimality-gate.mjs 2>&1 | tail -3 && echo "D03-GATE-GREEN"</automated>
</verify>
<acceptance_criteria>
- The script is committed; grep gates: `OPTIMALITY_EPSILON_PCT` ≥ 3 (const + mirror assert + report line); the mirror assert (`record.epsilon`) present; `alt-baseline.json` referenced as RECORD_PATH.
- The real run exits 0 printing per-pair rows for the record's full pair count (100), 0 violations, and the `GATE PASS: D-03 optimality …` line; the run's tail is quoted in the summary.
- Seconds/verdict-only law: the script contains NO node-path comparison (`grep -c "nodes\[0\]\|nodes.length" routing/scripts/measure-optimality-gate.mjs` = 0).
- Honest-refusal structure verified by reading main(): the epsilon + argVersion mirror asserts and the decodeGraph-null branch ALL precede any measurement call (code-order check, quoted in the summary).
</acceptance_criteria>
<done>D-03 is a machine gate and it is GREEN: post-ALT seconds equal the frozen pre-ALT record within ±0.1% with verdict classes preserved.</done>
</task>
<task type="auto">
<name>task 2: The CPU verdict campaign (SC-1 + SC-1′) with the pre-committed ladder</name>
<files></files>
<read_first>
- routing/scripts/measure-route-cpu.mjs IN FULL (185 lines — the two modes whose output you will quote; the GATE lines' exact shapes)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md (D-04; Pitfall 3 — the remediation ladder and the temptation it forecloses; A6 — the verdict is expected but the gate decides)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-01-PLAN.md (the pre-ALT extended FAIL — the honest before-number this verdict answers)
</read_first>
<action>
Run BOTH verdict modes over the ARG2 binary and quote every gate line in the summary. These numbers are the phase's SC-1 evidence and the raw material for tasks 3–4's records.
1. `node routing/scripts/measure-route-cpu.mjs` — the 25-route CI gate. Expected: `GATE PASS: p95 <X> ms <= 8 ms budget`. Quote the p95 line, the worst 3 per-pair lines, and the routed/unreachable split.
2. `node routing/scripts/measure-route-cpu.mjs --extended` — the D-04 ALT verdict on ~100 coordinate-pinned cross-metro pairs. Expected: its own p95 + `GATE PASS` line naming the D-04 extended verdict. Quote the p95 line + the slowest pairs.
3. THE PRE-COMMITTED LADDER (Pitfall 3) — apply ONLY in this order, only on FAIL:
a. `node routing/scripts/build-road-graph.mjs --landmarks 32` (the measured D-02 knob, top of the rule) → re-run BOTH gates.
b. At L=32 still FAIL → STOP. Record the honest numbers; do NOT proceed to tasks 3–4 as retirements (they re-scope to an honest escalation record instead — D-05's evidence law forbids retiring on failing numbers); report to the user.
NEVER: edit P95_BUDGET_MS, touch routeAstar's stop rule/reductions, drop sample pairs, reseed, or re-run until a lucky box moment and quote only that one (print-the-measurement law gates even a PASS — quote the run you got; a suspiciously loaded box gets a NOTE in the summary, not a re-roll).
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && node routing/scripts/measure-route-cpu.mjs 2>&1 | tail -1 && node routing/scripts/measure-route-cpu.mjs --extended 2>&1 | tail -1 && echo "CPU-VERDICTS-RECORDED"</automated>
</verify>
<acceptance_criteria>
- The 25-route gate exits 0 with `GATE PASS: p95 … <= 8 ms budget` (or the ladder was exercised to its documented stop).
- The extended verdict exits 0 with its `GATE PASS … D-04 extended verdict` line (or the ladder/escalation path is documented).
- The summary quotes BOTH p95 lines + n values + date, and (if used) the `--landmarks 32` rebuild command and both re-measurements.
- `grep -c "P95_BUDGET_MS = 8" routing/scripts/measure-route-cpu.mjs` = 1 (the budget was never touched).
</acceptance_criteria>
<done>SC-1/SC-1′ are measured with ALT live; the numbers that retire (or honestly keep) the c3 class exist, printed and quoted.</done>
</task>
<task type="auto">
<name>task 3: Budget-record truth-out — the route-cpu gate law + docs/BUDGETS.md</name>
<files>scripts/lib/budget-gates.mjs, scripts/test/budget-audit.test.mjs, docs/BUDGETS.md</files>
<read_first>
- scripts/lib/budget-gates.mjs lines 20–135 IN FULL (the runCpuLadderGate law, ROUTE_CPU_DRIFT_GUARD_MS, the verdict strings citing the ACCEPTED c3 class + "next-milestone item")
- scripts/test/budget-audit.test.mjs IN FULL (the 19/19 vectors — identify which pin the stale strings/exit laws; they co-update here)
- docs/BUDGETS.md lines 50–70 (the route-cpu row) + 155–190 (the c3 section + the DRIFT GUARD paragraph)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md (D-05 — the record retires WITH evidence)
- Task 2's quoted numbers (the new measured p95s)
</read_first>
<action>
Retire the c3 acceptance in the gate law and the budget record — the audit must stop DOCUMENTING a degradation that no longer exists (Pitfall 6):
1. `scripts/lib/budget-gates.mjs` — re-anchor the route-cpu verdict law (the zero-arg CLI contract with measure-route-cpu.mjs and the `^p95:` / `^GATE` regex parsing stay BYTE-COMPATIBLE — that is pinned by budget-audit):
- ≤ P95_BUDGET_MS (8): unchanged PASS path.
- 8 < p95 ≤ ROUTE_CPU_DRIFT_GUARD_MS (200 — the constant STAYS, re-documented): exit 0 with a loud WARNING line: over the product budget within the shared-host variance guard (documented ≈2x box variance; the 06-04 band history cites 38–93 ms on IDENTICAL pre-ALT code) — "re-run on a quiet box before treating as regression; the c3 class was RETIRED in Phase 8 (ALT preprocessing)".
- p95 > 200: exit 1 "UNCHARTED REGRESSION — the c3 degradation class was retired in Phase 8; a p95 here means the ALT binary/graph or the search regressed".
- DELETE the stale strings: "DOCUMENTED, ACCEPTED c3 class", "the ALT landmark lever is a next-milestone item", and the doc-comment lines 24–33's c3-acceptance framing — replaced by the retirement framing with the Phase 8 citation.
2. `scripts/test/budget-audit.test.mjs` — co-update ONLY the vectors that pinned the stale strings/exit law (the suite must return to 19/19 green with the NEW law; the count stays 19 unless a vector was solely a stale-string pin, in which case it becomes the new-law equivalent — never silently dropped; the final line still reports `BUDGET-AUDIT SUITE: 19/19 vectors ok`).
3. `docs/BUDGETS.md` — the route-cpu row: new measured value (task 2's quoted p95 + date + n; keep the "not statically pinnable / re-run the audit" honesty and the shared-host variance note), the headroom cell flips positive, the "accepted c3 class" disposition cell becomes "RETIRED 2026-10-25 (Phase 8, ALT)". The c3 section becomes the retirement record in the same shape (what changed: ALT preprocessing + ARG2; the measured numbers that retired it; the DECIDED posture now: p95 ≤ 8 is the gate, the variance guard is measurement hygiene, NOT an accepted class; the ladder history stays as history). The DRIFT GUARD paragraph re-anchors to the new law (variance guard ≠ accepted degradation).
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && node scripts/test/budget-audit.test.mjs 2>&1 | tail -1 && grep -rn "next-milestone item" scripts/lib/budget-gates.mjs docs/BUDGETS.md | wc -l && echo "C3-GATE-LAW-REANCHORED"</automated>
</verify>
<acceptance_criteria>
- `node scripts/test/budget-audit.test.mjs` → final line `BUDGET-AUDIT SUITE: 19/19 vectors ok` (co-updated vectors included).
- grep law: zero hits for "next-milestone item" in scripts/lib/budget-gates.mjs and docs/BUDGETS.md; zero hits for "ACCEPTED c3" in budget-gates.mjs.
- ROUTE_CPU_DRIFT_GUARD_MS still exported and used (the constant survives with its new documented meaning).
- docs/BUDGETS.md's route-cpu row carries task 2's measured p95 + date; the c3 section leads with the retirement (grep "RETIRED" ≥ 1 in the file).
- The zero-arg child-process contract is untouched: `runCpuLadderGate` still executes measure-route-cpu.mjs with no args and the same regexes (the audit suite's own pins prove it).
</acceptance_criteria>
<done>The budget record and its gate law state the post-ALT truth: ≤8 ms is the gate, the variance guard is hygiene, c3 is retired with quoted numbers.</done>
</task>
<task type="auto">
<name>task 4: Docs retirement (D-05) — KNOWN-LIMITATIONS + ROAD-GRAPH (+ CONFIGURATION verify)</name>
<files>docs/KNOWN-LIMITATIONS.md, docs/ROAD-GRAPH.md</files>
<read_first>
- docs/KNOWN-LIMITATIONS.md IN FULL (104 lines — the c3 section 29–41; the /cari FIXED-record precedent 43–57, the exact shape to copy; the roadmap-pointer table 77–81)
- docs/ROAD-GRAPH.md IN FULL (130 lines — the CPU-gate section 77–106 incl. the open c1/c2/c3 decision paragraph; the fork contract's build/check steps; the key-facts table 119–130)
- docs/CONFIGURATION.md lines ~24, ~248, ~294 (verify-only: path/script-name claims that could have gone false)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md (D-05; Pitfall 6 — docs divorce; the ROAD-GRAPH.md section of PATTERNS)
- Tasks 1–3's quoted numbers (the retirement record's evidence)
</read_first>
<action>
1. `docs/KNOWN-LIMITATIONS.md`:
- Replace the "Cross-metro routes may not resolve" c3 section with the retirement record, in the /cari record's shape: ROOT CAUSE (BH's weak geographic/max-speed potentials on 20–30 km cross-metro routes — the survey-predicted weak spot), FIXED 2026-10-25 (Phase 8): ALT landmark preprocessing — build-time ĉ-metric distance tables in the ARG2 graph layout + max-combined ALT/haversine A\* steering; the measured numbers that retired it (25-route p95 X ms; extended 100-pair p95 Y ms — quoted from task 2's printed lines; the D-03 optimality gate green vs the frozen pre-ALT record); what REMAINS: equal-cost tie-paths may differ (documented behavior, never a failure), and an old ARG1 binary is router-pending until ONE re-upload — the designed degradation, now the only one. Workers Paid stays rejected ($0 law).
- Flip the roadmap-pointer ALT row: "Next-milestone item, not built" → built (Phase 8) with the measured p95; adjust the surrounding "none in this release" framing accordingly (the WASM-router and Engine-B rows stay as they are).
2. `docs/ROAD-GRAPH.md`:
- CPU-gate section: the ladder table gains the (c2) rung — executed, with the measured p95s + date; the decision paragraph flips from OPEN (c1/c2/c3) to DECIDED: c2 built (this phase), c1 and c3 rejected (c3 retired by measurement). Note the extended 100-pair verdict as the cross-metro evidence.
- New short section "The ARG2 layout": 72-byte header (landmarkCount / landmarkQuantumS / reserved), the u16 dual-family table suffix + u32 anchors, the sentinel 0xFFFF law, and the `landmarkQuantumS` fork knob — 0.1 s default ⇒ ≈6,553 s expressible ceiling, raise-at-rebuild instructions for larger forks (Pitfall 5). The storage-name-vs-format-truth note: the R2 key stays `kl-roadgraph.v1.bin` (D-01 lock); the HEADER version is the format truth — never rename the key.
- Key-facts table: "pinned CSR v1" → ARG2 wording; add the optimality-gate script to the tools list.
- The fork contract's build step text gains the landmark facts (the stage prints them; no new commands — the contract's commands are unchanged).
3. `docs/CONFIGURATION.md`: READ-verify — edit ONLY if a statement became false (paths and script names are unchanged; expected no-op). Note the check in the summary either way.
TONE LAW: honest-engineering voice throughout — quote measured numbers with their date, name what remains, never overpromise ("routes in budget" only as far as the gates measured).
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && grep -c "Next-milestone item, not built" docs/KNOWN-LIMITATIONS.md; grep -c "FIXED 2026-10-25" docs/KNOWN-LIMITATIONS.md docs/ROAD-GRAPH.md; grep -c "landmarkQuantumS" docs/ROAD-GRAPH.md && echo "DOCS-RETIRED"</automated>
</verify>
<acceptance_criteria>
- KNOWN-LIMITATIONS: `grep -c "Next-milestone item, not built"` = 0; the retirement record present with `FIXED 2026-10-25` + the measured p95 strings from task 2 (grep the exact quoted number ≥ 1); the tie-path and router-pending remainders named.
- ROAD-GRAPH: the (c2) ladder rung present with numbers (`grep -c "(c2)" ≥ 1` in the ladder table region); `landmarkQuantumS` ≥ 2; "ARG2" ≥ 3; the decision paragraph states c2 executed and c1/c3 rejected; the storage-name note present.
- No doc claims ARG1 binaries remain decodeable (grep -ci "arg1" in both docs shows only retirement/router-pending contexts — read the hits).
- CONFIGURATION.md untouched OR the touched lines quoted in the summary.
- Both docs keep the honest tone: no "always"/"never" claims beyond what the gates measured.
</acceptance_criteria>
<done>SC-3 is written: the c3 class is retired with quoted evidence in every record that documented it, and the fork contract teaches ARG2.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| frozen record → optimality gate | the record is the phase's comparison anchor; editing it must never be able to fake a pass |
| campaign verdicts → public docs | the retirement records quote measurements; unmeasured claims are the failure mode D-05 exists to prevent |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-13 | Tampering | gaming the optimality gate by editing the record | mitigate | the epsilon constant-mirror assert + the record's refuse-overwrite immutability (08-01) + committed git history make any edit visible; the gate re-snaps the record's COORDINATES and re-measures live, so only a genuine equal-seconds run passes |
| T-08-14 | Repudiation | retirement docs citing numbers never measured | mitigate | D-05's evidence law: tasks 3–4 quote task 2's printed gate lines verbatim; the review leg cross-checks the quotes against the summary |
| T-08-15 | Tampering | extract drift faking a pass/fail | mitigate | the gate prints the counts-diff WARNING; the seconds epsilon + verdict classes are the gates; a mass-failure escalates to the user — the record is never silently re-recorded (Pitfall 1) |
| T-08-16 | DoS | campaign runtime (100 routes + rebuild retries) | accept | maintainer-time; seconds-class measurements; the ladder is capped at one rebuild (L=32) |
</threat_model>
<verification>
- `node routing/scripts/measure-optimality-gate.mjs` → GATE PASS (D-03).
- Both measure-route-cpu modes → GATE PASS (SC-1/SC-1′) or the documented honest-FAIL escalation.
- `node scripts/test/budget-audit.test.mjs` → `BUDGET-AUDIT SUITE: 19/19 vectors ok`.
- `npm run routing:test && npm test -w bot && npm run routing:typecheck && npm run bot:typecheck` — the full battery green one final time at the phase's last code-touching commit (only the new gate script + doc/gate-law edits since 08-04).
- Docs greps (task 4's acceptance) all green; the summary quotes every gate line the records cite.
- MANUAL (maintainer, after this plan): `node routing/scripts/upload-road-graph.mjs` + the roundtrip `--check` — the R2 upload is an account-level act (validation map, manual-only); the plan stops at the local evidence.
</verification>
<success_criteria>
The D-03 optimality gate is green against the frozen pre-ALT record; the 25-route and extended CPU verdicts pass with ALT live (or the honest-FAIL escalation is recorded); every record that accepted the c3 degradation — budget gate law, BUDGETS, KNOWN-LIMITATIONS, ROAD-GRAPH — now states the measured post-ALT truth with dates and quoted numbers; the fork contract teaches ARG2; nothing claims more than the gates measured.
</success_criteria>
<output>
After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-05-SUMMARY.md`
</output>
@@ -0,0 +1,112 @@
# Phase 8: ALT Landmark Preprocessing (Routing Backend v2) - Context
**Gathered:** 2026-10-25
**Status:** Ready for planning
<domain>
## Phase Boundary
Cross-metro routes route in-budget on the free plan: ALT (A\*, Landmarks, Triangle-inequality) preprocessing over the CSR graph upgrades engine A's worst case while staying $0 — the c2 rung of the measured ladder, executed (committed by Phase 6's post-planning A1 ratification). Concretely: build-time landmark distance tables, a graph-layout v2 binary that carries them, the same R2 hosting + unchanged upload script, a corpus re-run proving optimality is unaffected, the free-plan CPU gate passing with p95 under the ceiling, and the c3 degradation class retiring from KNOWN-LIMITATIONS.
No new product surfaces — the user-visible outcome is unchanged routing behavior that now covers the longest cross-metro KL routes inside the CPU budget.
</domain>
<decisions>
## Implementation Decisions
### Binary layout compatibility (user decision D-01)
- **D-01:** **ARG2-only, fail-honest.** The graph layout version bumps; the landmark-carrying layout is a new version, and old ARG1 binaries decode to null (the existing fail-honest law in `graph.ts`) → the Phase 6 designed **router-pending state** covers them. There is exactly **one decode path and one search path** — no v1 fallback branch, no silent quality regression; a fork (or our own R2 during rollout) routes nothing until the v2 binary is uploaded once. Chosen over dual-version decode to keep the single-authority law: the permanent cost of two search paths (and a silently degraded mode that contradicts fail-honest) outweighs a small, honest, temporary unavailability window. The upload script stays unchanged (roadmap lock) — re-uploading the rebuilt binary is the whole migration.
### Landmark budget (user decision D-02)
- **D-02:** **A scaling rule, not a fixed count.** The selection *approach* is locked — build-time Dijkstra passes over well-spread anchor nodes (farthest-point/hill-climb family over the road graph; anchors are road-network nodes, NOT POIs — restaurants/shops live in the Phase 4 gazetteer). The landmark *count* is a measurement-justified, node-count-proportional quantity: the KL build ships whatever count the CPU-gate measurement justifies (expected in the lean ~16–32 range; the curve flattens fast at metro scale). Rationale locked in discussion: per-landmark bytes scale with node count (~1.2 MB/landmark at KL's 311k nodes), so a fixed "generous" count explodes at future Malaysia scale (~12–24 MB/landmark at country network size) while ALT's advantage itself narrows at that scale (that tier is Contraction Hierarchies' job, still deferred per Phase 6 D-02). A future Malaysia-wide build applies the same scaling rule at its own scale — nothing is over-provisioned now for a graph that doesn't exist.
### Optimality verification definition (user decision D-03)
- **D-03:** **Equal seconds, ties allowed.** The corpus re-run gate asserts: total route **seconds** match the pre-ALT baseline exactly (or within a named tiny epsilon, ~±0.1%) and every pair stays in the same verdict class (reachable/unreachable). ALT changes A\*'s steering, never its cost model — optimal seconds are guaranteed; but bidirectional search + different potentials may legitimately change **tie-breaking** (a different equal-cost path). Tie-path differences are documented behavior, NOT failures. Bit-identical node paths is explicitly NOT the gate (brittle — fails on legitimate ties).
### Measurement sample (user decision D-04)
- **D-04:** **Extend the sample for the ALT verdict.** The existing 25-route seeded gate (`measure-route-cpu.mjs`, seed `0x06d4`) stays as the CI-size snapshot; the ALT verdict additionally runs a maintainer-time extended seeded sample (~100 cross-metro worst-case pairs) for confidence that the longest routes route in-budget. Maintainer-time measurement is free — bigger evidence at zero runtime cost. Same print-the-measurement law.
### Limitations retirement (user decision D-05)
- **D-05:** **Retire the c3 class with evidence.** `docs/KNOWN-LIMITATIONS.md`'s cross-metro CPU degradation section is replaced by a short retirement record: what changed (ALT preprocessing), the measured numbers that retired it (p95 under the ceiling, from D-04's run), and the date. The ALT row in the limitations' roadmap-pointer table flips from "Next-milestone item, not built" to built (Phase 8). The honest audit trail stays in-document (measurements print; limitations never overpromise). Note: because of D-01 there is NO "fallback for landmark-less graphs" to document — an old graph is simply router-pending, which is already a designed, documented degradation.
### OpenCode's Discretion
- Landmark-selection algorithm specifics (farthest-point vs hill-climb vs hybrid), anchor count formula constants, and any quantization of stored distances (e.g., u16-seconds research) within the scaling rule
- Preprocessing build-time cost budget (each landmark = one full graph pass; build script runtime stays sanity-bounded)
- The exact epsilon value and its name (named constant, replay-tested, per phase-6 constant law)
- ARG2 layout byte design (header extensions aligned to the typed-array law; corrupt-binary fail-honest validation carried forward)
- How the committed mini-graph fixture and test suite version-bump (new-ARG2 fixtures; old-v1 fixture kept only as a decode-rejection case)
- KNOWN-LIMITATIONS retirement wording within the honest-engineering tone law
</decisions>
<specifics>
## Specific Ideas
- The user explicitly asked for (and received) the landmark concept explanation: ALT anchors are abstract road-graph reference points, not places. This distinction should survive into research/planning docs so future readers don't confuse it with POI search.
- The whole-malaysia question shaped D-02: the decision is deliberately future-compatible — a Malaysia build should re-derive landmark count from the same rule (and may reasonably graduate to CH per Phase 6's deferral), never inherit a KL-tuned constant.
- "Fail-honest beats silent degradation" was the deciding principle for D-01 — consistent with the repo's designed-degradation philosophy (router-pending is a feature, not an error).
</specifics>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Phase scope + decisions
- `.planning/ROADMAP.md` §Phase 8 — goal, 3 success criteria, "graph-layout v2 with landmark distances precomputed at build time; upload script unchanged"
- `.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md` — engine A pivot (D-01/D-02), the A1 ratification committing c2 (ALT) as this phase and rejecting Workers Paid; Phase 6's CH/turn-restrictions deferral list
- `.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-SUMMARY.md` — the measured ladder history (full graph unidirectional 38.4 ms p95 → drop-classes rejected on correctness → bidirectional BH kept) and the real-graph build facts (311,325 nodes / 677,068 directed edges / 14.7 MB / all landmarks in one strongly-connected component)
- `.planning/phases/07-hardening-private-launch/07-CONTEXT.md` — KNOWN-LIMITATIONS doc shape (07 D-08) and the consolidated budget record (07 D-06) that SC's numbers join
### Codebase
- `routing/src/graph.ts` — the pinned CSR binary layout ("ARG1" v1, version field, fail-honest decode): the codec this phase versions to ARG2; encode remains the single authority
- `routing/src/astar.ts` — the bidirectional A\* (BH) ALT builds on; its traversal law, equilibrate rule, and pure-module law
- `routing/scripts/measure-route-cpu.mjs` — the CPU gate (P95_BUDGET_MS 8, 25-route seeded sample, ladder history in its header): the gate this phase must pass
- `routing/scripts/build-road-graph.mjs` — the build tool where the preprocessing stage joins ( Overpass extract → dedup → encode )
- `routing/scripts/route-corpus.mjs` — the corpus re-run tool the optimality gate (D-03) rides
- `routing/scripts/upload-road-graph.mjs` — the unchanged fork migration path (D-01)
- `docs/KNOWN-LIMITATIONS.md` — the c3 section + ALT roadmap-pointer row that D-05 retires
- `bot/src/routing/worker-router.ts` — the R2-hosted graph consumer (key `kl-roadgraph.v1.bin`) that serves `/api/route`; consumes the v2 layout unchanged at its API
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `graph.ts` encode/decode single authority — the ARG2 layout extends one module, pins one header, keeps zero-copy typed-array laws
- `astar.ts` bidirectional search — ALT potentials slot into the existing lazy-potential mechanism; no new search skeleton
- `measure-route-cpu.mjs` + `route-corpus.mjs` — the gate and corpus harness both re-run as-is (sample extension is a parameter, not a rewrite)
- Committed mini-graph fixture — test corpus basis; version-bumped fixtures derive through the same encode path
- `snap.ts` — unchanged; snapping is orthogonal to ALT (D-05 phase-6 single-authority law holds)
### Established Patterns
- Fail-honest decode → router-pending (designed degradation, never an error)
- Named constants, replay-tested no magic numbers (applies to epsilon + landmark scaling constants)
- Budget gates print their measurement and gate on p95 with margin
- TDD RED→GREEN with hand-rolled runners; pure modules (no I/O in routing/src)
### Integration Points
- `build-road-graph.mjs` gains the preprocessing stage (one Dijkstra pass per landmark at build time)
- R2 `ROAD_GRAPH` binding + `upload-road-graph.mjs` — same key path, new-version binary
- Engine-B (Phase 11) downloads this same asset — the scaling rule + lean KL count keep the future phone payload in the ~30–50 MB class
- `docs/KNOWN-LIMITATIONS.md` and `docs/CONFIGURATION.md` — the record surfaces D-05 touches
</code_context>
<deferred>
## Deferred Ideas
- Contraction Hierarchies — remains the Phase 6 deferral; the noted future tier if routing ever graduates to Malaysia-wide scale (D-02's rationale points there)
- Engine B client-side router consuming the ARG2 asset — Phase 11 (the phone-download payload concern in this phase's discussion feeds its budget documentation)
- Turn restrictions in the graph — Phase 6 deferral stands
- Any traffic/time-dependent weighting — v2 per REQUIREMENTS
</deferred>
---
*Phase: 08-alt-landmark-preprocessing-routing-backend-v2*
*Context gathered: 2026-10-25*
@@ -0,0 +1,58 @@
# Phase 8: ALT Landmark Preprocessing (Routing Backend v2) - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions captured in `08-CONTEXT.md` — this log preserves the discussion.
**Date:** 2026-10-25
**Phase:** 08-alt-landmark-preprocessing-routing-backend-v2
**Mode:** discuss (default)
**Areas selected:** all four presented — v1 graph compatibility, landmark budget × phone payload, optimality verification, limitations retirement
## Prior-Decisions Carried Forward (not re-asked)
- Phase 6 A1 ratification: Workers Paid (c1) rejected ($0 law); drop-classes ladder rejected on correctness; bidirectional A* (BH) kept — ALT builds on it
- Measured-ladder law: budget gates print their measurement (BA9 precedent); p95 8 ms gate exists
- Roadmap locks: build-time preprocessing, upload script unchanged, ALT admissible
- Phase 11 dependency: same graph asset gets downloaded onto phones (payload awareness)
## Questions & Answers
### Area: v1 graph compatibility
| Q | Options presented | Selected |
|---|---|---|
| How should old ARG1 binaries be treated once the v2 layout ships? | (A) ARG2-only fail-honest · (B) dual-version decode w/ plain-A* fallback · (C) decode both, route ALT-only | **(A) ARG2-only, fail-honest** |
- User first asked for a major/minor difference explanation between paths before deciding (second presentation).
- Key rationale recorded: A swaps a small honest "unavailable" window for permanent code simplicity; B trades permanent complexity + silent degradation for zero unavailability. Fail-honest > silent, per repo law.
### Area: landmark budget × phone payload
| Q | Options presented | Selected |
|---|---|---|
| What is the landmark budget philosophy? | (a) lean ~16–32 · (b) generous ~48–64 · (c) measured constant | **Scaling rule** (approach locked; count node-proportional, measurement-justified) |
- Mid-area user asked: "what are landmarks here?" → explained ALT anchors = road-network nodes, not POIs; triangle-inequality lower bounds; destination-agnostic via snapping. Distinction preserved in CONTEXT.md.
- Mid-area user asked: "if I pick generous, wouldn't that be more ideal when expanding to whole Malaysia?" → explained the reversal: per-landmark bytes grow with node count (~1.2 MB at KL scale → ~12–24 MB per landmark at country scale; 64-key markup ≈ 0.8–1.5 GB), ALT advantage narrows at scale (CH is that tier, deferred), and v1 scope is KL metro. This pushed "lean vs generous" into "scaling rule" and the count became future-proof.
### Area: optimality verification
| Q | Options presented | Selected |
|---|---|---|
| Q1 — what does the re-run gate assert? | equal seconds + ties allowed · bit-identical paths · identical steps + carve ties | **Equal seconds, ties allowed** |
| Q2 — does the sample get bigger? | extend to ~100 seeded cross-metro pairs for ALT verdict · keep 25 · full-corpus only | **Extend sample for ALT gate** |
- Note: this area was asked three times due to two session interruptions (server restart aborted the first two presentations); the third presentation received answers. No decision changed across presentations.
### Area: limitations retirement
| Q | Options presented | Selected |
|---|---|---|
| How does the c3 section retire? | retire with evidence · delete outright · superseded appendix | **Retire with evidence** |
- D-01 pre-collapsed the area's other half: no fallback-for-landmark-less-graphs documentation needed (old graphs are router-pending by design, not a documented fallback).
## OpenCode's Discretion (explicitly left to downstream)
- Landmark selection algorithm specifics, count formula constants, distance quantization
- Preprocessing build-time budget; epsilon naming/value; ARG2 byte design; fixture version-bump mechanics; retirement wording
## External Research
None required to resolve the gray areas — codebase + phase-6 ladder evidence sufficed. All library/algorithm research belongs to the researcher for this phase.
## Deferred Ideas Noted
None captured (discussion stayed within phase scope; whole-Malaysia scale resolved as D-02's scaling rule rather than a deferral).
@@ -0,0 +1,286 @@
# Phase 8: ALT Landmark Preprocessing (Routing Backend v2) - Pattern Map
**Mapped:** 2026-10-25
**Files analyzed:** 13 (11 modified, 2 new)
**Analogs found:** 13 / 13 (all new/modified files have a strong in-repo analog; the baseline recorder is new but has two same-role templates)
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `routing/src/graph.ts` (mod) | model / codec | transform (binary ⇄ typed-array views) | **itself** (ARG1 version) — the phase extends it in place | exact |
| `routing/src/astar.ts` (mod) | service (pure compute) | request-response (pure fn, no I/O) | **itself** (BH/Ikeda version) — `potential()` internals swap only | exact |
| `routing/scripts/build-road-graph.mjs` (mod) | build script (maintainer-time) | batch / file-I/O + network fetch | **itself** — gains a preprocessing stage between edge assembly and encode | exact |
| `routing/scripts/measure-route-cpu.mjs` (mod) | measurement gate script | batch (in-process timing) | **itself** — gains extended-sample flag; `scripts/lib/budget-gates.mjs` is its CLI contract consumer | exact |
| `routing/scripts/route-corpus.mjs` (mod or new sibling gate) | measurement gate script | batch / file-I/O (committed fixtures) | **itself** — `measurePairs()` exported-core + CLI-guard pattern is the template for the new D-03 optimality gate | exact |
| NEW `routing/scripts/<baseline-recorder>.mjs` + committed record | measurement script + committed artifact | file-I/O (JSON record) | `route-corpus.mjs` (exported-core/CLI-guard) + `measure-route-cpu.mjs` (seeded sampling, printed measurements) | role-match |
| `routing/test/graph.test.mjs` (mod) | test (unit/codec gate) | batch | **itself** (G1–G8 suite), `routing/test/harness.mjs` + `runner.mjs` | exact |
| `routing/test/astar.test.mjs` (mod) | test (unit/optimality gate) | batch | **itself** (A1–A8 suite; A1 625-pair sweep + A8 structural pin) | exact |
| `routing/test/fixtures/build-mini-graph.mjs` (mod) | fixture-builder script | file-I/O (writes committed .bin) | **itself** — gains the landmark stage via `encodeGraph` | exact |
| `bot/src/routing/worker-router.ts` (io-only check) | controller (Worker handler) | request-response | **itself** — `loadGraph` → `decodeGraph` must stay untouched at its API | exact |
| `scripts/lib/budget-gates.mjs` (compat constraint, likely no edit) | gate consumer | subprocess batch | **itself** — `runCpuLadderGate()` executes `measure-route-cpu.mjs` with NO args and parses its stdout | exact |
| `docs/KNOWN-LIMITATIONS.md` (mod) | docs | — | **itself** — c3 section + roadmap-pointer table; the `/cari` fixed-record is the in-doc precedent | exact |
| `docs/ROAD-GRAPH.md` (mod) | docs (fork contract) | — | **itself** — envelope-law table + CPU-gate ladder table gain the ARG2 rung | exact |
**Note on "itself as analog":** this is a version-bump/extension phase — the strongest analog for every file is the file's own current code, which the phase extends or re-pins. Where behavior is genuinely NEW (baseline recorder, extended-sample flag, ARG2 section), the analog is the nearest same-role script listed above.
## Pattern Assignments
### `routing/src/graph.ts` (codec, transform — ARG1 → ARG2)
**Analog:** itself (`routing/src/graph.ts`, 299 lines). Excerpts below are the laws ARG2 must extend mechanically.
**Layout arithmetic** (`layoutFor`, lines 108–119) — ARG2's usable change: bump `HEADER_SIZE` to 72 (72 % 8 == 0 keeps f64 views aligned), add landmark section offsets, extend `total`:
```ts
const HEADER_SIZE = 64; // → 72 for ARG2; the pad keeps every f64 view 8-byte aligned
function layoutFor(nodeCount, edgeCount, nameCount, namePoolLen): Layout {
const lat = HEADER_SIZE;
...
return { lat, ..., total: namePool + namePoolLen };
}
```
**Fail-honest decode** (lines 125–144) — version/magic mismatch returns `null`; the exact-size check runs BEFORE any view allocation:
```ts
export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null {
if (buffer.byteLength < HEADER_SIZE) return null;
const view = new DataView(buffer);
if (view.getUint32(0, true) !== GRAPH_MAGIC) return null;
if (view.getUint32(4, true) !== GRAPH_VERSION) return null;
...
const lay = layoutFor(nodeCount, edgeCount, nameCount, namePoolLen);
if (buffer.byteLength !== lay.total) return null;
const lat = new Float64Array(buffer, lay.lat, nodeCount); // zero-copy views
```
**Encode-side loud-validate** (lines 180–222) — the `fail()` + field validation shape; GraphInput gains landmark input and the same validation joins these guards (research anti-pattern: NO second `encodeGraph`):
```ts
function fail(message: string): never {
throw new Error(`encodeGraph: ${message}`);
}
// per-field guards fail(`edge ${edge.from}->${edge.to} has a non-finite/negative weight`) etc.
const lay = layoutFor(...);
const buffer = new ArrayBuffer(lay.total);
const view = new DataView(buffer);
```
**Constants to re-pin** (lines 37–48): `GRAPH_MAGIC = 0x41524731` → add/keep as rejection data, `0x41524732` ("ARG2") for v2; `GRAPH_VERSION = 1` → 2.
**Header write block** (lines 253–275): little-endian `view.setUint32(...)` field-by-field in offset order — the pattern for writing `landmarkCount @56` (was pad) and `landmarkQuantumS f32 @60`.
### `routing/src/astar.ts` (pure search, request-response — ALT potentials)
**Analog:** itself (`routing/src/astar.ts`, 470 lines). Only `potential()` (lines 232–251) changes; everything else is pinned.
**The lazy-potential mechanism** (lines 232–251) — ALT slots into this exact shape (Float64Array caches NaN-init, allocation-free):
```ts
const hfCache = new Float64Array(n).fill(Number.NaN);
const hbCache = new Float64Array(n).fill(Number.NaN);
const pf = new Float64Array(n);
function potential(v: number): number {
let hf = hfCache[v]!;
if (Number.isNaN(hf)) { hf = metersTo(graph, v, goalLat, goalLng) * heuristicScale; hfCache[v] = hf; }
... const value = (hf - hb) / 2; pf[v] = value; return value;
}
```
The SURVEY combination `p_f(v) = (h_f − h_b)/2, p_b = −p_f` (lines 199–218 doc-comment) carries over verbatim — only the h_f/h_b bodies swap to ALT triangle bounds (max over valid ℓ of to/from-table families, haversine fallback).
**Per-search constant init precedent** (lines 227–235, 263–268): goal/origin coords hoisted once per search; `df[df[originNode] = 0; potential(originNode); ...]` pattern is where the L-length landmark-constant tables (`goalTo[ℓ]` etc.) get initialized.
**Per-graph memoization precedent** (`reverseFor`, lines 146–192) — if landmark-derived search constants ever need memoizing, this is the pattern (module-scope `WeakMap<RoadGraph, ...>`):
**Laws to preserve (pinned by tests):** pure module (line 31), allocation-free hot loop — **A8's structural test** (astar.test.mjs lines 277–283) greps for `new Map(` after stripping comments; real-seconds recompute along the node path (lines 352–364) is where tie-path noise must not leak into returned seconds.
### `routing/scripts/build-road-graph.mjs` (build script, batch — preprocessing stage)
**Analog:** itself (`routing/scripts/build-road-graph.mjs`, 375 lines). The new preprocessing stage joins between the edge-compaction block (lines 310–331) and the encode block (line 336).
**Structural pattern of the tool** (everything the stage must fit):
- Named constants at top (lines 58–128) with rationale doc-comments — e.g. `MAX_BINARY_BYTES`, `EXPECTED_MIN_BYTES`, `BBOX_MARGIN_DEG`. ALT constants (`QUANTUM_S`, landmark-count formula, `LANDMARK_MIN_COVERAGE_FRACTION`) join here. Copy the ladder-history comment block style (lines 80–92).
- `function fail(message) { console.error(...); process.exit(1); }` (lines 130–133) — the fail-honest exit for: coverage assertion failure, quantum-overflow assert (≤ 65,000 units), empty-graph refusal.
- Print-every-stat + gate-then-write block (lines 339–373) — "collect-then-write: the binary is written ONLY after the encode and the stats gate both pass":
```js
const buffer = encodeGraph({ lat: cLat, lng: cLng, edges, names, maxSpeedKmh: maxSeenKmh });
if (bytes > MAX_BINARY_BYTES) { fail(...) }
if (bytes <= MIN_BINARY_BYTES) { fail(...) }
if (bytes < EXPECTED_MIN_BYTES) { console.log("WARNING: ..."); }
mkdirSync(dirname(OUT_PATH), { recursive: true });
writeFileSync(OUT_PATH, Buffer.from(buffer));
```
The coverage/quantum/byte-budget gates go exactly here; `--check` mode (lines 137–159) prints node/edge stats via `decodeGraph` and must gain landmarkCount + coverage prints.
- Edge array it consumes: `edges = rawEdges.map(...)` (line 327–329) with `{from, to, seconds, flags, nameId}` — the preprocessing stage derives `ĉ(u,v) = Math.floor(seconds / QUANTUM_S)` from these, never re-reading Overpass.
### `routing/scripts/measure-route-cpu.mjs` (measurement gate — extended-sample flag)
**Analog:** itself (`routing/scripts/measure-route-cpu.mjs`, 185 lines), the gate this phase must pass (p95 ≤ `P95_BUDGET_MS` = 8).
**Seed-sampling structure** (lines 86–132) — the seeded-PRNG + labeled-pairs pattern; the D-04 extended sample re-uses this mechanic with a bigger N and a `MIN_PAIR_KM`-style filter:
```js
function mulberry32(seed) { ... } // deterministic, comparable re-runs
const rand = mulberry32(SAMPLE_SEED); // SAMPLE_SEED = 0x06d4, named const
pairs.push({ label: `quadrant q${a}->q${b}`, from, to });
```
**Pitfall note from RESEARCH (Pitfall 2):** quadrant sampling is INDEX-keyed — fine for the CI-size CPU snapshot, NOT for cross-version optimality equality. The extended sample and baseline record must pin pairs by lat/lng (e.g. derive coordinates from bbox + seeded PRNG, snap at run time via `snapToGraph`).
**Timing + gate structure** (lines 136–185) — the print-the-measurement law, verbatim shape for the extended verdict:
```js
function cpuMs(fn) { const t0 = process.hrtime.bigint(); const result = fn(); ... return { ms, result }; }
samples.sort((a, b) => a - b);
const p95 = samples[idx];
console.log(`p95: ${p95.toFixed(3)} ms (max ..., mean ..., n=${samples.length})`);
if (p95 > P95_BUDGET_MS) { console.error(`GATE FAIL: ...`); process.exit(1); }
console.log(`GATE PASS: p95 ...`);
```
**HARD CLI-COMPAT CONSTRAINT** — `scripts/lib/budget-gates.mjs` `runCpuLadderGate()` (lines 78–119) runs this script **with zero arguments** as a child process and parses its stdout:
```js
out = execFileSync(process.execPath, [script], { cwd: ..., encoding: "utf8", ... });
const p95 = Number(out.match(/^p95: (\d+(?:\.\d+)?) ms/m)?.[1] ?? NaN);
const childGateLine = out.match(/^GATE (?:FAIL|PASS): .*$/m)?.[0] ?? null;
```
The extended sample MUST be an opt-in flag (`--extended` or similar); the default run must keep printing the same anchor lines (`p95: <n> ms (max...`, `GATE PASS:|GATE FAIL: ...`) so `budget-audit` (Phase 7 record) keeps passing unchanged. The extended run's own verdict lines can be additional, not mutations.
### `routing/scripts/route-corpus.mjs` (corpus gate — the D-03 optimality-gate template)
**Analog:** itself (`routing/scripts/route-corpus.mjs`, 334 lines). A NEW D-03 optimality-gate script (either an edit here or a sibling like `route-optimality.mjs`) copies this file's architecture:
**Exported measurement core + CLI guard** (lines 68–73, 329–334) — one measurement, two reporters, no drift; test suites can assert the same numbers in-process:
```js
export function measurePairs(corpus, fixtures, gazetteer, fixturesPath = DEFAULT_FIXTURES_PATH) { ... }
const invoked = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
if (invoked === import.meta.url) { main(); }
```
**Check-registry + violations pattern** (lines 74–92, 283–314):
```js
const checks = {}; const check = (id, label) => { checks[id] = { id, label, violations: [] }; };
const violation = (id, pairId, reason) => { checks[id].violations.push({ pairId, reason }); };
...
const pass = allChecks.every((c) => c.violations.length === 0);
```
D-03's two checks map directly onto this: ("D03A seconds-equality ± named epsilon", "D03B verdict-class preserved") with per-pairId violations, and per the decision a tie-path difference must NOT emit a violation.
**Named-constant bands** (lines 41–53): `LENGTH_RATIO_BAND = [0.85, 1.10]` style — the epsilon is a named constant here (per the phase-6 constant law), not a magic number.
### NEW: baseline recorder script + committed baseline record (Wave 0)
**Analog (role-match):** `route-corpus.mjs` for script architecture (exported core + CLI guard, committed-readFileSync inputs, header contract doc-comment) + `measure-route-cpu.mjs` for the run mechanics (decodeGraph → snapToGraph → routeAstar over the REAL binary, `toArrayBuffer()` helper at lines 75–77, printed measurements, exit codes).
**Record format guidance (from RESEARCH Pitfall 1):** committed JSON; each entry = pair id + origin/destination **coordinates** (lat/lng, never node indices) + route seconds (JSON number, f64-safe) + verdict class (`reachable`/`unreachable`) at recording time. Wave-0 task: recorded BEFORE any `graph.ts` version change, because ARG2-only decode (D-01) makes old-code+old-binary unreproducible from the working tree afterward.
### `routing/test/graph.test.mjs` (codec tests — new ARG2 G-cases)
**Analog:** itself (`routing/test/graph.test.mjs`, 191 lines). Copy-cases for the new suite:
**Case harness pattern** (lazy-import RED compatibility, line 43–47; per-case named IDs, doc-comment case index at lines 17–31):
```js
let graphPromise = null;
function loadGraph() { if (!graphPromise) graphPromise = import("../src/graph.ts"); return graphPromise; }
test("G2 bad magic -> null — a tampered header never half-decodes (T-06-03)", async () => { ... });
```
**Tamper-vector pattern** (lines 98–133) — the exact mechanics for ARG2's rejection matrix (version≠2, landmarkCount=0, oversized landmarkCount on a tiny buffer):
```js
const tampered = buf.slice(0);
new DataView(tampered).setUint32(0, 0xdeadbeef, true);
assert.equal(decodeGraph(tampered), null, "wrong magic -> null, never a throw");
// hostile header: valid magic/version but huge count on a tiny buffer -> null "before any allocation"
```
**Fixture-decode pin pattern** (G7, lines 159–184) — re-pin for the committed ARG2 fixture: counts, bbox, one oneway flag, both speed classes. G8 (lines 186–191) re-pins `GRAPH_MAGIC`/`GRAPH_VERSION` exported constants → must expect `0x41524732`/2 and gain the ARG1 rejection fixture (`fixtures/mini-graph-arg1.bin`, bytes preserved) as a new decode-null case (G3 merges into it — "version !== 2 refuses").
### `routing/test/astar.test.mjs` (A-suite — A1 re-run + new island case)
**Analog:** itself (`routing/test/astar.test.mjs`, 283 lines). The load-bearing pattern for ALT's optimality seal:
**The exhaustive Dijkstra-equivalence sweep** (A1, lines 95–122) — the test-local brute-force Dijkstra (lines 69–91) with the SAME traversal law (`edgeFlags bit0` never relaxed); every pair asserts cost equality within 1e-6, endpoints, and honest nulls. The ARG2 re-run with ALT potentials live must stay EXACTLY this shape: `npm run routing:test`, 625 pairs on the regenerated fixture.
**Synthetic disconnected graph pattern** (A3, lines 147–171) — `encodeGraph` a hand-made 2-component graph inline; the new island case (ALT active + islanded fixture) copies this build-synthetic-vector mechanic and asserts `routeAstar` = Dijkstra on every pair touching the island.
**Structural pin pattern** (A8, lines 277–283) — source-grep with comment stripping:
```js
const src = readFileSync(path.join(here, "..", "src", "astar.ts"), "utf8");
const code = src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
assert.ok(!code.includes("new Map("), "no Map allocation — the hot loop is typed arrays");
```
→ the new upload-unchanged structural pin (upload-road-graph.mjs byte-for-byte unchanged) copies THIS grep-shape, not RP-style subprocess git diffs.
**Runner/registry context** (`routing/test/harness.mjs` 10 lines + `runner.mjs` 48 lines): zero-dep auto-discovering runner; new case files named `*.test.mjs` in `routing/test/` join automatically; run via `npm run routing:test`.
### `routing/test/fixtures/build-mini-graph.mjs` (fixture builder — landmark stage)
**Analog:** itself (`routing/test/fixtures/build-mini-graph.mjs`, 85 lines). Pattern to extend:
```js
const bin = encodeGraph({ lat, lng, edges, names, maxSpeedKmh: SPEED_PERIMETER_KMH }); // line 83
writeFileSync(new URL("./mini-graph.bin", import.meta.url), Buffer.from(bin)); // line 84
```
The landmark stage: tiny L for the fixture, computed in-script (farthest-point over the 5×5 grid or hand-picked anchors), passed into the extended `encodeGraph`/`GraphInput`; fail-loud invariant check before write (lines 78–81 style: `if (edges.length !== 80) throw`). Research requirement: old committed ARG1 bytes preserved first as `fixtures/mini-graph-arg1.bin` (the decode-rejection vector) BEFORE this script regenerates `mini-graph.bin` as ARG2.
### `bot/src/routing/worker-router.ts` (io-only check — consumer unchanged)
**Analog:** itself (`bot/src/routing/worker-router.ts`, 200 lines). **Planner note: NO code change here.** The consumption seam that must stay untouched:
```ts
export const GRAPH_KEY = "kl-roadgraph.v1.bin"; // line 55 — name unchanged (D-01 lock)
memoized = env.ROAD_GRAPH.get(GRAPH_KEY)
.then(async (obj) => (obj === null ? null : decodeGraph(await obj.arrayBuffer())))
.catch(() => null); // lines 72–74 — ONE decode path
```
ARG2's decode-path change (v1 → null) flows through this EXACT line — the new-graph → `routeAstar` → `deriveSteps` contract at lines 178–199 needs no edit. The io-only verification (_graph decodes at the same API) lands in a routing-bot suite pin case at most.
### `docs/KNOWN-LIMITATIONS.md` (D-05 retirement record)
**Analog:** itself (`docs/KNOWN-LIMITATIONS.md`, 104 lines).
**Section to replace:** "## Cross-metro routes may not resolve" (lines 29–41) — the c3 class paragraph, ending "The committed upgrade lever is ALT landmark preprocessing... it is a next-milestone item and is **not** in this release."
**In-doc precedent for the retirement wording:** the `/cari` menu section (lines 43–57) — its structure is exactly the honest-fixed-record shape D-05 wants: ROOT CAUSE / what changed / verified date / what REMAINS. Copy: `FIXED <date>: <what changed — ALT preprocessing>; measured <numbers from the D-04 run>; <remaining honest caveat: tie-path differences can appear, ARG1 binaries are router-pending until one re-upload>`.
**Roadmap-pointer table to flip** (lines 77–81, column "Honest status"):
```md
| ALT landmark preprocessing | The cross-metro CPU degradation ... | Next-milestone item, not built |
```
→ status becomes built (Phase 8, with the measured p95). Keep the "none in this release" framing adjusted accordingly.
### `docs/ROAD-GRAPH.md` (fork contract — ARG2 layout + gate rung)
**Analog:** itself (`docs/ROAD-GRAPH.md`, 130 lines).
**CPU-gate section to extend:** "## The CPU gate (A1)" (lines 77–106) — the ladder table (lines 93–98) gains the (c2) rung with measured numbers, and the decision paragraph (lines 100–104) flips c2 to built/decided. Note the doc's path/knobs table (lines 119–130) listing `routing/data/kl-roadgraph.v1.bin` and "pinned CSR v1" — the ARG2 layout section documents: header 72 B, `landmarkQuantumS` knob + its scaling formula (Pitfall 5: 0.1 s ⇒ ≈6,553 s expressible ceiling; fork-raising instructions), the u16 table suffix, and the emitted warning that the STORAGE NAME keeps "v1" while the header version is the format truth (RESEARCH anti-pattern + docs-divorce Pitfall 6). Also note `scripts/lib/source-gates.mjs` cites `measure-route-cpu.mjs lines 3–9` verbatim in a GPL notice — flag if the script's header line numbers drift.
### `docs/CONFIGURATION.md` (check-touch only)
Line 24 and line 294 reference the build/upload pipeline by artifact path (name-only — no format claim); line 248 lists `measure-route-cpu.mjs` among maintainer scripts. Only touch if any statement becomes false after ARG2 (path and script names are unchanged; likely a no-op — verify, don't preemptively edit).
## Shared Patterns
### 1. Named-constant laws (no inline magic)
**Source:** `build-road-graph.mjs` lines 56–128; `measure-route-cpu.mjs` lines 59–64 (`P95_BUDGET_MS = 8`, `SAMPLE_SEED = 0x06d4`); `route-corpus.mjs` lines 41–53.
**Apply to:** every file this phase touches — epsilon (~±0.1%), `QUANTUM_S = 0.1`, `LANDMARK_MIN_COVERAGE_FRACTION`, landmark-count formula constants, `MIN_PAIR_KM` all get named, doc-commented constants with measured justification.
### 2. Fail-honest discrimination: null (decode/runtime) vs throw+exit-1 (build)
**Source:** `graph.ts` lines 120–144 (decode → null, never throws); `graph.ts` lines 180–182 + `build-road-graph.mjs` `fail()` (encode/build time → loud exit 1).
**Apply to:** `decodeGraph` ARG2 laws (version/size/landmarkCount → null), build-stage asserts (coverage, quantum overflow) → exit 1, `encodeGraph` input validation → throw.
### 3. Print-the-measurement gates (verdict never assumed)
**Source:** `measure-route-cpu.mjs` lines 173–185; `route-corpus.mjs` `report()` lines 296–314; `build-road-graph.mjs` stats block lines 339–367.
**Apply to:** the build stats gate (prints L, per-anchor coverage, quantum audit, byte budget), the CPU gate + extended verdict, the D-03 gate (prints per-pair equality/verdict-class and the epsilon used).
### 4. Collect-then-write / deterministic regeneration
**Source:** `build-road-graph.mjs` lines 22–24 + 369–373 ("written ONLY after the encode and the stats gate both pass"); `build-mini-graph.mjs` (deterministic rebuild through `encodeGraph`).
**Apply to:** ARG2 build path and fixture regeneration — nothing overwrites a committed/good binary after a failed gate; the baseline record is written only after all pairs measure.
### 5. Hand-rolled TDD harness (zero-dep)
**Source:** `routing/test/harness.mjs` (10-line registry) + `runner.mjs` (auto-discovery); case-ID doc-index headers (`G1–G8` at graph.test.mjs lines 17–31); lazy-import RED pattern for new modules.
**Apply to:** all new G/A cases, the island case, the upload-unchanged pin — `npm run routing:test` must stay the single fast per-commit command.
### 6. CLI-contract compatibility for downstream parsres
**Source:** `scripts/lib/budget-gates.mjs` lines 78–119 — re-runs `measure-route-cpu.mjs` with **no args**, regex-parses `^p95: (\d+…) ms` and `GATE (?:FAIL|PASS):`;
**Apply to:** `measure-route-cpu.mjs` — extended sample behind an opt-in flag, default stdout byte-compatible (`routing/data/kl-roadgraph.v1.bin` path read also stays for the default mode... note budget-gates hard-codes that path at line 81; renaming the local artifact is prohibited anyway).
### 7. Allocation-free typed-array discipline + memo-once
**Source:** `astar.ts` potential/heap (no Map — enforced by A8), `reverseFor`'s WeakMap memo (built once per graph, amortized per isolate), `worker-router.ts` `graphMemo` WeakMap.
**Apply to:** the ALT potential internals (L-length constant Float64Arrays at search start, NaN skipping, sentinel-0xFFFF handled as integer BEFORE any float conversion) and any landmark-cache reuse.
## No Analog Found
| File | Role | Data Flow | Reason | Fallback |
|------|------|-----------|--------|----------|
| (none — every file maps) | | | The two genuinely new artifacts (baseline record JSON; extended-sample flag output) are format-new but mechanism-covered: JSON-records precedent = `route-corpus.mjs` committed-JSON inputs; flag mechanics = trivial argv read like `build-road-graph.mjs --check` (lines 137–141). | — |
## Metadata
**Analog search scope:** `routing/src/`, `routing/scripts/`, `routing/test/`, `routing/test/fixtures/`, `bot/src/routing/`, `bot/test/` (spot), `scripts/lib/` (budget-gates contract), `docs/`
**Files read in full:** graph.ts, astar.ts, build-road-graph.mjs, measure-route-cpu.mjs, route-corpus.mjs, upload-road-graph.mjs, graph.test.mjs, astar.test.mjs, build-mini-graph.mjs, worker-router.ts, KNOWN-LIMITATIONS.md, ROAD-GRAPH.md, budget-gates.mjs (targeted), harness.mjs, runner.mjs, route-privacy.test.mjs (partial, RP-pin style)
**Pattern extraction date:** 2026-10-25
@@ -0,0 +1,482 @@
# Phase 8: ALT Landmark Preprocessing (Routing Backend v2) - Research
**Researched:** 2026-10-25
**Domain:** ALT (A\*, Landmarks, Triangle inequality) preprocessing over a hand-rolled typed-array CSR graph; binary layout versioning (ARG1 → ARG2); Cloudflare Workers free-plan CPU budget
**Confidence:** HIGH (core algorithm + integration verified against code + literature; build-runtime figures derived from measured numbers and flagged)
<user_constraints>
## User Constraints (from CONTEXT.md — LOCKED, do not re-litigate)
### D-01 — ARG2-only, fail-honest
Graph layout version bumps to a new version carrying landmark tables. Old ARG1 binaries decode to **null** → the Phase 6 designed router-pending state covers them. Exactly **one decode path and one search path** — no v1 fallback branch, no silent quality regression. Upload script stays unchanged (roadmap lock); **re-uploading the rebuilt binary is the whole migration** (same R2 key, new bytes).
### D-02 — Landmark count is a scaling rule, not a fixed constant
Selection *approach* is locked: build-time Dijkstra passes over well-spread anchor nodes (farthest-point / hill-climb family over the road graph); **anchors are road-network nodes, NOT POIs**. The count is measurement-justified and node-count-proportional (expected lean ~16–32 at KL's 311k nodes). A future Malaysia-wide build re-derives the count from the same rule; nothing over-provisioned for a graph that doesn't exist.
### D-03 — Equal seconds, ties allowed (the optimality gate)
Corpus re-run asserts: total route **seconds** match the pre-ALT baseline exactly or within a named tiny epsilon (~±0.1%) and every pair keeps the same verdict class (reachable/unreachable). ALT changes steering, never the cost model. Bidirectional search + different potentials may legitimately change tie-breaking (a different equal-cost path) — tie-path differences are documented behavior, NOT failures. Bit-identical node paths is explicitly NOT the gate.
### D-04 — Extended seeded maintainer-time sample for the ALT verdict
The existing 25-route seeded gate (`measure-route-cpu.mjs`, seed `0x06d4`) stays as the CI-size snapshot; the ALT verdict additionally runs a maintainer-time extended seeded sample (~100 cross-metro worst-case pairs). Same print-the-measurement law. Maintainer-time measurement is free.
### D-05 — Retire the c3 class with evidence
`docs/KNOWN-LIMITATIONS.md`'s cross-metro CPU degradation section is replaced by a short retirement record: what changed (ALT preprocessing), the measured numbers that retired it (p95 under the ceiling, from D-04's run), and the date. The ALT row in the limitations' roadmap-pointer table flips from "Next-milestone item, not built" to built (Phase 8). Because of D-01 there is NO "fallback for landmark-less graphs" to document — an old graph is simply router-pending, which is already a designed, documented degradation.
### OpenCode's Discretion (research must recommend inside these)
- Landmark-selection algorithm specifics (farthest-point vs hill-climb vs hybrid), anchor count formula constants, quantization of stored distances within the scaling rule
- Preprocessing build-time cost budget (each landmark = one full graph pass; build script runtime stays sanity-bounded)
- The exact epsilon value and its name (named constant, replay-tested, per phase-6 constant law)
- ARG2 layout byte design (header extensions aligned to the typed-array law; corrupt-binary fail-honest validation carried forward)
- How the committed mini-graph fixture and test suite version-bump (new-ARG2 fixtures; old-v1 fixture kept only as a decode-rejection case)
- KNOWN-LIMITATIONS retirement wording within the honest-engineering tone law
### Deferred Ideas (OUT OF SCOPE — do not touch)
- Contraction Hierarchies (Phase 6 deferral stands; the noted future tier for Malaysia-wide scale)
- Engine B client-side router consuming the ARG2 asset (Phase 11)
- Turn restrictions in the graph (Phase 6 deferral stands)
- Any traffic/time-dependent weighting (v2 per REQUIREMENTS)
</user_constraints>
## Summary
This phase adds ALT preprocessing to the existing bidirectional A\* (`routeAstar` in `routing/src/astar.ts`) without a new search skeleton, a new binary layout (ARG2) that appends quantized landmark distance tables to the pinned CSR layout, and a measurement campaign (build → check → upload → gate → corpus → docs) that retires the c3 degradation class. The decisive verified finding: **the repo's BH potential structure is already the canonical bidirectional-ALT combination scheme**. The route-planning survey (Bast, Delling, Goldberg, Müller-Hannemann, Pajor, Sanders, Wagner 2015) states that two arbitrary *feasible* potentials πf, πr are combined into consistent potentials by using (πf−πr)/2 forward and (πr−πf)/2 backward — which is *literally* the code's existing `p_f(v) = (h_f(v) − h_b(v))/2, p_b = −p_f` (Ikeda) form with its proven v-independent meet-sum constant. Consequently **ALT slots in by replacing the two haversine/max-speed heuristics h_f, h_b with ALT triangle-inequality bounds — the only function that changes in `routeAstar` is `potential()`**. The heap logic, balanced alternation, meet probes, stop rule, and real-seconds reconstruction all stay byte-for-byte identical, and A1's exhaustive 625-pair Dijkstra-equivalence sweep remains the standing optimality proof.
The second decisive finding is the **quantization trick that makes u16 tables exactly correct with no epsilon fudge**: instead of quantizing stored landmark *distances* and patching admissibility with a subtractive constant, the build quantizes the *edge weights* of a surrogate metric (ĉ(u,v) = floor(seconds/q) in fixed 0.1 s units) and runs the landmark Dijkstras over that integer metric. Distances within the ĉ metric are exactly 1-Lipschitz in ĉ, and ĉ ≤ c edge-wise, so ALT bounds computed inside the metric are (a) exactly admissible lower bounds on real seconds (`d̂(v,ℓ) − d̂(t,ℓ) ≤ d̂(v,t) ≤ d(v,t)` — the directed triangle via t) and (b) exactly 1-Lipschitz w.r.t. the real f32 edges (since |d̂(u,ℓ)−d̂(v,ℓ)| ≤ ĉ(u,v) ≤ c(u,v)). Both properties A* needs — admissibility *and* the feasibility/consistency that the balanced-potential construction and the BH stop rule require — hold by construction with integer arithmetic, no rounding correction, no epsilon. The U16 range check (KL's worst distance ≈ 5,400 s ≈ 54,000 units of 0.1 s < 65,535) closes the design.
Budget arithmetic (from the measured 311,325 nodes / 677,068 edges / 14.7 MB base): dual-family u16 tables cost L × 311,325 × 2 B × 2 ≈ 1.19 MB per landmark — L=16 → ~33.7 MB total, L=24 → ~43.2 MB, L=32 → ~52.7 MB. All fit the enforced 1–80 MB envelope with the advisory 30–80 MB band *satisfied* (the current below-band WARNING disappears at L≥16). Build-time preprocessing is ~2L Dijkstra passes over 677k edges ≈ 2 × 24 × ~40 ms ≈ 2 s of measured-order work (the 38 ms unidirectional A* p95 over the same graph bounds one full pass) — comfortably inside any sane build budget. Expected query behavior: the 20–30 km cross-metro class that currently measures p95 38–93 ms is exactly the regime where ALT's sharpened potentials cut settled nodes by roughly an order of magnitude; the pass verdict (p95 ≤ 8 ms) is *expected but not guaranteed* — the gate decides, and the D-02 scaling rule (raise L toward 32) is the pre-planned remediation knob before anything else.
**Primary recommendation:** Implement ALT as (1) an ARG2 layout appending two u16 landmark-table families (to-landmark + from-landmark) produced by integer ĉ-metric Dijkstras; (2) a `potential()`-only change in `astar.ts` that max-combines the four triangle-bound families per landmark with sentinel-skip and a haversine fallback; (3) a committed coordinate-identified baseline record *measured before the code change*, then the D-03 seconds-equality gate + D-04 extended 100-pair verdict + the existing 25-route p95 gate; (4) D-05's docs retirement. Do not touch `upload-road-graph.mjs`, `worker-router.ts`'s API contract, the snap module, or the reverse-CSR/WeakMap patterns.
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Landmark selection + table Dijkstras | Build scripts (`routing/scripts/`, Node, maintainer-time) | — | Preprocessing is build-time by design (D-02, context); no runtime preprocessing is even conceivable in the Worker (V8 isolate CPU budget) |
| ARG2 binary encode/decode | `routing/src/graph.ts` (typed arrays, pure) | — | Single authority law: encode is inverse of decode, one module |
| ALT potentials at query time | `routing/src/astar.ts` `routeAstar` | — | The search is the only consumer; probe lookups are typed-array reads inside the existing lazy-potential mechanism |
| Binary hosting + rollout | Cloudflare R2 (same bucket/key) | `bot/src/routing/worker-router.ts` | R2 `ROAD_GRAPH` binding already deployed; Worker reads bytes → decodeGraph; zero API change |
| Rollout degradation (old binary present) | Worker `loadGraph` → decode null → 200-null | Mini App router-pending UI | D-01 fail-honest posture already shipped and tested (RT1–RT11) |
| Measurement + optimality gates | `routing/scripts/measure-route-cpu.mjs` + new baseline/gate tooling | corpus scripts | Maintainer-time repo tools, never runtime imports |
| Docs retirement record | `docs/KNOWN-LIMITATIONS.md`, `docs/ROAD-GRAPH.md` | `docs/CONFIGURATION.md` (verify touch) | D-05 + fork contract surface |
## Standard Stack
### Core
| Component | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Node.js | v24.x (nvm; 24.17/24.18 installed) [VERIFIED: shell] | Build scripts + hand-rolled test runners | Repo standard |
| `routing/src/graph.ts` codec | current (ARG1 v1) | The layout single authority this phase versions to ARG2 | Encode/decode laws + zero-copy typed-array alignment laws already |
| `routing/src/astar.ts` routeAstar | current (BH/Ikeda bidirectional) | The search ALT plugs into via `potential()` | Exhaustively proven optimal (A1, 625-pair sweep); meet-sum law |
| Zero new dependencies | — | Landmarks are arithmetic over existing typed arrays; Dijkstra runs inside build scripts | Repo zero-dep law (Phase 6 added zero runtime deps) |
### Supporting (existing, no changes)
`routing/scripts/build-road-graph.mjs` (gains a preprocessing stage), `routing/scripts/measure-route-cpu.mjs` (gains the extended-sample flag + ladder-history rung), `routing/test/fixtures/build-mini-graph.mjs` (regenerates fixture as ARG2), `routing/test/harness.mjs` (registry unchanged).
### Alternatives Considered (from Phase 6's A1 ratification — all waived, listed for completeness only)
| Instead of ALT (c2) | Option | Why rejected / out of scope |
|------------|-----------|----------|
| — | (c1) Workers Paid $5/mo | Breaks the $0 mandate (A1 ratification) |
| — | (c3) Accept degradation | retired BY this phase (D-05) |
| — | Contraction Hierarchies | Deferred future tier (Phase 6 D-02; Malaysia-scale job per D-02 rationale) |
**Installation:** none — zero dependencies. (Node via nvm present; verify `node --version` resumes on PATH inside the executing session's shell, v24.x installed.)
## Architecture Patterns
### System Architecture Diagram
```
┌──────────────────────── MAINTAINER TIME ───────────────────────────┐
Overpass extract ──▶│ build-road-graph.mjs │
(same query) │ fetch → dedup → CSR edges ──▶ NEW preprocessing stage: │
│ 1. quantize edges to integer ĉ (0.1 s units, floor) │
│ 2. select L anchors (farthest-point over ĉ, seeded/ID-tiebreak) │
│ 3. per landmark: reversed-graph ĉ-Dijkstra → TO table │
│ 4. per landmark: forward-graph ĉ-Dijkstra → FROM table │
│ 5. SCC-coverage assertion per anchor (fail honest otherwise) │
│ 6. encodeGraph (ARG2) — header v2 + u16 table sections │
│ output: routing/data/kl-roadgraph.v1.bin (ARG2 bytes, ~43 MB) │──┐
│ --check prints node/edge/landmark stats │ │
└────────────────────────────────────────────────────────────────────┘ │
▼
┌────────────────────── RUNTIME (Cloudflare Workers isolate) ─────────┐ upload (UNCHANGED
bot /api/route ────▶│ worker-router.loadGraph: 1 R2 GET (same key) → decodeGraph │◀── script, same key)
│ ARG2 header? → typed-array views (zero-copy), tables included │
│ ARG1/corrupt/absent? → null → 200 null (router-pending, D-01) │
│ routeAstar(graph, o, g): │
│ per-search landmark constants (L lookups) │
│ lazy potential(v) = (h_f(v) − h_b(v))/2 │
│ h_f(v) = max over valid ℓ of ALT toward-goal bounds │
│ (fall back to haversine bound at sentinel nodes) │
│ h_b(v) = same toward ORIGIN │
│ …everything else in routeAstar UNTOUCHED… │
└────────────────────────────────────────────────────────────────────┘
output: route → deriveSteps → RouteResult (unchanged)
```
Primary use case trace: maintainer builds ARG2 → uploads same key → Worker cold isolate R2 GET → decodeGraph validates v2 + exact size → route request → snap → routeAstar with ALT potentials → real-seconds recompute → deriveSteps. Rollout path: stale ARG1 in R2 → decode null → router-pending (designed, tested) until one upload.
### Component-file mapping (implementation side)
| Piece | Lives in |
|---|---|
| ARG2 codec (layoutFor, header, views, exact-size law) | `routing/src/graph.ts` (extended in place — remains THE single authority) |
| ALT potential + table probes | `routing/src/astar.ts` (`routeAstar.potential()` only) |
| Preprocessing stage (quantize, select, Dijkstras, coverage assert) | `routing/scripts/build-road-graph.mjs` (new stage between edge assembly and encode) |
| Extended sample + verdict | `routing/scripts/measure-route-cpu.mjs` (parameter/flag — "a parameter, not a rewrite" per 08-CONTEXT) |
| D-03 optimality gate + baseline record | new maintainer-time script beside `route-corpus.mjs` (naming discretion), fixtures/record under `routing/test/fixtures/` or `routing/data/` as fits the repo's artifact policy |
| Fixture version bump | `routing/test/fixtures/build-mini-graph.mjs` + committed `mini-graph.bin` (ARG2) + old bytes kept as the v1 rejection vector |
| D-05 records | `docs/KNOWN-LIMITATIONS.md`, `docs/ROAD-GRAPH.md`, (check) `docs/CONFIGURATION.md` |
### ARG2 Binary Layout (recommended — within D-01/D-01-adjacent discretion)
Extension discipline: **append, never mutate**. All ARG1 sections keep their absolute offsets and sizes; the landmark section is a suffix; the only header region touched is the 4 reserved pad bytes plus 8 fresh bytes past 64.
```
ARG2 layout (little-endian):
header (72 bytes, was 64):
magic u32 @0 = 0x41524732 ("ARG2") [VERIFIED pattern: graph.ts]
version u32 @4 = 2
nodeCount u32 @8 | edgeCount u32 @12 | nameCount u32 @16 | namePoolLen u32 @20
bbox 4×f64 @24..47 | maxSpeedKmh f32 @52..55 (unchanged)
landmarkCount u32 @56 (was pad; ≥ 1 REQUIRED — see law below)
landmarkQuantumS f32 @60 (recommended default 0.1 — the ĉ unit, seconds)
reserved u32 @64..67 (= 0)
reserved u32 @68..71 (= 0)
…all ARG1 node/edge/name sections byte-identical to ARG1, HEADER_SIZE = 72
(72 % 8 == 0 keeps the f64 lat/lng views 8-byte aligned — the alignment law holds)…
landmark tables (suffix; TOTAL must extend the ARG1-implied size by exactly
2 × landmarkCount × nodeCount × 2 bytes so the exact-size law extends):
toTable u16[landmarkCount × nodeCount] rounded 4-byte-aligned start
row ℓ = quantized ĉ-distance v→ℓ for every node v
sentinel 0xFFFF = v cannot reach ℓ
fromTable u16[landmarkCount × nodeCount] (immediately after)
row ℓ = quantized ĉ-distance ℓ→v
landmarkNodes u32[landmarkCount] rows' anchor node ids (audit + --check prints)
total = 72 + ARG1 sections + 2·L·N·2 + 4·L (+ alignment pad to 4)
```
**Laws carried forward into decodeGraph:**
- magic/version mismatch → null (D-01: version 1 → null; version ≥ 3 → null). The existing `GRAPH_VERSION` constant becomes 2; G8's pin updates.
- Exact byte-size check BEFORE any view allocation (T-06-03b) — now including the landmark suffix; a hostile huge-landmarkCount header rejects on arithmetic.
- `landmarkCount === 0` → null: D-01's no-silent-degradation law extended to the *content* level. A landmark-less ARG2 would silently route with weak steering; the build always ships ≥ 1 anchor, so decode refusing 0 keeps "no fallback branch" true at every layer. [ASSUMED — this is my recommended interpretation of D-01's spirit; flag in plan review.]
- Sentinel sanity: decode asserts every u16 table byte read is structurally possible (nothing to validate beyond exact-size + type law; 0xFFFF is data).
**Byte-budget table (measured base 14,749,343 B = 14.7 MB):**
| L (anchors) | u16 dual-family table bytes | Total binary | Envelope? | Advisory band? |
|---|---|---|---|---|
| 16 | 18.7 MB | ~33.4 MB | ✓ (≤80) | ✓ (≥30 — CURRENT below-band WARNING disappears) |
| 24 | 28.1 MB | ~42.8 MB | ✓ | ✓ |
| 32 | 37.4 MB | ~52.1 MB | ✓ | ✓ |
| 32 with f32 tables | — | ~90.7 MB | ✗ over 80 MB | — |
f32 tables die at L=32; u16 is what makes the L=16–32 scaling rule byte-safe. Engine-B/Phase 11's "phone payload ~30–50 MB class" stays satisfied for L ≤ ~28.
**u16/quantum range law:** with quantum 0.1 s, tables hold distances ≤ 65,535 → max expressible ≈ 6,553 s. KL's worst-route seconds ≈ 5,400 s measured-class → ~54,000 units, comfortable. Build asserts no stored value and no intermediate Dijkstra label exceeds 65,000 units (fail honest, printed). For forks at larger scale: the header's `landmarkQuantumS` is the knob (raise to 0.2 s × 65,535 ≈ 3.6 h), documented in ROAD-GRAPH.md. [VERIFIED numbers from phase-6 build stats; quantum ceiling arithmetic VERIFIED trivially.]
### Pattern 1: the ALT potential inside the existing BH (THE core integration)
**What:** `routeAstar`'s potentials are currently `h_f(v) = metersTo(v, goal)/maxSpeedMps`, `h_b(v) = metersTo(v, origin)/maxSpeedMps`, combined as `p(v) = (h_f(v) − h_b(v))/2` (astar.ts lines 232–251), with the doc-comment proof that the meet keys satisfy `d_f(v) + d_b(v) = real(o→g through v) − p_f(o) + p_f(g)`. The survey verifies this class of construction is exactly how bidirectional A* takes arbitrary feasible potentials: "[one] can combine two arbitrary feasible functions πf and πr into consistent potentials by using (πf−πr)/2 for the forward search and (πr−πf)/2 for the backward search". So ALT shipping means: replace the bodies of h_f/h_b with ALT triangle bounds; the combination, reductions, meet-probes, stop rule, and reconstruction are untouched.
**The four bound families per landmark ℓ** (Bast et al. survey, §2.2 Goal-Directed Techniques — verbatim inequality set):
- toward-goal family A: `dist(v,t) ≥ dist(v,ℓ) − dist(t,ℓ)` — uses **toTable** for both terms: per-node `toTable[ℓ][v]`, per-search constant `toTable[ℓ][t]`.
- toward-goal family B: `dist(v,t) ≥ dist(ℓ,t) − dist(ℓ,v)` — uses **fromTable** both: `fromTable[ℓ][t]` const, `fromTable[ℓ][v]` per-node.
- toward-origin family A: `dist(v,o) ≥ dist(v,ℓ) − dist(o,ℓ)` — toTable for both (per-node + const).
- toward-origin family B: `dist(v,o) ≥ dist(ℓ,o) − dist(ℓ,v)` — fromTable for both.
Rich lower bound = `h_f(v) = QUANTUM × max over ℓ of max(famA, famB)` in units, converted to seconds; `h_b(v)` likewise toward origin. Both h_f and h_b are then (i) admissible lower bounds on real seconds and (ii) exactly consistent (1-Lipschitz) — see Pattern 2 for why (i) and (ii) hold exactly, and Pattern 3 for the sentinel-island caveat plus its containment.
**Runtime shape (allocation-free, A8-law compatible):**
```ts
// search start: L-length constant tables (Float64Array sec) + skip-sentinel masks
const gTo = new Float64Array(L), gFrom = new Float64Array(L); // per-search goal consts, NaN = sentinel
const oTo = new Float64Array(L), oFrom = new Float64Array(L); // origin consts
// per-node lazy evaluation, max-of-bounds loop over L; on "no valid ℓ" fall back to
// the (already-consistent) haversine bound computed as today.
```
This is the same lazy/cache shape the module already has (`hfCache`/`hbCache` Float64Arrays, NaN-init). Cost per node touch: L ≈ 16–32 iterations of 2 typed-array reads + arithmetic — comparable to today's two haversine trig chains; the win is that vastly FEWER nodes are touched. No `Map`, no per-node objects (A8 pin stays green).
**Why this works with `p_b = −p_f`:** the survey-scheme potentials πf = h_f, πr = h_b are each feasible (lower bounds toward their own target); (πf−πr)/2 and (πr−πf)/2 are consistent in exactly the code's sign convention (`candidate = du + c + pfu − pv` forward; `du + c − pfu + pvv` backward telescopes to `d_b(v) = d(v,g) − p_f(v) + p_f(g)`). The v-independent meet-sum follows; the stop `topF + topB ≥ mu` retains its optimality proof verbatim. [VERIFIED: survey formulation; code sign-analysis verified line-by-line against astar.ts during research.]
**ALT beats the haversine bound exactly where it's weak** — survey: "geographical distance divided by the maximum travel speed… corresponding bounds are poor, and the performance gain is small or non-existent." That is the measured Phase 6 failure mode (38 ms p95 despite BH). **Recommendation (discretion):** also `max` the ALT bound with the existing haversine bound per direction — max of two consistent admissible heuristics stays consistent and admissible, and it guarantees a sane bound at sentinel/edge nodes. [ASSUMED max-rule; standard result.]
### Pattern 2: the ĉ-metric surrogate — quantization WITHOUT an admissibility epsilon
The classic u16 question ("does rounding break admissibility?") is resolved by never quantizing *stored distances* at all — quantize the *metric*, once:
1. Build computes integer weights ĉ(u,v) = `Math.floor(seconds(u,v) / q)` with q = 0.1 s (0 ≤ ĉ·q ≤ c — floor keeps the surrogate BELOW live edge cost; ≤ q loss per edge, ≈ 0.1 s on 1–5 s edges ⇒ < ~2% weight error localized to short residential edges; NIL on arterials).
2. Landmark Dijkstras run over ĉ **in exact integer arithmetic** (distances in q-units; JS numbers hold these exactly ≤ 65,535). No float round-up rule, no per-entry fudge.
3. The two triangle families are evaluated **within the ĉ metric**:
`h_ℓ(v) = d̂(v,ℓ) − d̂(t,ℓ) ≤ d̂(v,t) ≤ d(v,t)` — the first ≤ is the *directed triangle* (v→t ≥ (v→ℓ) − (t→ℓ), i.e. d(v,ℓ) ≤ d(v,t) + d(t,ℓ)), the second is ĉ ≤ c. **EXACT lower bound with no constant subtraction.** Every intermediate step is an exact statement about one integer metric; "difference of rounded distances" — the usual quantization leak — never occurs because a true metric is defined by the quantized weights.
4. Consistency (the property the balanced-potential construction + stop rule need): `|d̂(u,ℓ) − d̂(v,ℓ)| ≤ ĉ(u,v) ≤ c(u,v)` — Dijkstra distances are Lipschitz-1 in their own graph's edges, and ĉ ≤ c makes the bound hold against REAL f32 edgeSeconds. So the potentials are feasible on every real edge, both directions, exactly.
5. `max` over landmarks: `|max_ℓ f_ℓ(u) − max_ℓ f_ℓ(v)| ≤ max_ℓ |f_ℓ(u) − f_ℓ(v)| ≤ c(u,v)` — the max-combination stays exactly consistent (max of a fixed set of 1-Lipschitz functions is 1-Lipschitz).
Storage impact: distances same small integer domain as edge counts → u16 suffices; total bytes = 2 families × L × N × 2 B (see budget table). Build arithmetic (u16 stores + Int32/Float64 accumulation) is standard practice last verified in CH-era implementations that integerize weights for exactly this reason [ASSUMED as common practice; the *exactness proof* is mine and self-contained].
**Build must assert the metric doesn't overflow:** no final table value and no intermediate Dijkstra label > 65,000 units. If it ever fails (future fork at nation scale): raise q via the header knob before re-encoding (documented). Fail-honest exit otherwise.
### Pattern 3: sentinel / small-island handling (the classic ALT edge case, contained)
Sentinel (0xFFFF) appears only when v cannot reach / be reached from landmark ℓ — i.e. v lies outside ℓ's SCC fragment. Known graph facts: the real KL build is ONE giant SCC covering 305k of 311k nodes (Phase 6 build fact); landmarks are chosen inside it (build asserts each anchor's Dijkstra reached ≥ N·coverageFact nodes — abuse the same fragmentation machinery the build already has).
Containment layers (recommendation):
1. **Build-time:** anchors must live in the giant SCC; per-anchor coverage assertion; fail-honest otherwise (named constant `LANDMARK_MIN_COVERAGE_FRACTION`, e.g. 0.90, measured justification).
2. **Search-scoped constant skip:** for a given (o, g) search, drop landmark ℓ entirely from the max if its goal- or origin-side constant is sentinel (g or o can't reach ℓ) — a per-SEARCH decision, never per-node.
3. **Per-node fallback:** if a node's table entries are sentinel for all remaining ℓ (a fringe island node), its h_f/h_b fall back to the haversine bound (existing code, admissible + consistent everywhere).
4. **Correctness containment even on the boundary:** every node u that lies on ANY o→g path necessarily reaches g; if g reaches some valid ℓ then u reaches ℓ too (u → g → ℓ), so **u's table entries are non-sentinel for every useful ℓ** — the fully-connected consistency argument applies on precisely the productive search region. Dead-end island nodes only ever see locally-misordered keys, cannot create a premature valid mu, and their real-seconds are recomputed exact. ∎ [ASSUMED: my proof sketch — planner should schedule the A-case that empirically seals it: the A1 sweep MUST include an islanded synthetic fixture where ALT is ACTIVE and Dijkstra-equivalence holds on every pair.]
5. **Empirical seal:** A1's 625-pair exhaustive Dijkstra-equivalence re-run on the ARG2 fixture with ALT potentials live, plus a new dedicated island case.
### Anti-Patterns to Avoid
- **Quantizing stored distances then patching with an epsilon bound:** breaks exact consistency (max-of-bounds + per-node masking interacts badly); the ĉ-metric removes the whole class.
- **A per-node skip rule that varies the landmark set mid-max:** kills the max-of-Lipschitz consistency argument. Skip at the SEARCH scope (constants) and fall back per NODE, never mask individual ℓ per node while others stay active.
- **Reusing `pool`/DBG-style offsets:** the landmark suffix must extend `layoutFor`'s `total` arithmetic so decode's exact-size law stays arithmetic-only (T-06-03b).
- **Rebuilding the node order silently:** the baseline record must identify pairs by COORDINATES (snap at run time), never graph node indices — a re-fetch of Overpass (or any dedup-order drift) shifts indices and would fake a tie-difference. (The existing 25-route gate samples quadrant node indices — fine for CI-size CPU snapshotting, NOT for cross-version optimality equality.)
- **Losing sentinels into Float64Array:** 0xFFFF must be treated as sentinel BEFORE conversion (65535 × quantum is a large-but-finite bogus distance that would quietly dominate the max).
- **Renaming the R2 key or local artifact path:** upload script and key are locked; the "v1" suffix is a storage NAME, the ARG2 header is the format truth. Document loudly; never fix the name in this phase.
- **Second `encodeGraph`:** GraphInput gains landmark input; all call sites (graph.test synthetics, fixture builder, bot webhook corridor graphs) update in the same change. No v2-only encode function.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Shortest-path preprocessing to cut A\* worst case | custom steering hacks, speed-class masks, radius clamps | ALT with well-spread anchors (the literature's measured lever; beaten only by CH at scale, which is deferred) | Preserves exact optimality (proven formulation), $0, one-function integration |
| Lower-bound math on quantized values | admissibility epsilon patches, round-up tables + mixed corrections | ĉ-metric integer Dijkstra (Pattern 2) | Exact admissibility + consistency by construction |
| Bidirectional potential | new symmetric/dual search variant, meet-constant search | the existing πf/πr → (πf−πr)/2 scheme already in astar.ts | Verified canonical; the stop rule/meet-probe machinery carries over untouched |
| Landmark selection quality metric | hand-tuned POI picks ("anchors are NOT POIs" — D-02 context) | farthest-point over the graph metric, seeded/ID-deterministic | Determinism, reproducibility, spread; measured verdict decides count |
**Key insight:** the repo's remaining risk is measurement honesty, not algorithm novelty — every needed primitive (exact potentials, integer tables, fail-honest decode, seeded gates) already has a tested home in the codebase.
## Common Pitfalls
### Pitfall 1: baseline cannot be re-measured after ARG2 lands
**What goes wrong:** D-03 compares post-ALT seconds to pre-ALT seconds. Once `graph.ts` versions to 2, `decodeGraph` rejects the v1 binary — the "old code + old binary" baseline is no longer reproducible from the working tree.
**Why it happens:** D-01's ARG2-only law (correctly) removed the v1 path.
**How to avoid:** measure and COMMIT the baseline record FIRST (a Wave-0 task): current pre-phase code + the existing v1 build, pairs identified by coordinates, seconds + verdict class + pair fingerprints written to a committed JSON. All comparison happens against the record.
**Warning signs:** a plan that generates the baseline AFTER the codec change.
**JSON float caveat:** record seconds as JSON numbers only if produced by the same f32→f64 accumulation the runtime uses; routeAstar returns exact f64 accumulations of f32 edgeSeconds, JSON round-trips IEEE doubles losslessly — safe [VERIFIED: JS number semantics; the same equality law the A1 sweep uses].
### Pitfall 2: node-index instability across rebuilds
**What goes wrong:** the 25-route gate pricks quadrant arrays by node INDEX; a re-build of the binary (even byte-different only in name bytes) reorders nodes, and the same seed selects different pairs — the pre/post "same pair" claim collapses.
**How to avoid:** the extended sample + baseline record pin pairs by lat/lng (derived from the graph's order-independent bbox + seeded PRNG, snapped at run time); document that index-keyed sampling is a CI-size convenience only.
**Warning signs:** any "verify same route as baseline" logic that prints node ids instead of endpoint coordinates.
### Pitfall 3: L mis-tuned → gate still fails → temptation to touch the search
**What goes wrong:** ALT with too few anchors leaves the worst pairs slow; the wrong next move (heuristic scale-down, speed tweaks) breaks the A1 law.
**How to avoid:** pre-commit the remediation ladder in the plan: (a) raise L within the 16–32 rule and rebuild (lines up with D-02's scaling law); (b) only then report faithfully (print-the-measurement law gates even a FAIL verdict; a measured-fail c2 is an escalation, never a hack).
**Warning signs:** any edit to `routeAstar`'s stop rule or reductions during the campaign.
### Pitfall 4: header-extension breaks f64 alignment or exact-size law
**What goes wrong:** moving HEADER_SIZE from 64 to 72 must keep node f64s 8-byte aligned (72 % 8 = 0 ✓) and decode's total-size arithmetic must cover the variable-size suffix — a miscounted suffix manifests as "corrupt binary" on the WORKER without a single test failing (if tests skip the big fixture).
**How to avoid:** the G-suite gains count-driven table arithmetic cases; `--check` prints landmarkCount + coverage; the round-trip verify command in the fork contract re-runs after upload.
**Warning signs:** `RangeError` on any typed-array construction (alignment) — instant signal.
### Pitfall 5: the u16 quantum ceiling surprises a fork
**What goes wrong:** at 0.1 s units, distances ≥ 6,553.5 s are inexpressible; a larger-city fork overflows silently into wrong sentinel-collisions.
**How to avoid:** build-time assertion (≤ 65,000 units) + ROAD-GRAPH.md documents the `landmarkQuantumS` knob and its formula.
**Warning signs:** table bytes saturated at exactly 0xFFFF in `--check` stats.
### Pitfall 6: docs divorce (v1/ARG2 naming)
**What goes wrong:** keeping the R2 key `kl-roadgraph.v2.bin`… — NOT ALLOWED anyway (upload locked); but if ROAD-GRAPH.md / CONFIGURATION.md silently still say "v1 layout", the next fork's rebuild breaks.
**How to avoid:** D-05's docs tasks include ROAD-GRAPH.md's layout table (ARG2 version field, table sections, quantum knob) and the CPU-gate decision table gaining the (c2) rung.
**Warning signs:** any mention of "ARG1" as still-decodeable.
## Runtime State Inventory
> Refactor/version-bump phase — included. No rename of user-facing strings.
| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | R2 object `araharah-graphs`/`kl-roadgraph.v1.bin` currently ARG1 bytes | Re-upload once post-build (whole migration per D-01). No data migration — the object is a build artifact; decode-null rollout covers the window |
| Live service config | `bot/wrangler.jsonc` ROAD_GRAPH binding (bucket name), `GRAPH_KEY` constant in `worker-router.ts` ("kl-roadgraph.v1.bin") | NONE — both unchanged (D-01 lock). The name-vs-format rule documented |
| OS-registered state | None — no cron/scheduler registrations for the graph | None (verified: cron arm reads the same loadGraph, no separate key) |
| Secrets/env vars | `bot/.dev.vars` four keys (BOT_TOKEN/WEBHOOK_SECRET/WEBHOOK_URL/MINI_APP_URL) — unchanged | None |
| Build artifacts | `routing/data/kl-roadgraph.v1.bin` (gitignored, 14.7 MB, ARG1 on disk) — must be REBUILT by the phase; committed mini-graph fixture `mini-graph.bin` currently ARG1 → regenerated as ARG2; old bytes preserved as the v1 rejection vector | Rebuild + regenerate fixture + keep rejection fixture (fixture build script gains the landmark stage) |
**Nothing found in category:** OS-registered state — verified (repo has no launchd/systemd/pm2 surfaces; Workers cron is declared in wrangler.jsonc and untouched).
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Geographic max-speed A\* bound (2026-10-24 unidirectional) | ALT landmark bounds | This phase | Survey-verified: geo/max-speed is "poor… small or nonexistent gain"; ALT is the proven sharpening |
| Unidirectional → BH 2026-10-24 | BH + ALT (planes exactly on top) | This phase | Steering restored on both sides; no new search skeleton |
| ARG1 format (2026-10-24, 14.7 MB) | ARG2 append-only extension, u16 suffix | This phase | Exact-size law extends mechanically; v1 decode retired |
**Deprecated/outdated:** none of the repo's patterns become dead; the reverse-CSR WeakMap, snap grid, corpus harness, RT/H gate suites all carry unchanged.
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | hand-rolled registry (`routing/test/harness.mjs`) + `node:test`-style runner, **zero deps** [VERIFIED: harness.mjs] |
| Config file | none — `routing/test/runner.mjs` + npm workspace scripts (`npm run routing:test`, `npm test -w bot`) |
| Quick run command | `npm run routing:test` (seconds) |
| Full suite command | `npm run routing:test && npm test -w bot && npm run routing:typecheck && npm run bot:typecheck` |
| Big-graph gates | `node routing/scripts/build-road-graph.mjs` (preprocessing stage) · `… --check` · `node routing/scripts/measure-route-cpu.mjs` (p95 ≤ 8) · extended verdict flag · D-03 gate script |
### Phase Requirements → Test Map
No new REQ ids (SC3/SC4 carry-over). The phase requirements below mirror the ROADMAP §Phase 8 success criteria + the locked decisions:
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SC-1 P95 within free-plan budget | p95 ≤ 8 ms on the real ARG2 build | measurement gate | `node routing/scripts/measure-route-cpu.mjs` | ✅ exists (extends: history + campaign) |
| SC-1′ extended verdict (D-04) | ~100 cross-metro seeded pairs, p95 ≤ 8 | maintainer-time (script flag) | `node routing/scripts/measure-route-cpu.mjs --extended` (flag name discretion) | ❌ Wave 0 |
| SC-2 optimality unchanged (D-03) | corpus re-run: seconds equal (± named epsilon ~±0.1%) + same verdict class per pair vs committed baseline | offline gate script + in-process mirror | new `node routing/scripts/<optimality-gate>.mjs` (name discret.) | ❌ Wave 0 (baseline record ✅ prerequisite task) |
| A1 ALT-equivalence (outstanding optimality proof) | 625-pair sweep on ARG2 fixture WITH ALT potentials live | unit (suite) | `npm run routing:test` (A1 case) | ✅ exists — must stay green; new island case ❌ |
| G-suite ARG2 codec | ARG2 roundtrip incl. tables; ARG1 fixture → decode null; corrupt/version/landmarkCount=0/oversized-count → null; zero-copy views; sentinel integrity | unit (suite) | `npm run routing:test` (G cases) | partially — new cases ❌, G3/G8 re-pin ❌ |
| Rollout null path (RT trunks) | corridor/worker behavior on ARG2 corridor graph; decode-null → 200 null unchanged | bot suite | `npm test -w bot` (RT1–RT11 + H1–H8) | ✅ — corridor graphs flow through updated encodeGraph signature |
| Build pipeline | preprocessing stage prints L, per-anchor coverage, quantum check, byte budget; exit 1 honest | script gate | `node routing/scripts/build-road-graph.mjs` (+ `--check`) | ✅ extends (stage ❌ new) |
| Upload unchanged | `upload-road-graph.mjs` byte-for-byte unchanged | structural grep pin (RP-style) | recorded in bot/routing suite (add 1-line pin case) | ❌ small new case |
| SC-3 docs retirement (D-05) | KNOWN-LIMITATIONS c3 section → retirement record w/ measured numbers + date; ALT roadmap row flipped; ROAD-GRAPH.md CPU-gate table gains (c2) rung | doc acceptance (human-verifiable, supported by printed gates) | review leg of phase gate | edit ❌ new |
### Sampling Rate
- **Per task commit:** `npm run routing:test` (fast, includes A1 sweep + new G/A cases).
- **Per wave merge:** full suite (routing + bot + typechecks) + build `--check` on a committed-adjacent stub if built.
- **Phase gate:** the REAL build → `--check` → 25-route gate (p95 ≤ 8) → extended verdict → D-03 optimality gate green → upload once → `bot` deploy heartbeat (existing RT smoke path) → D-05 docs record carries the printed numbers.
### Wave 0 Gaps (new files/cases before implementation tasks)
- [ ] **Baseline record + recorder tool** (D-03 prerequisite — MUST be Wave 0 / first task; Pitfall 1)
- [ ] Extended-sample flag implementation (D-04) — deterministic coordinate-pair generator, `MIN_PAIR_KM`-style named constant, p95 print + exit-gate
- [ ] `fixtures/mini-graph-arg1.bin` — bytes preserved from the current committed ARG1 fixture (decode-rejection vector)
- [ ] `fixtures/build-mini-graph.mjs` gains a landmark stage (tiny L; ARG2 output)
- [ ] New G cases (ARG2 codec + rejection matrix) and an island A case (Pattern 3 seal)
- [ ] Upload-unchanged structural pin
- Test framework install: none needed (zero-dep harness present)
*Constraint to re-verify in planning:* `budget-audit` (Phase 7) re-runs `measure-route-cpu.mjs` — keep its CLI contract (silent default mode = the 25-route gate, exit codes) so the audit keeps passing, or account for the flag addition there.
## Security Domain
> security_enforcement enabled by default; included.
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no (untouched) — Bearer-claim gate on /api/route unchanged | existing `getClaimByToken` posture |
| V3 Session Management | no | KV TTL classes untouched |
| V4 Access Control | no (maintainer scripts stay repo-market internal) | scripts never imported by runtime (existing law) |
| V5 Input Validation | **yes — the binary is hostile input** | decodeGraph exact-size + version + landmarkCount laws (T-06-03/03b extended to the suffix); hostile header on tiny buffer rejects arithmetically BEFORE view allocation |
| V6 Cryptography | no | HMAC/uniqueness paths untouched |
| V7/V11 … | no | unchanged posture |
### Known Threat Patterns for this stack
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Hostile/corrupt binary with monster `landmarkCount` (allocation bomb) | Tampering/DoS | exact-size check before allocation (T-06-03b law extends — verified pattern in graph.ts today) |
| Sentinel-flood graphs (all u16 = 0xFFFF) degrading search CPU into 1102 | DoS | search-scoped constant skip drops such ℓ wholesale; the CPU gate prints per-route ms; category-only logs unchanged |
| Rollout confusion: Service receives ARG1 | Tampering/integrity | decode null → 200 null (router-pending); `--check` + roundtrip-verify groom |
| Coordinate/payload logging leakage | Info disclosure | category-only log law carries unchanged; gate prints per-route ms + ids-of-nothing (RT1–RT3 pins remain) |
**Threat surface unchanged beyond the binary content** — same one `/api/route` boundary, same Bearer claim, no new endpoints (RP1 law holds; nothing new fetches).
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | The (πf−πr)/2 + (πr−πf)/2 survey formulation is exactly what astar.ts's p_f/p_b already implement (signs verified against `candidate` equations) | Pattern 1 | Would force a `potential`-pair redesign — but verified line-by-line; low |
| A2 | ALT bound families computed in the ĉ metric are exactly admissible AND exactly consistent vs real f32 edges (directed-triangle + Lipschitz + ĉ≤c arguments) | Pattern 2 | Admissibility epsilon would be needed (a named-constant variant exists as fallback: subtract one quantum `K`); medium — planner should treat the epsilon-variant as a plan-level contingency note only; A1 sweep is the empirical seal |
| A3 | Sentinel/island handling: nodes on any o→g path have non-sentinel entries for validated anchors; boundary edges can't corrupt mu | Pattern 3 | Contained by A1 + island case + real-seconds recompute; would degrade to haversine-only behavior (perf regression, not correctness) |
| A4 | Build preprocessing cost ≈ 2L Dijkstra passes ≈ 2 s measured-order at L=24 (derived from measured 38 ms/full-graph pass) | Budget | Larger build times acceptable anyway (maintainer-time); low |
| A5 | `landmarkCount === 0 → decode null` is the correct reading of D-01's no-silent-degradation spirit | ARG2 layout | If the maintainer wants a landmark-less v2 capability, decode should accept 0 and fall back to haversine silently (contradicts D-01 wording "no v1 fallback branch"); flagged for plan review |
| A6 | p95 ≤ 8 achievable with L in 16–32 for the 20–30 km class (order-of-magnitude settled-node reduction expectation vs measured 38–93 ms) | Summary/gate | Verdict may land FAIL at first L — the D-02 scaling rule is the measured knob; escalate honestly if still failing at L=32 (the same print-the-measurement law) |
| A7 | u16 sentinel 0xFFFF never collides with real data (build asserts ≤ 65,000) | u16 design | Overflow → silent table corruption; the assertion makes it loud |
| A8 | f64 JSON round trip of route seconds in the baseline record is lossless | Pitfall 1 | Equality gate needs a fudge — trivially fixed by storing unit strings; low |
## Open Questions
1. **Final L count and its exact rule constant.**
- What we know: expected 16–32; D-02 makes it a measured product of the gate.
- What's unclear: should the plan hard-pin a formula (e.g. `clamp(round(N/13000), 16, 32)`) or build once per candidate and pick the smallest passing L?
- Recommendation: parameterize L in the build script (CLI arg + named default); land default 24; the campaign prints p95 at 24 and (only if FAIL) at 32; the verdict documents the chosen count.
2. **`landmarkQuantumS` value.**
- What we know: 0.1 s fits KL (54,000 max units) and gives sub-decisecond resolution; forks can raise it.
- Recommendation: 0.1, header field, doc'd knob.
3. **Does `docs/CONFIGURATION.md` / `budget-audit` need a touch?**
- Verified likely: `budget-audit` re-runs measure-route-cpu (per KNOWN-LIMITATIONS "run the audit yourself"); flag addition must stay default-compatible. Plan task: read `bot/scripts` budget-audit runner + keep contract.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | build/test/measurement | ✓ | v24.15/24.17/24.18 (nvm) [VERIFIED] | — |
| npx/wrangler | upload (unchanged), R2 roundtrip verify | ✓ (repo standard; R2 binding live) | — | — |
| Overpass public API | build re-fetch (preprocessing rides the SAME extract) | ✓ (used 2026-10-24) | — | retry logic already in build script |
| git | commits | ✓ | — | — |
| No new deps | — | — | — | — |
**Missing dependencies with no fallback:** none.
**Missing dependencies with fallback:** none. (PATH caveat: bare `node` resolves only with nvm sourced; executing sessions run through npm scripts which inherit the shell's node.)
## Code Examples
### (Reference-grade) the reduced-cost search with ALT potentials — the EXISTING loop, unchanged
```ts
// astar.ts (current, stays) — only `potential()` internals change:
const expandForward = fHeap.size <= bHeap.size;
const u = (expandForward ? fHeap : bHeap).pop()!;
// … forward relaxation:
const v = graph.edgeTarget[e]!;
const pv = potential(v);
const candidate = du + graph.edgeSeconds[e]! + pfu - pv;
// … the stop rule / meet probes / real-seconds reconstruction unchanged.
```
### Potential() rewrite sketch (typed arrays, allocation-free, sentinel-safe)
```ts
// L = graph.landmarkCount; T_TO = graph.landmarkTo (u16 rows of N);
// T_FROM = graph.landmarkFrom; Q = graph.landmarkQuantumS;
per search init:
for ℓ: goalTo[ℓ] = toTable[ℓ*N+goal] (NaN if 0xFFFF — search-scoped skip)
goalFrom[ℓ] = fromTable[ℓ*N+goal]
… likewise for origin
per-node lazy potential(v):
bestTo = -Infinity, bestFrom = -Infinity
for ℓ in 0..L-1:
const tv = toTable[ℓ*N+v]; if (tv === 0xFFFF) continue node-entry // per-node fallback candidate
if (goalTo[ℓ] not NaN) bestTo = max(bestTo, tv - goalTo[ℓ]);
if (goalFrom[ℓ] not NaN) bestFrom = max(bestFrom, goalFrom[ℓ] - fromTable[ℓ*N+v]);
// h_f = bestTo > -inf ? max(bestTo, havF(v)) * Q : havF(v) ← fallback keeps isle behavior sane
// h_b likewise with origin consts; return (h_f - h_b)/2 ← the SURVEY combination, exactly today's
```
*(Sketch only — final shape/naming is the executor's; laws to preserve: no `Map`, lazy caches, no logging.)*
### Build preprocessing stage skeleton
```js
// inside build-road-graph.mjs, after `edges` assembly, before encodeGraph:
const QUANTUM_S = 0.1; // named constant; header field value
const quanta = Math.floor(seconds / QUANTUM_S); // integer ĉ, ≥ per-edge (0.1s unit)
// 1) integer Dijkstra over REVERSED edges from each anchor → toTable row
// 2) integer Dijkstra over forward edges from each anchor → fromTable row
// 3) farthest-point selection over the ĉ metric (seeded, degree-≥2 filtered, ID tie-break)
// 4) coverage assertion per anchor; quantum-overflow assert; encode through encodeGraph
```
## Sources
### Primary (HIGH confidence)
- Bast, Delling, Goldberg, Müller-Hannemann, Pajor, Sanders, Wagner — *Route Planning in Transportation Networks* (arXiv:1504.05140, fetched via ar5iv): bidirectional potential combination (πf−πr)/2 scheme; ALT triangle-inequality bounds verbatim + feasibility claim; geographic-bound weakness; landmark selection guidance ("well-spaced close to the boundary leads to the best results"; minor-speedup caveat).
- Campbell — *Computing Shortest Paths using A\*, Landmarks, and Polygon Inequalities* (arXiv:1603.01607): ALT storage θ(|L|·|V|); heuristic = max over landmark inequalities; consistency is theorem-grade (their ALP variant NOT consistent — the class of pitfall Pattern 3 guards).
- Goldberg & Harrelson — *Computing the Shortest Path: A\* Search Meets Graph Theory* (SODA 2005; Princeton/MSR TR-2004-24 PDFs surfaced via search): far/sector landmark selection ("For each sector, pick a vertex farthest away from the center…").
- Goldberg & Werneck — *Better Landmarks within Reach* (via wea PDF snippet): the "avoid" greedy strategy ("picks landmarks one by one, always in regions… not already covered").
- Repo code + docs read in full this session: `routing/src/graph.ts`, `routing/src/astar.ts`, `routing/scripts/{build-road-graph,measure-route-cpu,route-corpus,upload-road-graph}.mjs`, `routing/test/{harness.mjs,graph.test.mjs,astar.test.mjs}`, `bot/src/routing/worker-router.ts`, `docs/ROAD-GRAPH.md`, `docs/KNOWN-LIMITATIONS.md`, fixture builder, `.planning/{STATE,REQUIREMENTS}.md`, `.planning/phases/{06…,07…,08…}/…`.
### Secondary (MEDIUM confidence)
- Build-runtime cost estimate A4 (derived, not measured).
- u16 integer-weight practice precedent (common in the CH-era ecosystem, not fetched specifically).
### Tertiary (LOW confidence)
- Expected p95 magnitude post-ALT (A6) — no published KL-metro analogue; the gate is the truth.
## Metadata
**Confidence breakdown:**
- Standard stack / integration shape: HIGH — verified against code + the canonical survey; the potential-integration finding is the load-bearing fact and it is pinned twice (survey + line-by-line code check).
- ARG2 layout: HIGH — append-only discipline keeps every pinned law mechanical; byte math checkerboarded against measured counts.
- Quantization exactness: HIGH on the math (self-contained proofs documented in Pattern 2); its *empirical* seal relies on the A1 sweep + new island case (planner should keep both mandatory).
- Perf verdict: MEDIUM — order-of-magnitude argument from measured baselines; the gate decides.
- Pitfalls: HIGH (all six are chase-able in this repo's own artifacts).
**Research date:** 2026-10-25
**Valid until:** 2026-11-24 (binary layout insights stable; only Node minor versions drift)
@@ -0,0 +1,92 @@
---
phase: 8
slug: alt-landmark-preprocessing-routing-backend-v2
status: planned
nyquist_compliant: true
wave_0_complete: false
created: 2026-10-25
---
# Phase 8 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | hand-rolled registry (`routing/test/harness.mjs`) + `node:test`-style runner, **zero deps** |
| **Config file** | none — `routing/test/runner.mjs` + npm workspace scripts |
| **Quick run command** | `npm run routing:test` |
| **Full suite command** | `npm run routing:test && npm test -w bot && npm run routing:typecheck && npm run bot:typecheck` |
| **Estimated runtime** | ~10–30 s (suite); big-graph gates are maintainer-time (build/measure) |
---
## Sampling Rate
- **After every task commit:** Run `npm run routing:test`
- **After every plan wave:** Run full suite (routing + bot + both typechecks)
- **Before `/gsd-verify-work`:** Full suite must be green
- **Max feedback latency:** 30 seconds (suite); the real-graph gates are phase-gate-only
---
## Per-task Verification Map
| task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| W0 baseline recorder | 01 | 1 | SC-2 (D-03 prereq) | — | N/A | script | `node routing/scripts/measure-optimality-baseline.mjs` → committed record | ❌ W0 | ⬜ pending |
| ARG1 fixture preservation | 01 | 1 | D-01 | T-08-02 | old binary → decode null (fail-honest) | unit | `npm run routing:test` (G rejection case) | ❌ W0 | ⬜ pending |
| SC-1′ extended-sample flag | 01 | 1 | SC-1 (D-04) | — | N/A | measurement gate | `node routing/scripts/measure-route-cpu.mjs --extended` | ❌ W0 (flag) | ⬜ pending |
| ARG2 codec G-suite | 02 | 2 | D-01/D-02 | T-08-01/02 | exact-size-before-allocation; version + landmarkCount laws; hostile header on tiny buffer rejects BEFORE view allocation | unit | `npm run routing:test` (G cases) | ❌ new | ⬜ pending |
| ALT potentials A-suite (A1 re-pin + island A9/A10 + A11 pin) | 03 | 3 | SC-2 | T-08-05/06 | sentinel-safe potentials; equivalence sealed under ALT | unit (A1 625-pair sweep + new cases) | `npm run routing:test` (A cases) | ⚙ extends | ⬜ pending |
| Rollout pins (upload-agnostic + GRAPH_KEY) | 03 | 3 | D-01 | T-08-08 | upload stays format-agnostic; loader keeps ONE decode path | structural grep (A8-shape) | `npm run routing:test` (RO1/RO2) | ❌ new | ⬜ pending |
| Build preprocessing stage | 04 | 4 | SC-1 (D-02) | T-08-09/10/11 | coverage + quantum gates before write; L scaling rule | script gate | `node routing/scripts/build-road-graph.mjs` + `--check` | ⚙ extends | ⬜ pending |
| Real ARG2 rebuild + check | 04 | 4 | SC-1 prereq | T-08-09 | envelope + coverage green on the real binary | script gate (network, maintainer-time) | `node routing/scripts/build-road-graph.mjs --check routing/data/kl-roadgraph.v1.bin` | ⚙ extends | ⬜ pending |
| CPU gate + extended verdict | 05 | 5 | SC-1 | T-08-16 | print-the-measurement; pre-committed L ladder | measurement | `node routing/scripts/measure-route-cpu.mjs` (+ `--extended`) | ✅ extends | ⬜ pending |
| Optimality gate (D-03) | 05 | 5 | SC-2 | T-08-13/15 | epsilon mirror; seconds+verdict only; record immutable | offline gate | `node routing/scripts/measure-optimality-gate.mjs` | ❌ new | ⬜ pending |
| Budget-record c3 truth-out | 05 | 5 | SC-3 | T-08-14 | gate law + audit vectors re-anchored; no accepted-degradation wording | suite + grep | `node scripts/test/budget-audit.test.mjs` | ⚙ extends | ⬜ pending |
| Docs retirement (D-05) | 05 | 5 | SC-3 | T-08-14 | quoted measured numbers; honest remainders | doc acceptance | review leg (supported by printed gate output) + grep gates | ❌ new | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [x] Test framework: none needed (zero-dep harness present)
- [ ] **Baseline record + recorder tool** (D-03 prerequisite — MUST be first task before ARG2 codec lands; research Pitfall 1)
- [ ] Extended-sample flag (D-04) — deterministic coordinate-pair generator, `MIN_PAIR_KM`-style named constant, p95 print + exit gate; CLI default mode (25-route gate) must stay backward-compatible for `budget-audit`
- [ ] `fixtures/mini-graph-arg1.bin` — bytes preserved from the current committed ARG1 fixture (decode-rejection vector)
- [ ] `fixtures/build-mini-graph.mjs` gains a landmark stage (tiny L; ARG2 output)
- [ ] New G cases (ARG2 codec + rejection matrix) + island A case
- [ ] Upload-unchanged structural pin case
*Constraint re-verified in planning: `budget-audit` (Phase 7) re-runs `measure-route-cpu.mjs` — keep its CLI contract or account for the flag addition.*
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| D-05 docs retirement wording | SC-3 | Honest tone is a human judgment; the evidence itself is automated | Read `docs/KNOWN-LIMITATIONS.md` retirement record against the printed gate numbers |
| R2 upload of the v2 binary (rollout act) | D-01 | Account-level wrangler/R2 act, maintainer-time | `node routing/scripts/upload-road-graph.mjs` after cargo edits are done → roundtrip `--check` |
*All other phase behaviors have automated verification.*
---
## Validation Sign-Off
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references
- [x] No watch-mode flags
- [x] Feedback latency < 30 s (suite tasks; the real-graph build/gates are maintainer-time phase-gate-only, per the sampling note above)
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** planning complete 2026-10-25 (plans 08-01…08-05); execution pending user approval