Files
araharah-executor 4ca7a71d15 feat(08.1-01): rebuild all fixtures and bot graph builders through the ARG3 path
- mini-graph-arg2.bin: the frozen ARG2-era bytes preserved once as the
  D-12 decode-rejection vector (never regenerated — the arg1 precedent)
- build-mini-graph.mjs: landmark stage out, CH stage in — contractGraph
  over the 25-node grid (41 shortcuts, 1.64/node, 0 budget hits) with
  fail-loud invariants: total-order rank, grid shortcut band, upward
  shortcuts, overlay-exact constituent weights (f64 replay, f32 compare),
  strong connectivity; deterministic ARG3 bytes
- graph.test.mjs: committed-fixture G4 added (ARG3 magic/version, 25
  nodes, 80 edges, rank 25, >=1 shortcut, ARG2 vector -> null); G7
  restored (full ARG3 pin)
- astar.test.mjs: A1-A6 restored — the 625-pair Dijkstra equivalence,
  oneway law, deriveSteps, depart/arrive, classifyTurn all green against
  the CH query on the committed ARG3 fixture
- snap.test.mjs: S1-S6 restored on the ARG3 fixture; S3's synthetic
  graph rebuilt as the ARG3 ch-block star
- webhook.test.mjs: landmarkBlock -> chBlock (contractGraph); corridor
  gains the fast nonstop chord (a pure line starves to zero shortcuts);
  sparse star keeps node ids 0-hub; RT9's split carrier is now two
  disconnected decodable stars (the honest A\* miss)
- guidance-cron.test.mjs: GC6b's 2-node graph becomes the lowest-id-mid
  3-node star (a 2-node graph cannot carry a valid shortcut)
- routing 87 passed / bot 126 passed; routing+bot+web typechecks green
2026-10-26 07:30:15 +08:00

336 lines
15 KiB
JavaScript

#!/usr/bin/env node
/*
* astar.test.mjs — the search surface + turn classifier + step derivation.
* ----------------------------------------------------------------------------
* RED first (plan 06-01 task 3): behavior cases fail while
* routing/src/astar.ts does not exist (lazy-import pattern).
*
* Phase 08.1 re-pin (plan 01 tasks 2-3): routeAstar is CH-backed — the ALT
* machinery is DELETED (D-04/D-05: one search path, no dormant engine).
* The exhaustive equivalence law (A1's 625-pair sweep) and the oneway /
* steps / classifier laws carry over UNCHANGED in intent; the
* committed-fixture cases (A1/A2/A4/A5/A6) were held out in task 2 while
* mini-graph.bin was still ARG2 and are restored here (task 3
* regenerated the fixture through the ARG3 encode path). The in-memory
* and structural cases were re-pinned in task 2:
*
* A3 unreachable -> null — honest miss (synthetic disconnected
* graph, ch block through the ONE authority)
* A7 public surface — index.ts exposes the engine-A exports;
* createHttpRoutingSource is LIVE
* A8 structural — no `new Map(` in astar.ts AND ch-query.ts
* (allocation-free hot loop: typed arrays only)
* A11 structural (CH law) — the uphill rank filter + shortcut
* unpacking exist in ch-query.ts CODE;
* astar.ts contains NO landmark token
* (D-04's one-search-path pin)
* A1 exhaustive equivalence — 625 pairs, cost equality + path endpoints
* A2 oneway respected — the flagged edge is never traversed; the
* open direction is direct
* 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: negative delta = left
*
* The A9/A10/A12/A13 ALT-era cases (island / search-scoped drop / oneway
* ring / mixed columns) folded into ch-query.test.mjs: their surviving
* content is CH-law Dijkstra equivalence over directed fixtures — the
* ALT-specific table assertions are gone with the tables (D-05).
*
* 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 CH. */
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;
}
}
}
/* ————— A3 — the in-memory unreachable-pair law ————— */
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. The ch block is
// hand-built (the plan's option B): contractGraph over two disjoint
// mirrors emits no shortcut, and an ARG3 with chShortcutCount = 0 is
// not a road graph (decode refuses — G11), so the block carries one
// harmless shortcut (0->1, via 3, weight 4 — equal to the direct edge,
// rank-upward: rank(from)=1 > rank(via)=0, rank(to)=2 > 0).
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,
ch: {
rank: Uint32Array.of(1, 2, 3, 0),
shortcutStart: Uint32Array.of(0, 1, 1, 1, 1),
shortcutTarget: Uint32Array.of(1),
shortcutSeconds: Float32Array.of(4),
shortcutVia: Uint32Array.of(3),
},
}),
);
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]);
assert.ok(Math.abs(within.seconds - 4) < 1e-6, "the hop costs its edge weight");
});
/* ————— A7/A8/A11 — the public surface + structural CH laws ————— */
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",
"contractGraph",
]) {
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 AND ch-query.ts (typed arrays only in the hot loop)", () => {
for (const file of ["astar.ts", "ch-query.ts"]) {
const src = readFileSync(path.join(here, "..", "src", file), "utf8");
const code = src
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
assert.ok(!code.includes("new Map("), `${file}: no Map allocation — the hot loop is typed arrays`);
}
});
test("A11 structural (CH law) — the uphill rank filter + unpacking live in ch-query.ts; astar.ts has NO landmark token (D-04)", () => {
// astar.ts is a thin CH delegation + turn/step derivation: the
// comment-stripped code must contain NONE of the deleted ALT
// machinery — a dormant second engine would violate D-04's one-path law.
const astarSrc = readFileSync(path.join(here, "..", "src", "astar.ts"), "utf8");
const astarCode = astarSrc
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
for (const token of ["landmarkTo", "landmarkFrom", "cleanColumnsFor", "LANDMARK_SENTINEL"]) {
assert.ok(!astarCode.includes(token), `comment-stripped astar.ts must NOT contain ${token}`);
}
assert.ok(astarCode.includes("routeCH"), "routeAstar delegates to the CH query in code");
// The CH machinery itself lives in ch-query.ts — the uphill rank filter
// (chRank reads), the shortcut CSR, and the via-unpacking are CODE, not
// comments (a comment-only mention would strip away and fail this pin).
const chSrc = readFileSync(path.join(here, "..", "src", "ch-query.ts"), "utf8");
const chCode = chSrc
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
for (const token of ["chRank", "shortcutVia", "shortcutStart", "edgeFlags"]) {
assert.ok(chCode.includes(token), `comment-stripped ch-query.ts must reference ${token} in code`);
}
assert.ok(chCode.includes("peekKey"), "the bidirectional stop rule reads the heap minima (peekKey)");
assert.ok(chCode.includes("landmark") === false, "ch-query.ts carries no landmark machinery either");
});
/* ————— suspended committed-fixture cases (task 3 un-suspends) ————— */
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("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");
});