diff --git a/routing/src/landmark.ts b/routing/src/landmark.ts new file mode 100644 index 0000000..b55d6fc --- /dev/null +++ b/routing/src/landmark.ts @@ -0,0 +1,387 @@ +/* + * 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 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. + qWeight[e] = Math.floor(edge.seconds / input.quantumS); + 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; +} diff --git a/routing/test/landmark.test.mjs b/routing/test/landmark.test.mjs index 66fd943..c7cd249 100644 --- a/routing/test/landmark.test.mjs +++ b/routing/test/landmark.test.mjs @@ -22,8 +22,8 @@ * 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, 24]; corner->corner 264 quanta - * (8 perimeter hops x 33); coverage 1.0 + * 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) */ @@ -160,11 +160,13 @@ test("L3 sentinel + self-cell — unreachable pairs -> 0xFFFF; the landmark's ow 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)); - // 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[1 * 4 + 2], 15, "1->2 traverses"); - assert.equal(to[1 * 4 + 2], 0xffff, "2->1 does not (oneway) — asymmetry is representable"); - assert.notEqual(to[1 * 4 + 2], from[1 * 4 + 2]); + // 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 () => { @@ -195,19 +197,21 @@ test("L5 overflow throws — any label > 65000 quanta fails loud, naming the fix ); }); -test("L6 5x5 grid shape — anchors [0,24]; corner->corner 264 quanta; coverage 1.0", async () => { +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); - assert.deepEqual([...anchors], [0, 24], "first candidate is node 0; farthest is the opposite corner"); + // 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 + 24], 0, "anchor 24's own from-cell 0"); - // Corner->corner: 8 perimeter hops (row 0 then column 4, 33 quanta each; - // interior hops cost 66 — strictly worse) = 264 exactly. - assert.equal(from[0 * 25 + 24], 264, "0->24 = 8 x 33 perimeter quanta, exact"); - assert.equal(to[1 * 25 + 0], 264, "the to-row mirrors on the grid"); + 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"); });