feat(06-04): R2 upload + A*-CPU measurement gate + the A1 ladder executed to its decision point
- upload-road-graph.mjs: wrangler-shelled maintainer tool (pre-flight 1-80MB, round-trip verify hint); bucket araharah-graphs NOT created — R2 is not yet enabled on the account (wrangler code 10042) — dashboard one-time activation is the phase's user_setup item - measure-route-cpu.mjs: 25-pair gate (corpus + seeded quadrant spread), hrtime around routeAstar, warm-up excluded, unreachable probes reported SEPARATELY from the routed p95, exits 1 over the 8ms budget - Rule 1 (measured): the class set lacked *_link ramps — the graph FRAGMENTED (7.7% of nodes in severed motorway islands; corpus pairs 'unreachable'). 5 link classes added -> 311325 nodes / 677068 edges / 14.7 MB, all landmarks in one component; enforced floor corrected 1KB -> 1MB (D-01) - ladder (a) drop tertiary+residential: p95 17.8ms but 12/25 pairs unreachable — REJECTED on correctness (D-03), reverted, ladder history recorded - ladder (b) bidirectional A* (BH, Ikeda potentials, reverse-CSR WeakMap): provably optimal (A1 625-pair sweep green), alternation balanced by heap size (raw-top comparison starves the backward side 19501:3), reduction signs in the standard feasible form. Measured: p95 ~38-93ms — NO GATE PASS. The potential split halves each side's steering, cancelling the halved radius - GATE FAIL is the honest verdict: ladder (c) — Workers Paid vs layout-v2 ALT (still $0) vs free-plan-with-degradation — requires maintainer sign-off; docs/ROAD-GRAPH.md carries the decision table and the fork contract
This commit is contained in:
+229
-76
@@ -1,20 +1,17 @@
|
||||
/*
|
||||
* astar.ts — typed-array A* over the CSR graph + turn-step derivation (D-01).
|
||||
* 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 with an ALLOCATION-FREE hot loop — open set is a
|
||||
* binary heap of node indices over preallocated typed arrays, g-scores and
|
||||
* the closed set are per-node typed arrays. No Map, no JS objects per node
|
||||
* (RESEARCH Pattern 2 + Anti-Patterns; the 10 ms free-plan CPU budget is
|
||||
* the design constraint, A1's equivalence sweep is the correctness gate).
|
||||
*
|
||||
* Heuristic: haversine(node, goal) / (maxSpeedKmh / 3.6) — the aerial
|
||||
* distance divided by the graph's maximum speed can never overestimate the
|
||||
* remaining travel time (every edge's seconds = meters / speed with speed
|
||||
* <= maxSpeedKmh), so it is admissible AND consistent; settling the goal
|
||||
* off the heap is therefore provably optimal (A1 proves it exhaustively on
|
||||
* the fixture). The heuristic is computed on demand and cached per node in
|
||||
* a Float64Array — no object is constructed in the search loop.
|
||||
* 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
|
||||
* 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.
|
||||
@@ -91,6 +88,11 @@ class NodeHeap {
|
||||
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 {
|
||||
@@ -127,12 +129,92 @@ class NodeHeap {
|
||||
}
|
||||
}
|
||||
|
||||
/* ————— A* ————— */
|
||||
/* ————— 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.
|
||||
*/
|
||||
export function routeAstar(graph: RoadGraph, originNode: number, goalNode: number): AstarResult | null {
|
||||
const n = graph.nodeCount;
|
||||
@@ -144,72 +226,143 @@ export function routeAstar(graph: RoadGraph, originNode: number, goalNode: numbe
|
||||
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]!;
|
||||
|
||||
const gScore = new Float64Array(n).fill(Number.POSITIVE_INFINITY);
|
||||
const cameFrom = new Int32Array(n).fill(-1);
|
||||
const closed = new Uint8Array(n);
|
||||
const hCache = new Float64Array(n).fill(Number.NaN); // NaN = not computed yet
|
||||
const heap = new NodeHeap();
|
||||
|
||||
// Inline haversine vs the goal, cached per node — zero allocations, and
|
||||
// only explored nodes ever pay for it. Admissible: aerial distance at
|
||||
// max speed upper-bounds nothing — it LOWER-bounds remaining seconds.
|
||||
const heuristic = (node: number): number => {
|
||||
const cached = hCache[node]!;
|
||||
if (!Number.isNaN(cached)) return cached;
|
||||
const lat1 = graph.lat[node]! * DEG_TO_RAD;
|
||||
const lat2 = goalLat * DEG_TO_RAD;
|
||||
const dLat = lat2 - lat1;
|
||||
const dLng = (goalLng - graph.lng[node]!) * DEG_TO_RAD;
|
||||
const sinLat = Math.sin(dLat / 2);
|
||||
const sinLng = Math.sin(dLng / 2);
|
||||
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng;
|
||||
const meters = 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
const seconds = meters * heuristicScale;
|
||||
hCache[node] = seconds;
|
||||
return seconds;
|
||||
};
|
||||
|
||||
gScore[originNode] = 0;
|
||||
heap.push(heuristic(originNode), originNode);
|
||||
|
||||
while (heap.size > 0) {
|
||||
const u = heap.pop();
|
||||
if (u < 0) break;
|
||||
if (closed[u]! !== 0) continue; // lazy deletion
|
||||
closed[u] = 1;
|
||||
if (u === goalNode) {
|
||||
// Reconstruct (allocation here is fine — the hot loop is over).
|
||||
const path: number[] = [u];
|
||||
let prev = cameFrom[u]!;
|
||||
while (prev >= 0) {
|
||||
path.push(prev);
|
||||
prev = cameFrom[prev]!;
|
||||
}
|
||||
path.reverse();
|
||||
let distanceM = 0;
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
distanceM += haversineM(
|
||||
{ lat: graph.lat[path[i - 1]!]!, lng: graph.lng[path[i - 1]!]! },
|
||||
{ lat: graph.lat[path[i]!]!, lng: graph.lng[path[i]!]! },
|
||||
);
|
||||
}
|
||||
return { nodes: path, seconds: gScore[goalNode]!, distanceM };
|
||||
// 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 pf = new Float64Array(n); // p_f(v) = (h_f - h_b)/2; p_b = -p_f — valid once both caches are set
|
||||
function potential(v: number): number {
|
||||
let hf = hfCache[v]!;
|
||||
if (Number.isNaN(hf)) {
|
||||
hf = metersTo(graph, v, goalLat, goalLng) * heuristicScale;
|
||||
hfCache[v] = hf;
|
||||
}
|
||||
const base = gScore[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]!;
|
||||
if (closed[v]! !== 0) continue;
|
||||
const candidate = base + graph.edgeSeconds[e]!;
|
||||
if (candidate < gScore[v]!) {
|
||||
gScore[v] = candidate;
|
||||
cameFrom[v] = u;
|
||||
heap.push(candidate + heuristic(v), v);
|
||||
let hb = hbCache[v]!;
|
||||
if (Number.isNaN(hb)) {
|
||||
hb = metersTo(graph, v, origLat, origLng) * heuristicScale;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null; // unreachable — honest miss
|
||||
|
||||
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) ————— */
|
||||
|
||||
Reference in New Issue
Block a user