feat(08-03): ALT potentials inside the proven BH skeleton — GREEN

- routeAstar's ONLY change: the h_f/h_b bodies become max(ALT triangle
  bounds, haversine) — per-search Float64Array(L) constant tables in quanta
  (0xFFFF written as NaN = search-scoped landmark drop), lazy per-node
  max over the four triangle families with RAW u16 sentinel compares
  BEFORE any float conversion, haversine max-fallback on sentinel-flooded
  nodes (Pattern 3 containment)
- the (h_f - h_b)/2 combination, reductions, balanced alternation, meet
  probes, BH stop rule, and real-seconds reconstruction are byte-identical
  (research Pattern 1 — the survey's (pf-pr)/2 is what p_f already is)
- no Map/Object allocations (A8 green), no new imports, one search path
  (L >= 1 is the decodeGraph guarantee, D-01)
- A1's 625-pair Dijkstra-equivalence sweep re-pinned under ALT: the
  standing optimality proof; A9 island + A10 skip laws sealed; A11
  structural pin green
- module header + routeAstar doc gain the ALT paragraph (c-metric
  exactness, sentinel laws, Ikeda history intact)
This commit is contained in:
araharah-executor
2026-10-25 19:02:21 +08:00
parent 92b97fc97f
commit ddd2f3c093
+90 -7
View File
@@ -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;