Files

199 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 08-alt-landmark-preprocessing-routing-backend-v2
plan: 03
type: execute
wave: 3
depends_on: ["08-02"]
files_modified:
- routing/src/astar.ts
- routing/test/astar.test.mjs
- routing/test/rollout.test.mjs
autonomous: true
requirements: [SC3, SC4]
must_haves:
truths:
- "routeAstar's h_f/h_b bodies become max(ALT triangle bounds, haversine bound); the (h_f−h_b)/2 combination, reductions, balanced alternation, meet probes, BH stop rule, and real-seconds reconstruction are byte-identical (research Pattern 1 — the ONLY function that changes is potential())"
- "Sentinels are compared as RAW u16 0xFFFF BEFORE any float conversion; landmark skip is SEARCH-scoped (a sentinel constant entry drops that family for that ℓ for the whole search) with per-node sentinel skips exactly as the research sketch — never value-based per-node ℓ-masking mid-max"
- "A node whose table entries are sentinel for every remaining ℓ falls back to the existing haversine bound — correct but weaker steering (Pattern 3 containment); the haversine bound is MAX-combined everywhere (max of two consistent admissible heuristics stays consistent + admissible)"
- "A1's exhaustive 625-pair Dijkstra-equivalence sweep stays green with ALT live (the standing optimality proof now seals ALT); A8's no-Map structural pin stays green"
- "worker-router.ts and upload-road-graph.mjs are structurally unchanged (D-01 locks) — pinned by a new rollout.test.mjs so the campaign cannot silently grow format awareness"
artifacts:
- path: "routing/src/astar.ts"
provides: "ALT potentials inside the proven BH skeleton — per-search L-length constant tables, lazy per-node max-of-bounds, haversine max-fallback"
- path: "routing/test/astar.test.mjs"
provides: "A9 island + A10 search-scoped-skip + A11 structural (ALT reads the tables) cases; A1 re-pinned under ALT"
- path: "routing/test/rollout.test.mjs"
provides: "RO1 upload-format-agnostic + RO2 one-decode-path structural pins (A8 grep-shape)"
key_links:
- from: "routing/src/astar.ts potential()"
to: "graph.landmarkTo / graph.landmarkFrom / graph.landmarkQuantumS (Plan 08-02's RoadGraph fields)"
via: "per-search Float64Array(L) quanta constants (NaN = sentinel skip) + lazy per-node max over ℓ"
pattern: "graph\\.landmarkTo"
- from: "routing/test/astar.test.mjs (A9/A10)"
to: "routing/src/landmark.ts"
via: "farthestPointAnchors/landmarkTables build the islanded fixtures' tables through the ONE landmark module"
pattern: "landmarkTables"
- from: "routing/test/rollout.test.mjs"
to: "routing/scripts/upload-road-graph.mjs + bot/src/routing/worker-router.ts"
via: "structural grep pins (A8 shape): format-agnostic upload, GRAPH_KEY unchanged, no version branch in the loader"
pattern: "kl-roadgraph\\.v1\\.bin"
---
<objective>
The ALT integration itself: swap `routeAstar`'s two heuristic bodies for max(ALT triangle bounds, haversine) inside the survey-verified (πf−πr)/2 bidirectional construction, sealed by the A-suite, with the D-01 rollout locks pinned before the campaign can touch them.
Purpose: this is the entire runtime change of Phase 8 — steering sharpened on the cross-metro class, optimality carried by the already-proven skeleton (A1), and the Worker/upload seam proven untouched.
Output: ALT-live `routing/src/astar.ts`, extended `astar.test.mjs` (A9/A10/A11 + A1 re-pin), new `routing/test/rollout.test.mjs`, full battery green.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md
@.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-VALIDATION.md
<interfaces>
<!-- The search being extended. Extracted from routing/src/astar.ts 2026-10-25 (470 lines — read it in full first). -->
routeAstar(graph, originNode, goalNode) @ line 219. The heuristic scaffolding (lines 225–230):
```ts
const maxSpeedMps = graph.maxSpeedKmh / 3.6;
const heuristicScale = maxSpeedMps > 0 ? 1 / maxSpeedMps : 1;
const goalLat = graph.lat[goalNode]!; /* … origin coords likewise … */
```
The lazy potential (lines 232–251) — the ONLY code that changes:
```ts
const hfCache = new Float64Array(n).fill(Number.NaN);
const hbCache = new Float64Array(n).fill(Number.NaN);
const pf = new Float64Array(n);
function potential(v: number): number {
let hf = hfCache[v]!;
if (Number.isNaN(hf)) { hf = metersTo(graph, v, goalLat, goalLng) * heuristicScale; hfCache[v] = hf; }
/* … hb likewise … */ const value = (hf - hb) / 2; pf[v] = value; return value;
}
```
Everything else is PINNED untouched: per-search init (263–268), the BH loop with reductions `du + c + pfu − pv` / `du + c − pfu + pvv` and meet probes (274–340), the stop rule (277), the real-seconds recompute (352–364), reverseFor's WeakMap memo (146–192). A8 (astar.test.mjs 277–283) greps comment-stripped source for `new Map(` — keep the hot loop typed-arrays-only.
RoadGraph gains (Plan 08-02): `landmarkCount: number; landmarkQuantumS: number; landmarkTo: Uint16Array; landmarkFrom: Uint16Array; landmarkNodes: Uint32Array` — u16 rows row-major `[ℓ*N + v]`, sentinel 0xFFFF = v cannot reach / be reached from ℓ (DATA, never validated away). decodeGraph guarantees landmarkCount ≥ 1.
landmark.ts exports (Plan 08-02 task 1) for the test fixtures: `farthestPointAnchors(input, count)`, `landmarkTables(input, anchors) → { to, from }` with `LandmarkInput = { nodeCount, edges: [{from,to,seconds,flags}], quantumS }`.
From 08-RESEARCH: Pattern 1 (the four bound families + the max-rule recommendation), Pattern 2 (why the ĉ-metric bounds are EXACTLY admissible + consistent — no epsilon), Pattern 3 (sentinel containment layers), Anti-Patterns ("Losing sentinels into Float64Array", "per-node skip rule that varies the landmark set mid-max" for VALUE-based masking).
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>task 1: ALT potentials in routeAstar + the containment A-cases (RED→GREEN)</name>
<files>routing/src/astar.ts, routing/test/astar.test.mjs</files>
<read_first>
- routing/src/astar.ts IN FULL (470 lines — you are editing potential() and its doc-comments; every other function is pinned)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md (Pattern 1 verbatim families + max-rule; Pattern 2 exactness; Pattern 3 sentinel containment; the potential() sketch in Code Examples)
- routing/test/astar.test.mjs IN FULL (283 lines — the case-index doc-comment, A1's sweep, A3's synthetic-graph mechanic you extend, A8's pin)
- routing/src/landmark.ts exports (Plan 08-02 task 1 — the fixture-table builder)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md (astar.ts section — the pinned laws)
</read_first>
<behavior>
- A11 structural (the RED case — fails before the swap): comment-stripped astar.ts code (A8's exact stripping shape) references `landmarkTo`, `landmarkFrom`, and `landmarkQuantumS` — the potential provably consults the tables; `new Map(` still absent (A8's law, unchanged)
- A9 island + per-node fallback: a two-component synthetic (e.g. a 3×3 grid + a separate 4-node path) whose landmark anchors are HAND-PINNED inside component A only (landmarkTables takes the anchors array — pin them, don't derive) so every component-B node is 0xFFFF in both families; every pair inside B routes Dijkstra-equal (haversine-fallback steering), every cross-component pair resolves null, every pair inside A routes Dijkstra-equal with real ALT bounds live
- A10 search-scoped constant skip: origin AND goal inside the anchorless component — every ℓ's goal/origin-side constant is sentinel, so all families drop for the whole search and the run is pure-haversine; assert the reachable pairs still route Dijkstra-equal (skip law: constants dropped at SEARCH scope, results still optimal)
- A1 (existing, re-pinned by execution): the 625-pair sweep on the ARG2 mini fixture stays green with ALT live — no case edits needed, the sweep IS the seal; seconds-only assertions everywhere (never node-path equality — D-03's tie law)
</behavior>
<action>
Write the A9/A10/A11 cases FIRST (they run against the current haversine implementation: A11 fails = RED; A9/A10 pass as baseline-equivalence and become the containment seal after the swap). Build each fixture via `encodeGraph({ …, landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from } })` with tables from `landmarkTables({ nodeCount, edges, quantumS }, anchors)` — keep a tiny local `buildFixture(edges, anchors)` helper; update the case-index doc-comment (A1–A8 → A1–A11).
Then swap the heuristic bodies in `routeAstar` (potential() + its init only):
1. Per-search constants (after the existing heuristic consts, before the caches): `const L = graph.landmarkCount;` `const Q = graph.landmarkQuantumS;` and four `Float64Array(L)` tables in QUANTA units — `goalToU[ℓ] = graph.landmarkTo[ℓ*n + goalNode]`, `goalFromU[ℓ] = graph.landmarkFrom[ℓ*n + goalNode]`, `origToU[ℓ] / origFromU[ℓ]` likewise for the origin — each entry stored as-is, with 0xFFFF written as NaN (the search-scoped sentinel skip; the constant tables are the ONLY place skip decisions live). L ≥ 1 is a decode guarantee (D-01) — no L===0 branch, no dead fallback path (one search path law; a comment cites the guarantee).
2. Inside potential()'s cache-miss branch: per direction, `bestTo/bestFrom` start at −Infinity; loop ℓ = 0..L−1 over RAW u16 reads: `const tv = graph.landmarkTo[ℓ*n + v]` — `if (tv === 0xffff) continue` (per-node sentinel skip, exactly the research sketch); if `goalToU[ℓ]` is not NaN → `bestTo = max(bestTo, tv − goalToU[ℓ])` (family A: d̂(v,ℓ) − d̂(t,ℓ)); `const fv = graph.landmarkFrom[ℓ*n + v]` — `if (fv === 0xffff) continue`; if `goalFromU[ℓ]` is not NaN → `bestFrom = max(bestFrom, goalFromU[ℓ] − fv)` (family B: d̂(ℓ,t) − d̂(ℓ,v)). Then `const altF = Math.max(bestTo, bestFrom)` and `hf = altF > -Infinity ? Math.max(havF, altF * Q) : havF` where `havF` is the EXISTING haversine expression (the max-rule — research Pattern 1 recommendation; sentinel-flooded nodes degrade to exactly today's behavior). Mirror for hb toward the origin. The `const value = (hf − hb) / 2; pf[v] = value; return value;` tail is BYTE-IDENTICAL to today.
3. Bounds may be negative (v closer to ℓ than t is) — legal: p_f already goes negative today when h_b > h_f; the BH meet-sum law is algebraic in p_f's sign. The max-rule with the non-negative haversine bound keeps hf/hb ≥ 0 anyway.
4. NO Map/Object allocations (A8); no new imports (graph types + geo are already imported); sentinel comparisons happen on the raw u16 BEFORE any float conversion (anti-pattern: 65535 × quantum as a bogus finite bound).
5. Doc-comments: the module header and routeAstar's BH block gain the ALT paragraph — the survey formulation (πf−πr)/2 is what p_f already implements (cite it), the ĉ-metric exactness in one sentence (floor-quantized edge weights → exact 1-Lipschitz bounds, no epsilon), the sentinel laws (search-scoped constant skip, per-node fallback, 0xFFFF is data), and that A1 re-run under ALT is the standing optimality proof. Keep the Ikeda/balanced-alternation history intact.
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && npm run routing:test 2>&1 | tail -3 && npm run routing:typecheck && echo "ALT-POTENTIALS-GREEN"</automated>
</verify>
<acceptance_criteria>
- `npm run routing:test` green INCLUDING A9, A10, A11 and the re-pinned A1 sweep (625 pairs) with ALT live; the case-index doc-comment lists A1–A11.
- A8's pin still green (no `new Map(` in comment-stripped astar.ts).
- grep law: `grep -c "landmarkTo" routing/src/astar.ts` ≥ 1 AND the A11 case (not just the grep) proves it is CODE not comments (comment-stripped source).
- grep law: `grep -c "0xffff" routing/src/astar.ts` ≥ 2 (raw sentinel comparisons on both table families) and `grep -c "65535" routing/src/astar.ts` = 0 (one spelling of the sentinel).
- Seconds-only assertions: no new case compares node paths (`grep -c "nodes.length ===" routing/test/astar.test.mjs` unchanged from its current A2 usage — A9/A10 assert seconds + nullness).
- typecheck clean; no new imports in astar.ts.
</acceptance_criteria>
<done>ALT steering is live inside the byte-identical BH skeleton; A1's exhaustive equivalence re-proves optimality under ALT; the island and skip laws have dedicated containment cases.</done>
</task>
<task type="auto">
<name>task 2: Rollout pins (RO1/RO2) + the full-battery seal with ALT live</name>
<files>routing/test/rollout.test.mjs</files>
<read_first>
- routing/scripts/upload-road-graph.mjs IN FULL (78 lines — the contract RO1 pins: KEY/BUCKET constants, wrangler shell, NO format awareness)
- bot/src/routing/worker-router.ts lines ~50–80 (GRAPH_KEY, loadGraph's ONE decode path, the null → router-pending flow)
- routing/test/astar.test.mjs A8 (the grep-pin shape to copy — comment stripping, assert on stripped code)
- bot/test/route-privacy.test.mjs RP1a (what is ALREADY pinned router-side — do not duplicate; RO2 pins the FORMAT-agnostic law, RP pins the identity/no-fetch law)
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md (D-01 — upload unchanged is a roadmap LOCK)
</read_first>
<action>
Create `routing/test/rollout.test.mjs` (zero deps, harness `test()` import, case-index doc-comment, A8's readFileSync + comment-strip mechanics):
- **RO1 upload-format-agnostic**: upload-road-graph.mjs source contains `KEY = "kl-roadgraph.v1.bin"` and `BUCKET = "araharah-graphs"` (the storage contract); comment-stripped code contains NONE of `landmark`, `encodeGraph`, `decodeGraph`, `0x41524731`, `0x41524732`, `GRAPH_VERSION` — the migration is byte-shipping of the same key (D-01 lock); the script must never grow format awareness (a fork's upload path stays layout-agnostic forever).
- **RO2 one decode path at the Worker**: worker-router.ts comment-stripped code contains `GRAPH_KEY = "kl-roadgraph.v1.bin"` (storage name unchanged), exactly one `decodeGraph` reference inside the loadGraph flow, and NO version-conditional branch (no `GRAPH_VERSION`, no `=== 2` against the graph bytes) — the router never branches on format; an undecodable binary flows to the existing null → router-pending path (D-01's one-path law at the consumer).
Then run the FULL battery at this commit — `npm run routing:test`, `npm test -w bot`, `npm run routing:typecheck`, `npm run bot:typecheck`. This is the first commit where routeAstar consults REAL tables on the bot suite's ARG2 corridor graphs (built in 08-02 task 3): the RT trunks and guidance-cron cases must stay green. Any failure here is an ALT integration bug — fix the integration, never the assertion (seconds are A1-law stable; the corridors' expected costs must not move).
</action>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && npm run routing:test 2>&1 | tail -2 && npm test -w bot 2>&1 | tail -2 && npm run routing:typecheck && npm run bot:typecheck && echo "BATTERY-ALT-GREEN"</automated>
</verify>
<acceptance_criteria>
- `routing/test/rollout.test.mjs` committed with RO1 + RO2 green (`npm run routing:test` reports 0 failed).
- Full battery green: routing suite, bot suite, both typechecks at this commit.
- grep law: `grep -c "0x41524732\|GRAPH_VERSION" ` over comment-stripped worker-router.ts = 0 (enforced by RO2 itself).
- `git status` shows worker-router.ts and upload-road-graph.mjs UNMODIFIED by this plan (the pins protect files this phase must not touch — `git diff --stat` on both empty).
</acceptance_criteria>
<done>The D-01 locks are mechanically pinned suite-side, and the whole battery is green with ALT potentials live on every fixture surface.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| graph binary (tables) → potential evaluation | the u16 tables are hostile-input-derived data at the Worker boundary; the potential is the consumer |
| measurement verdict → astar.ts | the campaign must never be able to "tune" the search into the gate (Pitfall 3) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-05 | DoS | potential() under sentinel-flooded tables | mitigate | search-scoped constant skip drops unusable ℓ wholesale; per-node sentinel skips are O(L) raw reads; worst case degrades to exactly today's haversine behavior (measured-safe), never to incorrect output (A1 seal) |
| T-08-06 | Tampering | potentials weakened to pass the CPU gate | mitigate | admissibility is STRUCTURAL (ĉ ≤ c edge-wise; no tunable scale on ALT bounds — heuristicScale touches only the haversine term); A1's 625-pair equivalence + A9/A10 re-prove optimality on every run; the campaign ladder (08-05) is rebuild-L-only |
| T-08-07 | Information disclosure | astar.ts internals | accept | pure-module law preserved (no logging/storage/network, T-06-05); no new imports |
| T-08-08 | Tampering | rollout drift (upload grows format logic / router grows a version branch) | mitigate | RO1/RO2 structural pins fail the suite on any such edit — the locks are enforced, not customary |
</threat_model>
<verification>
- `npm run routing:test` fully green (A1 seal + A9/A10/A11 + RO1/RO2).
- `npm test -w bot` green (RT trunks + cron on ARG2 corridor graphs with ALT live).
- `npm run routing:typecheck` and `npm run bot:typecheck` clean.
- `git diff --stat bot/src/routing/worker-router.ts routing/scripts/upload-road-graph.mjs` empty at this commit.
</verification>
<success_criteria>
routeAstar consults the ARG2 landmark tables through the survey-exact (πf−πr)/2 construction — max-of-bounds with sentinel-safe raw-u16 handling and a haversine max-fallback — while every other line of the search stays byte-identical; A1's 625-pair Dijkstra equivalence is the standing proof with ALT live; the island and search-scoped-skip laws have dedicated cases; the upload/router seams are pinned format-agnostic; the full battery is green.
</success_criteria>
<output>
After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-03-SUMMARY.md`
</output>