Files
arah2/docs/ROUTING.md

91 KiB
Raw Permalink Blame History

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):

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:

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.

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):

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.

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

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

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:

"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) + Landmarks (a fixed set of reference vertices) + Triangle 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.

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:

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
  • 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