diff --git a/routing/test/landmark.test.mjs b/routing/test/landmark.test.mjs new file mode 100644 index 0000000..75ff333 --- /dev/null +++ b/routing/test/landmark.test.mjs @@ -0,0 +1,208 @@ +#!/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, 24]; corner->corner 264 quanta + * (8 perimeter hops x 33); 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)); + // 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]); +}); + +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); + const input = { + nodeCount: 2, + edges: [ + { from: 0, to: 1, seconds: 10, flags: 0 }, // floor(10/0.0001) = 100000 quanta + { from: 1, to: 0, 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("L6 5x5 grid shape — anchors [0,24]; corner->corner 264 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"); + 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"); + const coverage = landmarkCoverage(to, anchors, 25); + assert.deepEqual(coverage, [1, 1], "the grid is strongly connected — full coverage"); +});