--- phase: 06-turn-by-turn-guidance-live-driving plan: 01 type: execute wave: 1 depends_on: [] files_modified: - routing/src/graph.ts - routing/src/snap.ts - routing/src/astar.ts - routing/src/types.ts - routing/src/index.ts - routing/test/graph.test.mjs - routing/test/snap.test.mjs - routing/test/astar.test.mjs - routing/test/fixtures/build-mini-graph.mjs - routing/test/fixtures/mini-graph.bin autonomous: true requirements: [GUID-02] must_haves: truths: - "A road graph binary decodes into typed arrays with zero per-node JS objects (D-01)" - "A tampered/truncated/mismatched graph binary decodes to null — never a throw, never a half-built graph (T-06-03)" - "A GPS fix snaps to the nearest graph node via edge-segment projection with cos(lat)-scaled equirectangular math (D-05)" - "A* finds the same optimal path as brute-force Dijkstra on the fixture mini-graph for every node pair (GUID-02 engine)" - "The backend ladder records the engine decision trail in index.ts: fixture #1, engine A #2 (D-01); the Valhalla-to-JS port ruled infeasible and geojson-path-finder rejected (D-02); Engine B documented as the post-v1 evolution of A (D-02)" - "One-way edges are respected by A* and ignored by snapping" - "Route steps carry turn kind, street name, distance, and polyline6 geometry derived from the node path" artifacts: - path: "routing/src/graph.ts" provides: "CSR binary decode/encode, header magic+version+size validation, street-name pool" exports: ["decodeGraph", "encodeGraph", "GRAPH_MAGIC", "GRAPH_VERSION"] - path: "routing/src/snap.ts" provides: "point-to-edge snapping with memoized coarse grid index" exports: ["snapToGraph"] - path: "routing/src/astar.ts" provides: "typed-array A* + step derivation + shared turn classifier" exports: ["routeAstar", "deriveSteps", "classifyTurn"] - path: "routing/test/fixtures/mini-graph.bin" provides: "committed hand-built tiny CSR — the A* correctness ground truth" key_links: - from: "routing/src/astar.ts" to: "routing/src/graph.ts" via: "typed-array reads over the decoded CSR" pattern: "graph\\.edgeStart\\[" - from: "routing/src/astar.ts" to: "routing/src/polyline.ts" via: "step geometry encoded via encodePolyline6" pattern: "encodePolyline6" - from: "routing/test/astar.test.mjs" to: "routing/src/astar.ts" via: "A* vs Dijkstra equivalence on mini-graph" pattern: "routeAstar" --- Engine A's pure data plane (D-01): the CSR road-graph binary codec, GPS-fix snapping, and a typed-array A* — the guidance engine's foundation, Node-testable with a committed mini-graph fixture before any Worker or R2 surface exists. Purpose: GUID-02's rerouting is REAL this phase because a real router exists. Everything server-side (plan 04) and every replay (plan 02) builds on these three pure modules. Output: `routing/src/{graph,snap,astar}.ts` + committed mini-graph binary + three RED→GREEN suites, all exports registered in `routing/src/index.ts`. @$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/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md @.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md @.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md export interface RoutingSource { readonly name: string; route(req: RouteRequest): Promise; } // RouteRequest = { origin: RoutePoint; destinationId: string } // RouteResult = { destinationId, origin, options: RouteOption[], backend: string } // RouteOption = { geometry: RouteGeometry, summary: RouteSummary, steps: RouteStep[] } // RouteGeometry= { points: RoutePoint[]; cumulativeDistanceM: number[] } (same length, [0]===0) // RouteStep = { distanceM?, durationS?, name?, geometry? } — this plan ADDS `kind?` (additive optional) // Existing pure helpers (reuse, never re-implement): export function haversineM(a: RoutePoint, b: RoutePoint): number; // routing/src/geo.ts export function deriveCumulativeDistanceM(points: readonly RoutePoint[]): number[]; // routing/src/geo.ts export function encodePolyline6(points: readonly PolyPoint[]): string; // routing/src/polyline.ts // Import convention (routing/src/index.ts header): consumers import the EXPLICIT // SUBPATH WITH EXTENSION — "@araharah/routing/src/index.ts". A bare specifier // resolves to NOTHING. New exports are added to index.ts as named exports only. **RESEARCH.md Pattern 1 (CSR binary layout) and Pattern 2 (typed-array A*) are the authoritative references** — no analog exists in the repo (PATTERNS.md "No Analog Found"). This plan PINS the binary layout; plans 04 (real graph build) and 07 (cron projection) depend on it. **PINNED CSR BINARY LAYOUT (little-endian, zero-copy DataView/TypedArray views):** ``` header: magic u32 (0x41524731 = "ARG1") | version u32 (=1) | nodeCount u32 | edgeCount u32 | nameCount u32 | namePoolLen u32 | bbox 4×f64 (minLat, minLng, maxLat, maxLng) | maxSpeedKmh f32 nodes: f64 lat[nodeCount] ++ f64 lng[nodeCount] offsets: u32 edgeStart[nodeCount + 1] (CSR row pointers) edges: u32 edgeTarget[edgeCount] ++ u8 edgeFlags[edgeCount] (flags bit0: oneway-reversed) weights: f32 edgeSeconds[edgeCount] (speed-derived travel time) edgeName: u32 edgeNameId[edgeCount] (index into name table; 0xFFFFFFFF = unnamed) names: u32 nameOffset[nameCount + 1] ++ u8 namePool[namePoolLen] (UTF-8, non-null-terminated) ``` Street names are NOT optional: GUID-01's banner renders `RouteStep.name` verbatim, and Overpass ways carry `name` tags. The name table is the layout's answer. **Constants (named exports, never inline):** `GRAPH_MAGIC = 0x41524731`, `GRAPH_VERSION = 1`, `UNNAMED_NAME_ID = 0xffffffff`. task 1: Mini-graph fixture + CSR graph codec (graph.ts) — RED→GREEN routing/test/fixtures/build-mini-graph.mjs, routing/test/fixtures/mini-graph.bin, routing/src/graph.ts, routing/test/graph.test.mjs - routing/src/polyline.ts (the codec posture: pure functions, decode/encode pair, round-trip-proven by tests — copy this discipline) - routing/src/geo.ts (the pure-module law header: no I/O, no logging, no storage) - routing/test/fixture-source.test.mjs + routing/test/harness.mjs (the suite style: `import { test } from "./harness.mjs"`, runner auto-discovers) - routing/src/types.ts (RoutePoint shape consumed by the codec) - encodeGraph → decodeGraph round-trips node coordinates, edge targets, flags, weights, and names exactly - decodeGraph on a buffer with wrong magic (e.g. 0xdeadbeef) → null (T-06-03 fail-honest) - decodeGraph on a buffer with wrong version (≠1) → null - decodeGraph on a TRUNCATED buffer (byte length ≠ header-declared size) → null - decodeGraph returns typed-array VIEWS over the input buffer (changing a decoded lat byte flips the view — zero-copy proof) - decoded name pool: edge with nameId 0 → "Jalan Mini 1"; edge with UNNAMED_NAME_ID → name undefined 1. Write `routing/test/graph.test.mjs` FIRST (RED): a dynamic `import("../src/graph.ts")` (Node 24 type-stripping, the `fixture-source.test.mjs` pattern) asserting the behaviors above. Run `npm run routing:test` — the import failure IS the red state. 2. PIN the mini-graph: `routing/test/fixtures/build-mini-graph.mjs` builds a hand-designed 5×5 grid graph in a ~0.002°×0.002° bbox near KL center (lat 3.1500–3.1520, lng 101.6900–101.6920): 25 nodes, ~80 directed edge entries, three named "streets" ("Jalan Mini 1" N–S horizontal rows, "Jalan Mini 2" E–W columns, "Jalan Persiaran Mini" the perimeter), exactly ONE oneway pair (two adjacent nodes with flags bit0 set on the reverse edge), two speed classes (60 and 30 km/h → weights = segment-meters / (kmh/3.6)). The script imports `encodeGraph` from `../src/graph.ts` and writes `mini-graph.bin`. Commit BOTH script and binary. 3. Implement `routing/src/graph.ts` (GREEN) with exactly the PINNED CSR layout above: `decodeGraph(buffer: ArrayBuffer): RoadGraph | null` (header validation: magic, version, exact byte-size check against header-declared counts — any mismatch → null; then zero-copy TypedArray views), `encodeGraph(input): ArrayBuffer`, and the exported `RoadGraph` interface: `{ nodeCount, edgeCount, nameCount, lat: Float64Array, lng: Float64Array, edgeStart: Uint32Array, edgeTarget: Uint32Array, edgeFlags: Uint8Array, edgeSeconds: Float32Array, edgeNameId: Uint32Array, nameAt(id: number): string | undefined, bbox: {minLat,minLng,maxLat,maxLng}, maxSpeedKmh: number }`. 4. Add `kind?:` to `RouteStep` in `routing/src/types.ts` — ADDITIVE OPTIONAL field, type `"depart" | "straight" | "left" | "right" | "slight-left" | "slight-right" | "uturn" | "arrive"`, doc comment "Engine A populates this (06-01); fixture-world steps stay empty arrays (D-10)". Do not touch any other line of types.ts. npm run routing:test && npm run routing:typecheck - `routing/src/graph.ts` exists and exports decodeGraph, encodeGraph, GRAPH_MAGIC, GRAPH_VERSION, RoadGraph - `routing/test/fixtures/mini-graph.bin` is committed and > 1000 bytes - grep `0x41524731` matches in routing/src/graph.ts - grep `expirationTtl|console\.|fetch\(` matches ZERO lines in routing/src/graph.ts (pure-module law) - graph.test.mjs contains tests asserting: round-trip, bad magic → null, bad version → null, truncated → null, zero-copy view mutation graph.test.mjs green; mini-graph.bin committed; decodeGraph fails honest (null) on every malformed-header class; RouteStep gains the optional `kind` field with no other types.ts change task 2: GPS-fix snapping (snap.ts) — RED→GREEN routing/src/snap.ts, routing/test/snap.test.mjs - routing/src/graph.ts (the RoadGraph shape just created in task 1 — snap consumes it) - routing/src/geo.ts (haversineM — the ONLY distance authority; RESEARCH Pitfall 8: scale lng by cos(lat), never raw Δdegrees) - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md Pattern 4 (server graph-snap vs client progress-snap — this task is the SERVER authority, D-05) - snapToGraph at a node's exact coordinates → that node, distanceM ≈ 0 - snapToGraph mid-edge → the CLOSER endpoint node, distanceM = haversine distance to it (never a fabricated mid-node) - SnapResult carries `nameId` = the MATCHED segment's edgeNameId (null when UNNAMED_NAME_ID) — ADDITIVE field plan 07's cron street hop consumes (edges carry names; nodes don't) - snapToGraph outside bbox + SNAP_BBOX_MARGIN_DEG margin → null (honest miss — router-pending upstream, D-03) - oneway edges: snapping IGNORES direction (a fix near a oneway segment still snaps to its endpoints) - two snaps for the same RoadGraph instance reuse the memoized grid (WeakMap — observable via a build-counter or timing assertion kept loose) 1. RED: `routing/test/snap.test.mjs` loads `mini-graph.bin` via decodeGraph and asserts the behaviors above. 2. GREEN: `routing/src/snap.ts` — `snapToGraph(graph: RoadGraph, point: RoutePoint): SnapResult | null` where `SnapResult = { node: number; distanceM: number; nameId: number | null }` — `nameId` is the best segment's `edgeNameId` found by the sweep (null when UNNAMED_NAME_ID); an ADDITIVE field, so `{ node, distanceM }` destructuring consumers keep working. Implementation: coarse grid index (cell ~0.002°) mapping cell-key → node ids, built ONCE per graph and memoized in a module-scope `WeakMap` (LAW 1 lazy memoize — PATTERNS shared pattern); candidate nodes from the 3×3 cell neighborhood; final distance via point-to-SEGMENT equirectangular projection (lng scaled by cos(lat)) over each candidate's edges, returning the closer endpoint of the best segment TOGETHER WITH that segment's `nameId`. `SNAP_BBOX_MARGIN_DEG = 0.01` named export. All distances through haversineM / cos-scaled math — never raw Δdegrees (Pitfall 8). npm run routing:test - routing/src/snap.ts exports snapToGraph and SNAP_BBOX_MARGIN_DEG - snap.test.mjs asserts: exact-node hit, mid-edge closer-endpoint, outside-bbox → null, oneway-direction ignored, nameId = the best segment's edgeNameId (null for the unnamed segment) - grep `cos(` matches routing/src/snap.ts (the Pitfall-8 lng scaling) - grep `WeakMap` matches routing/src/snap.ts (the per-graph memo) snap suite green; snapping is O(local neighborhood) not O(n) per fix; misses resolve null (the honest signal plans 04/06 turn into router-pending) task 3: Typed-array A* + step derivation (astar.ts) — RED→GREEN routing/src/astar.ts, routing/test/astar.test.mjs, routing/src/index.ts - routing/src/graph.ts + routing/src/snap.ts (tasks 1–2 outputs — astar consumes both) - routing/src/index.ts (add the new named exports in the same change — PATTERNS import-convention law) - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md Pattern 2 (binary heap of indices, allocation-free hot loop, haversine÷maxSpeed admissible heuristic) + Anti-Patterns (no JS-object graphs, no Map in the hot loop) - routeAstar(origin, goal) equals brute-force Dijkstra's optimal cost for EVERY (origin, goal) pair of the mini-graph (exhaustive 25×25 equivalence sweep — the correctness gate RESEARCH mandates) - oneway respected: the pair with the reversed-oneway edge routes the long way, matching Dijkstra - unreachable pair → null (honest miss) - deriveSteps on a path with a 90° bearing change → a step with kind "left" (or "right"), street name from the name table, distanceM > 0, geometry = polyline6 of the step's points - deriveSteps always starts with kind "depart" and ends with kind "arrive" - classifyTurn(0, -90) → "left"; classifyTurn(0, 90) → "right"; classifyTurn(0, 20) → "straight"; classifyTurn(0, 40) → "slight-left"; classifyTurn(0, 175) → "uturn" 1. RED: `routing/test/astar.test.mjs` — include a test-local brute-force Dijkstra over the mini-graph typed arrays (plain arrays are FINE in tests) and the exhaustive equivalence sweep. 2. GREEN: `routing/src/astar.ts`: - `routeAstar(graph: RoadGraph, originNode: number, goalNode: number): AstarResult | null`, `AstarResult = { nodes: number[]; seconds: number; distanceM: number }`. Open set = binary heap over preallocated `f32 key[]` + `u32 node[]` + `u32 heap[]` arrays (grew by doubling); g-scores `f64[]` indexed by node; closed `u8[]`; NO Map, NO objects in the hot loop. Heuristic: `haversineM(node, goal) / (graph.maxSpeedKmh / 3.6)` — admissible + consistent (maxSpeedKmh comes from the header). distanceM summed post-hoc via haversineM over the node path. A bidirectional variant is ALLOWED (D-01 discretion) but the equivalence sweep is the gate either way. - `classifyTurn(prevBearingDeg: number, nextBearingDeg: number): GuidanceTurnKind` — bearing delta normalized to (−180, 180]; named thresholds: `TURN_STRAIGHT_MAX_DEG = 25`, `TURN_SLIGHT_MAX_DEG = 60`, `TURN_UTURN_MIN_DEG = 150`. EXPORTED — plan 07's cron projection imports this exact classifier (one authority for "what is a turn"). - `deriveSteps(graph: RoadGraph, nodes: number[]): RouteStep[]` — walk the node path, split at edges where classifyTurn crosses out of "straight" or the street name changes; each step: `kind` (depart first / arrive last / classified mid-path), `name` from graph.nameAt (omit when unnamed), `distanceM` (haversine sum over the step's points), `geometry` via encodePolyline6. 3. `routing/src/index.ts`: add named exports — `decodeGraph, encodeGraph, GRAPH_MAGIC, GRAPH_VERSION` from graph.ts; `snapToGraph, SNAP_BBOX_MARGIN_DEG` from snap.ts; `routeAstar, deriveSteps, classifyTurn, TURN_STRAIGHT_MAX_DEG, TURN_SLIGHT_MAX_DEG, TURN_UTURN_MIN_DEG` from astar.ts. Named exports only; update the header comment's backend ladder note: backend #2 is now "engine-a" (D-01 supersedes the "wasm-router" note — engine B is the documented post-v1 evolution, D-02). npm run routing:test && npm run routing:typecheck - astar.test.mjs contains an exhaustive A*-vs-Dijkstra equivalence test over the mini-graph (25×25 pairs) - routing/src/astar.ts contains NO `new Map(` and NO object literal inside its hot loop (grep `new Map` → 0 matches in astar.ts) - routing/src/astar.ts exports routeAstar, deriveSteps, classifyTurn - routing/src/index.ts exports decodeGraph, snapToGraph, routeAstar, classifyTurn, createHttpRoutingSource is NOT here yet (plan 04 adds it) - grep `0xffffffff|UNNAMED_NAME_ID` matches routing/src/graph.ts A* provably optimal on the fixture graph, oneway-aware, allocation-free; steps carry kind/name/distance/polyline6; classifyTurn exported as the shared turn authority; index.ts ladder note records D-01/D-02 ## Trust Boundaries | Boundary | Description | |----------|-------------| | R2 object → Worker decode | The graph binary is fork-uploaded maintainer data (D-01) — a hostile/corrupt file must never produce a running router on garbage | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-06-03 | Tampering | decodeGraph (routing/src/graph.ts) | mitigate | Header magic + version + exact byte-size validation before any view is built; ANY mismatch → null (fail-honest). Worker side (plan 04) turns null into designed router-pending, never a crash. | | T-06-03b | Denial of Service (local) | decodeGraph | mitigate | Size check happens BEFORE allocating views — a hostile huge-count header on a small buffer rejects on the byte-length comparison, not on allocation. | | T-06-05 | Information Disclosure | snap/astar (pure modules) | mitigate-by-design | The modules are pure: no logging, no storage, no network (the geo.ts law). Coordinates exist only in the caller's frame — asserted by the grep gate (no console/fetch in the module). | - `npm run routing:test` — all three new suites green, existing suites untouched-green - `npm run routing:typecheck` — clean - A* ≡ Dijkstra exhaustive on the mini-graph (the committed correctness ground truth) - Malformed binary classes (magic/version/size) all → null Engine A's pure data plane exists and is proven: the pinned CSR codec round-trips and fails honest, snapping is local-window correct with the cos(lat) law, A* matches brute force on every mini-graph pair, and steps carry the turn kinds + street names + geometry every downstream consumer (banner, replay, cron) needs. No Worker code, no R2, no network — those are plan 04's surface. After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-01-SUMMARY.md`