diff --git a/docs/ROUTING.md b/docs/ROUTING.md new file mode 100644 index 0000000..c154977 --- /dev/null +++ b/docs/ROUTING.md @@ -0,0 +1,1585 @@ + +# 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