diff --git a/routing/src/astar.ts b/routing/src/astar.ts index 4d8012a..95f2b96 100644 --- a/routing/src/astar.ts +++ b/routing/src/astar.ts @@ -4,9 +4,12 @@ * ---------------------------------------------------------------------------- * 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) 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 + * 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 @@ -215,6 +218,23 @@ function reverseFor(graph: RoadGraph): ReverseAdj { * 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): the h_f/h_b bodies are max(ALT triangle bounds, + * haversine) over the ARG2 landmark tables (graph.landmarkTo / + * graph.landmarkFrom, quantized at graph.landmarkQuantumS). 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). Sentinel laws + * (research Pattern 3): a landmark whose goal/origin-side 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's island + + * skip seals) is the standing optimality proof. */ export function routeAstar(graph: RoadGraph, originNode: number, goalNode: number): AstarResult | null { const n = graph.nodeCount; @@ -229,20 +249,83 @@ export function routeAstar(graph: RoadGraph, originNode: number, goalNode: numbe const origLat = graph.lat[originNode]!; const origLng = graph.lng[originNode]!; + // ALT per-search constants (plan 08-03): the goal/origin-side 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. + const L = graph.landmarkCount; + const Q = graph.landmarkQuantumS; + const goalToU = new Float64Array(L); + const goalFromU = new Float64Array(L); + const origToU = new Float64Array(L); + const origFromU = 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 gFrom = graph.landmarkFrom[base + goalNode]!; + goalFromU[l] = gFrom === 0xffff ? Number.NaN : gFrom; + const oTo = graph.landmarkTo[base + originNode]!; + origToU[l] = oTo === 0xffff ? Number.NaN : oTo; + const oFrom = graph.landmarkFrom[base + originNode]!; + origFromU[l] = oFrom === 0xffff ? Number.NaN : oFrom; + } + // 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); // hav(v, goal) * scale - const hbCache = new Float64Array(n).fill(Number.NaN); // hav(v, origin) * scale + 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 + // whose constants are constTo/constFrom. Four families per landmark ℓ + // (Bast et al. survey §2.2), evaluated in quanta and converted to seconds + // ONCE at the end: + // family A: d(v,t) >= d(v,ℓ) − d(t,ℓ) (toTable both terms) + // family B: d(v,t) >= d(ℓ,t) − d(ℓ,v) (fromTable both terms) + // (mirrored toward the origin by the hb call site). 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, constFrom: Float64Array): number { + let bestTo = Number.NEGATIVE_INFINITY; + let bestFrom = 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 fv = graph.landmarkFrom[base + v]!; + if (fv === 0xffff) continue; + const cFrom = constFrom[l]!; + if (!Number.isNaN(cFrom)) bestFrom = Math.max(bestFrom, cFrom - fv); // family B: d(ℓ,t) − d(ℓ,v) + } + const hav = metersTo(graph, v, lat2, lng2) * heuristicScale; + const alt = Math.max(bestTo, bestFrom); + return alt > Number.NEGATIVE_INFINITY ? Math.max(hav, alt * Q) : hav; + } + function potential(v: number): number { let hf = hfCache[v]!; if (Number.isNaN(hf)) { - hf = metersTo(graph, v, goalLat, goalLng) * heuristicScale; + hf = bound(v, goalLat, goalLng, goalToU, goalFromU); hfCache[v] = hf; } let hb = hbCache[v]!; if (Number.isNaN(hb)) { - hb = metersTo(graph, v, origLat, origLng) * heuristicScale; + hb = bound(v, origLat, origLng, origToU, origFromU); hbCache[v] = hb; } const value = (hf - hb) / 2;