From 6adfd86ade215fa930e106863ecd512b1ffb5b34 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 12:13:00 +0800 Subject: [PATCH] test(06-01): add failing A* equivalence + step-derivation suite A1-A8 (RED) - A1: exhaustive 625-pair A*-vs-Dijkstra equivalence (the RESEARCH-mandated correctness gate); A2 oneway detour; A3 honest-null unreachable - A4/A5 deriveSteps: L-path turn kinds + name/distance/polyline6; depart/ arrive invariants; A6 classifyTurn pins (compass convention, documented) - A7 index.ts public surface (createHttpRoutingSource deliberately absent); A8 no-Map structural gate --- routing/test/astar.test.mjs | 284 ++++++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 routing/test/astar.test.mjs diff --git a/routing/test/astar.test.mjs b/routing/test/astar.test.mjs new file mode 100644 index 0000000..4f20f71 --- /dev/null +++ b/routing/test/astar.test.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +/* + * astar.test.mjs — typed-array A* + turn classifier + step derivation (A1–A8). + * ----------------------------------------------------------------------------- + * 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 NOT here yet + * (plan 04 adds it) + * A8 structural — no `new Map(` in astar.ts (allocation-free + * hot loop: typed arrays only) + * + * 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; + } + } +} + +/* ————— A1–A8 ————— */ + +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, + }), + ); + 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 south, then east. + const steps = deriveSteps(g, [0, 1, 6, 7]); + const kinds = steps.map((s) => s.kind); + assert.deepEqual(kinds, ["depart", "right", "left", "arrive"]); + assert.equal(steps[0].name, "Jalan Persiaran Mini", "step 0 rides the perimeter"); + assert.equal(steps[1].name, "Jalan Mini 2", "the right turn turns onto Jalan Mini 2"); + assert.equal(steps[2].name, "Jalan Mini 1", "the left 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; createHttpRoutingSource is NOT here yet", 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", + ]) { + assert.notEqual(mod[name], undefined, `index.ts must export ${name}`); + } + assert.equal( + mod.createHttpRoutingSource, + undefined, + "createHttpRoutingSource belongs to plan 04 — NOT this plan", + ); +}); + +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"); +});