# Routing — the deep dive
Arah² routes today. This document explains **how**, at the level of data structures,
algorithms, and measured numbers — and what the *committed but unbuilt* upgrade lever
(ALT landmark preprocessing — ALT landmarks are graph vertices, not POIs; see §0.8)
would change if it lands.
Three questions, answered in order — after a short vocabulary primer (§0):
1. **How does routing work today?** — the CSR road-graph binary, the bidirectional
typed-array A\* over it, snapping, turn classification, and the two-backend dispatch
between the fixture corpus and the live graph engine.
2. **How is ALT supposed to work?** — the mechanics, precisely, and where it would slot
into the existing bidirectional A\*. **Not built.** It is a next-milestone item.
3. **Pros and cons of every approach** — unidirectional A\*, bidirectional A\*, fixture
routes, ALT, and the graph-pruning experiment — with the numbers this repo actually
contains.
Every claim names a real file and a real function. Where a claim is a plan rather than
code, it says so.
---
## 0. Start here — the words this document uses
Routing has a small vocabulary that is easy to nod along to and hard to actually picture.
This section defines each term in plain language, using the real example from this
codebase, so §1 onward can use the words without stopping to re-explain them.
### 0.1 "Routing graph" — dots and arrows, not a drawing
A **routing graph** is a stripped-down model of the road network: a set of **nodes**
(points) and **edges** (one-way hops between two points), where each edge carries a
**weight** — a number saying how expensive that hop is.
In this repo:
- A **node** is one distinct coordinate on an OSM way. The build script does not invent
intersections: `internNode` in `routing/scripts/build-road-graph.mjs` keys every
geometry point by its coordinate rounded to 1e-6° (`round6`) and returns the same id
for the same point. An intersection — a coordinate shared by two OSM ways — therefore
becomes **one node where several road pieces meet**; an ordinary bend is a node where
exactly two road pieces meet (four directed edges, two each way); the end of a dead-end
street is a node where only one road piece meets (two directed edges, unless the street
is oneway).
- An **edge** is one consecutive pair of points along a way. For each pair the script
emits **two directed edges**, one each way:
`rawEdges.push({ from, to, seconds, flags: reverseOnly ? 1 : 0, nameId })` and its
mirror. So "677,068 directed edges" is roughly half that many physical road pieces,
each modelled in both directions.
- The **weight** is `seconds` — `meters / (kmh / 3.6)` — *travel time*, not distance.
That is why the router minimizes **seconds**, and why the returned `distanceM` has to
be recomputed afterwards (§1.3, "Reconstruction and real costs").
- The **oneway flag** is not a separate structure: it is bit 0 of `edgeFlags` on the
blocked mirror edge. That edge stays in the graph — so snapping can still see its
geometry and street name — but A\* never traverses it.
A three-node excerpt, using the committed mini-graph's real speed classes
(`routing/test/fixtures/build-mini-graph.mjs`: perimeter 60 km/h, interior 30 km/h, and
exactly one oneway pair, grid ids 2→3):
```mermaid
flowchart LR
classDef node fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef interior fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
A["Node A — grid id 2 (an OSM geometry vertex: a perimeter corner)"]:::node
B["Node B — grid id 3 (an OSM geometry vertex: the next perimeter corner)"]:::node
C["Node C — grid id 8 (an OSM geometry vertex: an interior grid point)"]:::interior
A -->|"edge A→B · ≈3.3 s Jalan Persiaran Mini · 60 km/h"| B
B -.->|"edge B→A is BLOCKED edgeFlags bit 0 · oneway mirror"| A
B -->|"edge B→C · ≈6.7 s Jalan Mini 2 · 30 km/h"| C
C -->|"edge C→B · ≈6.7 s the same street, other direction"| B
linkStyle 1 stroke:#ED3F1C,stroke-width:3px
```
Two misconceptions this picture is meant to kill: a node is **not** a city or a category
of junction — it is just a coordinate; and an edge is **not** a road — it is one directed
hop between two adjacent coordinates, so a long road is many edges.
### 0.2 "CSR" — the graph stored as flat arrays
**CSR** stands for **Compressed Sparse Row**. The idea is to stop storing one JavaScript
object per node and instead store a handful of parallel arrays plus one index array.
Imagine 311k nodes. The obvious JS shape is
`[{ lat, lng, edges: [...] }, ...]` — 311k objects plus 677k edge objects, every one of
them a heap allocation and a pointer chase. CSR instead keeps:
| Array | What it holds |
| --- | --- |
| `lat[]`, `lng[]` | one coordinate pair per node |
| `edgeStart[]` | `n+1` numbers; node *i*'s edges are the slice `edgeStart[i] .. edgeStart[i+1]` |
| `edgeTarget[]` | for each edge, the node it arrives at |
| `edgeSeconds[]` | the edge weight (travel time) |
| `edgeFlags[]` | bit 0 = the oneway mirror |
| `edgeNameId[]` | index into the street-name pool |
Walking node 42's neighbours is two reads and a short loop:
```ts
for (let e = graph.edgeStart[42]; e < graph.edgeStart[43]; e++) {
const neighbour = graph.edgeTarget[e];
const costSeconds = graph.edgeSeconds[e];
}
```
No object dereference, no per-node array, no Map. "Compressed" means the full *n × n*
adjacency matrix is never stored; "sparse row" refers to the row-pointer array
(`edgeStart`) that finds each node's slice. The full binary layout in §1.2 is just this
idea written down byte for byte: `Offsets` is `edgeStart`, `Edges` is
`edgeTarget` + `edgeFlags`, `Weights` is `edgeSeconds`.
**Why it avoids allocation.** `decodeGraph` in `routing/src/graph.ts` creates typed-array
**views over the same `ArrayBuffer`** — `new Float64Array(buffer, lay.lat, nodeCount)` —
not copies and not per-node objects (D-01). The A\* hot loop then allocates nothing per
node: its per-query state is a fixed set of O(n) typed arrays created once at the top of
`routeAstar` (`df`, `db`, `fPrev`, `bNext`, the two closed sets, the two heuristic caches,
`pf`). Under a 10 ms CPU ceiling and a 128 MB isolate, 311k objects per request is not an
option; the flat arrays are also why the hot loop has no GC pauses. `astar.test.mjs` A8
pins the discipline structurally: it strips comments from `astar.ts` and fails if
`new Map(` appears.
### 0.3 "A\*" — search with a guess
Plain Dijkstra explores outward in every direction until it happens to reach the goal.
**A\*** adds a guess `h(v)` of the time still needed from `v` to the goal, and always
expands the node with the smallest
```
f(v) = g(v) + h(v) g = seconds already spent, h = seconds still guessed
```
If the guess never overshoots the truth (the heuristic is **admissible**), the route A\*
returns is still optimal; the better the guess, the fewer nodes it has to expand.
In this codebase the guess is
```
h_f(v) = hav(v, goal) / maxSpeedMps (routing/src/astar.ts)
```
`maxSpeedMps` is the fastest class in the entire graph (`graph.maxSpeedKmh`, written at
build time). That makes the guess **admissible** — a straight line is the shortest
possible path, and dividing by the fastest possible speed gives the shortest possible
time — but **loose**: it assumes a 15 km trip could be driven entirely at 100 km/h. On a
local street the real time is longer, so the guess understates the remaining cost and A\*
explores many nodes it did not need to. **That looseness is the root cause ALT attacks**
(§2.1).
Both directional heuristics are scaled into seconds: `haversineM` returns metres, divided
by metres-per-second.
### 0.4 "Bidirectional A\*" — two searches that meet in the middle
Run the same search from the origin **forward** and from the goal **backward** over
**in-edges** (that is why `reverseFor` exists — a source-grouped CSR cannot list a node's
in-edges). Each side keeps its own heap and closed set. When an edge connects a
forward-reached node to a backward-reached node, that is a candidate meeting with total
cost `d_f(v) + d_b(v)`; keep the best one, `mu`. Stop when the two frontiers' smallest
keys sum to at least `mu` — no unexpanded node can then beat the best meeting.
```mermaid
flowchart LR
classDef origin fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef goal fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0
classDef frontier fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef meet fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
O["origin node (a GPS fix, after snapping)"]:::origin
F1["forward frontier ring 1"]:::frontier
F2["forward frontier ring 2"]:::frontier
M["meeting node (best mu seen so far)"]:::meet
B2["backward frontier ring 2"]:::frontier
B1["backward frontier ring 1"]:::frontier
G["goal node (a gazetteer POI, after snapping)"]:::goal
O --> F1 --> F2 --> M
G --> B1 --> B2 --> M
F2 -.->|"stop when top_f + top_b ≥ mu"| B2
```
**Why this is meant to halve the work.** A one-sided search must grow to radius `2r` to
touch a goal `2r` away; two searches of radius `r` cover the same corridor with roughly a
quarter of the area each, so total work is about half. That is the geometric intuition.
In *this* implementation the measured p95 did **not** improve, because the potential split
(§0.5) halves each side's heuristic steering at the same time. §3.4 carries the measured
verdict; the correctness proof is unaffected either way.
### 0.5 "Potential" — the word means two different things here
This is a place where a reader can get lost, so separate the two senses once:
**(a) The Ikeda potential used today (§1.3).** With the directional heuristics `h_f` and
`h_b` above, the search uses
```
p_f(v) = (h_f(v) − h_b(v)) / 2 p_b(v) = −p_f(v)
```
and relaxes an edge with `c + p(u) − p(v)` instead of `c`. Read the formula as **"forward
distance-to-goal minus backward distance-to-origin, halved"**. It is a **reweighting**, not
a distance and not a lower bound: it shifts every node's key by a node-dependent constant
so the two frontiers are measured on a comparable scale. The phrase "opposite potentials"
is literal — `p_b` is the negative of `p_f`. A useful intuition: in a symmetric graph
`h_f + h_b ≈` the whole route length, so `p_f ≈ h_f − (route length)/2`; the potential
recentres the forward guess on the halfway point. The reweighted edge costs stay
non-negative, so optimality survives, and the stop rule `top_f + top_b ≥ mu` becomes
valid. The code computes it lazily and caches it in `hfCache` / `hbCache` / `pf`
(`potential(v)` in `routing/src/astar.ts`).
**(b) The ALT potential proposed in §2.** There, "potential" means a **lower bound on the
remaining time**, `h_ALT(v)`, built from landmark distances. It *replaces* the haversine
`h_f` / `h_b` that feed (a); the Ikeda reweighting then still sits on top. So §2's `h_f`
is an input to §1.3's `p_f`, not a competitor.
### 0.6 "Snapping" — GPS point → graph node
A GPS fix is a coordinate, not a node id. `snapToGraph(graph, point)` answers "which graph
node is this fix on?" in three moves (the exact authority is §1.4):
1. Reject fixes outside the graph bbox plus 0.01° — an honest `null`.
2. Look only in the fix's ~0.002° grid cell and its eight neighbours (the 3×3
neighbourhood).
3. For every road segment touching those cells, project the fix onto the segment in
cos-scaled degree space and keep the closest; return the **nearer endpoint**
(`t <= 0.5 ? u : v`) plus that segment's street name.
Every route query starts and ends with a snap; a snap miss is a designed `null` /
router-pending answer, never a made-up node. Snapping deliberately ignores the oneway
flag — geometry is directionless; only A\* enforces direction.
### 0.7 Turn classification and step derivation
`classifyTurn(prevBearingDeg, nextBearingDeg)` in `routing/src/astar.ts` is the single
authority for "what is a turn". It compares the compass bearing of the segment you arrived
on with the bearing of the segment you leave on. Bearings increase clockwise (N=0, E=90),
so a **negative** delta is a **left** turn. Thresholds: ≤25° straight, ≤60° slight
left/right, ≥150° U-turn, otherwise left/right. The pinned example:
`classifyTurn(0, 40) === "slight-right"`.
`deriveSteps(graph, nodes)` turns a node path into human steps. It walks consecutive
pairs, computes each bearing, classifies the turn, and starts a new step whenever the turn
is **not** `"straight"` **or** the street name changes. The first step is always
`"depart"`; a final `"arrive"` step anchors the destination. Each step carries `kind`,
`name` (omitted when the edge is unnamed), `distanceM`, and polyline6 `geometry`. It
throws on a non-traversable pair — a malformed path is a caller bug, not a route miss.
Fixture routes have **empty** step arrays (D-10) — honest "not wired"; the live engine
populates them.
### 0.8 The two different things called "landmark"
> **Read this before §2.** This repository uses the English word **landmark** for two
> unrelated objects. They share a name and nothing else.
| | **Search / gazetteer landmark** | **ALT landmark** |
| --- | --- | --- |
| **What it is** | A Point of Interest a user searches for and drives to | A chosen **vertex of the routing graph**, used only as a distance reference |
| **Real examples** | `suria-klcc` ("Suria KLCC", aliases "KLCC", "KLCC Park"), `national-museum`, `kl-sentral` — 30 entries in `search/src/data/kl-gazetteer.json` | an integer vertex id with no name, address, or POI record (illustrative: none has been chosen yet — no ALT code exists) |
| **Where it lives** | `search/src/data/kl-gazetteer.json`; typed by `GazetteerEntry` in `search/src/types.ts` | would live in the landmark table of a **layout-v2 graph** — **nothing exists yet** |
| **Who uses it** | search (`getEntries` / `getEntryById`), fixture corpus endpoints, `/api/route`'s `destinationId` lookup, snapping targets | only a future A\* speedup |
| **How it is chosen** | curated by hand, with local aliases | algorithmically, by a farthest-point sweep over graph vertices (§2.2) |
| **Shown to users?** | yes — it is the product's destination | never |
**Which is which, wherever the doc says "landmark":**
- Sentences about search, the gazetteer, fixture pairs, POIs, snapping, or "all
landmarks in one strongly-connected component" mean **gazetteer landmarks**.
- Sentences about ALT, preprocessing, the 16–32 reference vertices, or a landmark table
mean **ALT landmarks**.
- §2 uses "landmark" exclusively in the ALT sense; §1.6 and the ladder history use it
exclusively in the POI sense.
**Why this matters for the graph-pruning story.** When §1.9 and §3.5 say the pruned graph
"severed the fine mesh tying landmarks to the arterials", that means **gazetteer (POI)
landmarks** — the fixture endpoints such as KL Tower, KL Sentral, and Suria KLCC. Those
POIs are reached by snapping to a nearby minor street; dropping `tertiary` and
`residential` removed the streets that connect those POI coordinates to the arterials, so
the pairs became unreachable. It has nothing to do with ALT landmarks: no ALT code existed
during that experiment and none exists now.
Conversely, an ALT landmark is just a vertex — but because the farthest-apart vertices in
travel-time terms tend to sit on long arterial or motorway chains, an ALT landmark will
*often look like* an arterial intersection. Looking like one is a consequence of the
selection, not its definition, and it is still not a POI. An ALT landmark at an arterial
junction is not "KLCC"; it is a graph vertex that happens to be far from the other
landmarks.
---
## 1. How routing works today
### 1.1 The shape of the system
Two routing backends answer the same contract (`RoutingSource` in
`routing/src/types.ts`), and the client composes them into a ladder
(`web/src/main.ts:431-436`):
```ts
const fixtureSource = createFixtureRoutingSource();
const httpSource = createHttpRoutingSource({ endpoint: "/api/route", bearer: () => getSession() });
const routingSource: RoutingSource = {
name: "ladder",
route: async (req) => (await httpSource.route(req)) ?? (await fixtureSource.route(req)),
};
```
- **Backend #1 — `"fixture"`** (`routing/src/fixture-source.ts`): a build-time-precomputed
corpus of 15 golden KL pairs, served from the bundle. It matches on
`destinationId` **and** a 200 m origin tolerance.
- **Backend #2 — `"engine-a"`** (`routing/src/http-source.ts`): a Bearer-gated
`POST /api/route` to the Worker, which runs the real router over the R2-hosted road
graph.
The `??` is the load-bearing operator: **engine-a first, fixture as the fallback.** A
null from engine-a (any miss class) falls through to the fixture corpus; a null from
both is the honest "router pending" state.
The ladder is **documented, not switchable** — no env read, no config read, no dynamic
registry. The import site constructs the backends in code (T-05-04). The ladder's shape
mirrors `SearchSource`'s (`docs/CORE-CONCEPTS.md` §2).
**Where the dispatch actually lives.** The fixture-vs-graph-engine choice is **client-side**
(`web/src/main.ts:431-436`). `bot/src/routing/` contains only the graph engine's server
arm — `handleRoute` in `worker-router.ts` — and has no fixture backend at all. The diagram
below is therefore split the same way the code is: orange = the Mini App's ladder, beige =
the Worker, red = the R2 data plane, olive = the in-bundle fallback.
```mermaid
flowchart TD
classDef client fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef server fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
classDef fallback fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
classDef pending fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0
CALL["web/src/main.ts route request"]:::client
LADDER["routingSource: 'ladder' httpSource.route() ?? fixtureSource.route()"]:::client
HTTP["POST /api/route Bearer session token"]:::client
WR["bot/src/routing/worker-router.ts handleRoute"]:::server
GRAPH[("R2 ROAD_GRAPH kl-roadgraph.v1.bin")]:::store
DEST["getEntryById(destinationId) gazetteer POI lookup"]:::server
SNAP["snapToGraph origin + destination"]:::server
ASTAR["routeAstar bidirectional A*"]:::server
RESULT["200 RouteResult backend: engine-a"]:::client
NULL["200 null (any miss class)"]:::pending
FIX["routing/src/fixture-source.ts 15 golden pairs · 200 m law"]:::fallback
PENDING["router-pending state (honest degradation)"]:::pending
CALL --> LADDER
LADDER -->|"1st: graph engine"| HTTP
HTTP --> WR
WR --> GRAPH
WR --> DEST
DEST --> SNAP --> ASTAR --> RESULT
RESULT --> CALL
WR -->|"miss"| NULL
NULL -->|"?? falls through"| FIX
FIX -->|"pair matches"| RESULT
FIX -->|"no match"| PENDING
```
Every arrow out of `handleRoute` that is not a `RouteResult` is the same `200 null` — the
null contract is the dispatch signal, not an error (§1.7).
### 1.2 The data plane: the CSR road-graph binary
Engine A routes over a **CSR (Compressed Sparse Row) binary** — an adjacency structure
where each node's outgoing edges are stored contiguously. The codec is
`routing/src/graph.ts`: `encodeGraph` (build-time) and `decodeGraph` (runtime) are
inverses, and the binary layout has exactly one authority — that file.
**The pinned layout** (little-endian, documented at `routing/src/graph.ts:9-21`):
| Region | Contents |
| --- | --- |
| Header (64 B) | magic `0x41524731` (`"ARG1"`), version `1`, `nodeCount`, `edgeCount`, `nameCount`, `namePoolLen`, bbox (4×f64), `maxSpeedKmh` (f32), 4 pad bytes |
| Nodes | `f64 lat[n]` ++ `f64 lng[n]` |
| Offsets | `u32 edgeStart[n+1]` — CSR row pointers; node *i*'s edges are `edgeStart[i]..edgeStart[i+1]` |
| Edges | `u32 edgeTarget[e]` ++ `u8 edgeFlags[e]` |
| Weights | `f32 edgeSeconds[e]` (4-byte aligned) |
| Names | `u32 edgeNameId[e]` ++ `u32 nameOffset[m+1]` ++ UTF-8 `namePool` |
The 60→64 byte header padding is not cosmetic: the f64 node arrays must be **8-byte
aligned** for zero-copy `Float64Array` views, and an unaligned `Float64Array` throws.
`decodeGraph` returns **typed-array views over the caller's `ArrayBuffer`** — zero
per-node JS objects, which is the whole point (D-01).
**Fail-honest decode (T-06-03).** `decodeGraph` validates magic, version, and the
**exact byte size implied by the header counts**, and returns `null` on any mismatch.
It never throws and never returns a half-built graph. The size check runs **before any
view allocation** (T-06-03b), so a hostile header with huge counts rejects on
arithmetic, not on allocation. The Worker turns that `null` into the router-pending
state.
**The oneway law (`edgeFlags` bit 0 = "oneway-reverse").** The build emits *both*
directions of every OSM way. The blocked mirror direction stays in the CSR with bit 0
set. This is deliberate: the edge still carries its **segment geometry and street
name**, so the snapper can find it, but A\* never traverses it. Snapping ignores
direction; A\* enforces it. See §1.4.
**Build pipeline** — `routing/scripts/build-road-graph.mjs`:
1. The bbox is **computed at run start** from the committed corpus (gazetteer
extremes + golden-pair coordinates ± 0.05°). It tunes itself when the corpus grows;
it is never hand-edited.
2. **One** Overpass query fetches every way whose `highway` tag is in
`ACTIVE_CLASSES` — 13 classes: `motorway`, `trunk`, `primary`, `secondary`,
`tertiary`, `residential`, `unclassified`, `living_street`, plus the five `*_link`
ramp classes. `footway`/`cycleway`/`service`/`path` are deliberately absent (a
walking path must never enter a road A\*).
3. Ways → nodes (deduplicated by coordinate rounded to 1e-6°, ≈ 0.11 m) → directed
edges with oneway flags, speed-derived seconds, and street-name pool ids.
4. `encodeGraph` produces the pinned binary. **Collect-then-write**: the file is
written only after the encode and the stats gate both pass — any failure exits 1
with the previous binary untouched.
The `*_link` classes are **load-bearing** (measured 2026-10-24): without them the graph
**fragments** — 7.7% of nodes sat in severed motorway islands and golden corpus pairs
resolved "unreachable". This was a real bug caught by the CPU measurement, fixed in
commit `ea9584d`.
**The envelope gate (D-01):** a binary over 80 MB is refused; one at or under 1 MB is
refused ("not a real road graph"); 30–80 MB is the expected band, with a smaller result
printing an advisory warning. The binary is a **build artifact, gitignored**
(`routing/data/.gitignore` → `*.bin`) — ODbL-derived data never enters the repo.
Forks build and upload their own (`docs/ROAD-GRAPH.md`).
### 1.3 The A\* core: `routing/src/astar.ts`
`routeAstar(graph, originNode, goalNode)` returns `{ nodes, seconds, distanceM }` or
`null` for an unreachable pair.
**It is bidirectional** — a BH (Bidirectional Heuristic) search with Ikeda potentials.
The module header states why: the free-plan **10 ms CPU budget** is the design
constraint, and the measured unidirectional p95 on the real KL graph was 3–5× over it.
#### The heap
A `NodeHeap` over two preallocated typed arrays: `Float64Array keys` and
`Uint32Array nodes`, grown by doubling. `push` / `pop` / `peekKey`. `peekKey` returns
`Infinity` on an empty heap — the BH stop rule reads it. Deletion is **lazy**: stale
entries are skipped by the closed-set check in `routeAstar`.
#### The hot loop is allocation-free
No `Map`, no per-node objects. `routing/test/astar.test.mjs` A8 asserts this
structurally: it strips comments from `astar.ts` and fails if `new Map(` appears.
State per search:
| Array | Type | Meaning |
| --- | --- | --- |
| `df`, `db` | `Float64Array(n)` | reduced distances, forward and backward |
| `fPrev`, `bNext` | `Int32Array(n)` | predecessor / successor on the two chains |
| `fClosed`, `bClosed` | `Uint8Array(n)` | closed sets |
| `hfCache`, `hbCache` | `Float64Array(n)` | lazily-computed heuristic caches (NaN = not yet) |
| `pf` | `Float64Array(n)` | the potential, `p_f(v)` |
#### The BH construction (Ikeda et al.)
With directional heuristics
```
h_f(v) = hav(v, goal) / maxSpeedMps
h_b(v) = hav(v, origin) / maxSpeedMps
```
(each consistent on its own search graph — the speed divisor comes from the graph
header's `maxSpeedKmh`), and the **opposite potentials**
```
p_f(v) = (h_f(v) − h_b(v)) / 2 p_b(v) = −p_f(v)
```
the reductions
```
forward, edge u→v: c + p_f(u) − p_f(v)
backward, in-edge v→u of settled u: c + p_b(u) − p_b(v)
```
are **non-negative** (each feasibility is the sum of the two consistencies, each ≤ c),
and the meet keys satisfy
```
d_f(v) + d_b(v) = real(o→g through v) − p_f(o) + p_f(g)
```
— a **v-independent constant**. Therefore stopping when `top_f + top_b ≥ mu` (the best
meeting cost seen) settles the goal **provably optimally**, at roughly half the
explored radius per side.
#### The stop rule and the alternation knob
```ts
if (fHeap.peekKey() + bHeap.peekKey() >= mu) break; // THE BH STOP RULE
const expandForward = fHeap.size <= bHeap.size; // BALANCED ALTERNATION
```
The stop rule carries optimality. The alternation is a **work-balancing knob only**.
This is not a stylistic claim — it was a bug: the first implementation compared raw
tops (`top_f <= top_b`), which starved the backward side (19,501 forward settles to 3
backward) and landed **4× slower than unidirectional**. Comparing **heap sizes** keeps
both rings growing. The fix is recorded in
`.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-SUMMARY.md` §2.
#### The reverse CSR
The backward search needs **in-edges**. A source-grouped CSR cannot yield them without
a reverse index, and reading out-edges as in-edges **fabricates phantom arcs** — the
06-04 bug where a 6.66 s "path" was found against Dijkstra's 26.67 s.
`reverseFor(graph)` builds the reverse adjacency **once per `RoadGraph`** and memoizes
it in a module-scope `WeakMap` (the same LAW 1 lazy-memoize pattern as the snap grid).
It holds `rowStart` (u32), `targets` (the in-edge's source node), and `edgeIds` (the
edge's index in the forward CSR, for the weight and flag lookup). Build cost is
O(V+E) per isolate, paid by the first route and amortized across the isolate's
requests. **The pinned binary layout is untouched** — the reverse index is a derived
in-memory structure.
#### Reconstruction and real costs
The path is `origin →(fPrev)→ meetFrom −[bridge]→ meetTo →(bNext)→ goal`. Then the
**real** costs are recomputed along the node path — `haversineM` for distance,
`graph.edgeSeconds[edge]` for time — so no reduced-cost arithmetic leaks into the
returned `seconds`. `distanceM` is summed **post-hoc** over the node path, which is the
plan's law (the graph stores travel *time*, not distance).
#### The reverse edge lookup
`findTraversableEdge(graph, u, v)` scans node *u*'s out-edges for one whose target is
*v* **and** whose flag bit 0 is clear. It returns `-1` if none. This is what makes
`deriveSteps` throw on a malformed path — a caller bug is **loud**, unlike a route miss
which stays `null`.
### 1.4 Snapping: `routing/src/snap.ts`
`engine A owns snapping — ONE authority for road geometry, so the client and the router
can never drift.` The **server** authority: a reroute origin lands here. (Phase 6's
client-side guidance walks the selected route polyline instead and never calls this
module.)
`snapToGraph(graph, point)` returns `{ node, distanceM, nameId }` or `null`.
**The grid.** A coarse grid of ~0.002° cells, built **once per graph** and memoized in
a module-scope `WeakMap`. A fix touches only the **3×3 cell
neighborhood** — O(local), never O(nodes). (A global nearest-node scan is the recorded
anti-pattern.)
**The projection.** Point-to-**segment** projection, not point-to-node, in **cos-scaled
degree space** (`lng × cos(lat)`). Pitfall 8's law: projection math scales lng by
cos(lat), never raw degree deltas. The parameter `t` is clamped to [0,1]; the snapped
node is the **closer endpoint** of the best segment (`t <= 0.5 ? u : v`). The reported
`distanceM` is **haversineM** — the only distance authority that leaves the module
(squared-degree is used for ranking only).
**The bbox miss.** A fix outside the graph bbox plus `SNAP_BBOX_MARGIN_DEG` (0.01°)
resolves `null` — the designed router-pending signal. Never a fabricated node, never a
throw.
**Direction plays no part.** A oneway segment's flagged reverse edge still carries
segment geometry and its street name, so fixes near a oneway road snap to its endpoints
exactly like two-way traffic. **A\* enforces the direction law; snapping ignores it.**
**Documented limitation.** Candidate segments come from the 3×3 neighborhood of the
*fix's* cell — a segment both of whose endpoints sit more than one cell (~0.002°) away
is not a candidate. KL-metro build data densifies at intersections, well inside this
window; a miss degrades to `null`, never a wrong snap.
The `nameId` field is an **additive** field on `SnapResult` — edges carry names, nodes
don't — and it is what Phase 7's cron street-hop consumes.
### 1.5 Turn classification and step derivation
Both live in `routing/src/astar.ts`.
#### `classifyTurn(prevBearingDeg, nextBearingDeg)` — the ONE authority
The convention: **compass bearings increase clockwise (N=0, E=90), so a NEGATIVE
bearing delta is a LEFT turn.** The delta normalizes to (−180, 180].
| Threshold | Constant | Result |
| --- | --- | --- |
| \|delta\| ≤ 25° | `TURN_STRAIGHT_MAX_DEG` | `straight` |
| \|delta\| ≤ 60° | `TURN_SLIGHT_MAX_DEG` | `slight-left` / `slight-right` |
| \|delta\| ≥ 150° | `TURN_UTURN_MIN_DEG` | `uturn` |
| otherwise | — | `left` / `right` |
A note on the convention: the original plan text contained
`classifyTurn(0, 40) → slight-left`, which contradicts its own `(0,−90) → left` and
`(0,90) → right` cases. The compass-consistent reading is what shipped and what
`astar.test.mjs` A6 pins: `classifyTurn(0, 40) === "slight-right"`.
`classifyTurn` is exported from the package index because **plan 07's cron projection
imports this exact classifier** — one authority for "what is a turn".
#### `deriveSteps(graph, nodes)` — node path → maneuver steps
Walks the node path, computing an initial compass bearing for each consecutive pair,
classifying each turn, and **splitting the path into steps** where:
- the bearing leaves `"straight"`, **or**
- the street name changes.
Each step carries `kind`, `distanceM` (haversine over its segments), `geometry`
(polyline6 of its points), and `name` (omitted when the edge is unnamed —
`UNNAMED_NAME_ID`). The first step is always `"depart"`; a terminal `"arrive"` step
anchors the goal node (its geometry is the single goal point).
**It throws** on a non-traversable consecutive pair — a caller bug is loud, unlike a
route miss which stays `null`.
Fixture-world steps are **always empty arrays** (D-10): an empty array is honest
"not wired"; a fabricated step list would not be. Engine A populates them.
### 1.6 Backend #1: the fixture source and its 200 m matching law
`routing/src/fixture-source.ts` — `createFixtureRoutingSource(data?)`.
The dataset (`routing/src/data/route-fixtures.json`) enters by **static import**
(LAW 2 — esbuild and Vite inline it at build time; bare Node 24 reads it via
type-stripping; a runtime retrieval would hard-fail in a Worker, which has no `fs`).
The index (Maps, decoded geometry) is built **lazily on first use** and memoized
(LAW 1 — Workers' 1 s startup limit and the webview's first paint both pay for eager
work).
**The matching law (D-02/D-03).** A pair may serve a request only when:
1. the request's `destinationId` **equals** the pair's `destinationId`, **and**
2. the request origin sits within **`ORIGIN_TOLERANCE_M` = 200 m** (haversine) of the
pair's pinned **public** origin.
The 200 m pad absorbs GPS jitter around a known gazetteer (POI) landmark. It **never
fabricates a route for an unknown origin**. Anything unmatched resolves `null` — the
honest router-pending state, never a nearest-match.
The corpus is 15 golden pairs (23 routes) — every origin and destination is a
**gazetteer (POI) landmark** (`suria-klcc`, `kl-sentral`, …), *not* an ALT landmark —
the two senses are distinguished in §0.8. The gate `routing/scripts/route-corpus.mjs`
enforces RT1–RT10 offline, including **RT2b**, the mechanical D-12 proof: every pinned origin must equal
its gazetteer landmark's committed coordinate within 1e-4° (≈ 11 m) on both axes. A
user coordinate therefore *cannot* enter a fixture.
**Defensive validation** (the `bot/src/kv/location-pin.ts` precedent): a malformed
pair — non-finite distance/duration, geometry that throws on decode, empty or
oversized routes array — is **skipped at index-build time**. It renders as an honest
null downstream, never a throw in the webview's render path and never a NaN coordinate.
### 1.7 Backend #2: the Worker router
`bot/src/routing/worker-router.ts` — `handleRoute(req, env)`.
```
POST /api/route { "origin": {lat, lng}, "destinationId": "" }
```
The pipeline, in order:
1. **Bearer gate.** The token resolves through `getClaimByToken`
(`bot/src/kv/sessions.ts`) to a claim. No/invalid token → `401 unauthorized`. The
claim is the identity gate; routing itself is chat-agnostic.
2. **Body cap.** `MAX_ROUTE_BODY_CHARS` = 4096, checked against the declared
`Content-Length` **and** the post-read length, both **before** any parse work.
Over-cap → `413`.
3. **Shape validation.** `validateRouteBody` — finite `lat`/`lng`, `destinationId`
matching `^[a-z0-9-]{1,64}$`. Fail → `400 malformed`.
4. **Graph load.** `loadGraph(env)` — see below. `null` → `200 null`.
5. **Bbox range check (V5).** The origin is range-checked against `graph.bbox`
**before any snap/A\* work** — untrusted coordinates never reach the compute-heavy
path. Fail → `400 malformed`.
6. **Destination short-circuit.** `getEntryById(destinationId)` from
`@araharah/search` — an unknown destination short-circuits before **any** graph
work. `null` → `200 null`.
7. **Snap origin.** `snapToGraph(graph, body.origin)` — `null` → `200 null`.
8. **Snap destination.** `snapToGraph(graph, { lat: dest.lat, lng: dest.lng })` —
`null` → `200 null`.
9. **Search.** `routeAstar(graph, originSnap.node, destSnap.node)` — `null` →
`200 null` (a disconnected pair).
10. **Assemble.** One option only: geometry (node coordinates + derived
`cumulativeDistanceM`), summary (`astar.distanceM`, `astar.seconds`), and
`steps: deriveSteps(graph, astar.nodes)`. `backend: "engine-a"`.
**ONE best route.** Alternatives generation is **prohibited** on this path — the
recorded anti-pattern is 2–3 penalty runs inside an active-drive storm-guard window:
CPU risk with zero UX need.
**Every miss class answers `200` with a JSON `null` body.** That is the null contract
crossing HTTP unchanged (D-03): the router-pending state, never a 500, never a blank
map, never a fake route.
**Logging law (D-14):** the only log lines are `"[route] rejected: "` —
`unauthorized`, `malformed` — never coordinates, ids, or payloads.
#### The graph load: isolate memoization is the cache that works
```ts
const graphMemo = new WeakMap