199 lines
19 KiB
Markdown
199 lines
19 KiB
Markdown
---
|
||
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>
|