Files
arah2/routing/test/astar.test.mjs
T
araharah-executor 92b97fc97f test(08-03): ALT containment cases — A9 island sweep + A10 search-scoped skip + A11 structural pin (RED)
- A9: two-component synthetic (3x3 grid + 4-node path), anchors HAND-PINNED
  inside component A only via landmarkTables; full 169-pair sweep asserts
  seconds-only Dijkstra equivalence (A-internal with ALT live, B-internal
  on the haversine fallback, 72 cross pairs -> null)
- A10: origin AND goal inside the anchorless component — all families drop
  at SEARCH scope, the 16 B-internal pairs still route Dijkstra-equal
- A11: comment-stripped astar.ts must reference landmarkTo/landmarkFrom/
  landmarkQuantumS in CODE; one sentinel spelling (0xffff, never 65535);
  A8's no-Map law unchanged — FAILS now (RED: the potential is still
  pure haversine)
- case-index doc-comment extended A1-A8 -> A1-A11; buildIslandFixture
  helper routes fixture tables through the ONE landmark module
2026-10-25 19:00:56 +08:00

455 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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");
});