#!/usr/bin/env node /* * astar.test.mjs — typed-array A* + turn classifier + step derivation (A1–A11). * ----------------------------------------------------------------------------- * RED first (plan 06-01 task 3): behavior cases fail while * routing/src/astar.ts does not exist (lazy-import pattern). * * The correctness gate RESEARCH mandates: A* must equal brute-force * Dijkstra's optimal cost for EVERY (origin, goal) pair of the mini-graph — * the exhaustive 25x25 equivalence sweep (A1). The test-local Dijkstra uses * plain arrays (fine in tests) and the SAME traversal law as routeAstar: * oneway-reverse edges (edgeFlags bit0) are never relaxed. * * A1 exhaustive equivalence — 625 pairs, cost equality + path endpoints * A2 oneway respected — the pair behind the flagged edge routes the * long way (matching Dijkstra); the open * direction is direct * A3 unreachable -> null — honest miss (synthetic disconnected graph) * A4 deriveSteps L-path — 90-deg bearing changes classify left/right; * steps carry name/distanceM/polyline6 * A5 depart + arrive always — straight paths still start "depart" and end * "arrive" * A6 classifyTurn pins — sign convention: compass bearings increase * clockwise, so negative delta = left * (NOTE: the plan's "classifyTurn(0, 40) -> * slight-left" contradicts its own * (0,-90)->left / (0,90)->right cases; the * compass-consistent reading is pinned here) * A7 public surface — index.ts exposes the engine-A exports; * createHttpRoutingSource is LIVE (re-pinned * by 06-04, the plan that added it — the * original pin named this plan as the owner) * A8 structural — no `new Map(` in astar.ts (allocation-free * hot loop: typed arrays only) * A9 ALT island (08-03) — two-component synthetic, anchors HAND-PINNED * inside component A only: pairs inside A route * Dijkstra-equal with real ALT bounds live, * pairs inside B route Dijkstra-equal on the * haversine fallback (per-node sentinel skip), * cross-component pairs resolve null * A10 ALT search-scoped skip — origin AND goal inside the anchorless * component: every l's goal/origin-side * constant is sentinel, all families drop for * the WHOLE search (constants dropped at * search scope, never per-node l-masking), * results still Dijkstra-equal * A11 structural (ALT) — comment-stripped astar.ts consults the * ARG2 landmark tables (landmarkTo/ * landmarkFrom/landmarkQuantumS in CODE, not * comments); `new Map(` still absent * * Run: npm run routing:test (exit 0 = all green) */ import { strict as assert } from "node:assert"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "./harness.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); // routing/test let astarPromise = null; function loadAstar() { if (!astarPromise) astarPromise = import("../src/astar.ts"); return astarPromise; } let graphPromise = null; function loadGraph() { if (!graphPromise) graphPromise = import("../src/graph.ts"); return graphPromise; } function toArrayBuffer(buf) { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } async function loadMiniGraph() { const { decodeGraph } = await loadGraph(); const g = decodeGraph(toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph.bin")))); assert.ok(g, "the committed mini-graph decodes"); return g; } /** Brute-force O(n^2) Dijkstra — plain arrays, SAME traversal law as A*. */ function dijkstra(g, origin, goal) { const dist = new Array(g.nodeCount).fill(Infinity); const done = new Array(g.nodeCount).fill(false); dist[origin] = 0; for (;;) { let u = -1; let best = Infinity; for (let i = 0; i < g.nodeCount; i++) { if (!done[i] && dist[i] < best) { best = dist[i]; u = i; } } if (u === -1 || u === goal) return dist[goal]; done[u] = true; for (let e = g.edgeStart[u]; e < g.edgeStart[u + 1]; e++) { if ((g.edgeFlags[e] & 1) !== 0) continue; // oneway-reverse: never relaxed const v = g.edgeTarget[e]; const nd = dist[u] + g.edgeSeconds[e]; if (nd < dist[v]) dist[v] = nd; } } } /* ————— A9/A10 island fixture (plan 08-03) ————— */ /** * Two-component synthetic: component A is a 3x3 grid (nodes 0-8), component * B a separate 4-node path (nodes 9-12), far apart. `anchors` are HAND-PINNED * by the caller (never derived — A9 pins them inside A only, so every * component-B node is the 0xFFFF sentinel in BOTH table families). Tables * come from the ONE landmark module (landmark.ts); encoding through the ONE * graph authority (encodeGraph) — the same path the real build uses. */ async function buildIslandFixture(anchors) { const { encodeGraph, decodeGraph } = await loadGraph(); const { LANDMARK_QUANTUM_S, landmarkTables } = await import("../src/landmark.ts"); const lat = []; const lng = []; // Component A: 3x3 grid near (3.15, 101.69); integer grid / 1e6 (the // codec's exact-decimal law — no float accumulation). for (let r = 0; r < 3; r++) { for (let c = 0; c < 3; c++) { lat.push((3150000 + r * 500) / 1e6); lng.push((101690000 + c * 500) / 1e6); } } // Component B: a straight 4-node path near (3.20, 101.75) — a different // metro entirely as far as the haversine fallback is concerned. for (let i = 0; i < 4; i++) { lat.push((3200000 + i * 500) / 1e6); lng.push((101750000 + i * 500) / 1e6); } const edges = []; const link = (u, v) => { // 10 s per hop — quantizes to exactly 100 quanta at the 0.1 s unit. edges.push({ from: u, to: v, seconds: 10, flags: 0, nameId: 0 }); edges.push({ from: v, to: u, seconds: 10, flags: 0, nameId: 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); } } for (let i = 0; i < 3; i++) link(9 + i, 10 + i); const { to, from } = landmarkTables( { nodeCount: 13, edges, quantumS: LANDMARK_QUANTUM_S }, anchors, ); const g = decodeGraph( encodeGraph({ lat, lng, edges, names: ["Island Road"], maxSpeedKmh: 60, landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from }, }), ); assert.ok(g, "the island synthetic decodes"); assert.equal(g.nodeCount, 13, "13 nodes: 9 in the anchored grid + 4 on the anchorless path"); return g; } /* ————— A1–A11 ————— */ test("A1 exhaustive equivalence — A* matches brute-force Dijkstra on EVERY 25x25 node pair", async () => { const { routeAstar } = await loadAstar(); const g = await loadMiniGraph(); let pairs = 0; let reachable = 0; for (let o = 0; o < g.nodeCount; o++) { for (let t = 0; t < g.nodeCount; t++) { pairs++; const optimal = dijkstra(g, o, t); const result = routeAstar(g, o, t); if (optimal === Infinity) { assert.equal(result, null, `pair ${o}->${t}: unreachable must resolve null, never a path`); continue; } reachable++; assert.ok(result, `pair ${o}->${t}: reachable must route`); assert.ok( Math.abs(result.seconds - optimal) < 1e-6, `pair ${o}->${t}: A* cost ${result.seconds} vs Dijkstra ${optimal}`, ); assert.equal(result.nodes[0], o, `pair ${o}->${t}: path starts at origin`); assert.equal(result.nodes[result.nodes.length - 1], t, `pair ${o}->${t}: path ends at goal`); assert.ok(result.distanceM > 0 || o === t, `pair ${o}->${t}: distanceM summed over the path`); } } assert.equal(pairs, 625, "the full 25x25 sweep ran"); assert.ok(reachable > 600, `the grid is densely connected (${reachable} reachable pairs)`); }); test("A2 oneway respected — the flagged edge is never traversed; the open direction is direct", async () => { const { routeAstar } = await loadAstar(); const g = await loadMiniGraph(); // The fixture's single oneway-reverse edge is 3 -> 2 (perimeter, row 0). // Routing AGAINST it (3 -> 2) must take the long way around the block. const against = routeAstar(g, 3, 2); assert.ok(against, "3->2 routes via the detour"); assert.equal( against.nodes.length > 2, true, `the flagged 1-edge path must NOT be used (path: ${against.nodes.join("->")})`, ); assert.ok( Math.abs(against.seconds - dijkstra(g, 3, 2)) < 1e-6, "the detour cost matches Dijkstra", ); // The open direction is the direct edge. const with_flow = routeAstar(g, 2, 3); assert.ok(with_flow, "2->3 routes"); assert.equal(with_flow.nodes.length, 2, "2->3 is the direct single edge"); assert.ok(Math.abs(with_flow.seconds - dijkstra(g, 2, 3)) < 1e-6); }); test("A3 unreachable -> null — a disconnected pair resolves the honest miss", async () => { const { routeAstar } = await loadAstar(); const { encodeGraph, decodeGraph } = await loadGraph(); // Two disconnected components: 0<->1 and 2<->3. const g = decodeGraph( encodeGraph({ lat: [3.15, 3.1505, 3.16, 3.1605], lng: [101.69, 101.6905, 101.69, 101.6905], edges: [ { from: 0, to: 1, seconds: 4, nameId: 0 }, { from: 1, to: 0, seconds: 4, nameId: 0 }, { from: 2, to: 3, seconds: 4, nameId: 0 }, { from: 3, to: 2, seconds: 4, nameId: 0 }, ], names: ["Isolated Road"], maxSpeedKmh: 60, // ARG2 (plan 08-02): every encode carries a landmark block — inert // steering data until ALT lands (08-03); hand values keep this case // focused on the unreachable-pair law. Sentinels mark component 2. landmarks: { quantumS: 0.5, nodes: [0], to: Uint16Array.of(0, 30, 0xffff, 0xffff), from: Uint16Array.of(0, 30, 0xffff, 0xffff), }, }), ); assert.ok(g, "synthetic graph decodes"); assert.equal(dijkstra(g, 0, 2), Infinity, "Dijkstra agrees: unreachable"); assert.equal(routeAstar(g, 0, 2), null, "unreachable -> null (never a fabricated path)"); const within = routeAstar(g, 0, 1); assert.ok(within, "the connected pair routes"); assert.deepEqual(within.nodes, [0, 1]); }); test("A4 deriveSteps L-path — 90-deg turns classify, steps carry name/distance/polyline6", async () => { const { routeAstar, deriveSteps } = await loadAstar(); const { decodePolyline6 } = await import("../src/polyline.ts"); const g = await loadMiniGraph(); // 0=(r0,c0) -> 1=(r0,c1) -> 6=(r1,c1) -> 7=(r1,c2): east, then north // (increasing row = increasing lat), then east. const steps = deriveSteps(g, [0, 1, 6, 7]); const kinds = steps.map((s) => s.kind); assert.deepEqual(kinds, ["depart", "left", "right", "arrive"]); assert.equal(steps[0].name, "Jalan Persiaran Mini", "step 0 rides the perimeter"); assert.equal(steps[1].name, "Jalan Mini 2", "the left turn turns onto Jalan Mini 2"); assert.equal(steps[2].name, "Jalan Mini 1", "the right turn turns onto Jalan Mini 1"); for (const step of steps) { if (step.kind === "arrive") continue; assert.ok(step.distanceM > 0, `${step.kind} step has positive distance`); const points = decodePolyline6(step.geometry); assert.equal(points.length, 2, `${step.kind} step geometry decodes to its two endpoints`); } const arrive = steps[steps.length - 1]; assert.equal(decodePolyline6(arrive.geometry).length, 1, "the arrive step anchors the goal node"); // round-trip: a full route's steps derive from A*'s own node path const routed = routeAstar(g, 0, 11); assert.ok(routed, "0->11 routes"); const routeSteps = deriveSteps(g, routed.nodes); assert.equal(routeSteps[0].kind, "depart", "route-derived steps start with depart"); assert.equal(routeSteps[routeSteps.length - 1].kind, "arrive", "and end with arrive"); const total = routeSteps .filter((s) => s.kind !== "arrive") .reduce((sum, s) => sum + s.distanceM, 0); assert.ok( Math.abs(total - routed.distanceM) < 1e-6, `step distances sum to the route distance (${total} vs ${routed.distanceM})`, ); }); test("A5 depart + arrive always — a straight same-name path still yields [depart, arrive]", async () => { const { deriveSteps } = await loadAstar(); const g = await loadMiniGraph(); const steps = deriveSteps(g, [0, 1]); assert.deepEqual( steps.map((s) => s.kind), ["depart", "arrive"], "even the simplest path starts depart and ends arrive", ); assert.equal(steps[0].name, "Jalan Persiaran Mini"); assert.ok(steps[0].distanceM > 0); assert.equal(deriveSteps(g, []).length, 0, "an empty path derives no steps"); assert.deepEqual( deriveSteps(g, [12]).map((s) => s.kind), ["arrive"], "a trivial single-node path is just the arrive step", ); }); test("A6 classifyTurn pins — compass convention: negative bearing delta = left", async () => { const { classifyTurn, TURN_STRAIGHT_MAX_DEG, TURN_SLIGHT_MAX_DEG, TURN_UTURN_MIN_DEG } = await loadAstar(); assert.equal(TURN_STRAIGHT_MAX_DEG, 25); assert.equal(TURN_SLIGHT_MAX_DEG, 60); assert.equal(TURN_UTURN_MIN_DEG, 150); assert.equal(classifyTurn(0, -90), "left"); assert.equal(classifyTurn(0, 90), "right"); assert.equal(classifyTurn(0, 20), "straight"); assert.equal(classifyTurn(0, -20), "straight"); // The plan's "(0, 40) -> slight-left" contradicts its own (0,-90)->left / // (0,90)->right cases; the compass-consistent reading is pinned: assert.equal(classifyTurn(0, 40), "slight-right"); assert.equal(classifyTurn(0, -40), "slight-left"); assert.equal(classifyTurn(0, 175), "uturn"); assert.equal(classifyTurn(0, -175), "uturn"); assert.equal(classifyTurn(0, 180), "uturn"); // boundaries: 25 stays straight, 61 is a full turn, 150 is uturn assert.equal(classifyTurn(0, 25), "straight"); assert.equal(classifyTurn(0, 26), "slight-right"); assert.equal(classifyTurn(0, 60), "slight-right"); assert.equal(classifyTurn(0, 61), "right"); assert.equal(classifyTurn(0, 149), "right"); assert.equal(classifyTurn(0, 150), "uturn"); // wrap-around normalization assert.equal(classifyTurn(350, 10), "straight", "20 deg across north is straight"); assert.equal(classifyTurn(350, 80), "right", "90 deg across north is a right"); }); test("A7 public surface — index.ts exposes engine A + the engine-a HTTP source (re-pinned 06-04)", async () => { const mod = await import("../src/index.ts"); for (const name of [ "decodeGraph", "encodeGraph", "GRAPH_MAGIC", "GRAPH_VERSION", "snapToGraph", "SNAP_BBOX_MARGIN_DEG", "routeAstar", "deriveSteps", "classifyTurn", "TURN_STRAIGHT_MAX_DEG", "TURN_SLIGHT_MAX_DEG", "TURN_UTURN_MIN_DEG", "createHttpRoutingSource", ]) { assert.notEqual(mod[name], undefined, `index.ts must export ${name}`); } assert.equal(typeof mod.createHttpRoutingSource, "function", "backend #2's client seam is live (D-01, 06-04)"); }); test("A8 structural — no `new Map(` in astar.ts (typed arrays only in the hot loop)", () => { const src = readFileSync(path.join(here, "..", "src", "astar.ts"), "utf8"); const code = src .replace(/\/\*[\s\S]*?\*\//g, " ") .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); assert.ok(!code.includes("new Map("), "no Map allocation — the hot loop is typed arrays"); }); test("A9 ALT island — anchors pinned in component A only; EVERY pair still Dijkstra-equal", async () => { const { routeAstar } = await loadAstar(); const g = await buildIslandFixture(Uint32Array.of(0, 8)); const n = g.nodeCount; // Fixture law (the island precondition): every component-B node is the // 0xFFFF sentinel in BOTH families for every landmark — 0xFFFF is DATA. for (let l = 0; l < g.landmarkCount; l++) { for (let v = 9; v < n; v++) { assert.equal(g.landmarkTo[l * n + v], 0xffff, `B node ${v} cannot reach anchor ${l}`); assert.equal(g.landmarkFrom[l * n + v], 0xffff, `anchor ${l} cannot reach B node ${v}`); } } // The sweep: A-internal pairs run with real ALT bounds live, B-internal // pairs run on the haversine fallback (per-node sentinel skip), cross // pairs resolve the honest miss. Seconds-only assertions — never // node-path equality (D-03's tie law). let pairs = 0; let crossNulls = 0; for (let o = 0; o < n; o++) { for (let t = 0; t < n; t++) { pairs++; const optimal = dijkstra(g, o, t); const result = routeAstar(g, o, t); const inA = o < 9 && t < 9; const inB = o >= 9 && t >= 9; if (!inA && !inB) { assert.equal(optimal, Infinity, `cross pair ${o}->${t}: Dijkstra agrees unreachable`); assert.equal(result, null, `cross pair ${o}->${t}: resolves null, never a fabricated path`); crossNulls++; continue; } assert.ok(result, `pair ${o}->${t} (${inA ? "A" : "B"}-internal) routes`); assert.ok( Math.abs(result.seconds - optimal) < 1e-6, `pair ${o}->${t}: ALT-steered cost ${result.seconds} vs Dijkstra ${optimal}`, ); } } assert.equal(pairs, 169, "the full 13x13 sweep ran"); assert.equal(crossNulls, 72, "every cross-component pair is a null"); }); test("A10 ALT search-scoped skip — origin AND goal in the anchorless component stays optimal", async () => { const { routeAstar } = await loadAstar(); const g = await buildIslandFixture(Uint32Array.of(0, 8)); const n = g.nodeCount; // With origin and goal inside B (9..12), every l's goal/origin-side // constant is the 0xFFFF sentinel — so ALL families drop for the WHOLE // search (the skip lives in the per-search constant tables, never a // per-node l-mask mid-max) and the run is pure-haversine. The skip law: // results stay exactly optimal regardless. let pairs = 0; for (let o = 9; o < n; o++) { for (let t = 9; t < n; t++) { pairs++; const optimal = dijkstra(g, o, t); const result = routeAstar(g, o, t); assert.ok(result, `anchorless pair ${o}->${t} routes (pure-haversine search)`); assert.ok( Math.abs(result.seconds - optimal) < 1e-6, `pair ${o}->${t}: cost ${result.seconds} vs Dijkstra ${optimal}`, ); } } assert.equal(pairs, 16, "the 4x4 anchorless sweep ran"); }); test("A11 structural (ALT) — astar.ts consults the landmark tables in CODE, not comments", () => { const src = readFileSync(path.join(here, "..", "src", "astar.ts"), "utf8"); const code = src .replace(/\/\*[\s\S]*?\*\//g, " ") .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // The potential provably consults Plan 08-02's ARG2 table fields — a // comment-only mention would strip away and fail this pin. for (const token of ["landmarkTo", "landmarkFrom", "landmarkQuantumS"]) { assert.ok(code.includes(token), `comment-stripped astar.ts must reference ${token} in code`); } // Raw u16 sentinel handling on the table families — one spelling of the // sentinel (0xffff in code, never the decimal 65535). assert.ok(code.includes("0xffff"), "the sentinel is compared raw (u16, before float conversion)"); assert.ok(!code.includes("65535"), "one spelling of the sentinel: 0xffff"); // A8's law is unchanged under ALT: the hot loop stays typed arrays. assert.ok(!code.includes("new Map("), "no Map allocation — the hot loop is typed arrays"); });