1586 lines
91 KiB
Markdown
1586 lines
91 KiB
Markdown
<!-- generated-by: gsd-doc-writer -->
|
||
# 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<br/>(an OSM geometry vertex:<br/>a perimeter corner)"]:::node
|
||
B["Node B — grid id 3<br/>(an OSM geometry vertex:<br/>the next perimeter corner)"]:::node
|
||
C["Node C — grid id 8<br/>(an OSM geometry vertex:<br/>an interior grid point)"]:::interior
|
||
|
||
A -->|"edge A→B · ≈3.3 s<br/>Jalan Persiaran Mini · 60 km/h"| B
|
||
B -.->|"edge B→A is BLOCKED<br/>edgeFlags bit 0 · oneway mirror"| A
|
||
B -->|"edge B→C · ≈6.7 s<br/>Jalan Mini 2 · 30 km/h"| C
|
||
C -->|"edge C→B · ≈6.7 s<br/>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<br/>(a GPS fix, after snapping)"]:::origin
|
||
F1["forward frontier<br/>ring 1"]:::frontier
|
||
F2["forward frontier<br/>ring 2"]:::frontier
|
||
M["meeting node<br/>(best mu seen so far)"]:::meet
|
||
B2["backward frontier<br/>ring 2"]:::frontier
|
||
B1["backward frontier<br/>ring 1"]:::frontier
|
||
G["goal node<br/>(a gazetteer POI, after snapping)"]:::goal
|
||
|
||
O --> F1 --> F2 --> M
|
||
G --> B1 --> B2 --> M
|
||
F2 -.->|"stop when<br/>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<br/>route request"]:::client
|
||
LADDER["routingSource: 'ladder'<br/>httpSource.route() ?? fixtureSource.route()"]:::client
|
||
HTTP["POST /api/route<br/>Bearer session token"]:::client
|
||
WR["bot/src/routing/worker-router.ts<br/>handleRoute"]:::server
|
||
GRAPH[("R2 ROAD_GRAPH<br/>kl-roadgraph.v1.bin")]:::store
|
||
DEST["getEntryById(destinationId)<br/>gazetteer POI lookup"]:::server
|
||
SNAP["snapToGraph<br/>origin + destination"]:::server
|
||
ASTAR["routeAstar<br/>bidirectional A*"]:::server
|
||
RESULT["200 RouteResult<br/>backend: engine-a"]:::client
|
||
NULL["200 null<br/>(any miss class)"]:::pending
|
||
FIX["routing/src/fixture-source.ts<br/>15 golden pairs · 200 m law"]:::fallback
|
||
PENDING["router-pending state<br/>(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<RoadGraph, Grid>`. 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": "<slug>" }
|
||
```
|
||
|
||
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: <category>"` —
|
||
`unauthorized`, `malformed` — never coordinates, ids, or payloads.
|
||
|
||
#### The graph load: isolate memoization is the cache that works
|
||
|
||
```ts
|
||
const graphMemo = new WeakMap<object, Promise<RoadGraph | null>>();
|
||
```
|
||
|
||
`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<br/>pick 16–32 ALT landmarks"]:::buildstep
|
||
B2["one Dijkstra per landmark<br/>over 311k nodes / 677k edges"]:::buildstep
|
||
B3["landmark table<br/>≈20–40 MB of seconds"]:::buildstep
|
||
B4["encode layout v2<br/>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<br/>once per cold isolate"]:::runstep
|
||
R2["A* inner loop<br/>array reads + adds"]:::runstep
|
||
R3["one optimal route"]:::run
|
||
R1 --> R2 --> R3
|
||
end
|
||
|
||
B4 -.->|"static artifact — no request<br/>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<br/>(a graph vertex — not a POI)"]:::landmark
|
||
V["vertex v<br/>(where the search is right now)"]:::unknown
|
||
S["source s<br/>(the snapped origin)"]:::known
|
||
T["target t<br/>(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 <n>` 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
|