Files
arah2/.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-03-PLAN.md
T

19 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
08-alt-landmark-preprocessing-routing-backend-v2 03 execute 3
08-02
routing/src/astar.ts
routing/test/astar.test.mjs
routing/test/rollout.test.mjs
true
SC3
SC4
truths artifacts key_links
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
path provides
routing/src/astar.ts ALT potentials inside the proven BH skeleton — per-search L-length constant tables, lazy per-node max-of-bounds, haversine max-fallback
path provides
routing/test/astar.test.mjs A9 island + A10 search-scoped-skip + A11 structural (ALT reads the tables) cases; A1 re-pinned under ALT
path provides
routing/test/rollout.test.mjs RO1 upload-format-agnostic + RO2 one-decode-path structural pins (A8 grep-shape)
from to via pattern
routing/src/astar.ts potential() graph.landmarkTo / graph.landmarkFrom / graph.landmarkQuantumS (Plan 08-02's RoadGraph fields) per-search Float64Array(L) quanta constants (NaN = sentinel skip) + lazy per-node max over ℓ graph.landmarkTo
from to via pattern
routing/test/astar.test.mjs (A9/A10) routing/src/landmark.ts farthestPointAnchors/landmarkTables build the islanded fixtures' tables through the ONE landmark module landmarkTables
from to via pattern
routing/test/rollout.test.mjs routing/scripts/upload-road-graph.mjs + bot/src/routing/worker-router.ts structural grep pins (A8 shape): format-agnostic upload, GRAPH_KEY unchanged, no version branch in the loader 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.

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_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

routeAstar(graph, originNode, goalNode) @ line 219. The heuristic scaffolding (lines 225–230):

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:

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" <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> 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" <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> The D-01 locks are mechanically pinned suite-side, and the whole battery is green with ALT potentials live on every fixture surface.

<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>
- `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.

<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>

After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-03-SUMMARY.md`