Files
arah2/routing/src/astar.ts
T
araharah-executor 149636ce0a fix(08-05): D-03 optimality gate green — fromTable families removed from the ALT potential
Task 1: the D-03 gate script (measure-optimality-gate.mjs) reads the frozen
pre-ALT record, re-routes the same coordinate-pinned pairs on the current
ARG2 binary, and gates on seconds-equality (±0.1%) + verdict-class
preservation — never on node paths (equal-cost ties are documented behavior).

Rule-1 deviation the gate forced on its first run: routeAstar returned
SUBOPTIMAL real-graph routes (x038: 1234.6s vs the 1214.8s Dijkstra optimum;
x045: 1475.5s vs 1460.6s) — the fixture sweep could not see it. Root cause:
the fromTable bound d̂(ℓ,t) − d̂(ℓ,v) is one-directional Lipschitz (its u-side
term needs the REVERSE edge; a oneway does not provide one — the anchor
reached node 264611 126.8s faster than its own 1.49s upstream neighbour
264610), and the shared Ikeda average p = (h_f − h_b)/2 is consistent only
when every family inside h_f/h_b is two-sided. The toTable bound
d̂(v,ℓ) − d̂(t,ℓ) rides stored traversable edges in BOTH directions; it alone
joins the haversine now. Stop rule, reductions, meet probes, reconstruction:
untouched. landmarkTables/build verified EXACT (0 mismatches vs independent
integer Dijkstras, both rows, all 24 anchors).

- measure-optimality-gate.mjs: exported core + CLI guard (route-corpus
  pattern); epsilon mirror + argVersion asserts precede any measurement;
  per-pair rows, extract-drift WARNING (never a re-record), honest exit law
- astar.ts: potential() consults landmarkTo only; doc-comments carry the
  one-directional-Lipschitz law and the x038/x045 evidence
- astar.test.mjs: A11 re-pinned (landmarkFrom must NOT appear in the
  comment-stripped search — re-adding it trips the pin); A12 oneway-ring
  sweep added (directed reach asymmetry stays Dijkstra-equal on every pair)

Battery: routing 80/80, bot 126/126, both typechecks green.
D-03 gate: GATE PASS — 53/53 routed pairs equal within ±0.1% (max |Δ| 0.005%),
verdict classes preserved (6 unreachable + 41 unsnappable, 0 re-snapped).
2026-10-25 19:54:24 +08:00

565 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* astar.ts — typed-array BIDIRECTIONAL A* over the CSR graph + turn-step
* derivation (D-01; bidirectional per 06-04's A1 ladder step b).
* ----------------------------------------------------------------------------
* The engine-A router core: routeAstar finds the minimum-seconds path
* between two graph nodes. The search is BIDIRECTIONAL (BH, Ikeda
* potentials — see routeAstar's doc) with ALT landmark steering (plan
* 08-03: h_f/h_b = max(ALT triangle bounds, haversine) over the ARG2
* landmark tables — see routeAstar's ALT paragraph) because the free-plan
* 10 ms CPU budget is the design constraint and the measured
* unidirectional p95 on the real KL graph was 3-5x over it. The hot loops
* remain allocation-free: binary
* heaps of node indices over preallocated typed arrays, typed-array
* distances/closed sets/chains, lazy on-demand potentials, no Map, no
* objects per node. Optimality is not presumed — astar.test.mjs A1
* exhaustively proves equivalence to brute-force Dijkstra on the fixture
* (625 pairs), and the oneway/unreachable laws have dedicated cases.
*
* Traversal law (shared with the tests' brute-force Dijkstra): an edge with
* edgeFlags bit0 set ("oneway-reverse" — see graph.ts) is NEVER relaxed.
* Snapping (snap.ts) ignores the flag; only A* enforces direction.
*
* classifyTurn is the ONE authority for "what is a turn" — plan 07's cron
* projection imports this exact classifier. Convention: compass bearings
* increase clockwise (N=0, E=90), so a NEGATIVE bearing delta is a left
* turn.
*
* deriveSteps walks a node path and splits it into maneuver steps at
* bearings that leave "straight" or at street-name changes; steps carry
* kind/name/distanceM and polyline6 geometry (RouteStep.kind — the field
* this phase added to types.ts; fixture-world steps stay empty arrays,
* D-10).
*
* Pure: no I/O, no logging, no storage (T-06-05).
*/
import type { RoadGraph } from "./graph.ts";
import { haversineM } from "./geo.ts";
import { encodePolyline6 } from "./polyline.ts";
import type { RoutePoint, RouteStep } from "./types.ts";
/** Maneuver kinds — structurally the RouteStep.kind union (one authority). */
export type GuidanceTurnKind = NonNullable<RouteStep["kind"]>;
/** |delta| <= this is "straight" (degrees). */
export const TURN_STRAIGHT_MAX_DEG = 25;
/** |delta| <= this (and > straight) is a "slight" turn (degrees). */
export const TURN_SLIGHT_MAX_DEG = 60;
/** |delta| >= this is a "uturn" (degrees). */
export const TURN_UTURN_MIN_DEG = 150;
const DEG_TO_RAD = Math.PI / 180;
/** Same mean-earth sphere as geo.ts haversineM (6 371 000 m). */
const EARTH_RADIUS_M = 6_371_000;
export interface AstarResult {
/** Node path, origin first, goal last. */
nodes: number[];
/** Total travel seconds (sum of traversed edgeSeconds). */
seconds: number;
/** Meters over the node path (haversineM post-hoc — the plan's law). */
distanceM: number;
}
/* ————— binary min-heap of node indices over preallocated typed arrays ————— */
const HEAP_INITIAL_CAPACITY = 1024;
class NodeHeap {
private keys: Float64Array;
private nodes: Uint32Array;
size = 0;
constructor() {
this.keys = new Float64Array(HEAP_INITIAL_CAPACITY);
this.nodes = new Uint32Array(HEAP_INITIAL_CAPACITY);
}
push(key: number, node: number): void {
if (this.size === this.keys.length) this.grow();
let i = this.size;
this.size += 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.keys[parent]! <= key) break;
this.keys[i] = this.keys[parent]!;
this.nodes[i] = this.nodes[parent]!;
i = parent;
}
this.keys[i] = key;
this.nodes[i] = node;
}
/** The min key without popping; Infinity when empty (the BH stop rule reads this). */
peekKey(): number {
return this.size === 0 ? Number.POSITIVE_INFINITY : this.keys[0]!;
}
/** Returns the min-key node, or -1 when empty. Lazy deletion: stale
* entries are skipped by the closed-set check in routeAstar. */
pop(): number {
if (this.size === 0) return -1;
const top = this.nodes[0]!;
this.size -= 1;
const lastKey = this.keys[this.size]!;
const lastNode = this.nodes[this.size]!;
let i = 0;
for (;;) {
const left = 2 * i + 1;
if (left >= this.size) break;
const right = left + 1;
const child = right < this.size && this.keys[right]! < this.keys[left]! ? right : left;
if (this.keys[child]! >= lastKey) break;
this.keys[i] = this.keys[child]!;
this.nodes[i] = this.nodes[child]!;
i = child;
}
if (this.size > 0) {
this.keys[i] = lastKey;
this.nodes[i] = lastNode;
}
return top;
}
private grow(): void {
const keys = new Float64Array(this.keys.length * 2);
keys.set(this.keys);
const nodes = new Uint32Array(this.nodes.length * 2);
nodes.set(this.nodes);
this.keys = keys;
this.nodes = nodes;
}
}
/* ————— A* — bidirectional (BH, Ikeda potentials) ————— */
/** Inline haversine from a graph node to a fixed (lat, lng), in METERS. */
function metersTo(graph: RoadGraph, v: number, lat2: number, lng2: number): number {
const lat1 = graph.lat[v]! * DEG_TO_RAD;
const lat2r = lat2 * DEG_TO_RAD;
const dLat = lat2r - lat1;
const dLng = (lng2 - graph.lng[v]!) * DEG_TO_RAD;
const sinLat = Math.sin(dLat / 2);
const sinLng = Math.sin(dLng / 2);
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2r) * sinLng * sinLng;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
}
/* ————— reverse adjacency (the backward search's in-edge scan) ————— */
/**
* The reverse CSR (in-edges per node), built ONCE per RoadGraph and
* memoized in a module-scope WeakMap — the snap-grid LAW 1 pattern. The
* backward search cannot iterate a node's in-edges out of a source-grouped
* CSR (that was the 06-04 GREEN bug the A1/A2 gates caught: out-edges read
* as in-edges fabricate phantom arcs and traverse oneway mirrors); this
* index makes them addressable without touching the pinned binary layout.
* One-time build is O(V+E) per isolate, paid by the first route (the CPU
* gate's warm-up covers it), amortized across the isolate's requests.
*/
interface ReverseAdj {
/** rowStart[u]..rowStart[u+1] index u's IN-edges inside targets/edgeIds. */
rowStart: Uint32Array;
/** The in-edge's SOURCE node (v, for an edge v -> u). */
targets: Uint32Array;
/** The edge's index in the forward CSR (weights/flags/seconds lookup). */
edgeIds: Uint32Array;
}
const reverseGraphs = new WeakMap<RoadGraph, ReverseAdj>();
function reverseFor(graph: RoadGraph): ReverseAdj {
const memoized = reverseGraphs.get(graph);
if (memoized !== undefined) return memoized;
const n = graph.nodeCount;
const edgeCount = graph.edgeCount;
const inDegree = new Uint32Array(n + 1);
for (let e = 0; e < edgeCount; e++) inDegree[graph.edgeTarget[e]! + 1] = inDegree[graph.edgeTarget[e]! + 1]! + 1;
for (let i = 0; i < n; i++) inDegree[i + 1] = inDegree[i + 1]! + inDegree[i]!;
const targets = new Uint32Array(edgeCount);
const edgeIds = new Uint32Array(edgeCount);
const cursor = inDegree.slice(0, n);
for (let u = 0; u < n; u++) {
for (let e = graph.edgeStart[u]!; e < graph.edgeStart[u + 1]!; e++) {
const v = graph.edgeTarget[e]!; // the edge u -> v is an IN-edge of v
const pos = cursor[v]!;
cursor[v] = pos + 1;
targets[pos] = u;
edgeIds[pos] = e;
}
}
const adj: ReverseAdj = { rowStart: inDegree, targets, edgeIds };
reverseGraphs.set(graph, adj);
return adj;
}
/**
* Minimum-seconds path origin -> goal over the graph, or null when the pair
* is unreachable (the honest miss). Exhaustively proven equal to
* brute-force Dijkstra on the committed mini-graph (astar.test.mjs A1).
*
* BIDIRECTIONAL (A1 ladder step b, 06-04): the free-plan 10 ms CPU ceiling
* demands it — measured on the real 311k-node KL graph, the unidirectional
* search's weak max-speed heuristic balloons on 20-30 km routes (p95 38 ms).
* BH construction (Ikeda et al.): with directional heuristics
* h_f(v)=hav(v,goal)/maxSpeed and h_b(v)=hav(v,origin)/maxSpeed (each
* consistent on its own search graph) 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 — so 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. Alternation balance is a work knob,
* not a correctness knob (the stop rule carries optimality); A1's
* exhaustive 625-pair Dijkstra-equivalence sweep is the standing proof,
* re-run on every pass.
*
* ALT (plan 08-03, amended 08-05): the h_f/h_b bodies are max(ALT triangle
* bounds, haversine) over the ARG2 landmark tables (graph.landmarkTo,
* quantized at graph.landmarkQuantumS) — the toTable families ONLY. The
* tables come from landmark.ts's integer ĉ-metric Dijkstras —
* floor-quantized EDGE WEIGHTS make the stored distances an exact metric,
* so the triangle bounds derived from them are exactly admissible and
* exactly consistent against the real f32 seconds (no admissibility
* epsilon). 08-05 amendment (the D-03 gate's catch): the fromTable
* families d̂(ℓ,t) − d̂(ℓ,v) are one-directional Lipschitz — their
* u-side bound needs the REVERSE edge (d̂(ℓ,u) ≤ d̂(ℓ,v) + ĉ(v→u)),
* which a oneway does not provide — and the shared Ikeda average
* p = (h_f − h_b)/2 is only consistent when EVERY family inside h_f/h_b
* is two-sided. Mixing them returned suboptimal real-graph routes
* (x038: 1234.6s vs 1214.8s optimum). The toTable bound d̂(v,ℓ) − d̂(t,ℓ)
* rides stored traversable edges in BOTH directions (u→v→ℓ, v→u→ℓ) —
* it alone joins the haversine. Sentinel laws (research Pattern 3): a
* landmark whose goal/origin-side to-cell is the 0xFFFF sentinel (DATA)
* drops for the WHOLE search — the search-scoped constant skip; a
* per-node sentinel cell skips that ℓ; a node sentinel for every
* remaining ℓ falls back to the pure haversine bound (admissible +
* consistent everywhere). The survey's (πf−πr)/2 combination is what the
* p_f below already implements — ONLY the h_f/h_b bodies changed; the
* reductions, meet probes, stop rule, and reconstruction are the proven
* BH skeleton, and A1's sweep re-run under ALT (with A9/A10/A12's island,
* skip, and oneway seals) is the standing optimality proof.
*/
export function routeAstar(graph: RoadGraph, originNode: number, goalNode: number): AstarResult | null {
const n = graph.nodeCount;
if (!Number.isInteger(originNode) || originNode < 0 || originNode >= n) return null;
if (!Number.isInteger(goalNode) || goalNode < 0 || goalNode >= n) return null;
if (originNode === goalNode) return { nodes: [originNode], seconds: 0, distanceM: 0 };
const maxSpeedMps = graph.maxSpeedKmh / 3.6;
const heuristicScale = maxSpeedMps > 0 ? 1 / maxSpeedMps : 1;
const goalLat = graph.lat[goalNode]!;
const goalLng = graph.lng[goalNode]!;
const origLat = graph.lat[originNode]!;
const origLng = graph.lng[originNode]!;
// ALT per-search constants (plan 08-03, amended 08-05): the goal/origin-side
// to-table cells hoisted ONCE per search, kept in QUANTA units with a 0xFFFF
// sentinel written as NaN. A sentinel constant drops that landmark for the
// WHOLE search — the search-scoped skip; these constant tables are the ONLY
// place skip decisions live (never a per-node ℓ-mask mid-max, which would
// vary the landmark set inside the max and break the max-of-Lipschitz
// consistency argument). L >= 1 is a decodeGraph guarantee (ARG2 refuses
// landmarkCount 0 — D-01), so there is no L===0 branch and no dead
// fallback path: one search path.
//
// 08-05 AMENDMENT — the fromTable families are GONE, and that is the fix:
// the D-03 optimality gate caught routeAstar returning suboptimal real-graph
// routes (pair x038: 1234.6s vs the 1214.8s Dijkstra optimum; x045: 1475.5s
// vs 1460.6s). Root cause: a fromTable bound d̂(ℓ,t) − d̂(ℓ,v) is Lipschitz
// across an edge ONLY in the direction the anchor's reach confirms —
// d̂(ℓ,u) ≤ d̂(ℓ,v) + ĉ(v→u) needs the REVERSE edge, which a oneway does
// not have (KL: the anchor reached a node 126.8 s faster than its own 1.49 s
// upstream neighbour). The toTable bound d̂(v,ℓ) − d̂(t,ℓ) is two-sided:
// u→v→ℓ AND v→u→ℓ both ride stored traversable edges. The Ikeda average
// p = (h_f − h_b)/2 is shared by BOTH search directions, so every family
// inside h_f/h_b must be two-sided-Lipschitz — only the toTable pair is.
// haversine is two-sided (its chord ≤ road cost in both directions). The
// fixture sweep never caught this: bidirectional synthetic edges have no
// one-way reach asymmetry.
const L = graph.landmarkCount;
const Q = graph.landmarkQuantumS;
const goalToU = new Float64Array(L);
const origToU = new Float64Array(L);
for (let l = 0; l < L; l++) {
const base = l * n;
const gTo = graph.landmarkTo[base + goalNode]!;
goalToU[l] = gTo === 0xffff ? Number.NaN : gTo;
const oTo = graph.landmarkTo[base + originNode]!;
origToU[l] = oTo === 0xffff ? Number.NaN : oTo;
}
// Directional heuristics + opposite potentials, lazy per node — only
// explored nodes ever pay, and the hot loops stay allocation-free.
const hfCache = new Float64Array(n).fill(Number.NaN); // h_f(v) = max(hav(v, goal) * scale, ALT bounds)
const hbCache = new Float64Array(n).fill(Number.NaN); // h_b(v) = max(hav(v, origin) * scale, ALT bounds)
const pf = new Float64Array(n); // p_f(v) = (h_f - h_b)/2; p_b = -p_f — valid once both caches are set
// One directional bound: the max-rule (research Pattern 1) — the sharper
// of the haversine bound and the best ALT triangle bound toward the target.
// TWO families per landmark ℓ — the toTable pair (amended 08-05, see the
// per-search comment above for why the fromTable pair is excluded):
// family A toward goal: d(v,t) >= d(v,ℓ) − d(t,ℓ) (this call)
// family A toward origin: d(v,o) >= d(v,ℓ) − d(o,ℓ) (the hb call site)
// evaluated in quanta and converted to seconds ONCE at the end. Per-node
// sentinel cells skip that ℓ — compared RAW (u16) BEFORE any float
// conversion (the sentinel read as a float would be a large-but-finite
// bogus bound dominating the max). A node sentinel for every remaining ℓ
// degrades to exactly the
// pure haversine bound. A family value may sit below zero (v closer to ℓ
// than t is) — legal: p_f already goes negative when h_b > h_f, the BH
// meet-sum law is algebraic in its sign, and the max with the
// non-negative haversine bound keeps h_f/h_b >= 0 anyway.
function bound(v: number, lat2: number, lng2: number, constTo: Float64Array): number {
let bestTo = Number.NEGATIVE_INFINITY;
for (let l = 0; l < L; l++) {
const base = l * n;
const tv = graph.landmarkTo[base + v]!;
if (tv === 0xffff) continue; // per-node sentinel skip — RAW u16 compare
const cTo = constTo[l]!;
if (!Number.isNaN(cTo)) bestTo = Math.max(bestTo, tv - cTo); // family A: d(v,ℓ) − d(t,ℓ)
}
const hav = metersTo(graph, v, lat2, lng2) * heuristicScale;
return bestTo > Number.NEGATIVE_INFINITY ? Math.max(hav, bestTo * Q) : hav;
}
function potential(v: number): number {
let hf = hfCache[v]!;
if (Number.isNaN(hf)) {
hf = bound(v, goalLat, goalLng, goalToU);
hfCache[v] = hf;
}
let hb = hbCache[v]!;
if (Number.isNaN(hb)) {
hb = bound(v, origLat, origLng, origToU);
hbCache[v] = hb;
}
const value = (hf - hb) / 2;
pf[v] = value;
return value;
}
// Reduced distances (BH keys), chain arrays, closed sets, one heap per side.
const df = new Float64Array(n).fill(Number.POSITIVE_INFINITY); // reduced origin->v
const db = new Float64Array(n).fill(Number.POSITIVE_INFINITY); // reduced v->goal
const fPrev = new Int32Array(n).fill(-1); // predecessor on the origin->v chain
const bNext = new Int32Array(n).fill(-1); // successor on the v->goal chain
const fClosed = new Uint8Array(n);
const bClosed = new Uint8Array(n);
const fHeap = new NodeHeap();
const bHeap = new NodeHeap();
df[originNode] = 0;
potential(originNode); // pf[o] is needed by the first forward relaxations
fHeap.push(0, originNode); // key = the REDUCED distance itself (the heuristic lives in the reduction)
db[goalNode] = 0;
potential(goalNode); // p_b(g) likewise for the first backward relaxations
bHeap.push(0, goalNode);
let mu = Number.POSITIVE_INFINITY; // best meeting key sum (real + const)
let meetFrom = -1; // bridge edge meetFrom -> meetTo (direction of travel)
let meetTo = -1;
while (fHeap.size > 0 && bHeap.size > 0) {
// THE BH STOP RULE: with consistent potentials, no future meeting can
// beat mu once both frontiers' minima sum past it.
if (fHeap.peekKey() + bHeap.peekKey() >= mu) break;
// BALANCED ALTERNATION: expand the side with the smaller frontier.
// (Measured 06-04: comparing raw tops starves the backward side — its
// keys start near the full route cost while the forward side's start
// near zero, so `top_f <= top_b` ran the forward search almost alone
// (19501:3 settles) and landed 4x SLOWER than unidirectional. Heap
// size keeps both rings growing; correctness lives entirely in the
// stop rule — alternation order is a work-balancing knob only.)
const expandForward = fHeap.size <= bHeap.size;
const u = (expandForward ? fHeap : bHeap).pop()!;
if (expandForward ? fClosed[u]! !== 0 : bClosed[u]! !== 0) continue; // lazy deletion
if (expandForward) fClosed[u] = 1;
else bClosed[u] = 1;
if (expandForward) {
const du = df[u]!;
const pfu = pf[u]!;
for (let e = graph.edgeStart[u]!; e < graph.edgeStart[u + 1]!; e++) {
if ((graph.edgeFlags[e]! & 1) !== 0) continue; // oneway-reverse: never traversed
const v = graph.edgeTarget[e]!;
const pv = potential(v);
const candidate = du + graph.edgeSeconds[e]! + pfu - pv;
if (candidate < df[v]!) {
df[v] = candidate;
fPrev[v] = u;
fHeap.push(candidate, v);
}
// Meet probe: v already reached backward? (candidate uses v's BEST d_b)
if (db[v]! < Number.POSITIVE_INFINITY) {
const meet = candidate + db[v]!;
if (meet < mu) {
mu = meet;
meetFrom = u;
meetTo = v;
}
}
}
} else {
const rev = reverseFor(graph);
const du = db[u]!;
const pfu = pf[u]!;
const end = rev.rowStart[u + 1]!;
for (let r = rev.rowStart[u]!; r < end; r++) {
const e = rev.edgeIds[r]!; // the edge v -> u
if ((graph.edgeFlags[e]! & 1) !== 0) continue; // oneway-reverse: never traversed
const v = rev.targets[r]!;
const pvv = potential(v);
const candidate = du + graph.edgeSeconds[e]! - pfu + pvv;
if (candidate < db[v]!) {
db[v] = candidate;
bNext[v] = u;
bHeap.push(candidate, v);
}
if (df[v]! < Number.POSITIVE_INFINITY) {
const meet = candidate + df[v]!;
if (meet < mu) {
mu = meet;
meetFrom = v; // the path travels v -> u here
meetTo = u;
}
}
}
}
}
if (meetFrom < 0) return null; // never met — unreachable (honest miss)
// Reconstruct: origin ->(fPrev)-> meetFrom -[bridge]-> meetTo ->(bNext)-> goal.
const fPart: number[] = [];
for (let x = meetFrom; x >= 0; x = fPrev[x]!) fPart.push(x);
fPart.reverse();
const bPart: number[] = [];
for (let y = meetTo; y >= 0; y = bNext[y]!) bPart.push(y);
const path = fPart.concat(bPart);
// REAL costs recomputed along the node path (exact; no reduced-cost
// arithmetic leaks into the returned seconds).
let seconds = 0;
let distanceM = 0;
for (let i = 1; i < path.length; i++) {
const a = path[i - 1]!;
const b = path[i]!;
const from = { lat: graph.lat[a]!, lng: graph.lng[a]! };
const to = { lat: graph.lat[b]!, lng: graph.lng[b]! };
distanceM += haversineM(from, to);
const edge = findTraversableEdge(graph, a, b);
seconds += edge >= 0 ? graph.edgeSeconds[edge]! : 0; // -1 only on a corrupt chain — loud in deriveSteps
}
return { nodes: path, seconds, distanceM };
}
/* ————— turn classification (the one authority; plan 07 imports this) ————— */
/**
* Classify the maneuver between two compass bearings (degrees, 0..360,
* clockwise). The delta normalizes to (-180, 180]; NEGATIVE delta is a
* LEFT turn (counter-clockwise). Thresholds are the exported TURN_*
* constants.
*/
export function classifyTurn(prevBearingDeg: number, nextBearingDeg: number): GuidanceTurnKind {
let delta = (nextBearingDeg - prevBearingDeg) % 360;
if (delta > 180) delta -= 360;
if (delta <= -180) delta += 360;
const abs = Math.abs(delta);
if (abs >= TURN_UTURN_MIN_DEG) return "uturn";
if (abs <= TURN_STRAIGHT_MAX_DEG) return "straight";
if (abs <= TURN_SLIGHT_MAX_DEG) return delta < 0 ? "slight-left" : "slight-right";
return delta < 0 ? "left" : "right";
}
/* ————— step derivation ————— */
function initialBearingDeg(a: RoutePoint, b: RoutePoint): number {
const lat1 = a.lat * DEG_TO_RAD;
const lat2 = b.lat * DEG_TO_RAD;
const dLng = (b.lng - a.lng) * DEG_TO_RAD;
const y = Math.sin(dLng) * Math.cos(lat2);
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
return (Math.atan2(y, x) / DEG_TO_RAD + 360) % 360;
}
/** The traversable (bit0 clear) edge u -> v, or -1. */
function findTraversableEdge(graph: RoadGraph, u: number, v: number): number {
for (let e = graph.edgeStart[u]!; e < graph.edgeStart[u + 1]!; e++) {
if (graph.edgeTarget[e]! === v && (graph.edgeFlags[e]! & 1) === 0) return e;
}
return -1;
}
interface StepAccumulator {
kind: GuidanceTurnKind;
name: string | undefined;
points: RoutePoint[];
distanceM: number;
}
/**
* Derive maneuver steps from a node path: split where the bearing leaves
* "straight" or the street name changes. Step 0 is always "depart"; a
* terminal "arrive" step anchors the goal node. Each step carries its
* street name (omitted when unnamed), haversine meters, and polyline6
* geometry (the RouteStep.kind field this phase added to types.ts —
* fixture-world steps stay empty arrays, D-10).
*
* Throws on a malformed path (a non-traversable consecutive pair) — a
* caller bug is loud, unlike a route miss which stays null.
*/
export function deriveSteps(graph: RoadGraph, nodes: number[]): RouteStep[] {
if (nodes.length === 0) return [];
if (nodes.length === 1) return [{ kind: "arrive" }];
const pointAt = (node: number): RoutePoint => ({ lat: graph.lat[node]!, lng: graph.lng[node]! });
const steps: RouteStep[] = [];
let current: StepAccumulator | null = null;
let prevBearing = 0;
for (let i = 0; i < nodes.length - 1; i++) {
const u = nodes[i]!;
const v = nodes[i + 1]!;
const edge = findTraversableEdge(graph, u, v);
if (edge < 0) {
throw new Error(`deriveSteps: nodes[${i}]->nodes[${i + 1}] (${u}->${v}) is not a traversable edge`);
}
const from = pointAt(u);
const to = pointAt(v);
const bearing = initialBearingDeg(from, to);
const kind: GuidanceTurnKind = i === 0 ? "depart" : classifyTurn(prevBearing, bearing);
prevBearing = bearing;
const name = graph.nameAt(graph.edgeNameId[edge]!);
const segmentM = haversineM(from, to);
const splits = current !== null && (kind !== "straight" || name !== current.name);
if (splits || current === null) {
if (current !== null) {
steps.push(toStep(current));
}
current = { kind, name, points: [from, to], distanceM: segmentM };
} else {
current.points.push(to);
current.distanceM += segmentM;
}
}
if (current !== null) steps.push(toStep(current));
const goal = pointAt(nodes[nodes.length - 1]!);
steps.push({ kind: "arrive", geometry: encodePolyline6([goal]) });
return steps;
}
function toStep(acc: StepAccumulator): RouteStep {
const step: RouteStep = {
kind: acc.kind,
distanceM: acc.distanceM,
geometry: encodePolyline6(acc.points),
};
if (acc.name !== undefined) step.name = acc.name;
return step;
}