committed hand-built tiny CSR — the A* correctness ground truth
from
to
via
pattern
routing/src/astar.ts
routing/src/graph.ts
typed-array reads over the decoded CSR
graph.edgeStart[
from
to
via
pattern
routing/src/astar.ts
routing/src/polyline.ts
step geometry encoded via encodePolyline6
encodePolyline6
from
to
via
pattern
routing/test/astar.test.mjs
routing/src/astar.ts
A* vs Dijkstra equivalence on mini-graph
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.
// 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.
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.
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)
- 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 }`. 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. `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
- 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
<threat_model>
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).
</threat_model>
- `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
<success_criteria>
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.
</success_criteria>
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-01-SUMMARY.md`