# 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>(); ``` `caches.default` is useless here — Bearer requests auto-bypass the Workers Cache and cron is never cached — so the decoded graph memoizes in a **module-scope map keyed on `env`**. Production effect: **one R2 GET per cold isolate**, the memo is never reset, and the cron arm imports the *same* `loadGraph`, so either arm warming the graph serves the other. Keying on `env` (not a bare module variable) means distinct bindings — tests, multi-binding tooling — can never read each other's graph. A missing object, a failed read, or a binary `decodeGraph` rejects resolves `null` → `200 null`. A failed read degrades exactly like a corrupt binary. #### Where the graph lives `bot/wrangler.jsonc` binds the R2 bucket: ```jsonc "r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }] ``` The object key is `kl-roadgraph.v1.bin`, pinned as `GRAPH_KEY` in `worker-router.ts` and mirrored in `routing/scripts/upload-road-graph.mjs`. A bucket is **not** a KV namespace. ### 1.8 The client seam: `routing/src/http-source.ts` `createHttpRoutingSource({ endpoint, bearer, fetchImpl? })` — backend #2's client side. - The endpoint and the bearer getter are **injected** by the caller (`web/src/main.ts:432`). The module never reads storage and never holds a token — `bearer` is a **getter**, so a token minted after construction is still sent. - **Fail-honest (the network edition).** A thrown fetch, a non-200, a malformed body, or a shape violation resolves `null`. A 200 with a JSON `null` body *is* the null contract crossing HTTP; it resolves `null` too. - **Defensive validation.** Every field is type-checked: the `destinationId` must answer *this* request, options must number 1..3, every number finite, the `cumulativeDistanceM` array must match `points` 1:1 (the day-one progress contract). A payload failing any check is treated as no answer. - **The slug guard runs first** — a hostile `destinationId` never reaches the network. ### 1.9 The measured cost, and where the budget breaks `routing/scripts/measure-route-cpu.mjs` is the gate. It decodes the **real** binary, snaps the corpus endpoints, adds **quadrant-sampled cross-city pairs** (a fixed-seed mulberry32 PRNG, so re-runs sample identical pairs), runs **one unmeasured warm-up** route (printed, never hidden — the numbers must reflect a warm isolate), then times `routeAstar` with `process.hrtime.bigint()`. **The gate: p95 ≤ 8 ms** — 20% margin under the 10 ms free-plan ceiling. Unreachable probes are reported **separately** (they are not routed trips and would misdescribe the hot path's budget). **The measured state (2026-10-24, the 311k-node linked graph): GATE FAIL.** | Ladder step | Outcome | | --- | --- | | full graph, unidirectional A\* | p95 ≈ 38 ms — **FAIL** | | (a) drop tertiary + residential, rebuild | p95 ≈ 18 ms — **FAIL**, and 12/25 pairs became **unreachable** (the dropped classes are the fine mesh tying **gazetteer/POI landmarks** — the fixture endpoints — to arterials; see §0.8) — **rejected on correctness, reverted** | | (b) bidirectional A\* (BH, Ikeda potentials) | p95 ≈ 38 ms — **FAIL**: the potential split halves each side's heuristic steering, cancelling the halved search radius. **Kept**: sound (the 625-pair Dijkstra equivalence still passes) and better on short product routes | | (c) Workers Paid | **NOT applied** — breaks the $0 law; needs a maintainer decision | `docs/BUDGETS.md` records the current measurement as **83.989 ms p95** (runs today: 67.5–84.0 ms; documented band 38–93 ms) against an 8 ms budget — a **−76.0 ms** headroom, printed as measured, never rounded away. The measurement host is a shared Linux dev box with **≈ 2× run-to-run CPU variance** — the best observed run is still ~5× over budget, so the conclusion is robust. **Short product routes (2–8 km — the common reroute) measure 2–8 ms**, inside the budget. The failures concentrate on **20–30 km cross-metro routes**. **The decided posture (c3):** cross-metro CPU exhaustion → the Worker's 1102 path → the client's fail-honest null → the **router-pending state**. Designed degradation, never a fabricated route. The `route-cpu` budget gate compares the re-run p95 against a **200 ms drift guard** (≈ 2× the worst documented run): inside the band = the documented accepted state; beyond it = uncharted regression, and the gate fails. ### 1.10 The correctness proof Optimality is not presumed. `routing/test/astar.test.mjs` **A1** runs an exhaustive **625-pair (25×25) equivalence sweep** against a brute-force O(n²) Dijkstra over the committed mini-graph (`routing/test/fixtures/mini-graph.bin` — a 5×5 grid, 25 nodes, ~80 directed edges, three named streets, exactly one oneway pair, two speed classes). The test-local Dijkstra uses plain arrays (fine in tests) and the **same traversal law** — oneway-reverse edges are never relaxed. The sweep asserts: cost equality within 1e-6, path endpoints, and `null` (never a fabricated path) for unreachable pairs. The remaining gates: | Gate | What it proves | | --- | --- | | A2 | The flagged edge is never traversed; the pair behind it routes the long way, matching Dijkstra | | A3 | A disconnected pair resolves the honest miss (`null`) | | A4 | 90° bearing changes classify left/right; steps carry name/distance/polyline6; step distances sum to the route distance | | A5 | Even a straight same-name path yields `[depart, arrive]` | | A6 | The compass convention is pinned, including the wrap-around (`350° → 10°` is straight) | | A7 | The public surface exposes the engine-A exports and `createHttpRoutingSource` | | A8 | Structural: no `new Map(` in `astar.ts` | --- ## 2. How ALT routing is *supposed* to work > **STATUS: COMMITTED, NOT BUILT.** ALT landmark preprocessing is the **c2 rung** of > the measured mitigation ladder, and it is scheduled as **Phase 8 — ALT Landmark > Preprocessing (Routing Backend v2)** in the v1.x milestone > (`.planning/ROADMAP.md`). `docs/KNOWN-LIMITATIONS.md` lists it as a > "Next-milestone item, not built". `docs/ROAD-GRAPH.md` names it as the still-$0 > alternative to Workers Paid. **No ALT code exists in this repository.** Everything > in this section is a description of the intended mechanics, not an inventory of > shipped code. The six design decisions Phase 8's plans specify — landmark count, > coverage gate, potential combination, gate semantics, drift guard, rollout mechanics — > and why each recommendation is the recommended one, are in §2.5. ### 2.0 ALT, word by word The name is an acronym: **A\*** (the search already in `routing/src/astar.ts`) **+ L**andmarks (a fixed set of reference vertices) **+ T**riangle inequality (the rule that turns distances to those references into a guaranteed underestimate). - **"A\*"** — the algorithm does not change. Same heap, same typed arrays, same bidirectional stop rule, same output. ALT only swaps the body of the heuristic function that supplies `h_f` and `h_b`. - **"Landmarks"** — in ALT, a landmark is a **vertex of the routing graph**, chosen once at build time and used as a fixed reference point. It has an id and coordinates; it has no name, address, or POI record. **It is not KLCC.** This is the single most confusing word in the document — §0.8 separates the two meanings. - **"Triangle inequality"** — for any three points `x`, `y`, `z`, going via a third point can never be shorter than going direct: ``` d(x, z) ≤ d(x, y) + d(y, z) ``` Rearranged, `d(y, z) ≥ d(x, z) − d(x, y)`: if two distances from a fixed reference `x` are known, the third distance gets a **guaranteed floor** without any search. That is the whole trick — distances to landmarks are precomputed, so the remaining distance can be bounded cheaply. - **What it buys** — a tighter `h` at query time. Same route, same optimality guarantee, fewer expanded nodes. A surveyor's-benchmarks analogy: if you know how far you are from three fixed benchmarks and how far the destination is from the same three, you can put a floor under how far you must still travel — no route from you to the destination can be shorter than (distance from benchmark to destination) − (distance from benchmark to you). Each benchmark gives one floor; take the highest. Replace "benchmark" with "ALT landmark" and "floor" with "admissible lower bound", and you have ALT. ### 2.1 The problem ALT solves The current heuristic is `hav(v, goal) / maxSpeedMps` — a straight-line bound inflated by the *fastest* speed in the entire graph. On a 311k-node metro graph where the fastest class is a 100 km/h motorway, the heuristic is extremely **loose** almost everywhere: on a local street the true route to a goal 15 km away might take 12 minutes, while the heuristic says "15 km at 100 km/h = 9 minutes". The gap between the bound and the truth is the *wasted search*. **ALT tightens the lower bound using precomputed distances to a small set of ALT landmarks (graph vertices — not POIs; §0.8).** Instead of "how far is the goal in a straight line, at the fastest speed possible", it becomes "how far is the goal *at all*, via the triangle inequality over a set of fixed reference points". ### 2.2 The mechanics #### Step 1 — ALT landmark selection (build time) Choose a small set **L** of **ALT landmarks** — graph vertices, not POIs (§0.8) — typically **16 to 32**, spread across the graph. The standard construction (Goldberg–Harrelson) picks them to be **far apart**, commonly with a farthest-point sweep: 1. Start from any vertex and run Dijkstra over the graph; the farthest reachable vertex becomes landmark 1. That Dijkstra row is also the first row of the landmark table, so selection and preprocessing share work. 2. For every vertex `v`, keep `nearest[v] = min over chosen l of d(l, v)`. 3. Pick the vertex with the largest `nearest[v]` as the next landmark, run Dijkstra from it, and update `nearest[]`. 4. Repeat until `|L|` is 16–32 (or until the sweep stops finding a meaningfully farther vertex). On this **directed** graph a single start vertex cannot reach every other vertex, so a practical sweep runs over the largest strongly-connected component (the measured KL build put all gazetteer landmarks in one such component) or over an undirected view used only for *selection*. Which choice the phase makes is a design decision; it does not change the runtime bound, which comes from the table in Step 2. **Why far apart.** A landmark gives a *tight* bound for a query whose shortest path passes near it; the triangle inequality still holds — just more loosely — when it does not. Spreading the landmarks maximizes the chance that at least one is near any given route. **Why ALT landmarks often look like arterial junctions.** Selecting by travel-time distance tends to land on long arterial or motorway chains, because those are the farthest-apart places in seconds. Looking like a junction is a *consequence* of the selection, not its criterion — and it does not make the vertex a POI (§0.8). **Why this is cheap where it counts.** Each iteration costs one Dijkstra run; that run also becomes the landmark's table row (Step 2). At 311k nodes and 677k directed edges the whole pass is minutes on a maintainer machine — entirely off the request path. #### Step 2 — the per-vertex landmark distances (build time) For each landmark `l ∈ L` and each vertex `v`, precompute `d(l, v)` — the shortest **time** from `l` to `v`. The result is a table of shape `|L| × |V|`: 16 landmarks × 311k nodes × 4 bytes ≈ **20 MB**; 32 landmarks ≈ **40 MB**. **Units are load-bearing.** Distances must be in the same units as the edge weights. Because the search minimizes **travel time in seconds** (`edgeSeconds`), the landmark table must be in **seconds**. A table measured in metres would be dimensionally wrong — it would not bound a seconds-valued edge cost, and the "lower bound" would not be a bound at all. **Directed-graph caveat.** This graph is directed — the oneway mirrors make `d(l, v)` and `d(v, l)` different, and some vertices are unreachable *from* a given landmark (`d(l, v)` is infinite for them; the `max` simply ignores those terms). The table must therefore be **directed** shortest-path time `d(l, ·)` in the direction traffic may actually flow. One from-landmark row per landmark is still enough for both directional heuristics, because both bound a distance *from `v` to a target* using `d(l, target) − d(l, v)`. An implementation that stores the transposed distances `d(·, l)` instead must keep them separate; which storage shape the ALT build chooses is a design decision, not a shipped fact. This is where the cost moves. The whole point of ALT is that **the preprocessing is build-time and the runtime cost is $0** — the table is uploaded once to R2 alongside (or inside) the graph binary, and the Worker reads it exactly like the graph. #### Step 3 — the lower-bound potential (runtime) For a query from `s` to `t`, the potential is ``` h_ALT(v) = max over l ∈ L of ( d(l, v) + d(l, t) − d(l, s) ) ``` Read it as: *the best landmark `l` gives a lower bound on the cost of a path `s → v → t`* — go from `s` to `l`, then from `l` to `v`, then from `v` to `t`, and subtract the part of the `s → l` leg that is already covered by `s → l → t`. The **triangle inequality** guarantees this is a lower bound on the true distance: ``` d(s, t) ≤ d(s, l) + d(l, t) ⟹ d(s, t) ≥ d(l, t) − d(s, l) ``` and the "via `v`" variant gives `d(s, v, t) ≥ d(l, v) + d(l, t) − d(l, s)`. **The bound the A\* frontier actually consumes — step by step.** For one landmark `l` and a forward search toward target `t`, the triangle inequality on `(l, v, t)` gives ``` d(l, t) ≤ d(l, v) + d(v, t) ⟹ h_l(v) = d(l, t) − d(l, v) ≤ d(v, t) ``` so `h_l(v)` never exceeds the true remaining time from `v` to `t`. That is the per-vertex underestimate the search needs; taking the maximum over landmarks picks the tightest one. For a path that goes `s → v → t`, both legs get their own floor: ``` d(s, v) ≥ d(l, v) − d(l, s) (triangle on l, s, v) d(v, t) ≥ d(l, t) − d(l, v) (triangle on l, v, t) ``` Adding the two cancels `d(l, v)`: ``` d(s, v) + d(v, t) ≥ d(l, t) − d(l, s) ``` That surviving quantity is **independent of `v`** — which is exactly the property the BH meet-key argument in §1.3 relies on. > **Implementation caution — the `+ d(l, v)` term.** The formula block above carries a > per-vertex `d(l, v)` term. The part the triangle inequality actually certifies is the > cancellation result just derived (`d(l, t) − d(l, s)`, v-independent), plus the > per-vertex form `h_l(v) = d(l, t) − d(l, v)`. The ALT implementation must either use one > of those proven forms or prove the written expression directly before relying on > admissibility. This is not pedantry: on a line graph with `l=0`, `s=10`, `v=8`, `t=20`, > the written expression gives `8 + 20 − 10 = 18`, while the true cost of `s → v → t` is > `2 + 12 = 14`. Success criterion 2 — "identical tolerance verdicts" on the corpus > re-run — is the gate that catches this class of algebra error; it is not a formality. **Why the `max` over landmarks matters.** Each landmark `l` produces a bound that is tight when the true shortest path from `s` to `t` passes *near* `l`. Different landmarks are tight for different pairs of endpoints, so taking the maximum picks the best-bound landmark per query. **This is the tightness/occlusion tradeoff:** | | Effect | | --- | --- | | **More landmarks** | Tighter bounds (less search) — but a bigger table (build-time bytes, R2 storage, decode cost, and cache pressure in the 128 MB isolate) | | **Fewer landmarks** | Smaller table — but more "occlusion": for a pair whose shortest path avoids all landmarks, every individual bound is loose and the max is still loose | | **Poorly chosen landmarks** | Clustering wastes the table: all landmarks in one district bound intra-district pairs well and cross-city pairs poorly | The classic mitigation for occlusion is the **"covering" refinement**: take two "covering" landmarks and use the bound `min( d(l1,v) + d(l1,t), d(l2,v) + d(l2,t) )` for a covering pair, combined with the max over the ordinary set. That is a refinement the phase would consider; it is not required for correctness. #### Step 4 — admissibility The ALT potential is **admissible** (never overestimates) and, with the right conditions, **consistent** — which means the same optimality proof applies. This is what the roadmap's success criterion 2 means by *"Route optimality unaffected (ALT is admissible — the corpus re-run stays green with identical tolerance verdicts)"*. Crucially, **admissibility is not free**: a *consistent* potential `h` is required for the plain A\* reductions to stay feasible. The ALT bound is consistent with respect to the *landmark* distances, but the graph's edge weights must satisfy the corresponding inequality for consistency to transfer — this is the technical detail the phase must resolve, and the reason the roadmap's criterion 2 ("identical tolerance verdicts") is a real gate rather than a formality. #### Step 5 — where the cost goes: build time vs request time This is the whole economic argument for ALT in one picture. The expensive work happens once, on a maintainer machine, when the graph is rebuilt. A request on the free-tier Worker only ever *reads* the resulting table. ```mermaid flowchart LR classDef build fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0 classDef buildstep fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0 classDef run fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A classDef runstep fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A subgraph BUILD["BUILD TIME — paid once per graph rebuild (maintainer machine)"] direction LR B1["farthest-point sweep
pick 16–32 ALT landmarks"]:::buildstep B2["one Dijkstra per landmark
over 311k nodes / 677k edges"]:::buildstep B3["landmark table
≈20–40 MB of seconds"]:::buildstep B4["encode layout v2
upload to R2"]:::build B1 --> B2 --> B3 --> B4 end subgraph RUN["REQUEST TIME — every /api/route call (free-tier Worker)"] direction LR R1["decode graph + table
once per cold isolate"]:::runstep R2["A* inner loop
array reads + adds"]:::runstep R3["one optimal route"]:::run R1 --> R2 --> R3 end B4 -.->|"static artifact — no request
ever runs the Dijkstra pass"| R1 ``` **What "preprocessing at build time, $0 at runtime" buys.** The `|L|` Dijkstra runs are O(|L| · E log V); at this graph size that is minutes of CPU. Moving that to build time means the recurring bill stays at zero: R2 stores the table inside the free tier, and the Worker's per-request cost is *lower* than today, because a landmark lookup is a couple of array reads where the haversine heuristic calls `Math.sin`, `Math.cos`, and `Math.asin` per node per side. Workers Paid, by contrast, buys CPU by the month — a recurring $5/mo that breaks the $0 law — while ALT pays once in engineering and then costs nothing. That is why ALT is the chosen lever and Workers Paid was rejected. ### 2.3 How ALT would slot into the existing bidirectional A\* This is the part that matters most for this codebase, because the current engine is **not** a textbook unidirectional A\* — it is a BH bidirectional search with Ikeda potentials. **Bidirectional A\* needs two potentials, not one.** The current engine has: - `h_f(v) = hav(v, goal) / maxSpeedMps` — the forward heuristic, - `h_b(v) = hav(v, origin) / maxSpeedMps` — the backward heuristic, - and the **opposite potentials** `p_f(v) = (h_f(v) − h_b(v)) / 2`, `p_b(v) = −p_f(v)`. So ALT must supply **both** `h_f` and `h_b`: ``` h_f(v) = max over l ∈ L of ( d(l, v) + d(l, goal) − d(l, origin) ) // bound on origin→v→goal h_b(v) = max over l ∈ L of ( d(l, v) + d(l, origin) − d(l, goal) ) // bound on goal→v→origin ``` These are the ALT potentials with the query endpoints swapped. Note the sign structure: `h_f` and `h_b` are **not** independent — they share the same landmark table, and the ALT form gives them a natural relationship. The Ikeda potential `p_f = (h_f − h_b)/2` is then recomputed from the ALT-derived values, and the feasibility argument (each reduction non-negative because each feasibility is the sum of two consistencies) has to be **re-proven for the ALT potentials**, not assumed. The same algebra caution as §2.2 applies to these two lines: the proven per-vertex form is `d(l, target) − d(l, v)`, so re-derive before implementing. The picture the formulas encode: ```mermaid flowchart LR classDef landmark fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0 classDef known fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A classDef unknown fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A classDef target fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0 L["ALT landmark l
(a graph vertex — not a POI)"]:::landmark V["vertex v
(where the search is right now)"]:::unknown S["source s
(the snapped origin)"]:::known T["target t
(the snapped destination)"]:::target L ---|"d(l,v) — precomputed"| V L ---|"d(l,s) — precomputed"| S L ---|"d(l,t) — precomputed"| T S -.->|"d(s,v) — NOT in the table"| V V -.->|"d(v,t) — the quantity h must never overshoot"| T ``` Solid lines are rows of the landmark table (known without searching). Dotted lines are the quantities the search is trying to learn. The triangle inequality relates them: `d(v,t) ≥ d(l,t) − d(l,v)` gives a floor for the dotted edge on the right, and the same move on `(l, s, v)` gives `d(s,v) ≥ d(l,v) − d(l,s)`. ALT is the art of squeezing the best floor out of those three known lengths. **Concretely, the code changes ALT would require:** | File | Change | | --- | --- | | `routing/src/graph.ts` | A **graph-layout v2** header that carries the landmark table (or a second R2 object holding it). The pinned CSR v1 layout stays readable — v2 must decode v1 unchanged, or v1 must be retired deliberately | | `routing/src/astar.ts` | `potential(v)` currently computes `hav(v,goal)·scale` and `hav(v,origin)·scale` lazily. ALT replaces the body with a landmark-table lookup — still lazy, still allocation-free, but the inner loop reads a `Float32Array` row instead of calling `Math.sin`/`Math.cos`/`Math.asin`. **That is the real win**: the ALT evaluation is a handful of array reads and adds, versus a trig-heavy haversine | | `routing/src/astar.ts` | The **consistency/feasibility argument** must be re-established for the ALT potentials (the BH stop rule's optimality proof depends on it) | | `routing/scripts/build-road-graph.mjs` | A landmark-selection pass + 16–32 Dijkstra runs over the built graph, writing the table | | `routing/test/astar.test.mjs` | The 625-pair Dijkstra-equivalence sweep (A1) **re-run** — the standing proof must stay green with ALT potentials substituted | | `routing/scripts/measure-route-cpu.mjs` | Re-measure the p95; the roadmap's criterion 1 is "longest KL-metro routes route within the free-plan CPU budget (p95 under the ceiling, measured by the existing CPU gate)" | **The runtime cost is $0** — the table is a static artifact uploaded once, and the per-query cost is *lower* than today (array reads instead of trigonometry). This is precisely why the roadmap calls it "still $0 at runtime" and why it beats Workers Paid on the project's own terms. ### 2.4 What ALT does **not** fix ALT tightens the **heuristic**. It does not change: - the **graph size** — the 311k nodes, the 14.7 MB binary; - the **oneway law** — bit 0 is still the traversal authority; - the **snapping** behavior, the 3×3-cell candidate window, or the honest-miss contract; - **one-route-only** policy on `/api/route`; - the **fixture backend's** 200 m matching law. It also does not fix the *fragmentation* failure mode that killed ladder step (a) — that was a graph-construction problem, and it was correctly rejected on correctness rather than patched. ### 2.5 The six planned decision points — the options, and what Phase 8 recommends > **Status: a planning record, not shipped code.** These six decisions are specified in > the Phase 8 plans (`.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-01-PLAN.md` > … `08-05-PLAN.md`). Every "will" in this section means "the plan specifies". The phase's > locked user decisions (D-01…D-05 in the phase CONTEXT) are the frame; what follows are > the decisions D-01…D-05 deliberately left open, each with the options that were on the > table and why the recommendation is the recommendation. The maintainer reviewed all six > and accepted the recommendations as written (2026-10-25) — recorded here so the *why* > survives next to the *what*. #### 1. How many landmarks — a formula, not a constant (inside D-02's scaling rule) **The decision.** D-02 locks that the count is node-count-proportional and measured, in the 16–32 band. It does not lock the formula. | Option | What it means | Why not / why yes | | --- | --- | --- | | (a) hard-pin a constant ("24, forever") | Simplest; one build, one number | A constant that is right at KL's 311k nodes is wrong at Malaysia's ~6M (per-landmark bytes scale with node count: ~1.19 MB/landmark at KL, ~12–24 MB/landmark at country scale — the trap D-02's wording exists to close). Every future rebuild re-litigates the number by hand. | | (b) build once per candidate L, keep the smallest that passes the CPU gate | Maximally "measured" | Turns every rebuild into a multi-build campaign (L=16, 20, 24, 28, 32 — five Overpass-class builds to touch one knob); the gate is the arbiter anyway, so the extra builds buy evidence the gate already provides. | | (c) **a named formula + a measured override** — `L = clamp(round(N / 13,000), 16, 32)`, plus a maintainer-only `--landmarks ` rebuild flag (hard-capped at 128) | **Recommended** | Self-scales (24 at KL today; re-derived mechanically at any future scale, per D-02's own rationale); the divisor 13,000 is a named, doc-commented constant with its measured justification; the override is the campaign's knob (raise to 32 only if the gate honestly fails at 24 — the pre-committed ladder), and its cap keeps a fat-fingered count from producing an envelope-busting table. | The formula lands on `round(311,325 / 13,000) = 24` — mid-band, where the research expects the curve to flatten (more landmarks past ~24 buy little steering at metro scale but linear bytes). #### 2. The coverage gate — every landmark must reach ≥ 90% of the graph **The decision.** Whether the build verifies the landmark tables are actually *usable* before writing the binary, and how hard. | Option | What it means | Why not / why yes | | --- | --- | --- | | (a) no coverage gate | Trust the giant SCC | A silently fragmented rebuild (the §1.2 `*_link` failure mode: 7.7% of nodes severed) would ship tables riddled with `0xFFFF` sentinels — steering degrades toward haversine for everyone, nothing prints a warning, and the CPU gate failure would be diagnosed as "ALT didn't work" when the real bug is extract fragmentation. | | (b) require 100% reachability per anchor | Strongest-sounding | Too strict for a **directed** graph with genuine fringe: dead-end driveways and small islands are allowed to be unreachable *from* a landmark; demanding 100% would fail honest builds forever and tempt someone to lower it quietly later — the worst gate shape. | | (c) **a named fraction gate** — `LANDMARK_MIN_COVERAGE_FRACTION = 0.90`; any anchor below it → build exits 1, previous binary untouched | **Recommended** | The measured KL build is one giant strongly-connected component covering ~305k of 311k nodes (≈ 0.98), so 0.90 has honest headroom while still catching a fragmented extract before it becomes a hosted binary. Named constant, doc-commented with the SCC measurement; the build prints per-anchor coverage either way (print-the-measurement law). | #### 3. The runtime potential — max(ALT bound, haversine bound), always **The decision.** What `h_f(v)` / `h_b(v)` are when at least one landmark's table entries are valid for `v`. | Option | What it means | Why not / why yes | | --- | --- | --- | | (a) pure ALT while any landmark is valid; haversine only when *every* entry is a sentinel | The textbook shape | Creates a steering cliff: a node whose last valid landmark entry flips to sentinel gets a discontinuous heuristic jump. Correctness is unaffected either way (both shapes are admissible + consistent), but the discontinuity is exactly where hard-to-reproduce search-path differences would live. | | (b) **always take the max of the ALT bound and the existing haversine bound, per direction** | **Recommended** | The max of two admissible heuristics is admissible; the max of two consistent heuristics is consistent — so optimality is untouched (A1's 625-pair sweep re-proves it with ALT live). The haversine term is already computed lazily, so the cost is two comparisons per node touch; the benefit is a heuristic that is continuous across the sentinel boundary and never worse than today's engine at any node (ALT can only *add* tightness, never subtract it). On the fringe nodes where all landmarks are sentinels, the max simply *is* the haversine bound — the fallback falls out of the max instead of being a special case. | The §0.5/§1.3 machinery is untouched by this choice: the ALT-derived `h_f`/`h_b` feed the same Ikeda potential `p_f = (h_f − h_b)/2`, whose survey-verified algebra is what the phase actually re-uses. #### 4. Pairs the frozen record could not snap, but the new build can — "re-snapped", info not violation **The decision.** D-03's optimality gate compares post-ALT route seconds against the frozen pre-ALT record (the 100-pair coordinate-pinned `alt-baseline.json`), per pair. What happens to a pair the *old* binary could not snap at all? | Option | What it means | Why not / why yes | | --- | --- | --- | | (a) any outcome-class change is a violation, including unsnappable → routed | Simplest rule to state | Conflates two unrelated things. "Unsnappable" was a property of the *old extract's snap grid* at that random coordinate — a data statement, not an optimality statement. ALT is steering-only (the A1 law: same edges, same cost model), so a re-snap is extract drift speaking, not an optimality break; failing the gate on it would manufacture a false failure and pressure someone into re-recording the baseline — which D-03's design forbids (the record is immutable; that is what makes the comparison trustworthy at all). | | (b) **gate exactly D-03's letter — the reachable ↔ unreachable flip is a violation — and report formerly-unsnappable pairs that now route as counted "re-snapped" info, with a printed warning if any exist** | **Recommended** | The seconds-equality gate (± 0.1%) and the reachable/unreachable class law are enforced exactly as decided; the re-snapped rows are *printed, counted, and warned about* — drift is never hidden, it is just not misfiled as an optimality failure. A mass of re-snapped rows is itself informative (the extract moved) and escalates to a human, exactly like the counts-drift warning the gate also prints. | #### 5. The 200 ms drift guard outlives the c3 class — as hygiene, not acceptance **The decision.** §1.9's `route-cpu` budget gate compares a re-run p95 against a 200 ms drift guard because the *documented accepted state* was 38–93 ms. When Phase 8 retires c3, what does the gate assert? | Option | What it means | Why not / why yes | | --- | --- | --- | | (a) delete the guard: any p95 > 8 ms fails `budget-audit` | Maximally strict | The measurement host is a shared Linux box with documented ≈ 2× run-to-run variance, and the 06-04 history measured 38–93 ms on *identical code*. An 8 ms hard gate on that host makes the audit flaky at the exact moment the record finally turns green — and a flaky gate trains everyone to ignore it. | | (b) **keep the two-tier law, re-anchor the wording**: ≤ 8 ms → PASS; 8–200 ms → exit 0 with a loud warning ("over the product budget within the shared-host variance guard — the c3 class was RETIRED in Phase 8; re-run on a quiet box before treating it as a regression"); > 200 ms → hard fail ("uncharted regression — the retired class does not explain this") | **Recommended** | The guard's *meaning* flips, not its threshold: it is no longer "the accepted degradation band" — that sentence dies with c3 — but "below this, suspect the box before suspecting the code". Beyond 200 ms nothing the host does explains it, so that stays a hard fail. The stale strings ("the DOCUMENTED, ACCEPTED c3 class", "the ALT landmark lever is a next-milestone item") are deleted from the gate law in the same change, so no record anywhere keeps accepting a degradation that no longer exists. | #### 6. The rebuild runs during execution; the R2 upload stays manual **The decision.** Where the line between "the phase executes" and "the maintainer acts" sits. | Option | What it means | Why not / why yes | | --- | --- | --- | | (a) plans stop at code; the maintainer builds, uploads, and measures by hand afterwards | Cleanest separation | The phase's success criteria are *measured numbers*, and the build stage is exactly the kind of code that can pass every unit gate and still be wrong on the real graph. Stopping at code would turn 08-05's verdicts into "measure it yourself later" — the phase would not be able to retire c3, because D-05's evidence law requires the printed numbers. | | (b) **the build plan runs the real Overpass fetch + rebuild + `--check` as an execution task (maintainer-time, minutes, the same public endpoint as the 2026-10-24 build); only the upload remains manual** | **Recommended** | The rebuild is what makes 08-05's campaign evidence rather than hope, and it is fully gated (envelope, coverage, quantum, collect-then-write — a failed gate leaves the previous binary untouched). The upload stays manual deliberately: it is an account-level act on the maintainer's own bucket (the fork contract — *your* graph, *your* bucket), and it is the migration's single go-live switch: D-01 makes re-uploading the rebuilt binary the *entire* rollout, and until that one upload, an old binary is the designed router-pending state, not an outage. Automating it would put a production-data replacement behind a script's exit code. | | (c) automate the upload too | Fully hands-off | Breaks the fork contract's ownership boundary and removes the human from the only irreversible step. | --- ## 3. Pros and cons of every approach ### 3.1 Summary table | Approach | Status | p95 CPU (real KL graph) | Optimality | Correctness risk | Cost | Verdict | | --- | --- | --- | --- | --- | --- | --- | | **Fixture routes** (backend #1) | **Shipped** | n/a — no search | as computed by the OSRM demo at build time | Very low — 15 curated pairs, RT2b-proven public origins | $0, 16,576 B gz / 102,400 B budget | Kept as the demo corpus and the ladder's fallback | | **Unidirectional A\*** | **Measured, replaced** | **≈ 38 ms** (p95) | Provably optimal | Low | $0 | Rejected on CPU; the heuristic's looseness is the cause | | **Bidirectional A\* (BH)** | **Shipped** | **≈ 38 ms** (p95); current band 38–93 ms; budget record 83.989 ms | Provably optimal (625-pair Dijkstra sweep) | Low — two soundness bugs found and fixed by the gates | $0 | Kept: no worse on p95, better on short product routes | | **Graph pruning** (drop tertiary + residential) | **Measured, reverted** | **≈ 18 ms** — still FAIL | — | **Fatal**: 12/25 pairs unreachable | — | **Rejected on correctness** (D-03) and reverted | | **ALT landmark preprocessing** | **Committed, not built** | Expected to bring cross-metro routes under the ceiling (the roadmap's criterion 1) — **no measurement exists** | Provably optimal (admissible; criterion 2 re-runs the corpus) | Medium — new build-time machinery, new binary layout | **$0 runtime**; build-time engineering + ~20–40 MB table | The committed upgrade lever | | **Workers Paid** | **Rejected** | Removes the ceiling entirely | unchanged | none | **$5/mo — breaks the $0 law** | Rejected by the maintainer | ### 3.2 Fixture routes — pros and cons **Pros** - **Zero runtime search.** No A\*, no graph decode, no CPU to speak of. The corpus rides the bundle. - **Deterministic and offline-provable.** `routing/scripts/route-corpus.mjs` recomputes every geometry↔summary consistency (haversine length within 0.85–1.10× the route's own `distanceM`; average speed within 1.0–30 m/s), proves the polyline6 round-trip exactly, checks the licence header, and enforces the gzip budget — all offline. - **Privacy-clean by construction.** Every origin and destination is a **gazetteer landmark**, and RT2b proves each pinned origin *equals* the landmark's committed coordinate within 1e-4°. No user coordinate can enter the dataset (D-12). - **Alternatives for free.** The corpus carries up to 3 routes per pair (8 multi-route pairs against a floor of 4) — RTNG-02's chip/redraw surface, which the Worker path deliberately does not offer. **Cons** - **The 200 m law is a hard ceiling.** A driver 250 m from the pinned origin gets `null`. The pad is sized for GPS jitter at a landmark, not for a real trip. - **15 destinations.** The coverage is the curated KL gazetteer, not the city. - **No maneuvers.** Fixture steps are always empty arrays (D-10) — honest, but the guidance surface needs real steps, which is exactly what engine A supplies. - **Build-time dependency on a free best-effort service.** The OSRM demo server produced the geometry. That is acceptable *precisely because the output is committed* — the runtime never touches it. A server policy change breaks the (rare) regeneration script, never the product. ### 3.3 Unidirectional A\* — pros and cons **Pros** - **Simplest possible implementation.** One search, one heap, one heuristic, one closed set. - **The heuristic is trivially consistent** — `hav(v, goal) / maxSpeedMps` — and the optimality proof is one line. - **Lower per-node overhead** than the bidirectional variant (no potential, no reverse index, no meet probes). **Cons** - **p95 ≈ 38 ms on the real graph — 4.75× over the 8 ms ladder budget.** The weak max-speed heuristic balloons on 20–30 km routes: it assumes every metre of the route is travelled at the fastest speed in the graph. - **Search radius grows with route length.** The classic A\* pathology on a loosely-heuristic'd graph. **Verdict:** replaced by the bidirectional variant. Not revisited — the ALT potential in §2 is the answer to *this* root cause (heuristic looseness), not to the search direction. ### 3.4 Bidirectional A\* (BH, Ikeda potentials) — pros and cons **Pros** - **Provably optimal.** The BH stop rule — `top_f + top_b ≥ mu` — settles the goal optimally, because the meet-sum is a **v-independent constant**. The 625-pair exhaustive Dijkstra-equivalence sweep is the standing proof, re-run on every pass. - **Better on the routes that matter.** Short product routes (2–8 km) improved measurably; the halved radius per side pays off exactly where the product's common trip lives. - **No layout change.** The reverse CSR is a derived in-memory `WeakMap` structure — the pinned binary layout is untouched. - **A real bug-finding machine.** The bidirectional implementation exposed **two** soundness bugs (phantom arcs from reading out-edges as in-edges; mirrored-infeasible reduction signs) that the equivalence sweep and the oneway law caught. A simpler engine would not have been stress-tested the same way. **Cons** - **p95 did not improve: still ≈ 38 ms.** The diagnosis in the code and the plan summary is exact — *the potential split halves each side's heuristic steering, cancelling the halved search radius*. Bidirectionality buys radius; the Ikeda potential costs steering. **The two effects cancel.** - **More machinery.** The reverse CSR, the potential computation, the meet probe, the alternation knob — all of it must be correct, and two of those things were wrong at first. - **The alternation knob is a trap.** Raw-top comparison starved the backward side (19,501 : 3) and ran **4× slower than unidirectional**. Heap-size comparison fixed it. This is a correctness-neutral choice that is easy to get wrong. **Verdict:** kept. It is sound, no worse on the p95 class, better on the short-route class, and it is the engine ALT would plug into. ### 3.5 Graph pruning (dropping road classes) — pros and cons **Pros** - **The only lever that actually moved the number.** p95 fell from ≈ 38 ms to ≈ 17.8 ms — nearly halving it — and the graph shrank from 311k to 127k nodes. - **Zero runtime cost, zero build complexity.** One edit to `ACTIVE_CLASSES` and a rebuild. - **A real, reproducible experiment** with the numbers recorded in `measure-route-cpu.mjs`'s LADDER HISTORY and `build-road-graph.mjs`'s comment block. **Cons** - **It broke the product.** 12/25 pairs became **unreachable**, including 8/15 golden corpus pairs. The dropped classes (tertiary, residential) are the **fine mesh tying gazetteer (POI) landmarks to the arterial network** — the fixture endpoints, e.g. KL Tower and KL Sentral; see §0.8. Without that mesh the graph fragments. - **The failure mode is the worst kind.** An unreachable pair resolves `null`, which renders as "the router is not ready for this trip". Half the product's destinations degrading to that is not a smaller graph — it is a broken one. - **Correctness is not negotiable against CPU.** The project rejected it on D-03 grounds and **reverted**. This is the clearest statement in the repo that the routing CPU budget is subordinate to the routing *correctness* law. **Verdict:** rejected. Reverted to the full 13-class set. The lesson is now encoded in the build script's comment, so the experiment is not silently re-proposed. ### 3.6 ALT landmark preprocessing — pros and cons > **Terminology check:** in this subsection "ALT landmark" means a **graph vertex**, not > one of the searchable POI landmarks. §0.8 has the full distinction. **Pros** - **$0 at runtime.** The expensive part is build-time; the runtime reads a static table. It beats Workers Paid on the project's own terms, which is why the maintainer chose it over the $5/mo plan. - **It attacks the root cause.** Every other lever either moved cost around (bidirectional) or broke correctness (pruning). ALT makes the heuristic **tight** — the one thing that was actually wrong. - **It is cheap at query time.** The ALT evaluation is array reads and adds, versus the current `haversine` call's `sin`/`cos`/`asin` per node per side. The hot loop gets *cheaper per node* even as the search gets smaller. - **Optimality is preserved.** ALT is admissible; the roadmap's criterion 2 re-runs the corpus and the A1 sweep. - **The infrastructure is already there.** The build script, the R2 upload path, the CPU gate, the equivalence sweep, the budget record — all of it exists and is exercised. The new work is a preprocessing pass and a layout v2. **Cons** - **It is not built.** No landmark selection code, no Dijkstra-per-landmark pass, no v2 layout, no ALT potential, no measurement. Every performance claim about it is a projection, not a measurement. - **Build-time cost.** 16–32 Dijkstra runs over 311k nodes, plus the landmark selection. Minutes of build time — acceptable, but it is real work. - **Storage cost.** ~20–40 MB of landmark table on top of the ~14.7 MB graph binary. R2 is free-tier, but the Worker isolate has a **128 MB memory ceiling** and must hold the decoded graph *and* the table. The decode cost also lands inside the cold-start path. - **Occlusion.** Landmarks that do not lie near the true shortest path give loose bounds. More landmarks tighten the bound but grow the table — the tightness/occlusion tradeoff in §2.2 is a genuine design decision, not a knob with a free setting. - **Bidirectional interaction is non-trivial.** The BH proof requires both `h_f` and `h_b` to be consistent on their own search graphs, and the Ikeda potential `p_f = (h_f − h_b)/2` is derived from their difference. ALT's landmark form gives `h_f` and `h_b` a *different* relationship than the current haversine pair, so the feasibility argument must be **re-established**, not inherited. This is the single largest technical risk in the phase. - **It inherits everything the current engine inherits.** The 10 ms ceiling, the 2× measurement variance, the shared dev box. **Verdict:** the committed upgrade lever (c2), scheduled as Phase 8, **not built**. ### 3.7 The backend ladder as a strategy The ladder itself is an approach worth naming, because it is the project's answer to "a single backend is fragile": | Rung | What it is | What it buys | | --- | --- | --- | | #1 `fixture` | The committed golden-pair corpus | The product always has *something* honest to draw for the flagship demo trips, even when the graph is absent or the CPU budget blows | | #2 `engine-a` | The live CSR graph router | Arbitrary origins, real reroutes, real maneuvers | | #3 (documented) | Fully client-side router (Engine B / WASM) | Routing cost leaves the Worker entirely; origin and destination never touch a server — the privacy audit's best outcome | | #4 (documented) | Valhalla on a VPS | A full-featured production router, if the $0 law is ever revisited | The ladder is **not switchable** — the import site constructs the backends in code. That is a deliberate choice: an env-driven backend switch would put a privacy-relevant behaviour difference behind a config value. --- ## 4. How this all relates to the project's philosophy ### 4.1 Dieter Rams — "less, but better" The design philosophy in `DESIGN.md` is functional clarity, accented colours, timeless restraint. The routing engine is where that philosophy becomes an engineering constraint rather than a visual one. **"Less" is the 10 ms budget.** A phone-based navigator that routes on a $0 Worker cannot afford a heavyweight engine. That single ceiling is why the search is a typed-array A\* over a CSR binary rather than a general-purpose routing library, why the graph is class-filtered, why only **one** route is returned per request, and why the hot loop allocates nothing. The Rams refusal to add ornament maps exactly onto the refusal to add alternatives generation, penalty runs, or a second search. **"Better" is measured, not asserted.** `routing/scripts/measure-route-cpu.mjs` times the real engine over the real binary; `routing/scripts/route-corpus.mjs` re-derives the fixture corpus's consistency offline; `npm run budget-audit` compares every number against its documented free-plan ceiling and **prints negative headroom as measured**. The A1 sweep proves optimality exhaustively rather than by spot check. This is Rams' "good design is as little design as possible" applied to an algorithm: nothing is added that does not earn its cost, and every claim is a measurement. **Honest failure is a designed surface.** When routing cannot be done in budget, the answer is the router-pending card — *not* a blank map, *not* a wrong line, *not* a 500. The engine's degradation path is: CPU exhausted → 1102 → the client's fail-honest `null` → the pending state. That is `docs/CORE-CONCEPTS.md` §5 ("Honest failure as the design language") applied to the algorithm. ### 4.2 Privacy-first **D-12 — public-data provenance.** The routing data plane is built entirely from **public OpenStreetMap data** fetched via the public Overpass API, with the ODbL attribution travelling with the derived artifact (`docs/ROAD-GRAPH.md` is the notice surface; the fixture corpus carries `_license` / `_attribution` / `_notice` / `_source` inside the file itself). No user coordinate ever enters a fixture — this is not a promise but a **mechanically proven property** (RT2b). **The origin lives in memory.** The live fix is the origin. It is matched in memory and dies with the frame. `routing/src/types.ts` and `fixture-source.ts` both state it: *"No logging, no storage, no network — request coordinates never leave the caller's frame."* The `routing/src` tree reads no environment, holds no secrets, and performs no network I/O. **One position does leave the device — deliberately.** `POST /api/route` sends the origin to *our own Worker*. This is the one in-drive position the privacy law permits transmitting (the origin must reach the router), and the audit records the fetch allowlist as **relative paths** (`/api/route`), not hosts. **Category-only logs.** The router's only log lines are `"[route] rejected: unauthorized"` / `"[route] rejected: malformed"` — never coordinates, ids, or payloads. The graph binary is public data; the request is not. **Engine B would make this strictly better.** The documented third rung — routing computed in the webview, graph downloaded and HTTP-cached on device — is the privacy audit's *best* outcome: origin and destination never touch a server at all. ALT is not a privacy change; it is what makes the $0 server-side engine good enough that Engine B remains a choice rather than a necessity. ### 4.3 Free-first infrastructure **The $0 law is a product constraint, not an aspiration** (`docs/BUDGETS.md`). Every ceiling is a documented Cloudflare limit; every measurement sits beside it. ALT was chosen over Workers Paid for exactly this reason: **$5/mo breaks the law**, while ALT costs build-time engineering and stays $0 at runtime. The maintainer's decision (recorded in the 06-04 summary) is unambiguous: *"c3 now, c2 next milestone."* The whole engine-A stack is free: Overpass public instance (build-time), Cloudflare R2 free tier (hosting), Workers free plan (compute), OpenFreeMap tiles (map). The routing package has **zero runtime dependencies** — the whole stack is V8-native. **The honest accounting.** `docs/BUDGETS.md` records the A\* CPU row as **−76.0 ms headroom** against the 8 ms ladder budget, with a 200 ms drift guard rather than a false green. A **c3 degradation class** is documented as the negotiated posture, not hidden. This is the Rams "honest failure" law applied to a budget: the number is printed where it was found, and the escalation ladder is documented rather than quietly closed. ### 4.4 The Engine A / Engine B ladder as an architectural statement The routing ladder (`fixture → engine-a → engine-b → valhalla`) is the same shape as the search ladder (`gazetteer → WASM geocoder → Photon`). The point of a ladder is that **no capability ever wedges to a single implementation**: - Each rung answers the same contract. - Failures resolve **honestly** — `null` → a designed router-pending surface, never a blank map, never a fake route. - The ladder is **documented, not switchable** — the import site constructs the backend in code. A routing backend is not a config value, because a routing backend is a privacy-relevant surface. Engine A is today's rung: **the Worker is the routing oracle** (RP1 — `worker-router.ts` never fetches, never proxies; the graph comes from the bound R2 bucket and the A\* is pure CPU). Engine B is the documented future rung, where the Worker serves only data. ALT sits inside Engine A as its **upgrade lever** — it does not change the ladder, it makes the current rung good enough on cross-metro trips. That is the minimal, Rams-shaped move: improve the thing that exists rather than add a new thing. ### 4.5 The one-sentence version Engine A routes today over a public OpenStreetMap CSR binary on a free-tier Worker with a provably-optimal, allocation-free bidirectional A\* that is **fast enough for the trips people actually take** (2–8 km, 2–8 ms) and **honestly degrades on the ones it isn't** (cross-metro, 38–93 ms → router-pending). The committed fix — ALT landmark preprocessing, build-time, $0 at runtime — is scheduled and unbuilt, and when it lands it will tighten the only thing that was actually wrong: the heuristic. --- ## Appendix A — File map | Path | Role | | --- | --- | | `routing/src/types.ts` | The routing contracts: `RoutePoint`, `RouteRequest`, `RouteResult`, `RouteOption`, `RouteStep`, `RouteGeometry`, `RoutingSource`, `FixturePair` | | `routing/src/graph.ts` | The CSR binary codec — `encodeGraph` / `decodeGraph`, the pinned layout, the oneway flag | | `routing/src/astar.ts` | The engine-A core — `routeAstar` (bidirectional BH), `classifyTurn`, `deriveSteps`, the `NodeHeap`, the reverse-CSR `WeakMap` | | `routing/src/snap.ts` | `snapToGraph` — the server-side snapping authority, the memoized 3×3-cell grid | | `routing/src/geo.ts` | `haversineM`, `deriveCumulativeDistanceM` — the distance authorities | | `routing/src/polyline.ts` | `decodePolyline6` / `encodePolyline6` — the polyline6 codec | | `routing/src/fixture-source.ts` | Backend #1 — `createFixtureRoutingSource`, `ORIGIN_TOLERANCE_M` (200 m) | | `routing/src/http-source.ts` | Backend #2's client seam — `createHttpRoutingSource` | | `routing/src/index.ts` | The package's public export surface | | `routing/src/data/route-fixtures.json` | The committed fixture corpus (15 pairs) with the ODbL envelope | | `routing/scripts/build-road-graph.mjs` | Overpass → CSR build, the envelope gate, the class filter, the ladder history | | `routing/scripts/measure-route-cpu.mjs` | The A\* CPU gate (p95 ≤ 8 ms) and the LADDER HISTORY | | `routing/scripts/route-corpus.mjs` | The offline RT1–RT10 fixture gate | | `routing/scripts/fetch-route-fixtures.mjs` | The maintainer-time OSRM fixture regeneration tool | | `routing/scripts/upload-road-graph.mjs` | The R2 upload tool (shells `wrangler`) | | `routing/data/kl-roadgraph.v1.bin` | The build artifact (gitignored) | | `routing/test/astar.test.mjs` | The A1–A8 gates (625-pair Dijkstra equivalence, oneway, turn pins) | | `routing/test/fixtures/mini-graph.bin` | The committed 5×5 mini-graph | | `routing/test/fixtures/golden-pairs.json` | The pinned 15-pair corpus + thresholds | | `bot/src/routing/worker-router.ts` | `handleRoute`, `loadGraph`, `GRAPH_KEY` — the server arm | | `web/src/main.ts` | The composed ladder (engine-a → fixture) | ## Appendix B — The measured numbers, in one place | Metric | Value | Source | | --- | --- | --- | | Free-plan Worker CPU ceiling | 10 ms/invocation | `docs/BUDGETS.md` | | The project's own ladder budget (20% margin) | p95 ≤ 8 ms | `measure-route-cpu.mjs` | | Unidirectional A\* p95 (311k-node graph) | ≈ 38 ms | LADDER HISTORY, 2026-10-24 | | Pruned-graph p95 (127k nodes) | ≈ 17.8 ms — and 12/25 pairs unreachable | LADDER HISTORY | | Bidirectional A\* p95 | ≈ 38 ms (documented band 38–93 ms) | LADDER HISTORY / `docs/BUDGETS.md` | | Current budget-record p95 | 83.989 ms (runs 67.5–84.0 ms), headroom −76.0 ms | `docs/BUDGETS.md` | | Short product routes (2–8 km) | 2–8 ms — inside budget | `docs/BUDGETS.md`, `docs/KNOWN-LIMITATIONS.md` | | Measurement run-to-run variance | ≈ 2× (shared dev box) | `docs/BUDGETS.md`, 06-04 summary | | Graph size | 311k nodes (linked 13-class build); ~14.7 MB binary | `build-road-graph.mjs`, 06-04 summary | | Graph binary envelope | 1 MB floor / 80 MB ceiling / 30–80 MB expected | `build-road-graph.mjs` | | Severed-node rate without `*_link` classes | 7.7% of nodes in motorway islands | `build-road-graph.mjs` | | Fixture corpus | 15 pairs / 23 routes; gz 16,576 B / 102,400 B budget | `docs/BUDGETS.md` | | Fixture origin tolerance | 200 m (haversine) | `fixture-source.ts` | | Multi-route fixture pairs | 8 (floor 4) | `route-corpus.mjs` | | A1 equivalence sweep | 625 pairs (25×25), cost equality within 1e-6 | `astar.test.mjs` | | Route payload (gz) | 79,815 B / 204,800 B | `docs/BUDGETS.md` | | Worker memory ceiling | 128 MB/isolate | `docs/BUDGETS.md` | | KV list-op ceiling (cron) | 720/day after the `*/2` retune (limit 1,000/day) | `docs/BUDGETS.md` | ## Appendix C — Related documents - `docs/ROAD-GRAPH.md` — the fork contract, the build/upload/check flow, the ODbL notice - `docs/BUDGETS.md` — the budget record: every measurement beside its free-plan ceiling - `docs/API.md` — the `POST /api/route` contract, the null contract, the error codes - `docs/KNOWN-LIMITATIONS.md` — the user-facing statement of the cross-metro degradation - `docs/CORE-CONCEPTS.md` — the ladders, the $0 law, the honest-failure language - `docs/ARCHITECTURE.md` — the module map - `DESIGN.md` — the Rams philosophy