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).
565 lines
24 KiB
TypeScript
565 lines
24 KiB
TypeScript
/*
|
||
* 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;
|
||
}
|