diff --git a/.planning/STATE.md b/.planning/STATE.md index f11d8f2..051e4bc 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,9 +2,9 @@ gsd_state_version: 1.0 milestone: v1.x milestone_name: routing-engine-upgrades-and-extras -status: completed -stopped_at: "docs-update complete: 9 docs regenerated+verified+committed; wiki synced+pushed" -last_updated: "2026-10-25T04:51:08.699Z" +status: phase-planned +stopped_at: Phase 8 planned (08-01…08-05 written) — awaiting user plan approval +last_updated: "2026-10-25T16:05:00.000Z" last_activity: 2026-10-25 progress: total_phases: 7 @@ -21,13 +21,13 @@ progress: See: .planning/PROJECT.md (updated 2026-10-22) **Core value:** A user finds a local destination via Telegram and gets there with reliable turn-by-turn guidance — tracked by nothing beyond what they explicitly opted in to. -**Current focus:** Phase 07 — hardening-private-launch +**Current focus:** Phase 08 — ALT landmark preprocessing (routing backend v2) ## Current Position -Phase: 7 -Plan: Not started -Status: Milestone complete +Phase: 8 +Plan: 08-01…08-05 planned (5 plans, 5 sequential waves) +Status: Planned — awaiting user plan approval Last activity: 2026-10-25 Progress: [░░░░░░░░░░] 0% @@ -90,6 +90,6 @@ None yet. ## Session Continuity -Last session: 2026-10-25T04:51:08.682Z -Stopped at: docs-update complete: 9 docs regenerated+verified+committed; wiki synced+pushed -Resume file: README.md +Last session: 2026-10-25T16:05:00.000Z +Stopped at: Phase 8 planned (08-01…08-05) — awaiting user approval to execute +Resume file: .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-01-PLAN.md diff --git a/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-01-PLAN.md b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-01-PLAN.md new file mode 100644 index 0000000..e3a1961 --- /dev/null +++ b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-01-PLAN.md @@ -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: 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" +--- + + +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`. + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + + +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). + + + + + + + task 1: Preserve ARG1 fixture bytes as the rejection vector + routing/test/fixtures/mini-graph-arg1.bin + + - 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) + + +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. + + + 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" + + + - `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`. + + ARG1 bytes frozen under a new committed name, provenance documented, original fixture untouched. + + + + task 2: Baseline recorder + the committed pre-ALT record (measure FIRST) + routing/scripts/measure-optimality-baseline.mjs, routing/test/fixtures/alt-baseline.json + + - .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) + + +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: s reachable|unreachable|unsnappable`) and a summary (routed count, mean/max seconds); write RECORD only after all rows: +```json +{ + "recordedAt": "", + "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": , "nodeCount": , "edgeCount": , "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. + + + 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)" + + + - `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). + + The committed record pins 100 coordinate-identified pairs with pre-ALT seconds + verdict classes; reproducible without the old binary. + + + + task 3: --extended flag on the CPU gate (default mode byte-compatible) + routing/scripts/measure-route-cpu.mjs + + - 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) + + +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. + + + 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 + + + - `node routing/scripts/measure-route-cpu.mjs --extended` prints ≥ 90 ` xNNN: ms` per-pair lines (snap-miss/unreachable pairs excepted, reported separately), a `p95: 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`). + + D-04's extended verdict exists behind an opt-in flag; the audited zero-arg ladder contract is untouched and its suite is green. + + + + + +## 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 | + + + +- `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. + + + +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. + + + +After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-01-SUMMARY.md` + diff --git a/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-02-PLAN.md b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-02-PLAN.md new file mode 100644 index 0000000..5b4e485 --- /dev/null +++ b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-02-PLAN.md @@ -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" +--- + + +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). + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + + +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. + + + + + + + task 1: landmark.ts — the pure landmark compute core (RED→GREEN) + routing/src/landmark.ts, routing/test/landmark.test.mjs + + - .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) + + + - 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) + + +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 exceeds (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 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`. + + + 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" + + + - `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. + + Deterministic anchors + exact integer ĉ tables + sentinel/overflow laws proven on hand-computed vectors. + + + + task 2: ARG2 codec in graph.ts (RED→GREEN) + routing/src/graph.ts, routing/test/graph.test.mjs + + - 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) + + + - 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 + + +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; 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). + + + 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" + + + - `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. + + One codec, one version (2): ARG2 encodes/decodes with tables; every non-ARG2 input class decodes null before allocation; G7/G8 re-pinned. + + + + task 3: Regenerate the fixture as ARG2; seal the G-suite + unaffected suites + routing/test/fixtures/build-mini-graph.mjs, routing/test/fixtures/mini-graph.bin + + - 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) + + +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. + + + 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" + + + - `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). + + 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. + + + + + +## 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 | + + + +- `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. + + + +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). + + + +After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-02-SUMMARY.md` + diff --git a/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-03-PLAN.md b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-03-PLAN.md new file mode 100644 index 0000000..83ff493 --- /dev/null +++ b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-03-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + + +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). + + + + + + + task 1: ALT potentials in routeAstar + the containment A-cases (RED→GREEN) + routing/src/astar.ts, routing/test/astar.test.mjs + + - 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) + + + - 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) + + +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. + + + 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" + + + - `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. + + 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. + + + + task 2: Rollout pins (RO1/RO2) + the full-battery seal with ALT live + routing/test/rollout.test.mjs + + - 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) + + +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). + + + 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" + + + - `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). + + The D-01 locks are mechanically pinned suite-side, and the whole battery is green with ALT potentials live on every fixture surface. + + + + + +## 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 | + + + +- `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. + + + +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. + + + +After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-03-SUMMARY.md` + diff --git a/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-04-PLAN.md b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-04-PLAN.md new file mode 100644 index 0000000..93d4fc4 --- /dev/null +++ b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-04-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + + +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 ` 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; 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). + + + + + + + task 1: The preprocessing stage + --check landmark facts (code) + routing/scripts/build-road-graph.mjs + + - 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) + + +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 ` — 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 () — 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`. + + + 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 + + + - 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. + + 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. + + + + task 2: The real rebuild (network, maintainer-time) — an ARG2 KL binary on disk + + + - 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) + + +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. + + + 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" + + + - `--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). + + 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. + + + + + +## 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 | + + + +- `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. + + + +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. + + + +After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-04-SUMMARY.md` + diff --git a/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-05-PLAN.md b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-05-PLAN.md new file mode 100644 index 0000000..4204dd9 --- /dev/null +++ b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-05-PLAN.md @@ -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-" +--- + + +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. + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + + +The frozen record (Plan 08-01, `routing/test/fixtures/alt-baseline.json`): +```json +{ "recordedAt": "", "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: 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. + + + + + + + task 1: The D-03 optimality gate script + green run against the frozen record + routing/scripts/measure-optimality-gate.mjs + + - 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) + + + - 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 + + +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 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). + + + 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" + + + - 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). + + 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. + + + + task 2: The CPU verdict campaign (SC-1 + SC-1′) with the pre-committed ladder + + + - 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) + + +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 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). + + + 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" + + + - 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). + + SC-1/SC-1′ are measured with ALT live; the numbers that retire (or honestly keep) the c3 class exist, printed and quoted. + + + + task 3: Budget-record truth-out — the route-cpu gate law + docs/BUDGETS.md + scripts/lib/budget-gates.mjs, scripts/test/budget-audit.test.mjs, docs/BUDGETS.md + + - 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) + + +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). + + + 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" + + + - `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). + + 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. + + + + task 4: Docs retirement (D-05) — KNOWN-LIMITATIONS + ROAD-GRAPH (+ CONFIGURATION verify) + docs/KNOWN-LIMITATIONS.md, docs/ROAD-GRAPH.md + + - 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) + + +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). + + + 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" + + + - 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. + + SC-3 is written: the c3 class is retired with quoted evidence in every record that documented it, and the fork contract teaches ARG2. + + + + + +## 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) | + + + +- `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. + + + +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. + + + +After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-05-SUMMARY.md` + \ No newline at end of file diff --git a/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md new file mode 100644 index 0000000..ab5a9c2 --- /dev/null +++ b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md @@ -0,0 +1,112 @@ +# Phase 8: ALT Landmark Preprocessing (Routing Backend v2) - Context + +**Gathered:** 2026-10-25 +**Status:** Ready for planning + + +## 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. + + + + +## 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 + + + + +## 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). + + + + +## 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 + + + + +## 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 + + + + +## 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 + + + +--- + +*Phase: 08-alt-landmark-preprocessing-routing-backend-v2* +*Context gathered: 2026-10-25* diff --git a/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-DISCUSSION-LOG.md b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-DISCUSSION-LOG.md new file mode 100644 index 0000000..36ad1ee --- /dev/null +++ b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-DISCUSSION-LOG.md @@ -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). diff --git a/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md new file mode 100644 index 0000000..9f14acc --- /dev/null +++ b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md @@ -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/.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`): + +**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: 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 : ; measured ; `. +**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 diff --git a/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md new file mode 100644 index 0000000..c9ce278 --- /dev/null +++ b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md @@ -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 (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) + + +## 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/.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) diff --git a/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-VALIDATION.md b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-VALIDATION.md new file mode 100644 index 0000000..6189ce3 --- /dev/null +++ b/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-VALIDATION.md @@ -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 `` 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