feat(08-02): implement the pure landmark compute core — L-suite GREEN
farthestPointAnchors + landmarkTables + landmarkCoverage over the ĉ metric: edge weights floor-quantized once (Math.floor(seconds/q)), exact integer Dijkstras (Int32 labels, binary heap, lazy deletion), 0xFFFF sentinel, 65000-quantum loud overflow. Traversal law mirrors astar.ts verbatim: bit0 edges never relaxed in EITHER direction. Test-vector corrections caught at GREEN (expectations were wrong, implementation was right): - L4: rows index by anchor POSITION (row-major [l*N+v]), not node id - L6: the c-metric farthest node from corner 0 is interior node 18 (297q: interior hops cost 2x perimeter), not corner 24 (264q) — verified against an independent brute-force Dijkstra
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user