26 KiB
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.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl | 01 | execute | 1 |
|
true |
|
|
Purpose: This is the mechanism that closes SC-1. The hierarchy prunes the request-time search structurally, while D-04/D-05 remove the ALT engine and its 28.5 MB landmark suffix rather than leaving a dormant fallback. Output: Pure CH construction/query modules, ARG3 codec, CH-backed routeAstar, ARG3 fixtures, and updated routing/bot test suites.
<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/ROADMAP.md @.planning/STATE.md @.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-CONTEXT.md @.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-RESEARCH.md @.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-PATTERNS.md @.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-VALIDATION.md @routing/src/graph.ts @routing/src/astar.ts @routing/src/landmark.ts @routing/src/index.ts @routing/test/graph.test.mjs @routing/test/astar.test.mjs @routing/test/fixtures/build-mini-graph.mjs @routing/test/rollout.test.mjs Existing contracts to preserve: ```typescript // routing/src/astar.ts export interface AstarResult { nodes: number[]; seconds: number; distanceM: number; } export function routeAstar(graph: RoadGraph, originNode: number, goalNode: number): AstarResult | null; export function deriveSteps(graph: RoadGraph, nodes: number[]): RouteStep[]; export function classifyTurn(prevBearingDeg: number, nextBearingDeg: number): GuidanceTurnKind; ```New internal contracts to implement:
// routing/src/contraction.ts
export interface ContractionInput {
nodeCount: number;
edges: Array<{ from: number; to: number; seconds: number; flags: number }>;
}
export interface ContractionResult {
rank: Uint32Array;
shortcutStart: Uint32Array;
shortcutTarget: Uint32Array;
shortcutSeconds: Float32Array;
shortcutVia: Uint32Array;
stats: {
shortcutCount: number;
ratioPerNode: number;
witnessSearches: number;
witnessBudgetHits: number;
};
}
export const CH_LAMBDA_DELETED_NEIGHBOURS = 1;
export const CH_WPS_MAX_SETTLED = 60;
export const CH_WPS_SETTLED_PER_DEGREE = 4;
export const CH_SHORTCUT_EXPLOSION_RATIO = 4;
export function contractGraph(input: ContractionInput): ContractionResult;
// routing/src/graph.ts
export interface ChTablesInput {
rank: Uint32Array;
shortcutStart: Uint32Array;
shortcutTarget: Uint32Array;
shortcutSeconds: Float32Array;
shortcutVia: Uint32Array;
}
export interface RoadGraph {
chShortcutCount: number;
chRank: Uint32Array;
shortcutStart: Uint32Array;
shortcutTarget: Uint32Array;
shortcutSeconds: Float32Array;
shortcutVia: Uint32Array;
}
ARG3 fork contract to implement exactly:
header 72 bytes, little-endian:
0 magic u32 = 0x41524733 ("ARG3")
4 version u32 = 3
8 nodeCount u32
12 edgeCount u32
16 nameCount u32
20 namePoolLen u32
24 bbox 4 x f64
56 maxSpeedKmh f32
60 chShortcutCount u32 (0 -> decode null)
64 reserved0 u32
68 reserved1 u32
sections:
lat[N] ++ lng[N] ++ edgeStart[N+1] ++ edgeTarget[E] ++ edgeFlags[E]
++ edgeSeconds[E] ++ edgeNameId[E] ++ nameOffset[M+1] ++ namePool
++ chRank[N] ++ shortcutStart[N+1] ++ shortcutTarget[S]
++ shortcutSeconds[S] ++ shortcutVia[S]
Implement these exact constants and exports:
- `CH_LAMBDA_DELETED_NEIGHBOURS = 1`
- `CH_WPS_MAX_SETTLED = 60`
- `CH_WPS_SETTLED_PER_DEGREE = 4`
- `CH_SHORTCUT_EXPLOSION_RATIO = 4`
- `ContractionInput { nodeCount, edges: Array<{from,to,seconds,flags}> }`
- `ContractionResult { rank, shortcutStart, shortcutTarget, shortcutSeconds, shortcutVia, stats }`
- `contractGraph(input): ContractionResult`
Implement the bottom-up CH algorithm exactly:
1. Validate node ids and finite non-negative edge seconds before constructing CSR views. An edge with `flags & 1` is blocked in every direction and must never be relaxed.
2. Compute priority as `edgeDifference(u) + CH_LAMBDA_DELETED_NEIGHBOURS * deletedNeighbours(u)`. Use a lazy binary priority queue. When a popped priority is stale, recompute it and requeue if it improved. Break priority ties by the lower node id.
3. Assign `rank[u]` as the strictly increasing contraction position, never as the priority value. Thus rank is a total order over all nodes.
4. For each node `u` being contracted, enumerate overlay in-neighbours `a` and overlay out-neighbours `b`. The candidate shortcut is directed `a -> b`, with seconds `w(a,u) + w(u,b)` and `via = u`.
5. Run each witness search on the overlay graph with `u` removed and all previously contracted nodes absent. Bound it by both the candidate distance and `max(CH_WPS_MAX_SETTLED, CH_WPS_SETTLED_PER_DEGREE * localDegree)`. If the witness search is bounded out or finds no path within the candidate distance, emit the shortcut; bounded witness searches may add superfluous shortcuts but must not remove a required one.
6. Remove `u` and its incident overlay edges before the next witness search. Emit shortcuts in deterministic order: ascending `from`, then ascending `to`.
7. Build shortcut CSR arrays where `shortcutStart` has length `nodeCount + 1` and every source's shortcuts are contiguous.
8. Fail loud if `shortcutCount > CH_SHORTCUT_EXPLOSION_RATIO * nodeCount`, with a message naming the ratio and the measured count.
9. Populate `stats` with `shortcutCount`, `ratioPerNode = shortcutCount / nodeCount`, `witnessSearches`, and `witnessBudgetHits`.
Determinism is mandatory: no PRNG, no `Date`, no Map iteration order, no reliance on object key order. Identical input arrays must yield identical output arrays. Do not use any external CH library and do not add a dependency.
In `layoutFor`, calculate the CH suffix in this order and use 4-byte alignment before every u32/f32 section: `chRank = (namePool + namePoolLen + 3) & ~3`, then `shortcutStart = chRank + 4*nodeCount`, `shortcutTarget = shortcutStart + 4*(nodeCount+1)`, `shortcutSeconds = shortcutTarget + 4*shortcutCount`, `shortcutVia = shortcutSeconds + 4*shortcutCount`, `total = shortcutVia + 4*shortcutCount`. Run the exact-size arithmetic check before allocating any view. `chShortcutCount === 0` must return null even when the byte size matches. `decodeGraph` must never throw for malformed bytes and must never return a half-built graph.
Create `routing/src/ch-query.ts` with the pure uphill bidirectional CH query. Its public result shape must be `{ nodes: number[]; seconds: number; distanceM: number }`. The forward search relaxes only edges whose target rank is strictly greater than the source rank. The backward search relaxes only incoming edges whose source rank is strictly greater than the settled node's rank. Use separate forward and backward edge access; never reuse the forward shortcut list in the backward search. Apply the shared traversal law in every scan: `if ((edgeFlags[e] & 1) !== 0) continue;`.
The stop rule is `if (forwardHeap.peekKey() + backwardHeap.peekKey() >= mu) break;`. Alternating the smaller frontier first is only a work-balance knob and must not affect optimality. Use the edge-based meet probe already present in astar.ts. After reconstructing the parent chains, recursively unpack every shortcut through its `via` field until only original edges remain. THEN compute `seconds` by summing `graph.edgeSeconds[findTraversableEdge(...)]` over consecutive original-edge pairs, and compute `distanceM` by summing haversine over that same unpacked path. Shortcut weights steer the search only; they never enter reported seconds.
Rewrite `routing/src/astar.ts` so `routeAstar` validates origin/goal, handles origin === goal, and delegates to the CH query for every other request. Preserve `AstarResult`, `deriveSteps`, `classifyTurn`, and all exported turn constants exactly. Delete the ALT machinery: `LANDMARK_SENTINEL_U16`, `cleanColumnsFor`, the `cleanColumnGraphs` memo, `metersTo`, `bound`, `potential`, the ALT/BH search body, and every landmark-table reference. Per D-04 there is exactly one search path.
Update `routing/src/index.ts` to export the existing public API and additively export `contractGraph`, `ContractionInput`, `ContractionResult`, and the four `CH_*` constants if the query implementation needs them. Export no landmark symbols.
Delete `routing/src/landmark.ts` and `routing/test/landmark.test.mjs` per D-05. Re-pin `routing/test/graph.test.mjs` for ARG3 and re-point `routing/test/astar.test.mjs` A1-A8 at the CH-backed `routeAstar`; remove or fold the ALT-specific A9-A13 cases into the CH query suite rather than retaining landmark fixtures.
Replace the landmark stage in `routing/test/fixtures/build-mini-graph.mjs` with a CH stage. Import `contractGraph` from `../../src/contraction.ts`, call it with the 25-node grid's directed edges, and pass its result as `ch:` to `encodeGraph`. Before writing, fail loud unless: `new Set(rank).size === 25`; every rank is in [0,25); the shortcut count is within the expected grid band; every `via` is a node id in [0,25); every shortcut's seconds equal its two constituent edge weights; and the graph remains strongly connected.
Update `bot/test/webhook.test.mjs`: replace the `landmark.ts` import and `landmarkBlock()` with a `chBlock(nodeCount, edges)` helper that computes `contractGraph` and returns the CH block. Replace every `landmarks: { ... }` argument to `encodeGraph` with `ch: { ... }`. There are three graph builders in this file; all must use the ARG3 path.
Update `bot/test/guidance-cron.test.mjs`: replace the pinned-anchor 2-node graph builder with a `contractGraph` call and encode with `ch:`. The 2-node graph must contract successfully; no anchor pin is allowed.
Run `node routing/test/fixtures/build-mini-graph.mjs` to regenerate `mini-graph.bin` as ARG3 and confirm the printed node/edge/shortcut/byte counts.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| R2 object -> Worker decoder | The R2-held road-graph bytes are untrusted to the decoder: corruption, truncation, hostile counts, or misalignment must produce null, never a throw or half-built graph. |
| Request -> Worker -> CH query | Untrusted route parameters reach the search only after the existing Bearer claim and bbox validation; the CH query must not broaden that surface. |
| Build pipeline -> binary | The Overpass-derived graph is transformed by build-time contraction into the persisted hierarchy; a stale hierarchy against changed topology is a build-artifact discipline problem. |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-08.1-01 | Tampering / DoS | decodeGraph ARG3 header and section arithmetic |
mitigate | layoutFor computes every offset in one place; exact-size validation runs before any typed-array allocation; chShortcutCount === 0 returns null; malformed bytes return null rather than throwing. Pinned by graph.test.mjs G3/G4/G11 successors. |
| T-08.1-02 | DoS | Misaligned Float32Array/Uint32Array CH views |
mitigate | Keep HEADER_SIZE = 72 for 8-byte f64 alignment and align the CH suffix with (namePool + namePoolLen + 3) & ~3; G1 round-trip and hostile-buffer tests decode the committed ARG3 fixture without RangeError. |
| T-08.1-03 | Tampering | Directed shortcut confusion across oneway edges | mitigate | Shortcuts are directed only; forward and backward searches use separate edge access; bit0 edges are never relaxed in forward, backward, or witness scans; the directed ring/chord/spur sweep compares both directions with the Dijkstra oracle. |
| T-08.1-04 | Tampering | Stale CH hierarchy after topology or weight changes | mitigate | ARG3 is rebuilt and re-uploaded as one whole artifact under the unchanged key; docs/ROAD-GRAPH.md records that the hierarchy is valid only for the topology and weights it was built against. |
| T-08.1-05 | DoS | Unbounded build-time witness search | mitigate | Witness search is bounded by candidate distance and max(CH_WPS_MAX_SETTLED, CH_WPS_SETTLED_PER_DEGREE * localDegree); build prints witness-search and budget-hit counts. |
| T-08.1-06 | Tampering / DoS | Shortcut explosion inflating binary and query cost | mitigate | CH_SHORTCUT_EXPLOSION_RATIO = 4 fails the build loud; the existing 1-80 MB envelope remains enforced; no fixed payload ceiling is invented. |
| T-08.1-07 | Information Disclosure | Coordinates, route payloads, or user identity in logs | accept | The phase adds no logging and changes no /api/route response shape; existing category-only log law and Bearer-claim identity remain unchanged. No new log statement may be introduced in routing modules. |
| T-08.1-08 | Spoofing / Tampering | Dormant ALT path or second decoder producing a different answer | mitigate | D-04/D-05 deletion is pinned structurally: no ALT tokens in astar.ts, landmark.ts absent, no landmark exports, one decode path in the Worker; routeAstar delegates every request to the CH query. |
| T-08.1-09 | DoS | CPU exhaustion on the request path | mitigate | The uphill query uses the peekKey() stop rule and returns null for unreachable pairs; the local CPU gate and tightened drift guard in Plan 02 are the enforcement point. |
| </threat_model> |
npm run routing:test && npm test -w bot && npm run routing:typecheck && npm run bot:typecheck && npm run web:typecheck
Also verify structural deletion and the one-path law:
test ! -e routing/src/landmark.ts && test ! -e routing/test/landmark.test.mjs
grep -v '^[[:space:]]*\*' routing/src/astar.ts | grep -v '^[[:space:]]*//' | grep -c 'landmarkTo\|landmarkFrom\|cleanColumnsFor\|LANDMARK_SENTINEL'
grep -c 'decodeGraph(' bot/src/routing/worker-router.ts
Expected: routing and bot suites green; all typechecks green; structural ALT count 0; Worker decodeGraph count 1.
<success_criteria>
- CH construction and query behavior is proven by the routing unit suite, including a hand-computed fixture, overlay witness law, oneway direction, and original-edge seconds.
- ARG3 is the only encoded/decoded format; ARG1 and ARG2 decode to null through the existing fail-honest path.
routeAstaris the sole exported search and is CH-backed for every request; no ALT runtime code or landmark payload remains.- The committed mini-graph and both bot graph builders are ARG3-shaped and produced through the same encode path.
npm run routing:test,npm test -w bot, and all three typechecks pass. </success_criteria>