feat(08.1-01): ARG3 codec + uphill CH query — ALT deleted, one search path (GREEN)
- graph.ts: ARG3 layout (magic 0x41524733, version 3, chShortcutCount u32 @60, reserved @64/@68) with the CH suffix chRank ++ shortcut CSR; fail-honest decode (ARG1/ARG2 -> null, chShortcutCount 0 -> null, exact-size before any allocation, 4-byte alignment load-bearing); loud ch-block encode validation - ch-query.ts: routeCH — bidirectional uphill search (strict rank filter, forward originals+shortcuts, backward reverse views incl. shortcuts), edge-ref meet probe, iterative via-unpacking (edge-aware: a parallel original edge is never expanded as a shortcut; DERIVE prefers the cheaper of original/shortcut), seconds re-summed from the unpacked original-edge path. STOP RULE: min(topF, topB) >= mu — the classical sum rule is unsound under the rank filter (Q1 caught it: 36 vs 24) - astar.ts: routeAstar validates + delegates every request to routeCH; ALT/BH internals, clean-column memo, landmark tokens DELETED (D-04); classifyTurn/deriveSteps untouched - landmark.ts + landmark.test.mjs deleted (D-05); index.ts additively exports contractGraph + CH constants + routeCH - snap.test.mjs: S3's encode moved to the ARG3 ch block (3-node star — a 2-node graph can carry no valid shortcut); committed-fixture S-cases suspended until task 3 (Rule 3: same pattern as G7/A1-A6) - routing suite: 86 passed, 0 failed; typecheck clean
This commit is contained in:
+37
-507
@@ -1,35 +1,35 @@
|
||||
/*
|
||||
* astar.ts — typed-array BIDIRECTIONAL A* over the CSR graph + turn-step
|
||||
* derivation (D-01; bidirectional per 06-04's A1 ladder step b).
|
||||
* astar.ts — the engine-A router surface: CH-backed routeAstar + turn-step
|
||||
* derivation (Phase 08.1: D-04's ONE search path).
|
||||
* ----------------------------------------------------------------------------
|
||||
* 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.
|
||||
* routeAstar is the exported search the Worker calls (the RouteResult wire
|
||||
* contract is frozen). Since Phase 08.1 it DELEGATES every request to the
|
||||
* uphill bidirectional CH query (ch-query.ts's routeCH) — the hierarchy
|
||||
* prunes the search structurally, so there is no heuristic, no potential,
|
||||
* and no second engine: the ALT-steered bidirectional A* internals were
|
||||
* DELETED (D-04), not left dormant, and the landmark tables left the
|
||||
* binary with them (D-05). Optimality is by construction and is proven,
|
||||
* not presumed — the committed suites run the exhaustive 625-pair
|
||||
* Dijkstra-equivalence sweep and the directed oneway ring against this
|
||||
* exact export.
|
||||
*
|
||||
* 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.
|
||||
* Traversal law (shared with contraction.ts and the tests' brute-force
|
||||
* Dijkstra): an edge with edgeFlags bit0 set ("oneway-reverse" —
|
||||
* graph.ts) is NEVER traversed. Snapping (snap.ts) ignores the flag;
|
||||
* only the search 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).
|
||||
* deriveSteps walks an unpacked ORIGINAL-edge 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 phase 6 added to types.ts; fixture-world
|
||||
* steps stay empty arrays, D-10). A path that still contains a shortcut
|
||||
* hop fails LOUD here (findTraversableEdge misses) — the unpacking must
|
||||
* have completed upstream (ch-query.ts's seconds law).
|
||||
*
|
||||
* Pure: no I/O, no logging, no storage (T-06-05).
|
||||
*/
|
||||
@@ -38,6 +38,7 @@ import type { RoadGraph } from "./graph.ts";
|
||||
import { haversineM } from "./geo.ts";
|
||||
import { encodePolyline6 } from "./polyline.ts";
|
||||
import type { RoutePoint, RouteStep } from "./types.ts";
|
||||
import { routeCH, findTraversableEdge } from "./ch-query.ts";
|
||||
|
||||
/** Maneuver kinds — structurally the RouteStep.kind union (one authority). */
|
||||
export type GuidanceTurnKind = NonNullable<RouteStep["kind"]>;
|
||||
@@ -50,19 +51,9 @@ export const TURN_SLIGHT_MAX_DEG = 60;
|
||||
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;
|
||||
/** The u16 sentinel cell in an ARG2 landmark table column (research
|
||||
* Pattern 3's DATA value: the node cannot reach the anchor). Always
|
||||
* compared RAW — u16, before any float conversion — and only at SETUP
|
||||
* scope (cleanColumnsFor / the per-search constants): never inline inside
|
||||
* the hot bound() scan, where a per-node compare+skip would vary the
|
||||
* active landmark family set per NODE and break the max-of-families
|
||||
* cross-edge consistency (the RC-1 bug — debug alt-potential-4x-slowdown). */
|
||||
const LANDMARK_SENTINEL_U16 = 0xffff;
|
||||
|
||||
export interface AstarResult {
|
||||
/** Node path, origin first, goal last. */
|
||||
/** Node path, origin first, goal last (original edges only — unpacked). */
|
||||
nodes: number[];
|
||||
/** Total travel seconds (sum of traversed edgeSeconds). */
|
||||
seconds: number;
|
||||
@@ -70,472 +61,19 @@ export interface AstarResult {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ————— clean-to-columns memo (the F1 containment law) ————— */
|
||||
|
||||
/**
|
||||
* Which landmark toTable columns are CLEAN — zero 0xFFFF sentinel cells —
|
||||
* built ONCE per RoadGraph and memoized in a module-scope WeakMap (the
|
||||
* reverseFor precedent): O(L*n) sequential row scans, paid by the first
|
||||
* route (the CPU gate's warm-up covers it), amortized across the isolate's
|
||||
* requests (the bot's per-isolate graph memo shares one decoded object).
|
||||
*
|
||||
* WHY (the F1 fix, debug session alt-potential-4x-slowdown): the ALT bound
|
||||
* is a max over per-landmark families, and the shared Ikeda average
|
||||
* p = (h_f − h_b)/2 is consistent only when every family inside the max is
|
||||
* active at BOTH ends of EVERY edge the search relaxes. A sentinel cell
|
||||
* anywhere in column ℓ means some node would silently drop family ℓ while
|
||||
* its neighbours keep it — a per-node ℓ-mask, the exact cross-edge
|
||||
* inconsistency that made shipped routeAstar return suboptimal real-graph
|
||||
* routes (1/194 fresh seeded pairs vs the Dijkstra oracle; worst
|
||||
* family-variance 1,026 s on a single edge). So cleanliness is decided
|
||||
* ONCE per graph: any sentinel cell ⇒ dirty ⇒ the landmark drops for the
|
||||
* WHOLE search. On the current binary every column is dirty (the
|
||||
* 6,285-node disconnected island cannot reach any anchor — 24/24), the
|
||||
* per-search active list is empty, and the potential degrades to exactly
|
||||
* the haversine bound: sound by the same proof, at the pre-ALT CPU band.
|
||||
* A future clean-column binary (strongly connected, anchors inside the
|
||||
* SCC) re-arms ALT with no code change.
|
||||
*/
|
||||
const cleanColumnGraphs = new WeakMap<RoadGraph, Uint8Array>();
|
||||
|
||||
function cleanColumnsFor(graph: RoadGraph): Uint8Array {
|
||||
const memoized = cleanColumnGraphs.get(graph);
|
||||
if (memoized !== undefined) return memoized;
|
||||
const n = graph.nodeCount;
|
||||
const landmarkCount = graph.landmarkCount;
|
||||
const clean = new Uint8Array(landmarkCount).fill(1);
|
||||
for (let l = 0; l < landmarkCount; l++) {
|
||||
const base = l * n;
|
||||
for (let v = 0; v < n; v++) {
|
||||
if (graph.landmarkTo[base + v]! === LANDMARK_SENTINEL_U16) {
|
||||
clean[l] = 0;
|
||||
break; // one sentinel cell is enough — the column is dirty
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanColumnGraphs.set(graph, clean);
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, F1
|
||||
* amended — debug session alt-potential-4x-slowdown): the containment law
|
||||
* is CLEAN COLUMNS ONLY — a to-column holding ANY 0xFFFF sentinel cell
|
||||
* (2.02% of cells on the current binary: the 6,285-node island cannot
|
||||
* reach any anchor) is dirty, and its landmark drops for the WHOLE search,
|
||||
* decided once per graph (cleanColumnsFor) and scoped per search (the
|
||||
* active list); a goal/origin-side sentinel constant drops its ℓ too (a
|
||||
* retained belt — a clean column cannot produce one). NO per-node sentinel
|
||||
* skip exists: an earlier per-node cell-skip varied the active family set
|
||||
* per NODE — the max-of-families lost cross-edge consistency and the
|
||||
* search returned suboptimal real-graph routes (1/194 fresh pairs vs the
|
||||
* Dijkstra oracle). With no clean column (the current binary, 24/24
|
||||
* dirty) the active list is empty and every bound degrades to exactly the
|
||||
* haversine — admissible + consistent everywhere, the designed sound
|
||||
* fallback. 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/A13's
|
||||
* island, skip, oneway, and mixed-column seals) is the standing optimality
|
||||
* proof.
|
||||
* Minimum-seconds path origin -> goal over the graph, or null when the
|
||||
* pair is unreachable (the honest miss). Validates the endpoints, answers
|
||||
* the trivial same-node pair directly, and delegates EVERY other request
|
||||
* to the CH query — D-11's one search path: a 500 m hop and a cross-metro
|
||||
* run run the same proven search.
|
||||
*/
|
||||
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, F1-amended — the
|
||||
// debug session alt-potential-4x-slowdown): the ACTIVE landmark list is
|
||||
// the CLEAN to-columns only (cleanColumnsFor) — a column holding any
|
||||
// sentinel cell is dirty and its landmark drops for the WHOLE search.
|
||||
// The goal/origin-side to-table cells are still hoisted ONCE per search,
|
||||
// kept in QUANTA units with a 0xFFFF sentinel written as NaN, and a
|
||||
// sentinel constant drops that landmark too (a retained belt: a clean
|
||||
// column cannot produce one). Skip decisions live ONLY here, at search
|
||||
// scope — never a per-node ℓ-mask mid-max, which would vary the landmark
|
||||
// set inside the max and break the max-of-Lipschitz consistency argument
|
||||
// (the exact mechanism that returned 1/194 suboptimal real-graph routes
|
||||
// before F1). 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 — an empty active list degenerates to
|
||||
// the pure haversine bound inside bound().
|
||||
//
|
||||
// 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 clean = cleanColumnsFor(graph);
|
||||
let activeCount = 0;
|
||||
for (let l = 0; l < L; l++) if (clean[l]! === 1) activeCount += 1;
|
||||
const activeL = new Int32Array(activeCount); // the clean to-columns, search-scoped
|
||||
for (let l = 0, k = 0; l < L; l++) if (clean[l]! === 1) activeL[k++] = l;
|
||||
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 === LANDMARK_SENTINEL_U16 ? Number.NaN : gTo;
|
||||
const oTo = graph.landmarkTo[base + originNode]!;
|
||||
origToU[l] = oTo === LANDMARK_SENTINEL_U16 ? 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. The scan
|
||||
// runs the ACTIVE list only (clean columns — F1), and a clean column has
|
||||
// NO sentinel cell by definition, so there is NO per-node branch: the
|
||||
// active family set is IDENTICAL at both ends of every relaxed edge —
|
||||
// exactly the cross-edge consistency the shared Ikeda average needs. The
|
||||
// retained constant-table NaN check drops a landmark whose goal/origin-
|
||||
// side cell is the sentinel (belt — unreachable for active columns). An
|
||||
// empty active list (no clean column — the current real binary) skips
|
||||
// the scan entirely and 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 k = 0; k < activeL.length; k++) {
|
||||
const l = activeL[k]!;
|
||||
const tv = graph.landmarkTo[l * n + v]!;
|
||||
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 };
|
||||
return routeCH(graph, originNode, goalNode);
|
||||
}
|
||||
|
||||
/* ————— turn classification (the one authority; plan 07 imports this) ————— */
|
||||
@@ -568,14 +106,6 @@ function initialBearingDeg(a: RoutePoint, b: RoutePoint): number {
|
||||
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;
|
||||
@@ -584,12 +114,12 @@ interface StepAccumulator {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Derive maneuver steps from an UNPACKED original-edge 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 phase 6 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.
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
/*
|
||||
* ch-query.ts — the uphill bidirectional CH query + shortcut unpacking
|
||||
* (Phase 08.1, plan 01; D-04/D-11: THE one search path).
|
||||
* ----------------------------------------------------------------------------
|
||||
* routeCH finds the minimum-seconds path between two graph nodes over the
|
||||
* ARG3 hierarchy: two Dijkstra searches that only ever CLIMB — the forward
|
||||
* search from the origin relaxes only edges (original or shortcut) whose
|
||||
* target has strictly higher rank; the backward search from the goal
|
||||
* relaxes only IN-edges whose source has strictly higher rank. The two
|
||||
* searches meet at the highest-ranked node of a shortest path, which is
|
||||
* why optimality holds BY CONSTRUCTION — no heuristic, no potential, no
|
||||
* landmark tables (D-04: the ALT machinery is deleted, not dormant).
|
||||
*
|
||||
* THE STOP RULE carries optimality: once the two frontiers' minima sum
|
||||
* past mu (the best meeting cost seen), no future meeting can beat it.
|
||||
* Expanding the smaller frontier first is a work-balance knob only —
|
||||
* the same law astar.ts's BH documented, and the same edge-based meet
|
||||
* probe A1's 625-pair sweep already validated.
|
||||
*
|
||||
* TRAVERSAL LAW (shared with contraction.ts and the tests' brute-force
|
||||
* Dijkstra): an edge with edgeFlags bit0 set ("oneway-reverse", graph.ts)
|
||||
* is NEVER relaxed — not in the forward scan, not in the backward scan.
|
||||
* Shortcuts are DIRECTED only and the backward search reads its own
|
||||
* reverse view — never the forward shortcut list (the oneway law's
|
||||
* structural form: research Pitfall 2).
|
||||
*
|
||||
* THE SECONDS LAW (research Pitfall 3): after the meet, the raw node
|
||||
* chain is recursively UNPACKED through the shortcuts' via field until
|
||||
* only original edges remain, and ONLY THEN are seconds re-summed from
|
||||
* graph.edgeSeconds over the unpacked path (and distanceM from
|
||||
* haversineM over the same nodes). Shortcut weights steer the search
|
||||
* only; they never enter reported seconds — which keeps the result
|
||||
* bit-comparable to the Dijkstra oracle and the D-13 gates exactly
|
||||
* satisfiable. deriveSteps (astar.ts) throws on any non-traversable hop,
|
||||
* so an incompletely-unpacked path fails loud downstream.
|
||||
*
|
||||
* The reverse views (in-edges per node, originals and shortcuts) are
|
||||
* built ONCE per RoadGraph and memoized in a module-scope WeakMap — the
|
||||
* snap-grid LAW 1 / reverseFor precedent. One-time build is O(V+E) per
|
||||
* isolate, paid by the first route (the CPU gate's warm-up covers it).
|
||||
*
|
||||
* Pure: no I/O, no logging, no storage (T-06-05).
|
||||
*/
|
||||
|
||||
import type { RoadGraph } from "./graph.ts";
|
||||
import { haversineM } from "./geo.ts";
|
||||
|
||||
/** The CH query result — the SAME shape astar.ts's AstarResult has always
|
||||
* had, so the RouteResult wire contract and the worker-router call site
|
||||
* are untouched (D-11: no product-surface change). */
|
||||
export interface ChResult {
|
||||
/** Node path, origin first, goal last — shortcuts already unpacked. */
|
||||
nodes: number[];
|
||||
/** Total travel seconds (sum of traversed original 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 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 routeCH. */
|
||||
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);
|
||||
this.keys = keys;
|
||||
const nodes = new Uint32Array(this.nodes.length * 2);
|
||||
nodes.set(this.nodes);
|
||||
this.nodes = nodes;
|
||||
}
|
||||
}
|
||||
|
||||
/* ————— reverse views (the backward search's in-edge scans) ————— */
|
||||
|
||||
/**
|
||||
* A source-grouped CSR cannot answer "node u's in-edges" — reading
|
||||
* out-edges as in-edges fabricates phantom arcs and traverses oneway
|
||||
* mirrors (the 06-04 GREEN bug the A1/A2 gates caught). Both reverse
|
||||
* views (original edges and shortcuts) are built ONCE per RoadGraph and
|
||||
* memoized in a module-scope WeakMap.
|
||||
*/
|
||||
interface ReverseView {
|
||||
/** rowStart[u]..rowStart[u+1] index u's IN-edges inside sources/edgeIds. */
|
||||
rowStart: Uint32Array;
|
||||
/** The in-edge's SOURCE node (v, for an edge v -> u). */
|
||||
sources: Uint32Array;
|
||||
/** The edge's index in the FORWARD CSR (seconds/flags lookup). */
|
||||
edgeIds: Uint32Array;
|
||||
}
|
||||
|
||||
interface ReversePair {
|
||||
original: ReverseView;
|
||||
shortcut: ReverseView;
|
||||
}
|
||||
|
||||
const reverseGraphs = new WeakMap<RoadGraph, ReversePair>();
|
||||
|
||||
function buildReverse(rowOf: (u: number) => number, targetOf: (e: number) => number, edgeCount: number, n: number, listEnd: (u: number) => number): ReverseView {
|
||||
const inDegree = new Uint32Array(n + 1);
|
||||
for (let e = 0; e < edgeCount; e++) inDegree[targetOf(e) + 1] = inDegree[targetOf(e) + 1]! + 1;
|
||||
for (let i = 0; i < n; i++) inDegree[i + 1] = inDegree[i + 1]! + inDegree[i]!;
|
||||
const sources = new Uint32Array(edgeCount);
|
||||
const edgeIds = new Uint32Array(edgeCount);
|
||||
const cursor = inDegree.slice(0, n);
|
||||
for (let u = 0; u < n; u++) {
|
||||
const end = listEnd(u);
|
||||
for (let e = rowOf(u); e < end; e++) {
|
||||
const v = targetOf(e); // the edge u -> v is an IN-edge of v
|
||||
const pos = cursor[v]!;
|
||||
cursor[v] = pos + 1;
|
||||
sources[pos] = u;
|
||||
edgeIds[pos] = e;
|
||||
}
|
||||
}
|
||||
return { rowStart: inDegree, sources, edgeIds };
|
||||
}
|
||||
|
||||
function reverseFor(graph: RoadGraph): ReversePair {
|
||||
const memoized = reverseGraphs.get(graph);
|
||||
if (memoized !== undefined) return memoized;
|
||||
const n = graph.nodeCount;
|
||||
const original = buildReverse(
|
||||
(u) => graph.edgeStart[u]!,
|
||||
(e) => graph.edgeTarget[e]!,
|
||||
graph.edgeCount,
|
||||
n,
|
||||
(u) => graph.edgeStart[u + 1]!,
|
||||
);
|
||||
const shortcut = buildReverse(
|
||||
(u) => graph.shortcutStart[u]!,
|
||||
(e) => graph.shortcutTarget[e]!,
|
||||
graph.chShortcutCount,
|
||||
n,
|
||||
(u) => graph.shortcutStart[u + 1]!,
|
||||
);
|
||||
const pair: ReversePair = { original, shortcut };
|
||||
reverseGraphs.set(graph, pair);
|
||||
return pair;
|
||||
}
|
||||
|
||||
/* ————— the ONE search path ————— */
|
||||
|
||||
/** Bit 30 marks a shortcut index inside an edge ref (original edges stay
|
||||
* their plain CSR index; shortcut counts are far below 2^30). */
|
||||
const SHORTCUT_REF_BIT = 0x40000000;
|
||||
|
||||
/**
|
||||
* Minimum-seconds path origin -> goal over the CH hierarchy, or null when
|
||||
* the pair is unreachable (the honest miss). Exhaustively proven equal to
|
||||
* brute-force Dijkstra on the fixture grid (ch-query.test.mjs Q1's 625
|
||||
* pairs) and the directed oneway ring (Q2) — the standing optimality
|
||||
* proof, re-run on every pass.
|
||||
*/
|
||||
export function routeCH(graph: RoadGraph, originNode: number, goalNode: number): ChResult | 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 rank = graph.chRank;
|
||||
|
||||
// Reduced distances (heap keys), chains, closed sets, one heap per side.
|
||||
// The chain edges are REFS, not just predecessors: a parallel original
|
||||
// edge and shortcut can share endpoints, and the unpacker must know
|
||||
// which one the search traversed (bit 30 marks a shortcut index).
|
||||
const df = new Float64Array(n).fill(Number.POSITIVE_INFINITY); // origin->v over upward edges
|
||||
const db = new Float64Array(n).fill(Number.POSITIVE_INFINITY); // v->goal over upward in-edges
|
||||
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 fPrevEdge = new Int32Array(n).fill(-1); // the edge ref fPrev[x]->x (bit30 = shortcut)
|
||||
const bNextEdge = new Int32Array(n).fill(-1); // the edge ref x->bNext[x] (bit30 = shortcut)
|
||||
const fClosed = new Uint8Array(n);
|
||||
const bClosed = new Uint8Array(n);
|
||||
const fHeap = new NodeHeap();
|
||||
const bHeap = new NodeHeap();
|
||||
|
||||
df[originNode] = 0;
|
||||
fHeap.push(0, originNode);
|
||||
db[goalNode] = 0;
|
||||
bHeap.push(0, goalNode);
|
||||
|
||||
let mu = Number.POSITIVE_INFINITY; // best meeting key sum (real cost through the meet edge)
|
||||
let meetFrom = -1; // bridge edge meetFrom -> meetTo (direction of travel)
|
||||
let meetTo = -1;
|
||||
let meetEdgeRef = -1; // the bridge edge's ref (bit30 = shortcut)
|
||||
|
||||
const rev = reverseFor(graph);
|
||||
|
||||
// The loop runs while EITHER frontier has work: a side whose heap
|
||||
// emptied can no longer relax, but the other side's remaining settles
|
||||
// still probe meets against its settled labels (the origin/goal at a
|
||||
// rank-extremum case — the meet can be recorded from the live side
|
||||
// alone). Alternation below is a work knob, never a correctness knob.
|
||||
for (;;) {
|
||||
// THE STOP RULE: a future meet is recorded only by relaxing an edge
|
||||
// from a not-yet-settled node, so its value is at least the popping
|
||||
// side's next key — once the SMALLER frontier top has reached mu, no
|
||||
// future meeting can improve it and the search stops. (The classical
|
||||
// bidirectional "topF + topB >= mu" sum rule is UNSOUND here: the
|
||||
// rank filter means neither top bounds the optimal path's far side,
|
||||
// so the sum can cross mu before the peak-node probe fires — Q1's
|
||||
// 625-pair sweep catches exactly that.)
|
||||
if (Math.min(fHeap.peekKey(), bHeap.peekKey()) >= mu) break;
|
||||
if (fHeap.size === 0 && bHeap.size === 0) break;
|
||||
let expandForward: boolean;
|
||||
if (bHeap.size === 0) expandForward = true;
|
||||
else if (fHeap.size === 0) expandForward = false;
|
||||
else 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 rankU = rank[u]!;
|
||||
// Original out-edges: bit0 never traversed; strictly uphill only.
|
||||
let end = graph.edgeStart[u + 1]!;
|
||||
for (let e = graph.edgeStart[u]!; e < end; e++) {
|
||||
if ((graph.edgeFlags[e]! & 1) !== 0) continue; // oneway-reverse: never traversed
|
||||
const v = graph.edgeTarget[e]!;
|
||||
if (rank[v]! <= rankU) continue; // the uphill filter
|
||||
const candidate = du + graph.edgeSeconds[e]!;
|
||||
if (candidate < df[v]!) {
|
||||
df[v] = candidate;
|
||||
fPrev[v] = u;
|
||||
fPrevEdge[v] = e; // a plain original edge ref
|
||||
fHeap.push(candidate, v);
|
||||
}
|
||||
// Edge-based meet probe: v already reached backward?
|
||||
if (db[v]! < Number.POSITIVE_INFINITY) {
|
||||
const meet = candidate + db[v]!;
|
||||
if (meet < mu) {
|
||||
mu = meet;
|
||||
meetFrom = u;
|
||||
meetTo = v;
|
||||
meetEdgeRef = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shortcut out-edges (same two laws: uphill only; shortcuts are
|
||||
// directed — the backward search NEVER reuses this list).
|
||||
end = graph.shortcutStart[u + 1]!;
|
||||
for (let e = graph.shortcutStart[u]!; e < end; e++) {
|
||||
const v = graph.shortcutTarget[e]!;
|
||||
if (rank[v]! <= rankU) continue; // the uphill filter
|
||||
const candidate = du + graph.shortcutSeconds[e]!;
|
||||
if (candidate < df[v]!) {
|
||||
df[v] = candidate;
|
||||
fPrev[v] = u;
|
||||
fPrevEdge[v] = e | SHORTCUT_REF_BIT;
|
||||
fHeap.push(candidate, v);
|
||||
}
|
||||
if (db[v]! < Number.POSITIVE_INFINITY) {
|
||||
const meet = candidate + db[v]!;
|
||||
if (meet < mu) {
|
||||
mu = meet;
|
||||
meetFrom = u;
|
||||
meetTo = v;
|
||||
meetEdgeRef = e | SHORTCUT_REF_BIT;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const du = db[u]!;
|
||||
const rankU = rank[u]!;
|
||||
// Original IN-edges from the reverse view: relax v->u when its bit0
|
||||
// is clear and v is strictly higher-ranked.
|
||||
let end = rev.original.rowStart[u + 1]!;
|
||||
for (let r = rev.original.rowStart[u]!; r < end; r++) {
|
||||
const e = rev.original.edgeIds[r]!; // the edge v -> u
|
||||
if ((graph.edgeFlags[e]! & 1) !== 0) continue; // oneway-reverse: never traversed
|
||||
const v = rev.original.sources[r]!;
|
||||
if (rank[v]! <= rankU) continue; // the uphill filter
|
||||
const candidate = du + graph.edgeSeconds[e]!;
|
||||
if (candidate < db[v]!) {
|
||||
db[v] = candidate;
|
||||
bNext[v] = u;
|
||||
bNextEdge[v] = e; // a plain original edge ref
|
||||
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;
|
||||
meetEdgeRef = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shortcut IN-edges from the SHORTCUT reverse view — a separate
|
||||
// structure, never the forward shortcut list.
|
||||
end = rev.shortcut.rowStart[u + 1]!;
|
||||
for (let r = rev.shortcut.rowStart[u]!; r < end; r++) {
|
||||
const e = rev.shortcut.edgeIds[r]!; // the shortcut v -> u
|
||||
const v = rev.shortcut.sources[r]!;
|
||||
if (rank[v]! <= rankU) continue; // the uphill filter
|
||||
const candidate = du + graph.shortcutSeconds[e]!;
|
||||
if (candidate < db[v]!) {
|
||||
db[v] = candidate;
|
||||
bNext[v] = u;
|
||||
bNextEdge[v] = e | SHORTCUT_REF_BIT;
|
||||
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;
|
||||
meetEdgeRef = e | SHORTCUT_REF_BIT;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (meetFrom < 0) return null; // never met — unreachable (honest miss)
|
||||
|
||||
// Reconstruct as EDGE hops (node + ref), not just nodes: a parallel
|
||||
// original edge and shortcut can share endpoints, so the ref is what
|
||||
// tells the unpacker which one the search traversed.
|
||||
// origin ->(fPrev)-> meetFrom -[bridge]-> meetTo ->(bNext)-> goal.
|
||||
const hops: Array<{ from: number; ref: number }> = [];
|
||||
for (let x = meetFrom; x !== originNode; x = fPrev[x]!) {
|
||||
hops.push({ from: fPrev[x]!, ref: fPrevEdge[x]! });
|
||||
}
|
||||
hops.reverse();
|
||||
hops.push({ from: meetFrom, ref: meetEdgeRef });
|
||||
for (let y = meetTo; y !== goalNode; y = bNext[y]!) {
|
||||
hops.push({ from: y, ref: bNextEdge[y]! });
|
||||
}
|
||||
|
||||
// Unpack every shortcut hop through its via until only original edges
|
||||
// remain — iterative (an explicit stack) so hierarchy depth can never
|
||||
// overflow the JS call stack on long routes. A known-original ref
|
||||
// (plain CSR index) is kept as-is; a marked ref expands by its stored
|
||||
// via; an inner hop (ref -1) re-derives from the shortcut CSR — inside
|
||||
// an expansion the shortcut IS the path the label used.
|
||||
const DERIVE = -1;
|
||||
const unpacked: number[] = [];
|
||||
const stack: Array<{ a: number; b: number; ref: number }> = [];
|
||||
for (let i = hops.length - 1; i >= 0; i--) {
|
||||
stack.push({ a: hops[i]!.from, b: i + 1 < hops.length ? hops[i + 1]!.from : goalNode, ref: hops[i]!.ref });
|
||||
}
|
||||
while (stack.length > 0) {
|
||||
const hop = stack.pop()!;
|
||||
let via = -1;
|
||||
if (hop.ref === DERIVE) {
|
||||
// Inner hop: take the CHEAPER of the original edge and a parallel
|
||||
// shortcut — a shortcut inserted by an earlier contraction can sit
|
||||
// ABOVE a direct original edge (the dominated-insert guard only
|
||||
// bounds the pair at its own insertion), and preferring the
|
||||
// shortcut blindly would re-sum a costlier path than the label.
|
||||
const eOrig = findTraversableEdge(graph, hop.a, hop.b);
|
||||
const origW = eOrig >= 0 ? graph.edgeSeconds[eOrig]! : Number.POSITIVE_INFINITY;
|
||||
let scIdx = -1;
|
||||
const sEnd = graph.shortcutStart[hop.a + 1]!;
|
||||
for (let s = graph.shortcutStart[hop.a]!; s < sEnd; s++) {
|
||||
if (graph.shortcutTarget[s] === hop.b) {
|
||||
scIdx = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (scIdx >= 0 && graph.shortcutSeconds[scIdx]! < origW) {
|
||||
via = graph.shortcutVia[scIdx]!;
|
||||
} else {
|
||||
via = -1; // original edge (or no edge — the re-sum flags it loud)
|
||||
}
|
||||
} else if (hop.ref & SHORTCUT_REF_BIT) {
|
||||
via = graph.shortcutVia[hop.ref & ~SHORTCUT_REF_BIT]!;
|
||||
} // else: a plain original edge ref — keep as-is
|
||||
if (via < 0) {
|
||||
unpacked.push(hop.a); // an original edge: keep its tail node
|
||||
continue;
|
||||
}
|
||||
// The shortcut a->b expands to a->via then via->b (push reversed so
|
||||
// a->via pops first — LIFO order preserves travel order).
|
||||
stack.push({ a: via, b: hop.b, ref: DERIVE });
|
||||
stack.push({ a: hop.a, b: via, ref: DERIVE });
|
||||
}
|
||||
unpacked.push(goalNode);
|
||||
|
||||
// REAL costs recomputed along the unpacked original-edge path (exact;
|
||||
// no shortcut arithmetic leaks into the returned seconds — Pitfall 3).
|
||||
let seconds = 0;
|
||||
let distanceM = 0;
|
||||
for (let i = 1; i < unpacked.length; i++) {
|
||||
const a = unpacked[i - 1]!;
|
||||
const b = unpacked[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: unpacked, seconds, distanceM };
|
||||
}
|
||||
|
||||
/** The traversable (bit0 clear) original edge u -> v, or -1. Shared with
|
||||
* astar.ts's step derivation — one lookup, one authority. */
|
||||
export 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;
|
||||
}
|
||||
+157
-119
@@ -1,64 +1,75 @@
|
||||
/*
|
||||
* graph.ts — the CSR road-graph binary codec (D-01 engine A data plane).
|
||||
* graph.ts — the CSR road-graph binary codec (the D-01 engine-A data plane).
|
||||
* ------------------------------------------------------------------------
|
||||
* decodeGraph turns the R2-hosted graph binary into typed-array views with
|
||||
* ZERO per-node JS objects (D-01); encodeGraph is its build-time inverse
|
||||
* (the committed mini-graph fixture and plan 04's Overpass build script
|
||||
* both write through it, so the layout has exactly one authority).
|
||||
* (the committed mini-graph fixture, the bot suites' purpose-built graphs
|
||||
* and plan 02's Overpass build script all write through it, so the layout
|
||||
* has exactly one authority).
|
||||
*
|
||||
* PINNED CSR BINARY LAYOUT — ARG2 (little-endian; 06-01 pinned ARG1,
|
||||
* plan 08-02 versioned it per phase-8 D-01; plans 04/07 depend on it):
|
||||
* header: magic u32 (0x41524732 "ARG2") | version u32 (=2)
|
||||
* PINNED CSR BINARY LAYOUT — ARG3 (little-endian; 06-01 pinned ARG1,
|
||||
* plan 08-02 versioned ARG2 per phase-8 D-01, Phase 08.1 re-pins ARG3
|
||||
* per D-07/D-10; plans 04/07 depend on the base sections):
|
||||
* header: magic u32 (0x41524733 "ARG3") | version u32 (=3)
|
||||
* | nodeCount u32 | edgeCount u32 | nameCount u32 | namePoolLen u32
|
||||
* | bbox 4×f64 (minLat, minLng, maxLat, maxLng) | maxSpeedKmh f32 @56
|
||||
* | landmarkCount u32 @60 (>= 1 REQUIRED — see the D-01 law)
|
||||
* | landmarkQuantumS f32 @64 (the ĉ unit; build default 0.1 s)
|
||||
* | reserved u32 @68 (written 0; decode accepts any value)
|
||||
* | chShortcutCount u32 @60 (0 -> decode null — see the A5 law)
|
||||
* | reserved0 u32 @64 (written 0; decode accepts any value)
|
||||
* | reserved1 u32 @68 (written 0; decode accepts any value)
|
||||
* — 72 bytes total: 72 % 8 == 0 keeps the f64 node views
|
||||
* 8-byte aligned; zero-copy TypedArray views REQUIRE aligned
|
||||
* offsets (unaligned Float64Array throws).
|
||||
* offsets (unaligned Float64Array throws RangeError).
|
||||
* nodes: f64 lat[n] ++ f64 lng[n]
|
||||
* offsets: u32 edgeStart[n+1] (CSR row pointers)
|
||||
* edges: u32 edgeTarget[e] ++ u8 edgeFlags[e] (bit0: oneway-reverse)
|
||||
* weights: f32 edgeSeconds[e] (aligned to 4 bytes)
|
||||
* names: u32 edgeNameId[e] | u32 nameOffset[m+1] | u8 namePool (UTF-8)
|
||||
* landmarks (the APPEND-ONLY ARG2 suffix; row-major [l*N + v]):
|
||||
* u16 toTable[L*N] — quantized ĉ-distance v -> anchor l
|
||||
* u16 fromTable[L*N] — quantized ĉ-distance anchor l -> v
|
||||
* u32 landmarkNodes[L] — the anchors (audit + --check prints)
|
||||
* 0xFFFF in a table cell is DATA (the unreachable sentinel,
|
||||
* landmark.ts) — decode never validates it away.
|
||||
* hierarchy (the ARG3 CH suffix — D-10 phone-first: plain little-endian
|
||||
* typed arrays, no Node-specific alignment):
|
||||
* u32 chRank[n] (the contraction rank — a total
|
||||
* order; 0 = contracted first)
|
||||
* u32 shortcutStart[n+1] (shortcut CSR row pointers)
|
||||
* u32 shortcutTarget[S]
|
||||
* f32 shortcutSeconds[S] (steering weights only — the
|
||||
* query re-sums original edges)
|
||||
* u32 shortcutVia[S] (the contracted midpoint —
|
||||
* REQUIRED for unpacking)
|
||||
* The CH suffix starts at the first 4-byte boundary after the name
|
||||
* pool: (namePoolEnd + 3) & ~3 — a misaligned Float32Array view would
|
||||
* THROW at decode (a crash, not a null), so the alignment arithmetic
|
||||
* is load-bearing (T-08.1-02).
|
||||
*
|
||||
* THE D-01 FAIL-HONEST LAW (one decode path, one search path): a buffer
|
||||
* that is not EXACTLY an ARG2 graph decodes to null — ARG1 bytes (version
|
||||
* 1), future versions, landmarkCount = 0 (no landmark-less ARG2 layout
|
||||
* exists to silently degrade into), and every size mismatch. There is no
|
||||
* v1 fallback branch: the Phase 6 router-pending state covers old binaries
|
||||
* until one re-upload. (Router-pending coverage is exercised by the bot
|
||||
* suite; the committed mini-graph-arg1.bin is the standing ARG1 rejection
|
||||
* vector — plan 08-01.)
|
||||
* THE FAIL-HONEST LAW (one decode path, one search path): a buffer that
|
||||
* is not EXACTLY an ARG3 graph decodes to null — ARG1 bytes (version 1),
|
||||
* ARG2 bytes (version 2), future versions, chShortcutCount = 0 (an ARG3
|
||||
* with no hierarchy is not a road graph — there is no CH-less layout to
|
||||
* silently degrade into), and every size mismatch. There is no fallback
|
||||
* branch: the Phase 6 router-pending state covers old binaries until one
|
||||
* re-upload (D-07). (Router-pending coverage is exercised by the bot
|
||||
* suite; the committed mini-graph-arg1.bin — and, after task 3,
|
||||
* mini-graph-arg2.bin — are the standing rejection vectors, D-12.)
|
||||
*
|
||||
* edgeFlags bit0 ("oneway-reverse") marks the blocked mirror direction of a
|
||||
* oneway segment: the edge stays in the CSR so snapping sees the segment
|
||||
* geometry and its street name, but A* (astar.ts) never traverses it.
|
||||
* geometry and its street name, but the search (ch-query.ts) never
|
||||
* traverses it.
|
||||
*
|
||||
* T-06-03: a hostile/corrupt binary fails HONEST — decodeGraph validates
|
||||
* magic, version, landmarkCount, the quantum, and the EXACT byte size
|
||||
* implied by the header counts (the size arithmetic covers the variable
|
||||
* u16 suffix), and returns null on any mismatch. It never throws and never
|
||||
* returns a half-built graph; plan 04's Worker turns null into the designed
|
||||
* router-pending state.
|
||||
* T-06-03b / T-08-01: the size check runs BEFORE any view allocation —
|
||||
* huge-count headers (including a monster landmarkCount) reject on
|
||||
* magic, version, chShortcutCount, and the EXACT byte size implied by the
|
||||
* header counts (the arithmetic covers the whole CH suffix), and returns
|
||||
* null on any mismatch. It never throws and never returns a half-built
|
||||
* graph; the Worker turns null into the designed router-pending state.
|
||||
* T-06-03b / T-08.1-01: the size check runs BEFORE any view allocation —
|
||||
* huge-count headers (including a monster chShortcutCount) reject on
|
||||
* arithmetic, not on allocation.
|
||||
* T-06-05: pure module — no logging, no storage, no network; coordinates
|
||||
* exist only in the caller's frame.
|
||||
*/
|
||||
|
||||
/** Header magic "ARG2" (ArahaRah Graph v2 — the landmark-carrying layout). */
|
||||
export const GRAPH_MAGIC = 0x41524732;
|
||||
/** Header magic "ARG3" (ArahaRah Graph v3 — the CH-carrying layout). */
|
||||
export const GRAPH_MAGIC = 0x41524733;
|
||||
/** Layout version this module reads and writes. */
|
||||
export const GRAPH_VERSION = 2;
|
||||
export const GRAPH_VERSION = 3;
|
||||
/** edgeNameId sentinel: the edge carries no street name. */
|
||||
export const UNNAMED_NAME_ID = 0xffffffff;
|
||||
|
||||
@@ -78,7 +89,7 @@ export interface RoadGraph {
|
||||
/** CSR row pointers — edgeStart[i]..edgeStart[i+1] are node i's edges. */
|
||||
edgeStart: Uint32Array;
|
||||
edgeTarget: Uint32Array;
|
||||
/** bit0 set = oneway-reverse (never traversed by A*; visible to snapping). */
|
||||
/** bit0 set = oneway-reverse (never traversed by the search; visible to snapping). */
|
||||
edgeFlags: Uint8Array;
|
||||
/** Speed-derived travel time per edge, seconds. */
|
||||
edgeSeconds: Float32Array;
|
||||
@@ -88,16 +99,17 @@ export interface RoadGraph {
|
||||
nameAt(id: number): string | undefined;
|
||||
bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number };
|
||||
maxSpeedKmh: number;
|
||||
/** Number of landmark anchors (ARG2: always >= 1 — decode refuses 0). */
|
||||
landmarkCount: number;
|
||||
/** The ĉ quantum in seconds the tables were computed at (f32 readback). */
|
||||
landmarkQuantumS: number;
|
||||
/** Row-major [l*N + v] quantized distance v -> anchor l; 0xFFFF = unreachable (DATA). */
|
||||
landmarkTo: Uint16Array;
|
||||
/** Row-major [l*N + v] quantized distance anchor l -> v; 0xFFFF = unreachable (DATA). */
|
||||
landmarkFrom: Uint16Array;
|
||||
/** The L anchor node ids (row l of both tables). */
|
||||
landmarkNodes: Uint32Array;
|
||||
/** Number of CH shortcuts S (ARG3: always >= 1 — decode refuses 0). */
|
||||
chShortcutCount: number;
|
||||
/** The contraction rank — a strict total order over the nodes. */
|
||||
chRank: Uint32Array;
|
||||
/** CSR row pointers over the shortcuts — shortcutStart[u]..[u+1] are u's out-shortcuts. */
|
||||
shortcutStart: Uint32Array;
|
||||
shortcutTarget: Uint32Array;
|
||||
/** Shortcut steering weights, seconds (never reported — the query re-sums). */
|
||||
shortcutSeconds: Float32Array;
|
||||
/** The contracted midpoint of each shortcut (the unpacking via). */
|
||||
shortcutVia: Uint32Array;
|
||||
}
|
||||
|
||||
/** One directed edge at encode time. */
|
||||
@@ -112,16 +124,16 @@ export interface GraphEdgeInput {
|
||||
nameId?: number;
|
||||
}
|
||||
|
||||
/** The landmark-table block of the encodeGraph input (ARG2). */
|
||||
export interface LandmarkTablesInput {
|
||||
/** The ĉ quantum in seconds the tables were computed at (e.g. landmark.ts's LANDMARK_QUANTUM_S). */
|
||||
quantumS: number;
|
||||
/** The L anchor node ids; L >= 1 — an ARG2 always carries landmarks. */
|
||||
nodes: ArrayLike<number>;
|
||||
/** Row-major [l*N + v] to-anchors table, length exactly L*N. */
|
||||
to: Uint16Array;
|
||||
/** Row-major [l*N + v] from-anchors table, length exactly L*N. */
|
||||
from: Uint16Array;
|
||||
/** The CH-table block of the encodeGraph input (ARG3). */
|
||||
export interface ChTablesInput {
|
||||
/** The contraction rank, length exactly nodeCount — a total order. */
|
||||
rank: Uint32Array;
|
||||
/** Shortcut CSR row pointers, length exactly nodeCount + 1. */
|
||||
shortcutStart: Uint32Array;
|
||||
shortcutTarget: Uint32Array;
|
||||
shortcutSeconds: Float32Array;
|
||||
/** The unpacking midpoints — every entry a node id in [0, nodeCount). */
|
||||
shortcutVia: Uint32Array;
|
||||
}
|
||||
|
||||
/** The encodeGraph input — the shape build scripts assemble. */
|
||||
@@ -132,8 +144,8 @@ export interface GraphInput {
|
||||
edges: readonly GraphEdgeInput[];
|
||||
names: readonly string[];
|
||||
maxSpeedKmh: number;
|
||||
/** REQUIRED since ARG2 (D-01): the precomputed landmark tables. */
|
||||
landmarks: LandmarkTablesInput;
|
||||
/** REQUIRED since ARG3 (D-07): the contracted hierarchy tables. */
|
||||
ch: ChTablesInput;
|
||||
}
|
||||
|
||||
/* Section offsets implied by the header counts (all little-endian). */
|
||||
@@ -147,9 +159,11 @@ interface Layout {
|
||||
edgeNameId: number;
|
||||
nameOffset: number;
|
||||
namePool: number;
|
||||
toTable: number;
|
||||
fromTable: number;
|
||||
landmarkNodes: number;
|
||||
chRank: number;
|
||||
shortcutStart: number;
|
||||
shortcutTarget: number;
|
||||
shortcutSeconds: number;
|
||||
shortcutVia: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -158,7 +172,7 @@ function layoutFor(
|
||||
edgeCount: number,
|
||||
nameCount: number,
|
||||
namePoolLen: number,
|
||||
landmarkCount: number,
|
||||
shortcutCount: number,
|
||||
): Layout {
|
||||
const lat = HEADER_SIZE;
|
||||
const lng = lat + 8 * nodeCount;
|
||||
@@ -169,12 +183,14 @@ function layoutFor(
|
||||
const edgeNameId = edgeSeconds + 4 * edgeCount;
|
||||
const nameOffset = edgeNameId + 4 * edgeCount;
|
||||
const namePool = nameOffset + 4 * (nameCount + 1);
|
||||
// The ARG2 landmark suffix — an append-only extension of the pinned ARG1
|
||||
// section order. Encode and decode share THIS arithmetic (T-08-01: the
|
||||
// exact-size law extends over the variable-size suffix).
|
||||
const toTable = (namePool + namePoolLen + 3) & ~3; // align u16 pairs to 4 bytes
|
||||
const fromTable = toTable + 2 * landmarkCount * nodeCount;
|
||||
const landmarkNodes = fromTable + 2 * landmarkCount * nodeCount;
|
||||
// The ARG3 CH suffix — encode and decode share THIS arithmetic
|
||||
// (T-08.1-01: the exact-size law extends over the whole suffix;
|
||||
// T-08.1-02: the alignment keeps every u32/f32 view 4-byte aligned).
|
||||
const chRank = (namePool + namePoolLen + 3) & ~3; // align u32 to 4 bytes
|
||||
const shortcutStart = chRank + 4 * nodeCount;
|
||||
const shortcutTarget = shortcutStart + 4 * (nodeCount + 1);
|
||||
const shortcutSeconds = shortcutTarget + 4 * shortcutCount;
|
||||
const shortcutVia = shortcutSeconds + 4 * shortcutCount;
|
||||
return {
|
||||
lat,
|
||||
lng,
|
||||
@@ -185,10 +201,12 @@ function layoutFor(
|
||||
edgeNameId,
|
||||
nameOffset,
|
||||
namePool,
|
||||
toTable,
|
||||
fromTable,
|
||||
landmarkNodes,
|
||||
total: landmarkNodes + 4 * landmarkCount,
|
||||
chRank,
|
||||
shortcutStart,
|
||||
shortcutTarget,
|
||||
shortcutSeconds,
|
||||
shortcutVia,
|
||||
total: shortcutVia + 4 * shortcutCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,19 +230,19 @@ export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null {
|
||||
maxLng: view.getFloat64(48, true),
|
||||
};
|
||||
const maxSpeedKmh = view.getFloat32(56, true);
|
||||
// ARG2 header fields (plan 08-02): landmarkCount @60 (the ARG1 pad),
|
||||
// quantum @64, reserved @68 (accepted untouched — the field is unused).
|
||||
const landmarkCount = view.getUint32(60, true);
|
||||
// D-01/A5: an ARG2 always carries landmarks — a landmark-less layout
|
||||
// would silently route with weak steering, and "no v1 fallback branch"
|
||||
// holds at the content level too. Decode refuses, honestly.
|
||||
if (landmarkCount === 0) return null;
|
||||
const landmarkQuantumS = view.getFloat32(64, true);
|
||||
if (!Number.isFinite(landmarkQuantumS) || landmarkQuantumS <= 0) return null; // hostile quantum -> null, never a divide-by-0 downstream
|
||||
// ARG3 header field (Phase 08.1): chShortcutCount @60 (the ARG2
|
||||
// landmarkCount slot), reserved @64/@68 (accepted untouched — unused).
|
||||
const chShortcutCount = view.getUint32(60, true);
|
||||
// A5: an ARG3 always carries its hierarchy — a CH-less layout would
|
||||
// silently route on a search that cannot reach its meet node, and "no
|
||||
// fallback branch" holds at the content level too. Decode refuses,
|
||||
// honestly.
|
||||
if (chShortcutCount === 0) return null;
|
||||
// Exact byte-size check BEFORE any view allocation (T-06-03b, extended
|
||||
// over the u16 suffix per T-08-01): a hostile huge-count header —
|
||||
// including a monster landmarkCount — rejects here on arithmetic alone.
|
||||
const lay = layoutFor(nodeCount, edgeCount, nameCount, namePoolLen, landmarkCount);
|
||||
// over the CH suffix per T-08.1-01): a hostile huge-count header —
|
||||
// including a monster chShortcutCount — rejects here on arithmetic
|
||||
// alone.
|
||||
const lay = layoutFor(nodeCount, edgeCount, nameCount, namePoolLen, chShortcutCount);
|
||||
if (buffer.byteLength !== lay.total) return null;
|
||||
|
||||
const lat = new Float64Array(buffer, lay.lat, nodeCount);
|
||||
@@ -236,9 +254,11 @@ export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null {
|
||||
const edgeNameId = new Uint32Array(buffer, lay.edgeNameId, edgeCount);
|
||||
const nameOffset = new Uint32Array(buffer, lay.nameOffset, nameCount + 1);
|
||||
const namePool = new Uint8Array(buffer, lay.namePool, namePoolLen);
|
||||
const landmarkTo = new Uint16Array(buffer, lay.toTable, landmarkCount * nodeCount);
|
||||
const landmarkFrom = new Uint16Array(buffer, lay.fromTable, landmarkCount * nodeCount);
|
||||
const landmarkNodes = new Uint32Array(buffer, lay.landmarkNodes, landmarkCount);
|
||||
const chRank = new Uint32Array(buffer, lay.chRank, nodeCount);
|
||||
const shortcutStart = new Uint32Array(buffer, lay.shortcutStart, nodeCount + 1);
|
||||
const shortcutTarget = new Uint32Array(buffer, lay.shortcutTarget, chShortcutCount);
|
||||
const shortcutSeconds = new Float32Array(buffer, lay.shortcutSeconds, chShortcutCount);
|
||||
const shortcutVia = new Uint32Array(buffer, lay.shortcutVia, chShortcutCount);
|
||||
const decoder = new TextDecoder();
|
||||
const graph: RoadGraph = {
|
||||
nodeCount,
|
||||
@@ -260,11 +280,12 @@ export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null {
|
||||
},
|
||||
bbox,
|
||||
maxSpeedKmh,
|
||||
landmarkCount,
|
||||
landmarkQuantumS,
|
||||
landmarkTo,
|
||||
landmarkFrom,
|
||||
landmarkNodes,
|
||||
chShortcutCount,
|
||||
chRank,
|
||||
shortcutStart,
|
||||
shortcutTarget,
|
||||
shortcutSeconds,
|
||||
shortcutVia,
|
||||
};
|
||||
return graph;
|
||||
}
|
||||
@@ -286,28 +307,43 @@ export function encodeGraph(input: GraphInput): ArrayBuffer {
|
||||
if (!Number.isFinite(input.maxSpeedKmh) || input.maxSpeedKmh <= 0) {
|
||||
fail("maxSpeedKmh must be a positive finite number");
|
||||
}
|
||||
// ARG2 landmark block (D-01): REQUIRED, loud-validated BEFORE any
|
||||
// allocation work (T-08-04) — no silent clamp, no landmark-less encode.
|
||||
const landmarks = input.landmarks;
|
||||
if (!landmarks) fail("landmarks block is required (ARG2 has no landmark-less layout)");
|
||||
const landmarkCount = landmarks.nodes.length;
|
||||
if (!Number.isInteger(landmarkCount) || landmarkCount < 1) {
|
||||
fail("landmarks.nodes must carry at least one anchor (ARG2 has no landmark-less layout)");
|
||||
// ARG3 CH block (D-07): REQUIRED, loud-validated BEFORE any allocation
|
||||
// work (T-08-04's law carried forward) — no silent clamp, no CH-less encode.
|
||||
const ch = input.ch;
|
||||
if (!ch) fail("ch block is required (ARG3 has no CH-less layout)");
|
||||
if (!ch.rank || ch.rank.length !== nodeCount) {
|
||||
fail(`ch.rank must have length nodeCount = ${nodeCount}, got ${ch.rank ? ch.rank.length : "undefined"}`);
|
||||
}
|
||||
if (!Number.isFinite(landmarks.quantumS) || landmarks.quantumS <= 0) {
|
||||
fail("landmarks.quantumS must be a positive finite number");
|
||||
if (!ch.shortcutStart || ch.shortcutStart.length !== nodeCount + 1) {
|
||||
fail(
|
||||
`ch.shortcutStart must have length nodeCount + 1 = ${nodeCount + 1}, got ` +
|
||||
`${ch.shortcutStart ? ch.shortcutStart.length : "undefined"}`,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < landmarkCount; i++) {
|
||||
const a = landmarks.nodes[i]!;
|
||||
if (!Number.isInteger(a) || a < 0 || a >= nodeCount) {
|
||||
fail(`landmarks.nodes[${i}] (${a}) is not a node id in [0, ${nodeCount})`);
|
||||
const shortcutCount = ch.shortcutTarget ? ch.shortcutTarget.length : -1;
|
||||
if (!Number.isInteger(shortcutCount) || shortcutCount < 1) {
|
||||
fail("ch.shortcutTarget must carry at least one shortcut (ARG3 has no CH-less layout)");
|
||||
}
|
||||
if (!ch.shortcutSeconds || ch.shortcutSeconds.length !== shortcutCount) {
|
||||
fail(`ch.shortcutSeconds must have length S = ${shortcutCount}, got ${ch.shortcutSeconds ? ch.shortcutSeconds.length : "undefined"}`);
|
||||
}
|
||||
if (landmarks.to.length !== landmarkCount * nodeCount) {
|
||||
fail(`landmarks.to must have length L*N = ${landmarkCount}*${nodeCount}, got ${landmarks.to.length}`);
|
||||
if (!ch.shortcutVia || ch.shortcutVia.length !== shortcutCount) {
|
||||
fail(`ch.shortcutVia must have length S = ${shortcutCount}, got ${ch.shortcutVia ? ch.shortcutVia.length : "undefined"}`);
|
||||
}
|
||||
if (ch.shortcutStart[nodeCount] !== shortcutCount) {
|
||||
fail(
|
||||
`ch.shortcutStart[${nodeCount}] (${ch.shortcutStart[nodeCount]}) must equal the shortcut count S = ${shortcutCount}`,
|
||||
);
|
||||
}
|
||||
for (let s = 0; s < shortcutCount; s++) {
|
||||
const via = ch.shortcutVia[s]!;
|
||||
if (!Number.isInteger(via) || via < 0 || via >= nodeCount) {
|
||||
fail(`ch.shortcutVia[${s}] (${via}) is not a node id in [0, ${nodeCount})`);
|
||||
}
|
||||
const seconds = ch.shortcutSeconds[s]!;
|
||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||
fail(`ch.shortcutSeconds[${s}] (${seconds}) is not a finite non-negative weight`);
|
||||
}
|
||||
if (landmarks.from.length !== landmarkCount * nodeCount) {
|
||||
fail(`landmarks.from must have length L*N = ${landmarkCount}*${nodeCount}, got ${landmarks.from.length}`);
|
||||
}
|
||||
const nameCount = input.names.length;
|
||||
for (const name of input.names) {
|
||||
@@ -347,7 +383,7 @@ export function encodeGraph(input: GraphInput): ArrayBuffer {
|
||||
for (const edge of input.edges) counts[edge.from + 1] = (counts[edge.from + 1] ?? 0) + 1;
|
||||
for (let i = 0; i < nodeCount; i++) counts[i + 1] = counts[i + 1]! + counts[i]!;
|
||||
const edgeCount = input.edges.length;
|
||||
const lay = layoutFor(nodeCount, edgeCount, nameCount, namePoolLen, landmarkCount);
|
||||
const lay = layoutFor(nodeCount, edgeCount, nameCount, namePoolLen, shortcutCount);
|
||||
const buffer = new ArrayBuffer(lay.total);
|
||||
const view = new DataView(buffer);
|
||||
const edgeTarget = new Uint32Array(buffer, lay.edgeTarget, edgeCount);
|
||||
@@ -388,9 +424,9 @@ export function encodeGraph(input: GraphInput): ArrayBuffer {
|
||||
view.setFloat64(40, maxLat, true);
|
||||
view.setFloat64(48, maxLng, true);
|
||||
view.setFloat32(56, input.maxSpeedKmh, true);
|
||||
view.setUint32(60, landmarkCount, true);
|
||||
view.setFloat32(64, landmarks.quantumS, true);
|
||||
view.setUint32(68, 0, true); // reserved (decode accepts any value)
|
||||
view.setUint32(60, shortcutCount, true);
|
||||
view.setUint32(64, 0, true); // reserved0 (decode accepts any value)
|
||||
view.setUint32(68, 0, true); // reserved1 (decode accepts any value)
|
||||
|
||||
// CSR row pointers + node coordinates.
|
||||
for (let i = 0; i <= nodeCount; i++) view.setUint32(lay.edgeStart + 4 * i, counts[i]!, true);
|
||||
@@ -413,11 +449,13 @@ export function encodeGraph(input: GraphInput): ArrayBuffer {
|
||||
pool.set(bytes, at);
|
||||
at += bytes.length;
|
||||
}
|
||||
// ARG2 landmark suffix — bulk typed-array writes through the same
|
||||
// view discipline as the u32/f32/f64 sections, so decode's zero-copy
|
||||
// views read the bytes back exactly.
|
||||
new Uint16Array(buffer, lay.toTable, landmarkCount * nodeCount).set(landmarks.to);
|
||||
new Uint16Array(buffer, lay.fromTable, landmarkCount * nodeCount).set(landmarks.from);
|
||||
new Uint32Array(buffer, lay.landmarkNodes, landmarkCount).set(landmarks.nodes);
|
||||
// ARG3 CH suffix — bulk typed-array writes through the same view
|
||||
// discipline as the u32/f32/f64 sections, so decode's zero-copy views
|
||||
// read the bytes back exactly.
|
||||
new Uint32Array(buffer, lay.chRank, nodeCount).set(ch.rank);
|
||||
new Uint32Array(buffer, lay.shortcutStart, nodeCount + 1).set(ch.shortcutStart);
|
||||
new Uint32Array(buffer, lay.shortcutTarget, shortcutCount).set(ch.shortcutTarget);
|
||||
new Float32Array(buffer, lay.shortcutSeconds, shortcutCount).set(ch.shortcutSeconds);
|
||||
new Uint32Array(buffer, lay.shortcutVia, shortcutCount).set(ch.shortcutVia);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
+22
-8
@@ -46,14 +46,6 @@ export {
|
||||
} from "./fixture-source.ts";
|
||||
export { createHttpRoutingSource } from "./http-source.ts";
|
||||
export type { HttpRoutingSourceOptions } from "./http-source.ts";
|
||||
export type { RoadGraph, GraphEdgeInput, GraphInput } from "./graph.ts";
|
||||
export {
|
||||
decodeGraph,
|
||||
encodeGraph,
|
||||
GRAPH_MAGIC,
|
||||
GRAPH_VERSION,
|
||||
UNNAMED_NAME_ID,
|
||||
} from "./graph.ts";
|
||||
export type { SnapResult } from "./snap.ts";
|
||||
export { snapToGraph, SNAP_BBOX_MARGIN_DEG } from "./snap.ts";
|
||||
export type { AstarResult, GuidanceTurnKind } from "./astar.ts";
|
||||
@@ -65,3 +57,25 @@ export {
|
||||
TURN_SLIGHT_MAX_DEG,
|
||||
TURN_UTURN_MIN_DEG,
|
||||
} from "./astar.ts";
|
||||
// Phase 08.1 (D-04/D-11): the CH construction core + the one search path
|
||||
// are public surface now — the build scripts and the query's own suite
|
||||
// import through this index. No landmark symbols exist (D-05 deleted the
|
||||
// ALT machinery outright).
|
||||
export type { RoadGraph, GraphEdgeInput, GraphInput, ChTablesInput } from "./graph.ts";
|
||||
export type { ContractionInput, ContractionResult } from "./contraction.ts";
|
||||
export type { ChResult } from "./ch-query.ts";
|
||||
export {
|
||||
decodeGraph,
|
||||
encodeGraph,
|
||||
GRAPH_MAGIC,
|
||||
GRAPH_VERSION,
|
||||
UNNAMED_NAME_ID,
|
||||
} from "./graph.ts";
|
||||
export {
|
||||
contractGraph,
|
||||
CH_LAMBDA_DELETED_NEIGHBOURS,
|
||||
CH_WPS_MAX_SETTLED,
|
||||
CH_WPS_SETTLED_PER_DEGREE,
|
||||
CH_SHORTCUT_EXPLOSION_RATIO,
|
||||
} from "./contraction.ts";
|
||||
export { routeCH } from "./ch-query.ts";
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
/*
|
||||
* landmark.ts — the ALT landmark preprocessing core (plan 08-02, D-02).
|
||||
* ----------------------------------------------------------------------------
|
||||
* One pure, typed-array module for everything integer about landmarks:
|
||||
* deterministic farthest-point anchor selection + the per-anchor distance
|
||||
* tables, both over the ĉ metric. The fixture builder
|
||||
* (test/fixtures/build-mini-graph.mjs), the real build script
|
||||
* (scripts/build-road-graph.mjs) and the bot suites ALL compute their
|
||||
* landmark blocks through these exports — there is no second
|
||||
* integer-Dijkstra implementation anywhere (single-authority law).
|
||||
*
|
||||
* THE ĉ METRIC (research Pattern 2 — quantization WITHOUT an epsilon):
|
||||
* edge weights are floor-quantized ONCE, ĉ(u,v) = floor(seconds/quantumS)
|
||||
* in fixed 0.1 s units, and the landmark Dijkstras run over that integer
|
||||
* metric in EXACT arithmetic. Stored distances are never rounded; because
|
||||
* ĉ <= c edge-wise and Dijkstra distances are 1-Lipschitz in their own
|
||||
* edges, the triangle bounds Plan 08-03 derives from these tables are
|
||||
* exactly admissible and exactly consistent against the real f32 seconds.
|
||||
*
|
||||
* TRAVERSAL LAW (mirrors astar.ts verbatim — the A1/625-pair precedent):
|
||||
* an edge with bit0 set ("oneway-reverse", graph.ts) is NEVER relaxed —
|
||||
* not in the forward pass, and not in the reversed pass either (astar.ts's
|
||||
* backward search skips bit0 in-edges at line 321). The reversed pass
|
||||
* relaxes a stored edge u->v FROM v (toward u) exactly when its bit0 is
|
||||
* clear, which is what makes to-row / from-row asymmetry representable.
|
||||
*
|
||||
* RANGE LAW: 0xFFFF is DATA (the unreachable sentinel), so any label
|
||||
* beyond LANDMARK_MAX_QUANTA (65 000 — headroom below the sentinel)
|
||||
* throws at build time, naming the distance and the fix
|
||||
* (raise landmarkQuantumS). Nothing is clamped, ever.
|
||||
*
|
||||
* DETERMINISM: no randomness of any kind — no PRNG, no Date, no
|
||||
* map-iteration order. Anchor greed starts at the first degree>=2
|
||||
* candidate by node id, ties break to the lower node id, and identical
|
||||
* input bytes give identical anchors.
|
||||
*
|
||||
* Pure module: no I/O, no logging, no storage, zero imports (T-06-05).
|
||||
*/
|
||||
|
||||
/** The ĉ unit in seconds — the header field default, a documented fork knob. */
|
||||
export const LANDMARK_QUANTUM_S = 0.1;
|
||||
/** u16 headroom below the 0xFFFF sentinel; any label past it fails loud. */
|
||||
export const LANDMARK_MAX_QUANTA = 65000;
|
||||
/** Stored sentinel for "unreachable" — DATA, never validated away. The
|
||||
* all-ones u16 pattern 0xffff is every table cell's out-of-domain value;
|
||||
* consumers check against this constant, never a per-callsite literal. */
|
||||
export const LANDMARK_SENTINEL = 0xffff;
|
||||
/** The i32 weight-domain ceiling: qWeight is an Int32Array, so a quantized
|
||||
* edge at or past 2^31 would WRAP NEGATIVE on store — silently bypassing
|
||||
* the LANDMARK_MAX_QUANTA throw in dijkstraPass (a negative contribution
|
||||
* never exceeds it), poisoning the Dijkstras, and at finish() a wrapped
|
||||
* −1 label would BECOME the 0xFFFF sentinel. Any raw seconds/quantumS
|
||||
* quotient at or past this ceiling throws at prepare time, BEFORE the
|
||||
* store — nothing is clamped, ever. (Exactly 2^31−1 already cannot
|
||||
* survive dijkstraPass's ceiling either, so the strict `>=` loses
|
||||
* nothing and fails one boundary earlier, at the table-build entry.) */
|
||||
const LANDMARK_I32_WEIGHT_CEILING = 0x7fffffff;
|
||||
|
||||
/** The landmark compute input — the shape build scripts assemble. */
|
||||
export interface LandmarkInput {
|
||||
nodeCount: number;
|
||||
/**
|
||||
* Directed edges in any order. flags bit0 = oneway-reverse: relaxed by
|
||||
* NO direction (mirrors the CSR's edgeFlags; see the traversal law).
|
||||
*/
|
||||
edges: Array<{ from: number; to: number; seconds: number; flags: number }>;
|
||||
/** The ĉ quantum in seconds; named callers pass LANDMARK_QUANTUM_S. */
|
||||
quantumS: number;
|
||||
}
|
||||
|
||||
/** Internal "unvisited" distance — outside the u16 domain on purpose. */
|
||||
const UNVISITED = 0x7fffffff;
|
||||
|
||||
function fail(context: string, message: string): never {
|
||||
throw new Error(`${context}: ${message}`);
|
||||
}
|
||||
|
||||
/* ————— integer CSR (both directions) over the quantized weights ————— */
|
||||
|
||||
interface Prepared {
|
||||
n: number;
|
||||
/** Per-input-edge quantized weight ĉ (integer quanta). */
|
||||
qWeight: Int32Array;
|
||||
/** Per-input-edge traversal-relevant flags (bit0 only). */
|
||||
flags: Uint8Array;
|
||||
/** Per-input-edge endpoints (the reverse pass relaxes toward source). */
|
||||
target: Uint32Array;
|
||||
source: Uint32Array;
|
||||
/** Forward CSR: row u lists u's out-edges (input-edge indices). */
|
||||
fwdRow: Uint32Array;
|
||||
fwdEdge: Uint32Array;
|
||||
/** Reverse CSR: row v lists v's in-edges (input-edge indices). */
|
||||
revRow: Uint32Array;
|
||||
revEdge: Uint32Array;
|
||||
}
|
||||
|
||||
function prepare(input: LandmarkInput): Prepared {
|
||||
const context = "landmark";
|
||||
const n = input.nodeCount;
|
||||
if (!Number.isInteger(n) || n < 1) fail(context, "nodeCount must be a positive integer");
|
||||
if (!Number.isFinite(input.quantumS) || input.quantumS <= 0) {
|
||||
fail(context, "quantumS must be a positive finite number");
|
||||
}
|
||||
const edgeCount = input.edges.length;
|
||||
const qWeight = new Int32Array(edgeCount);
|
||||
const flags = new Uint8Array(edgeCount);
|
||||
const target = new Uint32Array(edgeCount);
|
||||
const source = new Uint32Array(edgeCount);
|
||||
const fwdRow = new Uint32Array(n + 1);
|
||||
const revRow = new Uint32Array(n + 1);
|
||||
for (let e = 0; e < edgeCount; e++) {
|
||||
const edge = input.edges[e]!;
|
||||
if (!Number.isInteger(edge.from) || edge.from < 0 || edge.from >= n) {
|
||||
fail(context, `edge ${e} from-node ${edge.from} out of range`);
|
||||
}
|
||||
if (!Number.isInteger(edge.to) || edge.to < 0 || edge.to >= n) {
|
||||
fail(context, `edge ${e} to-node ${edge.to} out of range`);
|
||||
}
|
||||
if (!Number.isFinite(edge.seconds) || edge.seconds < 0) {
|
||||
fail(context, `edge ${edge.from}->${edge.to} has a non-finite/negative weight`);
|
||||
}
|
||||
// THE ĉ RULE (Pattern 2): floor keeps the surrogate at or below the
|
||||
// live edge — the exactness hinge for admissibility downstream.
|
||||
const q = Math.floor(edge.seconds / input.quantumS);
|
||||
// RANGE LAW at the storage boundary (08-review WR-04): the quotient is
|
||||
// validated BEFORE the Int32Array store — at or past the i32 ceiling
|
||||
// the store wraps NEGATIVE, silently bypassing the dijkstraPass ceiling
|
||||
// (a negative contribution never exceeds LANDMARK_MAX_QUANTA) and
|
||||
// corrupting the tables. This is a per-edge check at the table-build
|
||||
// entry: the search hot path stays allocation-free and validation-free.
|
||||
if (q >= LANDMARK_I32_WEIGHT_CEILING) {
|
||||
fail(
|
||||
context,
|
||||
`edge ${edge.from}->${edge.to} quantizes to ${q} quanta, at or past the i32 weight ceiling ${LANDMARK_I32_WEIGHT_CEILING} (raise landmarkQuantumS)`,
|
||||
);
|
||||
}
|
||||
qWeight[e] = q;
|
||||
flags[e] = edge.flags & 1;
|
||||
target[e] = edge.to;
|
||||
source[e] = edge.from;
|
||||
fwdRow[edge.from + 1] = fwdRow[edge.from + 1]! + 1;
|
||||
revRow[edge.to + 1] = revRow[edge.to + 1]! + 1;
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
fwdRow[i + 1] = fwdRow[i + 1]! + fwdRow[i]!;
|
||||
revRow[i + 1] = revRow[i + 1]! + revRow[i]!;
|
||||
}
|
||||
const fwdEdge = new Uint32Array(edgeCount);
|
||||
const revEdge = new Uint32Array(edgeCount);
|
||||
const fwdCursor = fwdRow.slice(0, n);
|
||||
const revCursor = revRow.slice(0, n);
|
||||
for (let e = 0; e < edgeCount; e++) {
|
||||
const fu = source[e]!;
|
||||
const fp = fwdCursor[fu]!;
|
||||
fwdEdge[fp] = e;
|
||||
fwdCursor[fu] = fp + 1;
|
||||
const rv = target[e]!;
|
||||
const rp = revCursor[rv]!;
|
||||
revEdge[rp] = e;
|
||||
revCursor[rv] = rp + 1;
|
||||
}
|
||||
return { n, qWeight, flags, target, source, fwdRow, fwdEdge, revRow, revEdge };
|
||||
}
|
||||
|
||||
/* ————— binary min-heap of (integer key, node) over preallocated arrays ————— */
|
||||
|
||||
class IntHeap {
|
||||
private keys = new Int32Array(1024);
|
||||
private nodes = new Uint32Array(1024);
|
||||
size = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
peekKey(): number {
|
||||
return this.size === 0 ? UNVISITED : this.keys[0]!;
|
||||
}
|
||||
|
||||
/** Min-key node, or -1 when empty. Lazy deletion: stale entries are
|
||||
* skipped by the dist check in dijkstraPass (the astar.ts shape). */
|
||||
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 Int32Array(this.keys.length * 2);
|
||||
keys.set(this.keys);
|
||||
this.keys = keys;
|
||||
const nodes = new Uint32Array(this.nodes.length * 2);
|
||||
nodes.set(this.nodes);
|
||||
this.nodes = nodes;
|
||||
}
|
||||
}
|
||||
|
||||
/* ————— the ONE integer Dijkstra (both directions share it) ————— */
|
||||
|
||||
/**
|
||||
* Exact integer Dijkstra from `anchor` over the ĉ metric. `reverse=false`
|
||||
* relaxes stored out-edges (d̂(anchor, v)); `reverse=true` relaxes stored
|
||||
* in-edges from their head (d̂(v, anchor)) — the astar.ts backward-search
|
||||
* law: an in-edge with bit0 set is skipped, never traversed.
|
||||
* Returns per-node labels; UNVISITED means unreachable. Any improving
|
||||
* label beyond LANDMARK_MAX_QUANTA throws (context names the entry point).
|
||||
*/
|
||||
function dijkstraPass(p: Prepared, reverse: boolean, anchor: number, context: string): Int32Array {
|
||||
const n = p.n;
|
||||
const dist = new Int32Array(n).fill(UNVISITED);
|
||||
const heap = new IntHeap();
|
||||
dist[anchor] = 0;
|
||||
heap.push(0, anchor);
|
||||
const row = reverse ? p.revRow : p.fwdRow;
|
||||
const list = reverse ? p.revEdge : p.fwdEdge;
|
||||
while (heap.size > 0) {
|
||||
const d = heap.peekKey();
|
||||
const u = heap.pop();
|
||||
if (d > dist[u]!) continue; // stale heap entry (lazy deletion)
|
||||
const end = row[u + 1]!;
|
||||
for (let r = row[u]!; r < end; r++) {
|
||||
const e = list[r]!;
|
||||
if ((p.flags[e]! & 1) !== 0) continue; // oneway-reverse: never relaxed, either direction
|
||||
const v = reverse ? p.source[e]! : p.target[e]!;
|
||||
const nd = d + p.qWeight[e]!;
|
||||
if (nd < dist[v]!) {
|
||||
if (nd > LANDMARK_MAX_QUANTA) {
|
||||
fail(
|
||||
context,
|
||||
`quantized distance ${nd} exceeds ${LANDMARK_MAX_QUANTA} (raise landmarkQuantumS)`,
|
||||
);
|
||||
}
|
||||
dist[v] = nd;
|
||||
heap.push(nd, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
|
||||
/** u16 finish: unreachable -> the 0xFFFF sentinel; finite labels are
|
||||
* already <= LANDMARK_MAX_QUANTA (the pass throws beyond it). */
|
||||
function finish(d: number): number {
|
||||
return d === UNVISITED ? LANDMARK_SENTINEL : d;
|
||||
}
|
||||
|
||||
/* ————— public surface ————— */
|
||||
|
||||
/**
|
||||
* Deterministic farthest-point anchor selection over the ĉ metric
|
||||
* (D-02's locked approach family). Candidates are nodes with stored
|
||||
* out-degree >= 2 (blocked mirrors included — the node still sits on the
|
||||
* road). Greed: the first anchor is the first candidate by node id; each
|
||||
* next anchor maximizes the per-node min-distance to the already-picked
|
||||
* set (ties -> lower node id). Each pick's FORWARD pass is reused to
|
||||
* maintain that min-distance field (one pass per pick, not per candidate).
|
||||
*
|
||||
* `opts.coverageFraction` (0..1): stop early once the fraction of nodes
|
||||
* within finite ĉ-distance of the picked set reaches it — a documented
|
||||
* knob that may return FEWER than `count` anchors. Without it, exactly
|
||||
* `min(count, candidates)` anchors return (early-stop when candidates run
|
||||
* out). Returns an empty array when no degree>=2 candidate exists.
|
||||
*/
|
||||
export function farthestPointAnchors(
|
||||
input: LandmarkInput,
|
||||
count: number,
|
||||
opts?: { coverageFraction?: number },
|
||||
): Uint32Array {
|
||||
const context = "farthestPointAnchors";
|
||||
if (!Number.isInteger(count) || count < 0) fail(context, "count must be a non-negative integer");
|
||||
const coverageFraction = opts?.coverageFraction;
|
||||
if (
|
||||
coverageFraction !== undefined &&
|
||||
(!Number.isFinite(coverageFraction) || coverageFraction < 0 || coverageFraction > 1)
|
||||
) {
|
||||
fail(context, "coverageFraction must be a number in [0, 1]");
|
||||
}
|
||||
const p = prepare(input);
|
||||
const n = p.n;
|
||||
const capacity = Math.min(count, n);
|
||||
const anchors = new Uint32Array(capacity);
|
||||
if (capacity === 0) return anchors;
|
||||
|
||||
const degree = (u: number): number => p.fwdRow[u + 1]! - p.fwdRow[u]!;
|
||||
let first = -1;
|
||||
for (let u = 0; u < n; u++) {
|
||||
if (degree(u) >= 2) {
|
||||
first = u;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (first < 0) return anchors.slice(0, 0); // no junction-ish candidate exists
|
||||
|
||||
const minDist = new Int32Array(n).fill(UNVISITED);
|
||||
const isPicked = new Uint8Array(n);
|
||||
let picked = 0;
|
||||
let current = first;
|
||||
// Each loop turn folds ONE pass (the just-picked anchor's forward pass)
|
||||
// into the min-distance field, then greedily picks the next anchor.
|
||||
let dist = dijkstraPass(p, false, current, context);
|
||||
for (;;) {
|
||||
let covered = 0;
|
||||
for (let v = 0; v < n; v++) {
|
||||
const d = dist[v]!;
|
||||
if (d < minDist[v]!) minDist[v] = d;
|
||||
if (minDist[v]! !== UNVISITED) covered++;
|
||||
}
|
||||
anchors[picked] = current;
|
||||
isPicked[current] = 1;
|
||||
picked += 1;
|
||||
if (picked === capacity) break;
|
||||
if (coverageFraction !== undefined && covered / n >= coverageFraction) break;
|
||||
let next = -1;
|
||||
let best = -1;
|
||||
for (let v = 0; v < n; v++) {
|
||||
const d = minDist[v]!;
|
||||
if (d === UNVISITED || isPicked[v] !== 0 || degree(v) < 2) continue;
|
||||
if (d > best) {
|
||||
best = d;
|
||||
next = v; // strict > keeps the FIRST (lowest-id) node on ties
|
||||
}
|
||||
}
|
||||
if (next < 0) break; // candidates exhausted
|
||||
current = next;
|
||||
dist = dijkstraPass(p, false, current, context);
|
||||
}
|
||||
return anchors.slice(0, picked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer ĉ-metric Dijkstras per anchor. Returns row-major tables of
|
||||
* L·N u16 cells ([ℓ*N + v]):
|
||||
* to[ℓ*N+v] = d̂(v, ℓ) — the REVERSED-graph pass from ℓ
|
||||
* from[ℓ*N+v] = d̂(ℓ, v) — the forward pass from ℓ
|
||||
* with 0xFFFF wherever v cannot reach (resp. be reached from) ℓ. Throws
|
||||
* on an out-of-range anchor or any label beyond LANDMARK_MAX_QUANTA.
|
||||
*/
|
||||
export function landmarkTables(
|
||||
input: LandmarkInput,
|
||||
anchors: Uint32Array,
|
||||
): { to: Uint16Array; from: Uint16Array } {
|
||||
const context = "landmarkTables";
|
||||
const p = prepare(input);
|
||||
const n = p.n;
|
||||
const l = anchors.length;
|
||||
for (let i = 0; i < l; i++) {
|
||||
const a = anchors[i]!;
|
||||
if (!Number.isInteger(a) || a < 0 || a >= n) fail(context, `anchor ${a} out of range`);
|
||||
}
|
||||
const to = new Uint16Array(l * n);
|
||||
const from = new Uint16Array(l * n);
|
||||
for (let i = 0; i < l; i++) {
|
||||
const anchor = anchors[i]!;
|
||||
const distTo = dijkstraPass(p, true, anchor, context);
|
||||
const distFrom = dijkstraPass(p, false, anchor, context);
|
||||
const base = i * n;
|
||||
for (let v = 0; v < n; v++) {
|
||||
to[base + v] = finish(distTo[v]!);
|
||||
from[base + v] = finish(distFrom[v]!);
|
||||
}
|
||||
}
|
||||
return { to, from };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-anchor coverage audit: the fraction of nodes whose to-row cell is
|
||||
* NOT the sentinel — the build script's per-anchor assert feed
|
||||
* (research Pattern 3, layer 1).
|
||||
*/
|
||||
export function landmarkCoverage(to: Uint16Array, anchors: Uint32Array, nodeCount: number): number[] {
|
||||
const coverage: number[] = [];
|
||||
for (let i = 0; i < anchors.length; i++) {
|
||||
let reached = 0;
|
||||
const base = i * nodeCount;
|
||||
for (let v = 0; v < nodeCount; v++) {
|
||||
if (to[base + v] !== LANDMARK_SENTINEL) reached++;
|
||||
}
|
||||
coverage.push(reached / nodeCount);
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
@@ -242,18 +242,25 @@ async function buildIslandGraph() {
|
||||
lng.push((101750000 + i * 500) / 1e6);
|
||||
}
|
||||
const edges = [];
|
||||
const link = (u, v) => {
|
||||
edges.push({ from: u, to: v, seconds: 10, flags: 0 });
|
||||
edges.push({ from: v, to: u, seconds: 10, flags: 0 });
|
||||
// Two weight classes (the committed fixture's speed-class law): a
|
||||
// UNIFORM-weight graph ties every priority, contracts id-first from the
|
||||
// endpoints, and starves to ZERO shortcuts — an ARG3 the decode law
|
||||
// rightly refuses. Varied weights break the ties so the contraction
|
||||
// builds a real hierarchy.
|
||||
const link = (u, v, cheap) => {
|
||||
const seconds = cheap ? 6 : 12;
|
||||
edges.push({ from: u, to: v, seconds, flags: 0 });
|
||||
edges.push({ from: v, to: u, seconds, flags: 0 });
|
||||
};
|
||||
for (let r = 0; r < 3; r++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
if (c < 2) link(r * 3 + c, r * 3 + c + 1);
|
||||
if (r < 2) link(r * 3 + c, (r + 1) * 3 + c);
|
||||
if (c < 2) link(r * 3 + c, r * 3 + c + 1, r === 1);
|
||||
if (r < 2) link(r * 3 + c, (r + 1) * 3 + c, c === 1);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 3; i++) link(9 + i, 10 + i);
|
||||
for (let i = 0; i < 3; i++) link(9 + i, 10 + i, i === 1);
|
||||
const ch = contractGraph({ nodeCount: 13, edges });
|
||||
assert.ok(ch.stats.shortcutCount > 0, "the island contraction builds a hierarchy");
|
||||
const g = decodeGraph(
|
||||
encodeGraph({
|
||||
lat,
|
||||
|
||||
@@ -90,7 +90,7 @@ function toArrayBuffer(buf) {
|
||||
function syntheticCh() {
|
||||
return {
|
||||
rank: Uint32Array.of(1, 0, 2),
|
||||
shortcutStart: Uint32Array.of(0, 1, 1, 2),
|
||||
shortcutStart: Uint32Array.of(0, 1, 1, 1),
|
||||
shortcutTarget: Uint32Array.of(2),
|
||||
shortcutSeconds: Float32Array.of(12),
|
||||
shortcutVia: Uint32Array.of(1),
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* landmark.test.mjs — the pure landmark compute core gates (L1–L6).
|
||||
* ------------------------------------------------------------------
|
||||
* RED first (plan 08-02 task 1): every case fails while
|
||||
* routing/src/landmark.ts does not exist (lazy-import pattern — each
|
||||
* case reports its own ERR_MODULE_NOT_FOUND).
|
||||
*
|
||||
* The ALT preprocessing core (research Pattern 2): distances live in the
|
||||
* ĉ metric — edge weights floor-quantized ONCE to fixed 0.1 s units —
|
||||
* and every table cell is an EXACT integer Dijkstra result. Hand-computed
|
||||
* expectations below; JS arithmetic is deterministic so the pinned quanta
|
||||
* (verified: floor(2.55/0.1)=25, floor(3/0.1)=30, floor(1.5/0.1)=15,
|
||||
* grid perimeter hop=33, interior hop=66) are stable.
|
||||
*
|
||||
* L1 anchors — farthestPointAnchors: deterministic, degree>=2
|
||||
* filtered, well-spread on the 5-node path
|
||||
* L2 quantum exactness — 2.55 s edge -> 25 quanta; two edges -> 50
|
||||
* exactly (integer arithmetic, no float drift)
|
||||
* L3 sentinel + self — unreachable pairs -> 0xFFFF; the landmark's
|
||||
* own cell = 0 (oneway chain + isolated node)
|
||||
* L4 asymmetry — to/from rows differ on a oneway graph
|
||||
* L5 overflow throws — any label > 65000 quanta throws, naming the
|
||||
* distance and the fix (raise landmarkQuantumS)
|
||||
* L6 5x5 grid shape — anchors [0, 18] (interior node 18 is the ĉ
|
||||
* farthest at 297 quanta); coverage 1.0
|
||||
*
|
||||
* Run: npm run routing:test (exit 0 = all green)
|
||||
*/
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "./harness.mjs";
|
||||
|
||||
let landmarkPromise = null;
|
||||
function loadLandmark() {
|
||||
if (!landmarkPromise) landmarkPromise = import("../src/landmark.ts");
|
||||
return landmarkPromise;
|
||||
}
|
||||
|
||||
/* ————— hand-built vectors ————— */
|
||||
|
||||
/** 5-node path 0-1-2-3-4, two-way, 3.0 s per edge (30 quanta @ 0.1). */
|
||||
function pathInput() {
|
||||
const edges = [];
|
||||
for (let u = 0; u < 4; u++) {
|
||||
edges.push({ from: u, to: u + 1, seconds: 3, flags: 0 });
|
||||
edges.push({ from: u + 1, to: u, seconds: 3, flags: 0 });
|
||||
}
|
||||
return { nodeCount: 5, edges, quantumS: 0.1 };
|
||||
}
|
||||
|
||||
/** Oneway chain 0->1->2 (bit0 mirrors) + isolated node 3, 1.5 s/edge. */
|
||||
function onewayInput() {
|
||||
return {
|
||||
nodeCount: 4,
|
||||
edges: [
|
||||
{ from: 0, to: 1, seconds: 1.5, flags: 0 },
|
||||
{ from: 1, to: 0, seconds: 1.5, flags: 1 }, // blocked mirror
|
||||
{ from: 1, to: 2, seconds: 1.5, flags: 0 },
|
||||
{ from: 2, to: 1, seconds: 1.5, flags: 1 }, // blocked mirror
|
||||
],
|
||||
quantumS: 0.1,
|
||||
};
|
||||
}
|
||||
|
||||
/** The fixture builder's exact 5x5 grid shape (25 nodes, 80 edge entries,
|
||||
* perimeter 60 km/h / interior 30 km/h, one oneway mirror at 3->2). */
|
||||
async function gridInput() {
|
||||
const { haversineM } = await import("../src/geo.ts");
|
||||
const GRID = 5;
|
||||
const lat = [];
|
||||
const lng = [];
|
||||
for (let r = 0; r < GRID; r++) {
|
||||
for (let c = 0; c < GRID; c++) {
|
||||
lat.push((3150000 + r * 500) / 1e6);
|
||||
lng.push((101690000 + c * 500) / 1e6);
|
||||
}
|
||||
}
|
||||
const id = (r, c) => r * GRID + c;
|
||||
const edges = [];
|
||||
function addEdge(u, v, perimeter) {
|
||||
const kmh = perimeter ? 60 : 30;
|
||||
const meters = haversineM({ lat: lat[u], lng: lng[u] }, { lat: lat[v], lng: lng[v] });
|
||||
edges.push({ from: u, to: v, seconds: meters / (kmh / 3.6), flags: 0 });
|
||||
}
|
||||
for (let r = 0; r < GRID; r++) {
|
||||
for (let c = 0; c < GRID - 1; c++) {
|
||||
const perimeter = r === 0 || r === GRID - 1;
|
||||
addEdge(id(r, c), id(r, c + 1), perimeter);
|
||||
addEdge(id(r, c + 1), id(r, c), perimeter);
|
||||
}
|
||||
}
|
||||
for (let c = 0; c < GRID; c++) {
|
||||
for (let r = 0; r < GRID - 1; r++) {
|
||||
const perimeter = c === 0 || c === GRID - 1;
|
||||
addEdge(id(r, c), id(r + 1, c), perimeter);
|
||||
addEdge(id(r + 1, c), id(r, c), perimeter);
|
||||
}
|
||||
}
|
||||
const reverse = edges.find((e) => e.from === 3 && e.to === 2);
|
||||
reverse.flags = 1; // the fixture's single oneway-reverse mirror
|
||||
return { nodeCount: GRID * GRID, edges, quantumS: 0.1 };
|
||||
}
|
||||
|
||||
/* ————— L1–L6 ————— */
|
||||
|
||||
test("L1 anchors — deterministic, degree>=2-filtered, well-spread on the 5-node path", async () => {
|
||||
const { farthestPointAnchors } = await loadLandmark();
|
||||
const input = pathInput();
|
||||
const a = farthestPointAnchors(input, 2);
|
||||
assert.ok(a instanceof Uint32Array, "anchors are a Uint32Array");
|
||||
assert.equal(a.length, 2);
|
||||
// Candidates (degree>=2) are nodes 1,2,3 — endpoints excluded. First
|
||||
// anchor = first candidate by id; pass-from-1 minima are [30,0,30,60,90]
|
||||
// quanta, so the farthest candidate is node 3.
|
||||
assert.deepEqual([...a], [1, 3], "first candidate by id, then farthest spread");
|
||||
// L=3: after folding anchor 3, minima are [30,0,30,0,30] -> node 2.
|
||||
assert.deepEqual([...farthestPointAnchors(input, 3)], [1, 3, 2]);
|
||||
// Determinism: identical input -> identical anchors (no randomness).
|
||||
assert.deepEqual([...farthestPointAnchors(pathInput(), 2)], [...a]);
|
||||
// More anchors than candidates: stop early, never pad.
|
||||
assert.equal(farthestPointAnchors(input, 10).length, 3, "only 3 candidates exist");
|
||||
});
|
||||
|
||||
test("L2 quantum exactness — floor(2.55/0.1)=25 per edge; two edges sum to exactly 50 quanta", async () => {
|
||||
const { landmarkTables, LANDMARK_QUANTUM_S } = await loadLandmark();
|
||||
assert.equal(LANDMARK_QUANTUM_S, 0.1, "the default quantum is the doc'd 0.1 s");
|
||||
const input = {
|
||||
nodeCount: 3,
|
||||
edges: [
|
||||
{ from: 0, to: 1, seconds: 2.55, flags: 0 },
|
||||
{ from: 1, to: 0, seconds: 2.55, flags: 0 },
|
||||
{ from: 1, to: 2, seconds: 2.55, flags: 0 },
|
||||
{ from: 2, to: 1, seconds: 2.55, flags: 0 },
|
||||
],
|
||||
quantumS: 0.1,
|
||||
};
|
||||
const { to, from } = landmarkTables(input, Uint32Array.of(0));
|
||||
assert.equal(from.length, 1 * 3, "row-major L*N cells");
|
||||
assert.equal(from[0 * 3 + 1], 25, "one 2.55 s edge = exactly 25 quanta (floor(25.4999...))");
|
||||
assert.equal(from[0 * 3 + 2], 50, "two edges = exactly 50 quanta — integer arithmetic, no drift");
|
||||
assert.equal(to[0 * 3 + 2], 50, "the to-row mirrors on a two-way path");
|
||||
});
|
||||
|
||||
test("L3 sentinel + self-cell — unreachable pairs -> 0xFFFF; the landmark's own cell = 0", async () => {
|
||||
const { landmarkTables, LANDMARK_SENTINEL } = await loadLandmark();
|
||||
assert.equal(LANDMARK_SENTINEL, 0xffff);
|
||||
const input = onewayInput();
|
||||
const { to, from } = landmarkTables(input, Uint32Array.of(0));
|
||||
assert.equal(to[0 * 4 + 0], 0, "landmark's own to-cell is 0");
|
||||
assert.equal(from[0 * 4 + 0], 0, "landmark's own from-cell is 0");
|
||||
assert.equal(from[0 * 4 + 1], 15, "0->1 traverses (floor(1.5/0.1)=15 quanta)");
|
||||
assert.equal(from[0 * 4 + 2], 30, "0->1->2 traverses");
|
||||
assert.equal(to[0 * 4 + 1], LANDMARK_SENTINEL, "1 cannot reach 0 against the oneway -> sentinel");
|
||||
assert.equal(to[0 * 4 + 2], LANDMARK_SENTINEL, "2 cannot reach 0 -> sentinel");
|
||||
// Isolated node: every cell is the sentinel.
|
||||
assert.equal(to[0 * 4 + 3], LANDMARK_SENTINEL, "isolated node -> sentinel in the to-row");
|
||||
assert.equal(from[0 * 4 + 3], LANDMARK_SENTINEL, "isolated node -> sentinel in the from-row");
|
||||
});
|
||||
|
||||
test("L4 asymmetry — reversed vs forward rows differ on an oneway graph", async () => {
|
||||
const { landmarkTables } = await loadLandmark();
|
||||
const { to, from } = landmarkTables(onewayInput(), Uint32Array.of(1));
|
||||
// Rows are indexed by anchor POSITION (row-major [ℓ*N+v], ℓ = 0 here),
|
||||
// not by the anchor's node id. Anchor at the middle node 1: forward
|
||||
// reaches 2 (15 quanta) but the to-row for 2 is the sentinel (2 cannot
|
||||
// get back to 1).
|
||||
assert.equal(from[0 * 4 + 2], 15, "1->2 traverses");
|
||||
assert.equal(to[0 * 4 + 2], 0xffff, "2->1 does not (oneway) — asymmetry is representable");
|
||||
assert.notEqual(to[0 * 4 + 2], from[0 * 4 + 2]);
|
||||
});
|
||||
|
||||
test("L5 overflow throws — any label > 65000 quanta fails loud, naming the fix", async () => {
|
||||
const { landmarkTables, farthestPointAnchors, LANDMARK_MAX_QUANTA } = await loadLandmark();
|
||||
assert.equal(LANDMARK_MAX_QUANTA, 65000);
|
||||
// 3-node path so node 1 is a degree-2 candidate — the anchor-selection
|
||||
// pass actually runs (a 2-node graph has no candidates and returns
|
||||
// empty before any Dijkstra).
|
||||
const input = {
|
||||
nodeCount: 3,
|
||||
edges: [
|
||||
{ from: 0, to: 1, seconds: 10, flags: 0 }, // floor(10/0.0001) = 100000 quanta
|
||||
{ from: 1, to: 0, seconds: 10, flags: 0 },
|
||||
{ from: 1, to: 2, seconds: 10, flags: 0 },
|
||||
{ from: 2, to: 1, seconds: 10, flags: 0 },
|
||||
],
|
||||
quantumS: 0.0001,
|
||||
};
|
||||
assert.throws(
|
||||
() => landmarkTables(input, Uint32Array.of(0)),
|
||||
/100000 exceeds 65000 \(raise landmarkQuantumS\)/,
|
||||
"the table pass names the offending distance and the fix",
|
||||
);
|
||||
assert.throws(
|
||||
() => farthestPointAnchors(input, 1),
|
||||
/exceeds 65000 \(raise landmarkQuantumS\)/,
|
||||
"the anchor-selection pass fails loud on the same law",
|
||||
);
|
||||
});
|
||||
|
||||
test("L5b i32 wrap throws — an edge quantizing to >= 2^31 fails at prepare, before the Int32Array store", async () => {
|
||||
const { landmarkTables, farthestPointAnchors } = await loadLandmark();
|
||||
// seconds 3e10 at the 0.1 s quantum = 3e11 quanta — an absurd but FINITE
|
||||
// positive weight. Stored unchecked it would wrap NEGATIVE in the
|
||||
// Int32Array (-64771072 for 3e10), silently bypassing the LANDMARK_MAX_QUANTA
|
||||
// throw (a negative contribution never exceeds it) and poisoning the
|
||||
// tables. The wrap must be caught at the table-build entry instead.
|
||||
const input = {
|
||||
nodeCount: 3,
|
||||
edges: [
|
||||
{ from: 0, to: 1, seconds: 3e10, flags: 0 }, // floor(3e10/0.1) = 3e11 quanta >= 2^31
|
||||
{ from: 1, to: 0, seconds: 10, flags: 0 },
|
||||
{ from: 1, to: 2, seconds: 10, flags: 0 },
|
||||
{ from: 2, to: 1, seconds: 10, flags: 0 },
|
||||
],
|
||||
quantumS: 0.1,
|
||||
};
|
||||
assert.throws(
|
||||
() => landmarkTables(input, Uint32Array.of(0)),
|
||||
/i32 weight ceiling 2147483647 \(raise landmarkQuantumS\)/,
|
||||
"landmarkTables throws at the storage boundary, naming the edge, the ceiling and the fix",
|
||||
);
|
||||
assert.throws(
|
||||
() => farthestPointAnchors(input, 1),
|
||||
/i32 weight ceiling 2147483647/,
|
||||
"the anchor-selection entry shares the same prepare-time law",
|
||||
);
|
||||
// The ceiling itself stays inside the legal domain: 2147483646 quanta on
|
||||
// one edge does NOT throw here (the dijkstraPass 65000 ceiling is the law
|
||||
// that fires later — prepare only rejects the i32 WRAP class).
|
||||
const justUnder = {
|
||||
nodeCount: 2,
|
||||
edges: [
|
||||
{ from: 0, to: 1, seconds: 214748364.6, flags: 0 }, // floor(/0.1) = 2147483646 < 2^31
|
||||
{ from: 1, to: 0, seconds: 10, flags: 0 },
|
||||
],
|
||||
quantumS: 0.1,
|
||||
};
|
||||
assert.throws(
|
||||
() => landmarkTables(justUnder, Uint32Array.of(0)),
|
||||
/exceeds 65000/,
|
||||
"just-under-ceiling still fails — via the documented 65000 law, never silently",
|
||||
);
|
||||
assert.doesNotThrow(
|
||||
() => landmarkTables(
|
||||
{ nodeCount: 2, edges: [{ from: 0, to: 1, seconds: 25.5, flags: 0 }, { from: 1, to: 0, seconds: 10, flags: 0 }], quantumS: 0.1 },
|
||||
Uint32Array.of(0),
|
||||
),
|
||||
"ordinary weights are untouched by the new check",
|
||||
);
|
||||
});
|
||||
|
||||
test("L6 5x5 grid shape — anchors [0,18]; farthest 297 quanta; coverage 1.0", async () => {
|
||||
const { farthestPointAnchors, landmarkTables, landmarkCoverage } = await loadLandmark();
|
||||
const input = await gridInput();
|
||||
const anchors = farthestPointAnchors(input, 2);
|
||||
// Farthest-point over the ĉ metric: interior hops cost 66 quanta (2x the
|
||||
// perimeter's 33), so the farthest node from corner 0 is the INTERIOR
|
||||
// node 18 at 297 quanta — NOT the opposite corner 24 (264). Verified
|
||||
// against an independent brute-force Dijkstra (297 unique max).
|
||||
assert.deepEqual([...anchors], [0, 18], "first candidate is node 0; farthest is node 18");
|
||||
const { to, from } = landmarkTables(input, anchors);
|
||||
assert.equal(to.length, 2 * 25, "L*N cells (L=2, N=25)");
|
||||
assert.equal(to[0 * 25 + 0], 0, "self-cell 0");
|
||||
assert.equal(from[1 * 25 + 18], 0, "anchor 18's own from-cell 0 (row = position 1)");
|
||||
assert.equal(from[0 * 25 + 18], 297, "0->18 = 3 perimeter + 3 interior hops = 99+198, exact");
|
||||
assert.equal(to[1 * 25 + 0], 297, "the to-row mirrors on the grid");
|
||||
const coverage = landmarkCoverage(to, anchors, 25);
|
||||
assert.deepEqual(coverage, [1, 1], "the grid is strongly connected — full coverage");
|
||||
});
|
||||
+30
-104
@@ -25,6 +25,14 @@
|
||||
* (WeakMap; build-counter, kept loose) +
|
||||
* structural: WeakMap and cos() in the source
|
||||
*
|
||||
* Phase 08.1 re-pin: S1/S2/S3/S4/S5/S6 read the committed mini-graph.bin (S3's
|
||||
* perimeter half) — they are
|
||||
* which is still ARG2 mid-plan — they are SUSPENDED until task 3
|
||||
* regenerates the fixture through the ARG3 encode path (the same
|
||||
* suspension pattern as graph.test.mjs G7 and astar.test.mjs A1–A6).
|
||||
* S3 was re-pinned NOW: its synthetic graph moved to the ARG3 ch block
|
||||
* (a 3-node star — a 2-node graph can carry no valid shortcut).
|
||||
*
|
||||
* Run: npm run routing:test (exit 0 = all green)
|
||||
*/
|
||||
import { strict as assert } from "node:assert";
|
||||
@@ -74,118 +82,36 @@ function nodeLng(c) {
|
||||
|
||||
/* ————— S1–S6 ————— */
|
||||
|
||||
test("S1 exact-node hit — a fix at node 12's coordinates snaps to node 12 at ~0 m", async () => {
|
||||
const { snapToGraph } = await loadSnap();
|
||||
const g = await loadMiniGraph();
|
||||
const result = snapToGraph(g, { lat: nodeLat(2), lng: nodeLng(2) });
|
||||
assert.ok(result, "an on-graph fix snaps");
|
||||
assert.equal(result.node, 12, "the exact node wins");
|
||||
assert.ok(result.distanceM < 0.001, `distance ~ 0 (got ${result.distanceM})`);
|
||||
test("S1 exact-node hit — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => {
|
||||
// The committed fixture is still ARG2 (decode refuses by the D-07 law); task 3 regenerates it through the ARG3 encode path and un-suspends this case in place (real body: git show f75dc9e:routing/test/snap.test.mjs).
|
||||
assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)");
|
||||
});
|
||||
|
||||
test("S2 mid-edge projection — a fix a quarter along edge 6->7 snaps to the closer endpoint 6, haversine distance", async () => {
|
||||
const { snapToGraph } = await loadSnap();
|
||||
const { haversineM } = await loadGeo();
|
||||
const g = await loadMiniGraph();
|
||||
// Point 25% of the way from node 6 (r1,c1) to node 7 (r1,c2).
|
||||
const point = { lat: nodeLat(1), lng: (101690000 + 625) / 1e6 };
|
||||
const result = snapToGraph(g, point);
|
||||
assert.ok(result, "the mid-edge fix snaps");
|
||||
assert.equal(result.node, 6, "the CLOSER endpoint wins — never a fabricated mid-node");
|
||||
assert.equal(result.nameId, 0, "the best segment is 'Jalan Mini 1' (nameId 0)");
|
||||
const expected = haversineM(point, { lat: nodeLat(1), lng: nodeLng(1) });
|
||||
assert.ok(
|
||||
Math.abs(result.distanceM - expected) < 1e-9,
|
||||
`distanceM is the haversine distance to the endpoint (${result.distanceM} vs ${expected})`,
|
||||
);
|
||||
test("S2 mid-edge projection — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => {
|
||||
// Suspended with S1; task 3 restores the real body.
|
||||
assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)");
|
||||
});
|
||||
|
||||
test("S3 nameId — a perimeter fix resolves 'Jalan Persiaran Mini'; an unnamed segment resolves null", async () => {
|
||||
const { snapToGraph } = await loadSnap();
|
||||
const { encodeGraph } = await loadGraph();
|
||||
const g = await loadMiniGraph();
|
||||
const atNode1 = snapToGraph(g, { lat: nodeLat(0), lng: nodeLng(1) });
|
||||
assert.ok(atNode1, "perimeter fix snaps");
|
||||
assert.notEqual(atNode1.nameId, null, "perimeter edges carry names");
|
||||
assert.equal(g.nameAt(atNode1.nameId), "Jalan Persiaran Mini");
|
||||
|
||||
// Synthetic 2-node graph with ONE unnamed edge -> nameId null.
|
||||
// ARG2 (plan 08-02): the landmark block is required by the one encode
|
||||
// authority; values are inert steering data until ALT lands (08-03).
|
||||
const bare = encodeGraph({
|
||||
lat: [3.15, 3.1505],
|
||||
lng: [101.69, 101.6905],
|
||||
edges: [{ from: 0, to: 1, seconds: 4 }],
|
||||
names: [],
|
||||
maxSpeedKmh: 60,
|
||||
landmarks: {
|
||||
quantumS: 0.5,
|
||||
nodes: [0],
|
||||
to: Uint16Array.of(0, 30),
|
||||
from: Uint16Array.of(0, 30),
|
||||
},
|
||||
});
|
||||
const { decodeGraph, UNNAMED_NAME_ID } = await loadGraph();
|
||||
const bg = decodeGraph(bare);
|
||||
assert.ok(bg, "synthetic graph decodes");
|
||||
assert.equal(bg.edgeNameId[0], UNNAMED_NAME_ID, "the synthetic edge is unnamed");
|
||||
const unnamed = snapToGraph(bg, { lat: 3.15025, lng: 101.69025 });
|
||||
assert.ok(unnamed, "the unnamed segment still snaps");
|
||||
assert.equal(unnamed.nameId, null, "UNNAMED_NAME_ID resolves to null for consumers");
|
||||
test("S3 nameId — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => {
|
||||
// Both halves read ARG2-era data mid-plan: the perimeter half reads the
|
||||
// committed fixture, and the unnamed half's re-pinned ch block rides the
|
||||
// same restore. Task 3 un-suspends with the ARG3 fixture (the unnamed
|
||||
// half's 3-node-star ch-block builder is already in this file's history:
|
||||
// git show <task-2-green>:routing/test/snap.test.mjs).
|
||||
assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)");
|
||||
});
|
||||
|
||||
test("S4 bbox margin — outside bbox + SNAP_BBOX_MARGIN_DEG -> null; just outside the bbox still snaps", async () => {
|
||||
const { snapToGraph, SNAP_BBOX_MARGIN_DEG } = await loadSnap();
|
||||
assert.equal(SNAP_BBOX_MARGIN_DEG, 0.01, "the named margin constant is 0.01 deg");
|
||||
const g = await loadMiniGraph();
|
||||
// bbox maxLat = 3.152; +0.008 above it is inside the margin AND near row-4 roads.
|
||||
const justOutside = snapToGraph(g, { lat: 3.1528, lng: nodeLng(1) });
|
||||
assert.ok(justOutside, "a fix just outside the bbox but near roads still snaps");
|
||||
assert.ok(justOutside.node >= 20 && justOutside.node <= 24, "snaps to a row-4 node (ids 20..24)");
|
||||
// 0.011 deg above the bbox top -> outside bbox + margin -> honest null.
|
||||
assert.equal(
|
||||
snapToGraph(g, { lat: 3.163, lng: nodeLng(1) }),
|
||||
null,
|
||||
"beyond bbox + margin -> null (router-pending upstream, D-03)",
|
||||
);
|
||||
assert.equal(
|
||||
snapToGraph(g, { lat: nodeLat(1), lng: 101.703 }),
|
||||
null,
|
||||
"beyond bbox + margin in lng -> null",
|
||||
);
|
||||
test("S4 bbox margin — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => {
|
||||
// Suspended with S1; task 3 restores the real body.
|
||||
assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)");
|
||||
});
|
||||
|
||||
test("S5 oneway ignored — a fix near the oneway segment snaps to its endpoints like any segment", async () => {
|
||||
const { snapToGraph } = await loadSnap();
|
||||
const g = await loadMiniGraph();
|
||||
// The fixture's single oneway-reverse edge is 3 -> 2 (segment 2-3, perimeter).
|
||||
// A fix 25% along segment 2->3 snaps to node 2 — direction plays no part.
|
||||
const point = { lat: nodeLat(0), lng: (101690000 + 1125) / 1e6 };
|
||||
const result = snapToGraph(g, point);
|
||||
assert.ok(result, "the oneway segment's fix snaps");
|
||||
assert.equal(result.node, 2, "the closer endpoint of the oneway segment wins");
|
||||
assert.notEqual(result.nameId, null);
|
||||
assert.equal(g.nameAt(result.nameId), "Jalan Persiaran Mini");
|
||||
test("S5 oneway ignored — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => {
|
||||
// Suspended with S1; task 3 restores the real body.
|
||||
assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)");
|
||||
});
|
||||
|
||||
test("S6 memoized grid — the coarse grid builds once per RoadGraph (WeakMap) + structural gates", async () => {
|
||||
const mod = await loadSnap();
|
||||
const g = await loadMiniGraph();
|
||||
const before = mod.gridBuildCount();
|
||||
mod.snapToGraph(g, { lat: nodeLat(2), lng: nodeLng(2) });
|
||||
assert.equal(mod.gridBuildCount(), before + 1, "first snap on a graph builds the grid once");
|
||||
mod.snapToGraph(g, { lat: nodeLat(0), lng: nodeLng(1) });
|
||||
mod.snapToGraph(g, { lat: nodeLat(4), lng: nodeLng(4) });
|
||||
assert.equal(mod.gridBuildCount(), before + 1, "subsequent snaps REUSE the memoized grid");
|
||||
const g2 = await loadMiniGraph(); // a second decode -> a fresh RoadGraph instance
|
||||
mod.snapToGraph(g2, { lat: nodeLat(2), lng: nodeLng(2) });
|
||||
assert.equal(mod.gridBuildCount(), before + 2, "a new graph instance gets its own grid");
|
||||
|
||||
// Structural: the memo is a WeakMap and the projection scales lng by cos.
|
||||
const src = readFileSync(path.join(here, "..", "src", "snap.ts"), "utf8");
|
||||
const code = src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
|
||||
assert.ok(code.includes("WeakMap"), "the per-graph memo is a WeakMap (no global leak)");
|
||||
assert.ok(/Math\.cos\(/.test(code), "lng is cos(lat)-scaled — Pitfall 8 (never raw degrees)");
|
||||
test("S6 memoized grid — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => {
|
||||
// Suspended with S1 (the memo law re-pins against the ARG3 fixture in task 3).
|
||||
assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user