Task 1: the D-03 gate script (measure-optimality-gate.mjs) reads the frozen pre-ALT record, re-routes the same coordinate-pinned pairs on the current ARG2 binary, and gates on seconds-equality (±0.1%) + verdict-class preservation — never on node paths (equal-cost ties are documented behavior). Rule-1 deviation the gate forced on its first run: routeAstar returned SUBOPTIMAL real-graph routes (x038: 1234.6s vs the 1214.8s Dijkstra optimum; x045: 1475.5s vs 1460.6s) — the fixture sweep could not see it. Root cause: the fromTable bound d̂(ℓ,t) − d̂(ℓ,v) is one-directional Lipschitz (its u-side term needs the REVERSE edge; a oneway does not provide one — the anchor reached node 264611 126.8s faster than its own 1.49s upstream neighbour 264610), and the shared Ikeda average p = (h_f − h_b)/2 is consistent only when every family inside h_f/h_b is two-sided. The toTable bound d̂(v,ℓ) − d̂(t,ℓ) rides stored traversable edges in BOTH directions; it alone joins the haversine now. Stop rule, reductions, meet probes, reconstruction: untouched. landmarkTables/build verified EXACT (0 mismatches vs independent integer Dijkstras, both rows, all 24 anchors). - measure-optimality-gate.mjs: exported core + CLI guard (route-corpus pattern); epsilon mirror + argVersion asserts precede any measurement; per-pair rows, extract-drift WARNING (never a re-record), honest exit law - astar.ts: potential() consults landmarkTo only; doc-comments carry the one-directional-Lipschitz law and the x038/x045 evidence - astar.test.mjs: A11 re-pinned (landmarkFrom must NOT appear in the comment-stripped search — re-adding it trips the pin); A12 oneway-ring sweep added (directed reach asymmetry stays Dijkstra-equal on every pair) Battery: routing 80/80, bot 126/126, both typechecks green. D-03 gate: GATE PASS — 53/53 routed pairs equal within ±0.1% (max |Δ| 0.005%), verdict classes preserved (6 unreachable + 41 unsnappable, 0 re-snapped).
537 lines
24 KiB
JavaScript
537 lines
24 KiB
JavaScript
#!/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");
|
||
});
|
||
|
||
/* ————— A12: oneway ring under ALT (plan 08-05) ————— */
|
||
|
||
/**
|
||
* A directed 8-node ring (10 s one-way hops; every reverse direction stored
|
||
* with bit0 — never relaxed) + a bidirectional chord (0<->4, 15 s) + a
|
||
* one-way-in spur (8->3: nothing reaches 8, so its fromTable cells are the
|
||
* 0xFFFF sentinel while its toTable cells are finite). This is the fixture
|
||
* shape the 08-05 consistency fix is ABOUT: directed reach asymmetry (the
|
||
* anchor reaches a node far faster than its upstream neighbour) is exactly
|
||
* what made the four-family potential inconsistent on the real graph — the
|
||
* D-03 gate caught it as x038/x045 suboptimality. Under the amended
|
||
* toTable-only potential EVERY pair must stay Dijkstra-equal.
|
||
*/
|
||
test("A12 oneway ring under ALT — directed reach asymmetry stays Dijkstra-equal on every pair", async () => {
|
||
const { encodeGraph, decodeGraph } = await loadGraph();
|
||
const { routeAstar } = await loadAstar();
|
||
const { LANDMARK_QUANTUM_S, landmarkTables } = await import("../src/landmark.ts");
|
||
const lat = [];
|
||
const lng = [];
|
||
// Ring + chord + spur laid out around (3.10, 101.70), integer / 1e6.
|
||
for (let i = 0; i < 8; i++) {
|
||
lat.push((3100000 + Math.round(400 * Math.sin((i / 8) * 2 * Math.PI))) / 1e6);
|
||
lng.push((101700000 + Math.round(400 * Math.cos((i / 8) * 2 * Math.PI))) / 1e6);
|
||
}
|
||
lat.push(3095000 / 1e6); // node 8: the spur head, off-ring
|
||
lng.push(101705000 / 1e6);
|
||
const edges = [];
|
||
for (let i = 0; i < 8; i++) {
|
||
const next = (i + 1) % 8;
|
||
edges.push({ from: i, to: next, seconds: 10, flags: 0, nameId: 0 }); // the one-way hop
|
||
edges.push({ from: next, to: i, seconds: 10, flags: 1, nameId: 0 }); // bit0: stored, never relaxed
|
||
}
|
||
edges.push({ from: 0, to: 4, seconds: 15, flags: 0, nameId: 0 }); // bidirectional chord
|
||
edges.push({ from: 4, to: 0, seconds: 15, flags: 0, nameId: 0 });
|
||
edges.push({ from: 8, to: 3, seconds: 10, flags: 0, nameId: 0 }); // one-way-in spur
|
||
const anchors = new Uint32Array([0, 4]); // hand-pinned, in the ring's SCC
|
||
const { to, from } = landmarkTables(
|
||
{ nodeCount: 9, edges, quantumS: LANDMARK_QUANTUM_S },
|
||
anchors,
|
||
);
|
||
const g = decodeGraph(
|
||
encodeGraph({
|
||
lat,
|
||
lng,
|
||
edges,
|
||
names: ["Ring Road"],
|
||
maxSpeedKmh: 60,
|
||
landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from },
|
||
}),
|
||
);
|
||
assert.ok(g, "the oneway-ring synthetic decodes");
|
||
assert.equal(g.nodeCount, 9, "9 nodes: ring + spur head");
|
||
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`);
|
||
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(pairs, 81, "the full 9x9 sweep ran");
|
||
assert.ok(reachable > 60, `the ring is densely reachable (${reachable} pairs)`);
|
||
});
|
||
|
||
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", "landmarkQuantumS"]) {
|
||
assert.ok(code.includes(token), `comment-stripped astar.ts must reference ${token} in code`);
|
||
}
|
||
// 08-05 amendment pin: the fromTable families are EXCLUDED from the
|
||
// potential — their bounds are one-directional Lipschitz (the u-side term
|
||
// needs the reverse edge; a oneway does not have one), which broke the
|
||
// shared Ikeda average's consistency and returned suboptimal real-graph
|
||
// routes (the D-03 gate's x038/x045 catch). Re-adding landmarkFrom to the
|
||
// search trips this pin and re-opens that bug.
|
||
assert.ok(!code.includes("landmarkFrom"), "comment-stripped astar.ts must NOT consult landmarkFrom (08-05 consistency law)");
|
||
// 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");
|
||
});
|