test(08.1-01): re-pin codec + search suites for ARG3 and the CH query (RED)
- graph.test.mjs: G1-G11 re-pinned for the ARG3 layout (chShortcutCount @60, CH suffix); G7 suspended until task 3 regenerates the fixture - astar.test.mjs: committed-fixture cases A1/A2/A4/A5/A6 suspended; A3 re-pinned on an in-memory ch block; A8 extended to ch-query.ts; A11 becomes the D-04 one-search-path pin; ALT-era A9/A10/A12/A13 fold - ch-query.test.mjs: Q1-Q5 (625-pair equivalence, directed oneway law, original-edge seconds, island nulls, structural deletion) + G1-G3 in-memory ARG3 pins; all fixtures through contractGraph + encodeGraph
This commit is contained in:
+95
-575
@@ -1,68 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* astar.test.mjs — typed-array A* + turn classifier + step derivation (A1–A13).
|
||||
* -----------------------------------------------------------------------------
|
||||
* 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).
|
||||
*
|
||||
* 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.
|
||||
* Phase 08.1 re-pin (plan 01 task 2): 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) are SUSPENDED here because
|
||||
* mini-graph.bin is still ARG2 at this point in the plan — task 3
|
||||
* regenerates the fixture through the ARG3 encode path and un-suspends
|
||||
* them in place. The in-memory and structural cases are re-pinned HERE:
|
||||
*
|
||||
* 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)
|
||||
* 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 (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, F1) — two-component synthetic, anchors HAND-PINNED
|
||||
* inside component A only: every to-column
|
||||
* holds a sentinel (B nodes reach no anchor),
|
||||
* so the F1 clean-columns law drops EVERY
|
||||
* landmark search-scoped and the WHOLE
|
||||
* fixture runs the haversine fallback —
|
||||
* every pair still Dijkstra-equal, cross
|
||||
* pairs resolve null
|
||||
* A10 ALT search-scoped skip — origin AND goal inside the anchorless
|
||||
* component: with all columns dirty the
|
||||
* landmarks drop at SEARCH scope (the active
|
||||
* list is empty — never a per-node l-mask),
|
||||
* results still Dijkstra-equal
|
||||
* A11 structural (ALT, F1) — comment-stripped astar.ts consults the
|
||||
* ARG2 landmark tables (landmarkTo/
|
||||
* landmarkQuantumS in CODE, not comments) AND
|
||||
* the F1 clean-column machinery (per-graph
|
||||
* memo + per-search active list; the
|
||||
* per-node sentinel compare is BANNED);
|
||||
* landmarkFrom still absent; `new Map(`
|
||||
* still absent
|
||||
* 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 — SUSPENDED until task 3 (ARG3 fixture)
|
||||
* A2 oneway respected — SUSPENDED until task 3 (ARG3 fixture)
|
||||
* A4 deriveSteps L-path — SUSPENDED until task 3 (ARG3 fixture)
|
||||
* A5 depart + arrive always — SUSPENDED until task 3 (ARG3 fixture)
|
||||
* A6 classifyTurn pins — SUSPENDED until task 3 (ARG3 fixture)
|
||||
*
|
||||
* A12 oneway ring (08-05) — directed ring + spur, STRONGLY connected:
|
||||
* every to-column is CLEAN, so the fixture
|
||||
* runs its FULL active list — the ALT-active
|
||||
* directed-asymmetry seal; every pair
|
||||
* Dijkstra-equal
|
||||
* A13 mixed columns (F1) — one-way-bottleneck twin rings, anchors in
|
||||
* BOTH parts: some columns clean, some dirty
|
||||
* — the active list is a strict SUBSET and
|
||||
* every pair stays Dijkstra-equal
|
||||
* The A9/A10/A12/A13 ALT-era cases (island / search-scoped skip / 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)
|
||||
*/
|
||||
@@ -89,14 +60,10 @@ 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;
|
||||
}
|
||||
// loadMiniGraph() returns in task 3, when the committed fixture is ARG3
|
||||
// and the suspended cases un-suspend.
|
||||
|
||||
/** Brute-force O(n^2) Dijkstra — plain arrays, SAME traversal law as A*. */
|
||||
/** 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);
|
||||
@@ -121,125 +88,17 @@ function dijkstra(g, origin, goal) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ————— 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);
|
||||
});
|
||||
/* ————— 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.
|
||||
// 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],
|
||||
@@ -252,14 +111,12 @@ test("A3 unreachable -> null — a disconnected pair resolves the honest miss",
|
||||
],
|
||||
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),
|
||||
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),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -269,89 +126,10 @@ test("A3 unreachable -> null — a disconnected pair resolves the honest miss",
|
||||
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");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
/* ————— 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");
|
||||
@@ -369,328 +147,70 @@ test("A7 public surface — index.ts exposes engine A + the engine-a HTTP source
|
||||
"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 (typed arrays only in the hot loop)", () => {
|
||||
const src = readFileSync(path.join(here, "..", "src", "astar.ts"), "utf8");
|
||||
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("), "no Map allocation — the hot loop is typed arrays");
|
||||
assert.ok(!code.includes("new Map("), `${file}: 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. The fixture
|
||||
* is STRONGLY connected, so under the F1 clean-columns law it runs its
|
||||
* FULL active list — the ALT-active counterpart to A9's all-dirty island.
|
||||
*/
|
||||
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");
|
||||
// The F1 ALT-active seal: the fixture is STRONGLY connected — the ring
|
||||
// reaches itself, and the spur head rides 8->3 into it — so every
|
||||
// to-column is sentinel-FREE. The clean-columns law therefore keeps the
|
||||
// FULL active list here: the directed reach asymmetry lives inside the
|
||||
// triangle-exact tables (never inside a per-node skip), which is exactly
|
||||
// why the sweep below stays Dijkstra-equal.
|
||||
for (let l = 0; l < g.landmarkCount; l++) {
|
||||
for (let v = 0; v < g.nodeCount; v++) {
|
||||
assert.notEqual(
|
||||
g.landmarkTo[l * g.nodeCount + v],
|
||||
0xffff,
|
||||
`column ${l} is clean: node ${v} reaches anchor ${g.landmarkNodes[l]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
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 F1 containment corollary (debug alt-potential-4x-slowdown): every
|
||||
// to-column holds a B-node sentinel, so EVERY column is dirty — the same
|
||||
// shape as the current real binary (24/24 dirty). The clean-columns law
|
||||
// drops every landmark search-scoped; the WHOLE fixture (A-internal pairs
|
||||
// included) runs the haversine fallback — the designed sound degradation,
|
||||
// never a per-node skip.
|
||||
for (let l = 0; l < g.landmarkCount; l++) {
|
||||
let clean = true;
|
||||
for (let v = 0; v < n; v++) {
|
||||
if (g.landmarkTo[l * n + v] === 0xffff) {
|
||||
clean = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.equal(clean, false, `column ${l} is dirty (a B-node sentinel sits in it)`);
|
||||
}
|
||||
// The sweep: with every column dirty, ALL pairs — A-internal included —
|
||||
// route on the haversine fallback, 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 — and every COLUMN is dirty anyway
|
||||
// (A9's corollary), so the F1 active list is empty and the landmarks
|
||||
// drop at SEARCH scope (never a per-node l-mask mid-max): the run is
|
||||
// pure-haversine. The containment 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, F1) — astar.ts consults the landmark tables + the clean-column law in CODE, not comments", () => {
|
||||
const src = readFileSync(path.join(here, "..", "src", "astar.ts"), "utf8");
|
||||
const code = src
|
||||
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");
|
||||
// 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`);
|
||||
for (const token of ["landmarkTo", "landmarkFrom", "cleanColumnsFor", "LANDMARK_SENTINEL"]) {
|
||||
assert.ok(!astarCode.includes(token), `comment-stripped astar.ts must NOT contain ${token}`);
|
||||
}
|
||||
// 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)");
|
||||
// The F1 shape (debug alt-potential-4x-slowdown): the per-graph
|
||||
// clean-column memo and the per-search active list exist in CODE — dirty
|
||||
// columns drop landmarks at SEARCH scope ("clean columns only"), the
|
||||
// containment law that keeps the max-of-families cross-edge consistent.
|
||||
assert.ok(code.includes("cleanColumnsFor"), "the per-graph clean-column memo must exist in code (F1)");
|
||||
assert.ok(code.includes("activeL"), "the per-search active-landmark list must exist in code (F1)");
|
||||
// NO per-node sentinel branch survives anywhere: the RC-1 bug's exact
|
||||
// shape was an inline per-cell compare+skip inside bound(), which varied
|
||||
// the active family set PER NODE and broke the bound across edges
|
||||
// leaving the disconnected island (1/194 fresh pairs suboptimal). Every
|
||||
// sentinel compare must ride the one named constant, at setup scope
|
||||
// only — the literal survives only in its declaration.
|
||||
assert.ok(!code.includes("=== 0xffff"), "no inline sentinel compare — the per-node skip shape is banned (F1)");
|
||||
assert.ok(code.includes("LANDMARK_SENTINEL_U16"), "the sentinel reads through the one named constant");
|
||||
// Raw u16 sentinel spelling — one spelling of the sentinel (0xffff in
|
||||
// code, never the decimal 65535).
|
||||
assert.ok(code.includes("0xffff"), "the sentinel is spelled 0xffff (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");
|
||||
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");
|
||||
});
|
||||
|
||||
/* ————— A13: mixed clean/dirty columns under the F1 law ————— */
|
||||
/* ————— suspended committed-fixture cases (task 3 un-suspends) ————— */
|
||||
|
||||
/**
|
||||
* Two directed 4-rings joined by a ONE-WAY bridge (x0 -> y0; nothing
|
||||
* returns): the Y-anchor's to-column is CLEAN — every node reaches y0 (the
|
||||
* X side rides x0->y0, the Y side goes around its ring) — while the
|
||||
* X-anchor's column is DIRTY (the Y side can never reach back). This is
|
||||
* the branch neither A9 (all-dirty) nor A12 (all-clean) exercises: the F1
|
||||
* active list is a strict SUBSET of the landmarks, and the shared Ikeda
|
||||
* average stays consistent because every family inside it is a clean
|
||||
* (two-sided) column. EVERY pair must stay Dijkstra-equal.
|
||||
*/
|
||||
test("A13 mixed columns — a one-way bridge leaves some columns clean, some dirty; the subset active list stays optimal", async () => {
|
||||
const { encodeGraph, decodeGraph } = await loadGraph();
|
||||
const { routeAstar } = await loadAstar();
|
||||
const { LANDMARK_QUANTUM_S, landmarkTables } = await import("../src/landmark.ts");
|
||||
const lat = [];
|
||||
const lng = [];
|
||||
// X ring: nodes 0-3 around (3.12, 101.68); Y ring: nodes 4-7 around
|
||||
// (3.12, 101.72) — ~4 km apart, integer / 1e6 grid (the codec's
|
||||
// exact-decimal law).
|
||||
for (let i = 0; i < 4; i++) {
|
||||
lat.push((3120000 + Math.round(300 * Math.sin((i / 4) * 2 * Math.PI))) / 1e6);
|
||||
lng.push((101680000 + Math.round(300 * Math.cos((i / 4) * 2 * Math.PI))) / 1e6);
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
lat.push((3120000 + Math.round(300 * Math.sin((i / 4) * 2 * Math.PI))) / 1e6);
|
||||
lng.push((101720000 + Math.round(300 * Math.cos((i / 4) * 2 * Math.PI))) / 1e6);
|
||||
}
|
||||
const edges = [];
|
||||
const ring = (base) => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
edges.push({ from: base + i, to: base + ((i + 1) % 4), seconds: 10, flags: 0, nameId: 0 }); // the one-way hop
|
||||
edges.push({ from: base + ((i + 1) % 4), to: base + i, seconds: 10, flags: 1, nameId: 0 }); // bit0: stored, never relaxed
|
||||
}
|
||||
};
|
||||
ring(0); // X: one-way ring
|
||||
ring(4); // Y: one-way ring
|
||||
edges.push({ from: 0, to: 4, seconds: 10, flags: 0, nameId: 0 }); // the one-way bridge x0 -> y0
|
||||
const anchors = new Uint32Array([0, 4]); // hand-pinned: one anchor per part
|
||||
const { to, from } = landmarkTables(
|
||||
{ nodeCount: 8, edges, quantumS: LANDMARK_QUANTUM_S },
|
||||
anchors,
|
||||
);
|
||||
const g = decodeGraph(
|
||||
encodeGraph({
|
||||
lat,
|
||||
lng,
|
||||
edges,
|
||||
names: ["Bridge Road"],
|
||||
maxSpeedKmh: 60,
|
||||
landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from },
|
||||
}),
|
||||
);
|
||||
assert.ok(g, "the twin-ring synthetic decodes");
|
||||
assert.equal(g.nodeCount, 8, "8 nodes: two directed rings");
|
||||
assert.equal(g.landmarkNodes[0], 0, "landmark 0 is x0");
|
||||
assert.equal(g.landmarkNodes[1], 4, "landmark 1 is y0");
|
||||
// The fixture's MIXED law: the X-anchor to-column is dirty (Y-node
|
||||
// sentinels — no way back across the bridge), the Y-anchor to-column is
|
||||
// clean (every node reaches y0). The active list must therefore be the
|
||||
// strict subset {y0}.
|
||||
let xColumnClean = true;
|
||||
let yColumnClean = true;
|
||||
for (let v = 0; v < g.nodeCount; v++) {
|
||||
if (g.landmarkTo[v] === 0xffff) xColumnClean = false; // column 0: anchor x0
|
||||
if (g.landmarkTo[g.nodeCount + v] === 0xffff) yColumnClean = false; // column 1: anchor y0
|
||||
}
|
||||
assert.equal(xColumnClean, false, "the X-anchor column is dirty (Y nodes sentinel behind the one-way bridge)");
|
||||
assert.equal(yColumnClean, true, "the Y-anchor column is clean (every node reaches y0)");
|
||||
// The full 8x8 sweep: every reachable pair Dijkstra-equal (X->Y crosses
|
||||
// the bridge under the subset active list), Y->X the honest null.
|
||||
let pairs = 0;
|
||||
let reachable = 0;
|
||||
let crossNulls = 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`);
|
||||
crossNulls++;
|
||||
continue;
|
||||
}
|
||||
reachable++;
|
||||
assert.ok(result, `pair ${o}->${t}: reachable must route`);
|
||||
assert.ok(
|
||||
Math.abs(result.seconds - optimal) < 1e-6,
|
||||
`pair ${o}->${t}: subset-ALT cost ${result.seconds} vs Dijkstra ${optimal}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.equal(pairs, 64, "the full 8x8 sweep ran");
|
||||
assert.equal(reachable, 48, "48 reachable pairs: 12 per ring + 16 over the bridge");
|
||||
assert.equal(crossNulls, 16, "every Y->X pair is a null (the bridge is one-way)");
|
||||
test("A1 exhaustive equivalence — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => {
|
||||
// The committed fixture is still ARG2 here; the CH query's exhaustive
|
||||
// 625-pair Dijkstra-equivalence law runs in ch-query.test.mjs (Q1) on
|
||||
// an in-memory ARG3 grid until task 3 re-pins this case in place.
|
||||
assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)");
|
||||
});
|
||||
|
||||
test("A2 oneway respected — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => {
|
||||
assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)");
|
||||
});
|
||||
|
||||
test("A4 deriveSteps L-path — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => {
|
||||
assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)");
|
||||
});
|
||||
|
||||
test("A5 depart + arrive always — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => {
|
||||
assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)");
|
||||
});
|
||||
|
||||
test("A6 classifyTurn pins — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => {
|
||||
assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* ch-query.test.mjs — the uphill bidirectional CH query gates (Q1–Q5)
|
||||
* plus the in-memory ARG3 codec pins (G1–G3).
|
||||
* ----------------------------------------------------------------------------
|
||||
* RED first (plan 08.1-01 task 2): every case fails while
|
||||
* routing/src/ch-query.ts does not exist (lazy-import pattern).
|
||||
*
|
||||
* This suite is where the deleted ALT cases (astar.test.mjs A9/A10/A12/A13)
|
||||
* FOLDED per D-04/D-05: their surviving content — Dijkstra equivalence over
|
||||
* directed fixtures, the unreachable miss, the oneway law — is re-pinned
|
||||
* against the CH query; the ALT table assertions are gone with the tables.
|
||||
* ALL fixtures here are IN-MEMORY (encodeGraph → decodeGraph through the
|
||||
* ONE authority); the committed mini-graph.bin is still ARG2 in this task
|
||||
* and is NOT read (task 3 regenerates it and re-pins the committed-fixture
|
||||
* cases in graph/astar.test.mjs).
|
||||
*
|
||||
* Q1 exhaustive equivalence — the CH query equals brute-force Dijkstra
|
||||
* for EVERY pair of the 25-node in-memory
|
||||
* grid; path endpoints are origin/goal
|
||||
* Q2 directed oneway law — ring + chord + one-way-in spur: every pair
|
||||
* matches the oracle in BOTH directions and
|
||||
* every returned hop is a traversable
|
||||
* original edge (bit0 never relaxed)
|
||||
* Q3 original-edge seconds — reported seconds equal a fresh sum over
|
||||
* the returned ORIGINAL-edge path (shortcut
|
||||
* weights steer only — Pitfall 3)
|
||||
* Q4 unreachable -> null — the island fold (A9/A10): cross-component
|
||||
* pairs resolve null; internal pairs stay
|
||||
* Dijkstra-equal
|
||||
* Q5 structural deletion — no ALT tokens in astar.ts; landmark.ts and
|
||||
* landmark.test.mjs ABSENT; index.ts exports
|
||||
* no landmark symbols (D-04/D-05)
|
||||
* G1 ARG3 round-trip — contractGraph output round-trips rank +
|
||||
* shortcut CSR exactly through zero-copy views
|
||||
* G2 rejection vectors — ARG1 bytes -> null; a version-2 tamper -> null
|
||||
* G3 fail-honest decode — truncated / hostile-count / chShortcutCount=0
|
||||
* buffers -> null BEFORE any typed-array
|
||||
* allocation (T-08.1-01/T-08.1-02)
|
||||
*
|
||||
* Run: npm run routing:test (exit 0 = all green)
|
||||
*/
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync, existsSync } 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 chQueryPromise = null;
|
||||
function loadChQuery() {
|
||||
if (!chQueryPromise) chQueryPromise = import("../src/ch-query.ts");
|
||||
return chQueryPromise;
|
||||
}
|
||||
let graphPromise = null;
|
||||
function loadGraph() {
|
||||
if (!graphPromise) graphPromise = import("../src/graph.ts");
|
||||
return graphPromise;
|
||||
}
|
||||
let contractionPromise = null;
|
||||
function loadContraction() {
|
||||
if (!contractionPromise) contractionPromise = import("../src/contraction.ts");
|
||||
return contractionPromise;
|
||||
}
|
||||
|
||||
function toArrayBuffer(buf) {
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
||||
}
|
||||
|
||||
let geoPromise = null;
|
||||
async function haversineM(a, b) {
|
||||
if (!geoPromise) geoPromise = import("../src/geo.ts");
|
||||
const { haversineM: h } = await geoPromise;
|
||||
return h(a, b);
|
||||
}
|
||||
|
||||
/** Brute-force O(n^2) Dijkstra — plain arrays, SAME traversal law. */
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The traversable (bit0 clear) original edge u -> v, or -1. */
|
||||
function findTraversableEdge(g, u, v) {
|
||||
for (let e = g.edgeStart[u]; e < g.edgeStart[u + 1]; e++) {
|
||||
if (g.edgeTarget[e] === v && (g.edgeFlags[e] & 1) === 0) return e;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Fresh sum over the returned ORIGINAL-edge path (the Pitfall 3 oracle). */
|
||||
function resumSeconds(g, nodes) {
|
||||
let seconds = 0;
|
||||
for (let i = 1; i < nodes.length; i++) {
|
||||
const e = findTraversableEdge(g, nodes[i - 1], nodes[i]);
|
||||
if (e < 0) return -1; // a non-traversable hop: the caller asserts loud
|
||||
seconds += g.edgeSeconds[e];
|
||||
}
|
||||
return seconds;
|
||||
}
|
||||
|
||||
/* ————— in-memory fixtures (encodeGraph → decodeGraph, the ONE authority) ————— */
|
||||
|
||||
/**
|
||||
* The 25-node grid, the committed fixture's shape rebuilt in memory:
|
||||
* 5x5, 80 directed edge entries, one oneway pair (2->3), perimeter 60
|
||||
* km/h + interior 30 km/h, three street names. Contracted through the
|
||||
* ONE CH core, encoded through the ONE codec.
|
||||
*/
|
||||
async function buildGridGraph() {
|
||||
const { encodeGraph, decodeGraph } = await loadGraph();
|
||||
const { contractGraph } = await loadContraction();
|
||||
const lat = [];
|
||||
const lng = [];
|
||||
for (let r = 0; r < 5; r++) {
|
||||
for (let c = 0; c < 5; c++) {
|
||||
lat.push((3150000 + r * 500) / 1e6);
|
||||
lng.push((101690000 + c * 500) / 1e6);
|
||||
}
|
||||
}
|
||||
const id = (r, c) => r * 5 + c;
|
||||
const edges = [];
|
||||
const addEdge = async (u, v, perimeter) => {
|
||||
const kmh = perimeter ? 60 : 30;
|
||||
const meters = await haversineM({ lat: lat[u], lng: lng[u] }, { lat: lat[v], lng: lng[v] });
|
||||
edges.push({ from: u, to: v, seconds: meters / (kmh / 3.6) });
|
||||
};
|
||||
for (let r = 0; r < 5; r++) {
|
||||
for (let c = 0; c < 4; c++) {
|
||||
await addEdge(id(r, c), id(r, c + 1), r === 0 || r === 4);
|
||||
await addEdge(id(r, c + 1), id(r, c), r === 0 || r === 4);
|
||||
}
|
||||
}
|
||||
for (let c = 0; c < 5; c++) {
|
||||
for (let r = 0; r < 4; r++) {
|
||||
await addEdge(id(r, c), id(r + 1, c), c === 0 || c === 4);
|
||||
await addEdge(id(r + 1, c), id(r, c), c === 0 || c === 4);
|
||||
}
|
||||
}
|
||||
const reverse = edges.find((e) => e.from === 3 && e.to === 2);
|
||||
if (!reverse) throw new Error("oneway reverse edge not found — grid invariant broken");
|
||||
reverse.flags = 1;
|
||||
if (edges.length !== 80) throw new Error(`expected 80 directed edges, built ${edges.length}`);
|
||||
const ch = contractGraph({ nodeCount: 25, edges });
|
||||
const g = decodeGraph(
|
||||
encodeGraph({
|
||||
lat,
|
||||
lng,
|
||||
edges,
|
||||
names: ["Jalan Mini 1", "Jalan Mini 2", "Jalan Persiaran Mini"],
|
||||
maxSpeedKmh: 60,
|
||||
ch,
|
||||
}),
|
||||
);
|
||||
assert.ok(g, "the in-memory ARG3 grid decodes");
|
||||
assert.ok(g.chShortcutCount > 0, "the contracted grid carries a hierarchy");
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* The directed oneway fixture (the A12 fold): a directed 8-node ring
|
||||
* (10 s one-way hops; every reverse mirror stored with bit0 — never
|
||||
* relaxed), a bidirectional chord (0<->4, 15 s), and a one-way-in spur
|
||||
* head (node 8 -> 3: nothing reaches 8). Directed reach asymmetry is
|
||||
* exactly what broke the ALT four-family potential; under CH the
|
||||
* shortcuts inherit direction structurally.
|
||||
*/
|
||||
async function buildRingGraph() {
|
||||
const { encodeGraph, decodeGraph } = await loadGraph();
|
||||
const { contractGraph } = await loadContraction();
|
||||
const lat = [];
|
||||
const lng = [];
|
||||
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
|
||||
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 }); // the one-way hop
|
||||
edges.push({ from: next, to: i, seconds: 10, flags: 1 }); // bit0: stored, never relaxed
|
||||
}
|
||||
edges.push({ from: 0, to: 4, seconds: 15, flags: 0 }); // bidirectional chord
|
||||
edges.push({ from: 4, to: 0, seconds: 15, flags: 0 });
|
||||
edges.push({ from: 8, to: 3, seconds: 10, flags: 0 }); // one-way-in spur
|
||||
const ch = contractGraph({ nodeCount: 9, edges });
|
||||
const g = decodeGraph(
|
||||
encodeGraph({
|
||||
lat,
|
||||
lng,
|
||||
edges,
|
||||
names: ["Ring Road"],
|
||||
maxSpeedKmh: 60,
|
||||
ch,
|
||||
}),
|
||||
);
|
||||
assert.ok(g, "the oneway-ring synthetic decodes");
|
||||
assert.equal(g.nodeCount, 9);
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* The island fixture (the A9/A10 fold): component A is a 3x3 grid
|
||||
* (nodes 0-8), component B a separate 4-node path (nodes 9-12) far
|
||||
* apart. Cross-component pairs are unreachable — the honest miss.
|
||||
*/
|
||||
async function buildIslandGraph() {
|
||||
const { encodeGraph, decodeGraph } = await loadGraph();
|
||||
const { contractGraph } = await loadContraction();
|
||||
const lat = [];
|
||||
const lng = [];
|
||||
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);
|
||||
}
|
||||
}
|
||||
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) => {
|
||||
edges.push({ from: u, to: v, seconds: 10, flags: 0 });
|
||||
edges.push({ from: v, to: u, seconds: 10, flags: 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 ch = contractGraph({ nodeCount: 13, edges });
|
||||
const g = decodeGraph(
|
||||
encodeGraph({
|
||||
lat,
|
||||
lng,
|
||||
edges,
|
||||
names: ["Island Road"],
|
||||
maxSpeedKmh: 60,
|
||||
ch,
|
||||
}),
|
||||
);
|
||||
assert.ok(g, "the island synthetic decodes");
|
||||
assert.equal(g.nodeCount, 13);
|
||||
return g;
|
||||
}
|
||||
|
||||
/* ————— Q1–Q5 ————— */
|
||||
|
||||
test("Q1 exhaustive equivalence — the CH query matches brute-force Dijkstra on EVERY 25x25 node pair", async () => {
|
||||
const { routeCH } = await loadChQuery();
|
||||
const g = await buildGridGraph();
|
||||
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 = routeCH(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}: CH 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.equal(pairs, 625, "the full 25x25 sweep ran");
|
||||
assert.ok(reachable > 600, `the grid is densely connected (${reachable} reachable pairs)`);
|
||||
});
|
||||
|
||||
test("Q2 directed oneway law — ring + chord + spur matches the oracle in BOTH directions; every hop is a traversable original edge", async () => {
|
||||
const { routeCH } = await loadChQuery();
|
||||
const g = await buildRingGraph();
|
||||
let pairs = 0;
|
||||
let reachable = 0;
|
||||
let crossNulls = 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 = routeCH(g, o, t);
|
||||
if (optimal === Infinity) {
|
||||
assert.equal(result, null, `pair ${o}->${t}: unreachable must resolve null`);
|
||||
crossNulls++;
|
||||
continue;
|
||||
}
|
||||
reachable++;
|
||||
assert.ok(result, `pair ${o}->${t}: reachable must route`);
|
||||
assert.ok(
|
||||
Math.abs(result.seconds - optimal) < 1e-6,
|
||||
`pair ${o}->${t}: CH cost ${result.seconds} vs Dijkstra ${optimal}`,
|
||||
);
|
||||
// The unpacking law: every returned hop is a TRAVERSABLE original
|
||||
// edge — no bit0 mirror and no unpacked shortcut survives.
|
||||
for (let i = 1; i < result.nodes.length; i++) {
|
||||
assert.ok(
|
||||
findTraversableEdge(g, result.nodes[i - 1], result.nodes[i]) >= 0,
|
||||
`pair ${o}->${t}: hop ${result.nodes[i - 1]}->${result.nodes[i]} is a traversable original edge`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(pairs, 81, "the full 9x9 sweep ran");
|
||||
assert.ok(reachable > 40, `the ring is reachable (${reachable} pairs)`);
|
||||
assert.ok(crossNulls >= 8, `the spur head is one-way-in (${crossNulls} pairs can never reach it)`);
|
||||
});
|
||||
|
||||
test("Q3 original-edge seconds — reported seconds equal a fresh sum over the unpacked ORIGINAL-edge path", async () => {
|
||||
const { routeCH } = await loadChQuery();
|
||||
const g = await buildGridGraph();
|
||||
// A long cross-grid pair (the route rides shortcuts) and the oneway
|
||||
// detour pair: both must re-sum exactly over original edges.
|
||||
for (const [o, t] of [[0, 24], [3, 2], [6, 19], [12, 12]]) {
|
||||
const result = routeCH(g, o, t);
|
||||
if (o === t) {
|
||||
assert.deepEqual(result.nodes, [o], "the trivial pair is the single node");
|
||||
assert.equal(result.seconds, 0, "the trivial pair costs 0");
|
||||
continue;
|
||||
}
|
||||
assert.ok(result, `${o}->${t} routes`);
|
||||
const fresh = resumSeconds(g, result.nodes);
|
||||
assert.ok(fresh >= 0, `${o}->${t}: every returned hop is a traversable original edge`);
|
||||
assert.ok(
|
||||
Math.abs(result.seconds - fresh) < 1e-6,
|
||||
`${o}->${t}: reported ${result.seconds} === original-edge re-sum ${fresh} (shortcut weights never enter seconds)`,
|
||||
);
|
||||
const optimal = dijkstra(g, o, t);
|
||||
assert.ok(Math.abs(result.seconds - optimal) < 1e-6, `${o}->${t}: and it is the Dijkstra optimum`);
|
||||
// distanceM is the haversine sum over the SAME unpacked path.
|
||||
let meters = 0;
|
||||
for (let i = 1; i < result.nodes.length; i++) {
|
||||
const a = { lat: g.lat[result.nodes[i - 1]], lng: g.lng[result.nodes[i - 1]] };
|
||||
const b = { lat: g.lat[result.nodes[i]], lng: g.lng[result.nodes[i]] };
|
||||
meters += await haversineM(a, b);
|
||||
}
|
||||
assert.ok(
|
||||
Math.abs(result.distanceM - meters) < 1e-6,
|
||||
`${o}->${t}: distanceM is the haversine sum over the unpacked path`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Q4 unreachable -> null — the island fold: cross pairs resolve null, internal pairs stay Dijkstra-equal", async () => {
|
||||
const { routeCH } = await loadChQuery();
|
||||
const g = await buildIslandGraph();
|
||||
let pairs = 0;
|
||||
let crossNulls = 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 = routeCH(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}: CH 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("Q5 structural deletion — no ALT tokens in astar.ts; landmark.ts + landmark.test.mjs ABSENT; index exports none (D-04/D-05)", () => {
|
||||
const src = readFileSync(path.join(here, "..", "src", "astar.ts"), "utf8");
|
||||
const code = src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
|
||||
for (const token of ["landmarkTo", "landmarkFrom", "cleanColumnsFor", "LANDMARK_SENTINEL"]) {
|
||||
assert.ok(!code.includes(token), `comment-stripped astar.ts must not contain ${token}`);
|
||||
}
|
||||
assert.equal(existsSync(path.join(here, "..", "src", "landmark.ts")), false, "landmark.ts is deleted (D-05)");
|
||||
assert.equal(
|
||||
existsSync(path.join(here, "landmark.test.mjs")),
|
||||
false,
|
||||
"landmark.test.mjs is deleted (D-05)",
|
||||
);
|
||||
const indexSrc = readFileSync(path.join(here, "..", "src", "index.ts"), "utf8");
|
||||
const indexCode = indexSrc
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
|
||||
assert.ok(!indexCode.includes("landmark"), "index.ts exports no landmark symbols");
|
||||
});
|
||||
|
||||
/* ————— G1–G3 — the in-memory ARG3 codec pins ————— */
|
||||
|
||||
test("G1 ARG3 round-trip — contractGraph output round-trips rank + shortcut CSR exactly through zero-copy views", async () => {
|
||||
const { encodeGraph, decodeGraph } = await loadGraph();
|
||||
const { contractGraph } = await loadContraction();
|
||||
const g0 = await buildGridGraph(); // builds through encode/decode already
|
||||
assert.ok(g0, "the grid decoded (G1's round-trip ran inside the builder)");
|
||||
// Explicit round-trip on a fresh contraction output.
|
||||
const { contractGraph: contract2 } = await loadContraction();
|
||||
const edges = [
|
||||
{ from: 0, to: 1, seconds: 1, flags: 0 },
|
||||
{ from: 1, to: 0, seconds: 1, flags: 0 },
|
||||
{ from: 0, to: 2, seconds: 1, flags: 0 },
|
||||
{ from: 2, to: 0, seconds: 1, flags: 0 },
|
||||
];
|
||||
const ch = contract2({ nodeCount: 3, edges });
|
||||
const { encodeGraph: enc, decodeGraph: dec } = await loadGraph();
|
||||
const buf = enc({
|
||||
lat: [3.15, 3.1505, 3.151],
|
||||
lng: [101.69, 101.6905, 101.691],
|
||||
edges,
|
||||
names: [],
|
||||
maxSpeedKmh: 60,
|
||||
ch,
|
||||
}).slice(0);
|
||||
const g = dec(buf);
|
||||
assert.ok(g, "decode succeeds");
|
||||
assert.equal(g.chShortcutCount, ch.shortcutTarget.length, "the header count matches the contraction");
|
||||
assert.deepEqual([...g.chRank], [...ch.rank], "rank restores exactly");
|
||||
assert.deepEqual([...g.shortcutStart], [...ch.shortcutStart], "shortcutStart restores exactly");
|
||||
assert.deepEqual([...g.shortcutTarget], [...ch.shortcutTarget], "shortcutTarget restores exactly");
|
||||
assert.deepEqual([...g.shortcutSeconds], [...ch.shortcutSeconds], "shortcutSeconds restores exactly");
|
||||
assert.deepEqual([...g.shortcutVia], [...ch.shortcutVia], "shortcutVia restores exactly");
|
||||
assert.equal(g.chRank.buffer, buf, "chRank is a zero-copy view");
|
||||
assert.equal(g.shortcutVia.buffer, buf, "shortcutVia is a zero-copy view");
|
||||
});
|
||||
|
||||
test("G2 rejection vectors — ARG1 bytes -> null; a version-2 tamper -> null (D-07 one decode path)", async () => {
|
||||
const { decodeGraph } = await loadGraph();
|
||||
// The frozen Plan 08-01 vector (the ARG1 rejection fixture).
|
||||
const arg1 = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph-arg1.bin")));
|
||||
assert.equal(decodeGraph(arg1), null, "ARG1 bytes -> null, never a throw or a fallback decode");
|
||||
// A version-2 tamper of an in-memory ARG3 buffer refuses too.
|
||||
const { encodeGraph } = await loadGraph();
|
||||
const { contractGraph } = await loadContraction();
|
||||
const edges = [
|
||||
{ from: 0, to: 1, seconds: 1, flags: 0 },
|
||||
{ from: 1, to: 0, seconds: 1, flags: 0 },
|
||||
{ from: 0, to: 2, seconds: 1, flags: 0 },
|
||||
{ from: 2, to: 0, seconds: 1, flags: 0 },
|
||||
];
|
||||
const buf = encodeGraph({
|
||||
lat: [3.15, 3.1505, 3.151],
|
||||
lng: [101.69, 101.6905, 101.691],
|
||||
edges,
|
||||
names: [],
|
||||
maxSpeedKmh: 60,
|
||||
ch: contractGraph({ nodeCount: 3, edges }),
|
||||
});
|
||||
const v2 = buf.slice(0);
|
||||
new DataView(v2).setUint32(4, 2, true);
|
||||
assert.equal(decodeGraph(v2), null, "version 2 -> null (ARG2 bytes decode to nothing — D-07)");
|
||||
});
|
||||
|
||||
test("G3 fail-honest decode — truncated / hostile-count / chShortcutCount=0 buffers -> null BEFORE any allocation", async () => {
|
||||
const { decodeGraph, GRAPH_MAGIC, GRAPH_VERSION } = await loadGraph();
|
||||
const { encodeGraph } = await loadGraph();
|
||||
const { contractGraph } = await loadContraction();
|
||||
const edges = [
|
||||
{ from: 0, to: 1, seconds: 1, flags: 0 },
|
||||
{ from: 1, to: 0, seconds: 1, flags: 0 },
|
||||
{ from: 0, to: 2, seconds: 1, flags: 0 },
|
||||
{ from: 2, to: 0, seconds: 1, flags: 0 },
|
||||
];
|
||||
const ch = contractGraph({ nodeCount: 3, edges });
|
||||
const buf = encodeGraph({
|
||||
lat: [3.15, 3.1505, 3.151],
|
||||
lng: [101.69, 101.6905, 101.691],
|
||||
edges,
|
||||
names: [],
|
||||
maxSpeedKmh: 60,
|
||||
ch,
|
||||
});
|
||||
assert.equal(decodeGraph(buf.slice(0, buf.byteLength - 1)), null, "truncated -> null");
|
||||
const hostile = new ArrayBuffer(72);
|
||||
const dv = new DataView(hostile);
|
||||
dv.setUint32(0, GRAPH_MAGIC, true);
|
||||
dv.setUint32(4, GRAPH_VERSION, true);
|
||||
dv.setUint32(8, 1_000_000, true); // a million nodes on a 72-byte buffer
|
||||
assert.equal(decodeGraph(hostile), null, "hostile count -> null on arithmetic, no allocation");
|
||||
const zeroed = buf.slice(0);
|
||||
new DataView(zeroed).setUint32(60, 0, true);
|
||||
assert.equal(decodeGraph(zeroed), null, "chShortcutCount = 0 -> null even when the size matches");
|
||||
});
|
||||
+128
-124
@@ -5,27 +5,32 @@
|
||||
* RED first (plan 06-01 task 1): every decode-bearing case fails while
|
||||
* routing/src/graph.ts does not exist (lazy-import pattern — each case
|
||||
* reports its own ERR_MODULE_NOT_FOUND, the fixture-source.test.mjs style).
|
||||
* Re-pinned + extended by plan 08-02: the layout version bumped to ARG2 —
|
||||
* a 72-byte header (landmarkCount u32 @60, landmarkQuantumS f32 @64,
|
||||
* reserved u32 @68) plus a u16 landmark-table SUFFIX (to-table, from-table,
|
||||
* u32 anchor ids). One decode path, one search path: anything that is not
|
||||
* exactly an ARG2 graph decodes to null (D-01 fail-honest) — the committed
|
||||
* mini-graph-arg1.bin (Plan 08-01) is the standing ARG1 rejection vector.
|
||||
* Re-pinned by Phase 08.1 plan 01 task 2: the layout version bumped to
|
||||
* ARG3 — the CH-carrying layout. One decode path, one search path:
|
||||
* anything that is not exactly an ARG3 graph decodes to null (the D-01
|
||||
* fail-honest family) — the committed mini-graph-arg1.bin / (task 3)
|
||||
* mini-graph-arg2.bin files are the standing rejection vectors.
|
||||
*
|
||||
* The binary layout is PINNED by 06-01 + 08-02 (plans 04/07 depend on it):
|
||||
* little-endian CSR — header (magic/version/counts/bbox/maxSpeed/landmark
|
||||
* block) ++ f64 lat++lng ++ u32 edgeStart[n+1] ++ u32 targets ++ u8 flags
|
||||
* ++ f32 seconds ++ u32 nameIds ++ u32 nameOffsets[m+1] ++ u8 name pool ++
|
||||
* u16 toTable[L*N] ++ u16 fromTable[L*N] ++ u32 landmarkNodes[L].
|
||||
* The header is padded to 72 bytes so the f64 node arrays are 8-byte
|
||||
* aligned — zero-copy TypedArray views REQUIRE aligned offsets (an
|
||||
* unaligned Float64Array constructor throws RangeError in every engine).
|
||||
* The binary layout is PINNED by 06-01 + 08-02 + Phase 08.1 (plans 04/07
|
||||
* depend on the base; Phase 11 will download the same bytes):
|
||||
* little-endian CSR — header (magic/version/counts/bbox/maxSpeed +
|
||||
* chShortcutCount u32 @60, reserved u32 @64/@68) ++ f64 lat ++ lng ++
|
||||
* u32 edgeStart[n+1] ++ u32 targets ++ u8 flags ++ f32 seconds ++
|
||||
* u32 nameIds ++ u32 nameOffsets[m+1] ++ u8 name pool ++ u32 chRank[n]
|
||||
* ++ u32 shortcutStart[n+1] ++ u32 shortcutTarget[S] ++ f32
|
||||
* shortcutSeconds[S] ++ u32 shortcutVia[S].
|
||||
* The header stays 72 bytes so the f64 node arrays are 8-byte aligned —
|
||||
* zero-copy TypedArray views REQUIRE aligned offsets (an unaligned
|
||||
* Float64Array constructor throws RangeError in every engine). The CH
|
||||
* suffix starts at the first 4-byte boundary after the name pool.
|
||||
*
|
||||
* G1 round-trip — encodeGraph → decodeGraph restores coords,
|
||||
* targets, flags, weights, names, bbox exactly
|
||||
* targets, flags, weights, names, bbox AND the
|
||||
* CH fields (rank + shortcut CSR) exactly
|
||||
* G2 bad magic -> null — T-06-03 fail-honest (0xdeadbeef)
|
||||
* G3 bad version -> null — ARG1 fixture bytes -> null (D-01 one-path
|
||||
* law); version 1 and 3 tamper -> null
|
||||
* law); version 1, 2 and 4 tampers -> null
|
||||
* (the accepted version is 3)
|
||||
* G4 truncated/hostile -> null — byte-length ≠ header-declared size
|
||||
* (incl. huge-count header on a tiny buffer,
|
||||
* T-06-03b: size check BEFORE view allocation)
|
||||
@@ -33,19 +38,20 @@
|
||||
* flipping an input byte flips the decoded view
|
||||
* G6 name pool — nameAt(0) === "Jalan Mini 1";
|
||||
* nameAt(UNNAMED_NAME_ID) === undefined
|
||||
* G7 committed fixture — mini-graph.bin (ARG2) decodes: 25 nodes, 80
|
||||
* edges, 3 names, maxSpeed 60, exactly ONE
|
||||
* oneway flag, landmarkCount >= 1, quantum 0.1
|
||||
* G8 exported constants — GRAPH_MAGIC === 0x41524732, GRAPH_VERSION === 2
|
||||
* G9 table round-trip — the four landmark properties restore exactly;
|
||||
* the 0xFFFF sentinel is DATA; u16 tables are
|
||||
* zero-copy views
|
||||
* G10 encode validation — malformed landmark input throws NAMED
|
||||
* G7 committed fixture — SUSPENDED until task 3 regenerates
|
||||
* mini-graph.bin through the ARG3 encode path
|
||||
* (the committed bytes are still ARG2 here);
|
||||
* task 3 re-pins this case in place
|
||||
* G8 exported constants — GRAPH_MAGIC === 0x41524733, GRAPH_VERSION === 3
|
||||
* G9 table round-trip — the CH fields restore exactly; the CH arrays
|
||||
* are zero-copy views over the input buffer
|
||||
* G10 encode validation — malformed ch input throws NAMED
|
||||
* encodeGraph: errors (no silent clamp, T-08-04)
|
||||
* G11 decode rejection — landmarkCount = 0 -> null even when the size
|
||||
* matches (D-01/A5: no landmark-less ARG2);
|
||||
* monster landmarkCount on a tiny buffer ->
|
||||
* null by arithmetic BEFORE allocation (T-08-01)
|
||||
* G11 decode rejection — chShortcutCount = 0 -> null even when the
|
||||
* size matches (an ARG3 with no CH is not a
|
||||
* road graph — A5); monster chShortcutCount on
|
||||
* a tiny buffer -> null by arithmetic BEFORE
|
||||
* allocation (T-08.1-01)
|
||||
*
|
||||
* Run: npm run routing:test (exit 0 = all green)
|
||||
*/
|
||||
@@ -71,6 +77,26 @@ function toArrayBuffer(buf) {
|
||||
|
||||
/* ————— synthetic 3-node graph (the round-trip vector) ————— */
|
||||
|
||||
/**
|
||||
* Hand-built CH block for the 3-node synthetic (rank of length 3,
|
||||
* shortcutStart of length 4, ONE shortcut so the round-trip is
|
||||
* meaningful). The shortcut 0->2 (via 1) is upward against this rank:
|
||||
* rank(from)=1 > rank(via)=0 and rank(to)=2 > 0 — the same law
|
||||
* contractGraph guarantees. (contractGraph over the 3 synthetic edges
|
||||
* itself emits no shortcut — the 1->2 mirror is bit0-blocked — so the
|
||||
* hand block is the codec's round-trip vector, exactly the plan's
|
||||
* option B.)
|
||||
*/
|
||||
function syntheticCh() {
|
||||
return {
|
||||
rank: Uint32Array.of(1, 0, 2),
|
||||
shortcutStart: Uint32Array.of(0, 1, 1, 2),
|
||||
shortcutTarget: Uint32Array.of(2),
|
||||
shortcutSeconds: Float32Array.of(12),
|
||||
shortcutVia: Uint32Array.of(1),
|
||||
};
|
||||
}
|
||||
|
||||
function syntheticInput() {
|
||||
return {
|
||||
lat: [3.15, 3.1505, 3.151],
|
||||
@@ -82,24 +108,18 @@ function syntheticInput() {
|
||||
],
|
||||
names: ["Jalan Mini 1", "Jalan Mini 2"],
|
||||
maxSpeedKmh: 60,
|
||||
// ARG2 landmark block (plan 08-02): L=1 anchored at node 0. quantum 0.5
|
||||
// is f32-exact so the round-trip asserts plain equality; the to-row
|
||||
// carries a 0xFFFF sentinel ON PURPOSE — sentinels are DATA (row-major
|
||||
// [l*N + v]: node 2 cannot reach the anchor in this made-up table).
|
||||
landmarks: {
|
||||
quantumS: 0.5,
|
||||
nodes: [0],
|
||||
to: Uint16Array.of(0, 25, 0xffff),
|
||||
from: Uint16Array.of(0, 30, 60),
|
||||
},
|
||||
// ARG3 CH block (Phase 08.1): REQUIRED — an ARG3 with no CH is not a
|
||||
// road graph. Hand-built, upward, one shortcut; see syntheticCh().
|
||||
ch: syntheticCh(),
|
||||
};
|
||||
}
|
||||
|
||||
/* ————— G1–G8 ————— */
|
||||
|
||||
test("G1 round-trip — encodeGraph -> decodeGraph restores coords, targets, flags, weights, names, bbox exactly", async () => {
|
||||
test("G1 round-trip — encodeGraph -> decodeGraph restores coords, targets, flags, weights, names, bbox, and the CH tables exactly", async () => {
|
||||
const { encodeGraph, decodeGraph } = await loadGraph();
|
||||
const input = syntheticInput();
|
||||
const ch = input.ch;
|
||||
const buf = encodeGraph(input);
|
||||
assert.ok(buf instanceof ArrayBuffer, "encodeGraph returns an ArrayBuffer");
|
||||
const g = decodeGraph(buf);
|
||||
@@ -121,6 +141,17 @@ test("G1 round-trip — encodeGraph -> decodeGraph restores coords, targets, fla
|
||||
"bbox derived from the nodes at encode time",
|
||||
);
|
||||
assert.equal(g.maxSpeedKmh, 60);
|
||||
// ARG3 CH fields round-trip (task 2's re-pin).
|
||||
assert.equal(g.chShortcutCount, 1, "the header carries the shortcut count");
|
||||
assert.equal(g.chRank.length, 3, "chRank length equals nodeCount");
|
||||
assert.equal(g.shortcutStart.length, 4, "shortcutStart length equals nodeCount + 1");
|
||||
assert.equal(g.shortcutTarget.length, g.chShortcutCount, "target length equals chShortcutCount");
|
||||
assert.equal(g.shortcutSeconds.length, g.chShortcutCount, "seconds length equals chShortcutCount");
|
||||
assert.equal(g.shortcutVia.length, g.chShortcutCount, "via length equals chShortcutCount");
|
||||
assert.deepEqual([...g.chRank], [...ch.rank], "rank values match the input exactly");
|
||||
assert.deepEqual([...g.shortcutTarget], [...ch.shortcutTarget], "shortcut targets match");
|
||||
assert.deepEqual([...g.shortcutSeconds], [...ch.shortcutSeconds], "shortcut seconds match");
|
||||
assert.deepEqual([...g.shortcutVia], [...ch.shortcutVia], "shortcut vias match");
|
||||
});
|
||||
|
||||
test("G2 bad magic -> null — a tampered header never half-decodes (T-06-03)", async () => {
|
||||
@@ -131,20 +162,23 @@ test("G2 bad magic -> null — a tampered header never half-decodes (T-06-03)",
|
||||
assert.equal(decodeGraph(tampered), null, "wrong magic -> null, never a throw");
|
||||
});
|
||||
|
||||
test("G3 bad version -> null — ARG1 fixture bytes refuse; version 1 and 3 tamper to null (D-01 one-path law)", async () => {
|
||||
test("G3 bad version -> null — ARG1 fixture bytes refuse; version 1, 2 and 4 tampers to null (D-01 one-path law)", async () => {
|
||||
const { encodeGraph, decodeGraph } = await loadGraph();
|
||||
// The frozen Plan 08-01 vector: REAL ARG1 bytes decode to null — the
|
||||
// Phase 6 router-pending state covers every pre-ARG2 binary (D-01).
|
||||
// There is exactly one decode path; no v1 fallback branch exists.
|
||||
// Phase 6 router-pending state covers every pre-ARG3 binary (D-01).
|
||||
// There is exactly one decode path; no v1/v2 fallback branch exists.
|
||||
const arg1 = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph-arg1.bin")));
|
||||
assert.equal(decodeGraph(arg1), null, "ARG1 bytes -> null, never a throw or a fallback decode");
|
||||
const buf = encodeGraph(syntheticInput());
|
||||
const v1 = buf.slice(0);
|
||||
new DataView(v1).setUint32(4, 1, true);
|
||||
assert.equal(decodeGraph(v1), null, "version 1 -> null");
|
||||
const v3 = buf.slice(0);
|
||||
new DataView(v3).setUint32(4, 3, true);
|
||||
assert.equal(decodeGraph(v3), null, "version 3 -> null");
|
||||
const v2 = buf.slice(0);
|
||||
new DataView(v2).setUint32(4, 2, true);
|
||||
assert.equal(decodeGraph(v2), null, "version 2 -> null (ARG2 is a rejection vector — D-07)");
|
||||
const v4 = buf.slice(0);
|
||||
new DataView(v4).setUint32(4, 4, true);
|
||||
assert.equal(decodeGraph(v4), null, "version 4 -> null (the accepted version is 3)");
|
||||
});
|
||||
|
||||
test("G4 truncated + hostile-size buffer -> null — exact byte-size check BEFORE any view allocation (T-06-03b)", async () => {
|
||||
@@ -175,6 +209,7 @@ test("G5 zero-copy views — the views' .buffer IS the input buffer; flipping an
|
||||
assert.ok(g, "decode succeeds");
|
||||
assert.equal(g.lat.buffer, buf, "lat is a VIEW over the input buffer (zero-copy)");
|
||||
assert.equal(g.edgeSeconds.buffer, buf, "edgeSeconds is a VIEW over the input buffer");
|
||||
assert.equal(g.chRank.buffer, buf, "chRank is a VIEW over the input buffer (CH zero-copy)");
|
||||
// The padded header ends at byte 72 — the first lat byte. Flip it.
|
||||
const dv = new DataView(buf);
|
||||
const before = g.lat[0];
|
||||
@@ -192,104 +227,74 @@ test("G6 name pool — nameAt(0) resolves the first pool name; the unnamed senti
|
||||
assert.equal(g.nameAt(999), undefined, "out-of-range id -> undefined");
|
||||
});
|
||||
|
||||
test("G7 committed ARG2 fixture — decodes to 25 nodes / 80 edges / 3 names / L>=1 landmarks, maxSpeed 60, ONE oneway", async () => {
|
||||
const { decodeGraph } = await loadGraph();
|
||||
const bin = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph.bin")));
|
||||
assert.ok(bin.byteLength > 1000, `the committed fixture is substantial (${bin.byteLength} B)`);
|
||||
const g = decodeGraph(bin);
|
||||
assert.ok(g, "the committed fixture decodes cleanly");
|
||||
assert.equal(g.nodeCount, 25, "5x5 grid");
|
||||
assert.equal(g.edgeCount, 80, "20 H + 20 V segments, both directions");
|
||||
assert.equal(g.nameCount, 3, "Jalan Mini 1 / Jalan Mini 2 / Jalan Persiaran Mini");
|
||||
assert.equal(g.nameAt(0), "Jalan Mini 1");
|
||||
assert.equal(g.nameAt(2), "Jalan Persiaran Mini");
|
||||
assert.equal(g.maxSpeedKmh, 60, "the perimeter speed class is the header max");
|
||||
// bbox of the hand-built grid (lat 3.1500–3.1520, lng 101.6900–101.6920)
|
||||
assert.deepEqual(g.bbox, { minLat: 3.15, minLng: 101.69, maxLat: 3.152, maxLng: 101.692 });
|
||||
let flagged = 0;
|
||||
for (let e = 0; e < g.edgeCount; e++) if ((g.edgeFlags[e] & 1) !== 0) flagged++;
|
||||
assert.equal(flagged, 1, "exactly ONE oneway-reverse edge in the whole fixture");
|
||||
// both speed classes present: perimeter ~55.6 m / 16.67 ≈ 3.3 s; interior ≈ 6.7 s
|
||||
let sawFast = false;
|
||||
let sawSlow = false;
|
||||
for (let e = 0; e < g.edgeCount; e++) {
|
||||
if (g.edgeSeconds[e] < 5) sawFast = true;
|
||||
if (g.edgeSeconds[e] > 5) sawSlow = true;
|
||||
}
|
||||
assert.ok(sawFast && sawSlow, "both speed classes (60 and 30 km/h) exist in the weights");
|
||||
// ARG2 landmark laws (plan 08-02): the fixture regenerates THROUGH the
|
||||
// encode authority with a tiny real landmark stage (L=2).
|
||||
assert.equal(g.landmarkCount, 2, "the build's tiny L");
|
||||
assert.ok(
|
||||
Math.abs(g.landmarkQuantumS - 0.1) < 1e-6,
|
||||
`quantum is the 0.1 s c-unit (f32 readback ${g.landmarkQuantumS})`,
|
||||
);
|
||||
assert.equal(g.landmarkTo.length, g.landmarkCount * g.nodeCount, "to-table is L*N row-major");
|
||||
assert.equal(g.landmarkFrom.length, g.landmarkCount * g.nodeCount, "from-table is L*N row-major");
|
||||
for (let l = 0; l < g.landmarkCount; l++) {
|
||||
const a = g.landmarkNodes[l];
|
||||
assert.ok(Number.isInteger(a) && a >= 0 && a < g.nodeCount, `anchor ${l} in range`);
|
||||
assert.equal(g.landmarkTo[l * g.nodeCount + a], 0, `anchor ${l}'s own to-cell is 0`);
|
||||
assert.equal(g.landmarkFrom[l * g.nodeCount + a], 0, `anchor ${l}'s own from-cell is 0`);
|
||||
}
|
||||
test("G7 committed ARG3 fixture — SUSPENDED until task 3 regenerates mini-graph.bin through the ARG3 encode path", async () => {
|
||||
// The committed mini-graph.bin is still ARG2 at this point in the plan
|
||||
// (task 2 flips the codec; task 3 regenerates the fixture through the
|
||||
// ONE encode authority and re-pins this case in place, together with
|
||||
// the preserved mini-graph-arg2.bin rejection vector). Documented
|
||||
// no-op — the ONLY suspended case in this suite.
|
||||
assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)");
|
||||
});
|
||||
|
||||
test("G8 exported constants — GRAPH_MAGIC === 0x41524732 ('ARG2'), GRAPH_VERSION === 2", async () => {
|
||||
test("G8 exported constants — GRAPH_MAGIC === 0x41524733 ('ARG3'), GRAPH_VERSION === 3", async () => {
|
||||
const mod = await loadGraph();
|
||||
assert.equal(mod.GRAPH_MAGIC, 0x41524732);
|
||||
assert.equal(mod.GRAPH_VERSION, 2);
|
||||
assert.equal(mod.GRAPH_MAGIC, 0x41524733);
|
||||
assert.equal(mod.GRAPH_VERSION, 3);
|
||||
assert.equal(mod.UNNAMED_NAME_ID, 0xffffffff);
|
||||
});
|
||||
|
||||
test("G9 table round-trip — the four landmark properties restore exactly; sentinels are DATA; tables are zero-copy", async () => {
|
||||
test("G9 table round-trip — the CH fields restore exactly; the CH arrays are zero-copy views", async () => {
|
||||
const { encodeGraph, decodeGraph } = await loadGraph();
|
||||
const buf = encodeGraph(syntheticInput()).slice(0);
|
||||
const ch = syntheticCh();
|
||||
const buf = encodeGraph({ ...syntheticInput(), ch }).slice(0);
|
||||
const g = decodeGraph(buf);
|
||||
assert.ok(g, "decode succeeds");
|
||||
assert.equal(g.landmarkCount, 1, "landmarkCount === L");
|
||||
assert.equal(g.landmarkTo.length, 1 * 3, "to-table row-major L*N");
|
||||
assert.equal(g.landmarkFrom.length, 1 * 3, "from-table row-major L*N");
|
||||
assert.deepEqual([...g.landmarkNodes], [0], "landmarkNodes[i] matches the input anchors");
|
||||
assert.equal(g.landmarkQuantumS, 0.5, "quantum restores exactly (0.5 is f32-exact)");
|
||||
assert.deepEqual([...g.landmarkTo], [0, 25, 0xffff], "to-table bytes restore exactly — 0xFFFF is DATA");
|
||||
assert.deepEqual([...g.landmarkFrom], [0, 30, 60], "from-table bytes restore exactly");
|
||||
assert.equal(g.landmarkTo.buffer, buf, "the u16 to-table is a zero-copy view too");
|
||||
assert.equal(g.landmarkFrom.buffer, buf, "the u16 from-table is a zero-copy view too");
|
||||
assert.equal(g.chShortcutCount, 1, "chShortcutCount === S");
|
||||
assert.deepEqual([...g.chRank], [...ch.rank], "chRank restores exactly");
|
||||
assert.deepEqual([...g.shortcutStart], [...ch.shortcutStart], "shortcutStart restores exactly");
|
||||
assert.deepEqual([...g.shortcutTarget], [...ch.shortcutTarget], "shortcutTarget restores exactly");
|
||||
assert.deepEqual([...g.shortcutSeconds], [...ch.shortcutSeconds], "shortcutSeconds restores exactly");
|
||||
assert.deepEqual([...g.shortcutVia], [...ch.shortcutVia], "shortcutVia restores exactly");
|
||||
assert.equal(g.chRank.buffer, buf, "chRank is a zero-copy view");
|
||||
assert.equal(g.shortcutStart.buffer, buf, "shortcutStart is a zero-copy view");
|
||||
assert.equal(g.shortcutTarget.buffer, buf, "shortcutTarget is a zero-copy view");
|
||||
assert.equal(g.shortcutSeconds.buffer, buf, "shortcutSeconds is a zero-copy view");
|
||||
assert.equal(g.shortcutVia.buffer, buf, "shortcutVia is a zero-copy view");
|
||||
});
|
||||
|
||||
test("G10 encode validation — malformed landmark input throws NAMED encodeGraph: errors (T-08-04, no silent clamp)", async () => {
|
||||
test("G10 encode validation — malformed ch input throws NAMED encodeGraph: errors (T-08-04, no silent clamp)", async () => {
|
||||
const { encodeGraph } = await loadGraph();
|
||||
const base = syntheticInput();
|
||||
const mangle = (over) => ({ ...base, landmarks: { ...base.landmarks, ...over } });
|
||||
const mangle = (over) => ({ ...base, ch: { ...base.ch, ...over } });
|
||||
const cases = [
|
||||
["zero anchors", mangle({ nodes: [] })],
|
||||
["to-row length != L*N", mangle({ to: Uint16Array.of(0, 25) })],
|
||||
["from-row length != L*N", mangle({ from: Uint16Array.of(0) })],
|
||||
["anchor id >= nodeCount", mangle({ nodes: [7] })],
|
||||
["anchor id negative", mangle({ nodes: [-1] })],
|
||||
["quantum 0", mangle({ quantumS: 0 })],
|
||||
["quantum negative", mangle({ quantumS: -1 })],
|
||||
["quantum NaN", mangle({ quantumS: Number.NaN })],
|
||||
["landmarks block missing", { ...base, landmarks: undefined }],
|
||||
["ch block missing", { ...base, ch: undefined }],
|
||||
["rank length != nodeCount", mangle({ rank: Uint32Array.of(1, 0) })],
|
||||
["shortcutStart length != nodeCount+1", mangle({ shortcutStart: Uint32Array.of(0, 1, 1) })],
|
||||
["target length != S (via disagrees)", mangle({ shortcutVia: Uint32Array.of() })],
|
||||
["seconds length != S (target disagrees)", mangle({ shortcutSeconds: Float32Array.of() })],
|
||||
["via id >= nodeCount", mangle({ shortcutVia: Uint32Array.of(7) })],
|
||||
["via id negative-ish (huge)", mangle({ shortcutTarget: Uint32Array.of(0xffff_ffff), shortcutVia: Uint32Array.of(0xffff_ffff) })],
|
||||
["non-finite shortcut seconds", mangle({ shortcutSeconds: Float32Array.of(Number.NaN) })],
|
||||
["negative shortcut seconds", mangle({ shortcutSeconds: Float32Array.of(-1) })],
|
||||
];
|
||||
for (const [label, input] of cases) {
|
||||
assert.throws(() => encodeGraph(input), /^Error: encodeGraph: /, `${label} -> named encodeGraph: error`);
|
||||
}
|
||||
});
|
||||
|
||||
test("G11 decode rejection — landmarkCount 0 -> null even when the size matches; monster count -> null by arithmetic (T-08-01)", async () => {
|
||||
test("G11 decode rejection — chShortcutCount 0 -> null even when the size matches; monster count -> null by arithmetic (T-08.1-01)", async () => {
|
||||
const { encodeGraph, decodeGraph, GRAPH_MAGIC, GRAPH_VERSION } = await loadGraph();
|
||||
const buf = encodeGraph(syntheticInput());
|
||||
const zeroed = buf.slice(0);
|
||||
new DataView(zeroed).setUint32(60, 0, true);
|
||||
assert.equal(decodeGraph(zeroed), null, "landmarkCount = 0 -> null (D-01/A5: no landmark-less ARG2)");
|
||||
// A CONSISTENT landmark-less buffer (byte size exactly matches the L=0
|
||||
// layout: header + sections + align4(namePoolEnd), no suffix) still
|
||||
// refuses — the law is the count itself, not a size mismatch. Quantum
|
||||
// is valid here so the L=0 law is isolated.
|
||||
assert.equal(decodeGraph(zeroed), null, "chShortcutCount = 0 -> null (no CH-less ARG3 — A5)");
|
||||
// A CONSISTENT CH-less buffer (byte size exactly matches the S=0 ARG3
|
||||
// layout: header + base sections + align4(namePoolEnd) + chRank[n] +
|
||||
// shortcutStart[n+1]) still refuses — the law is the count itself, not
|
||||
// a size mismatch.
|
||||
const n = 1;
|
||||
const lay = 72 + 8 * n + 8 * n + 4 * (n + 1) + 4 * 1; // = 100: header + lat + lng + edgeStart[2] + nameOffset[1]; no suffix
|
||||
const bare = new ArrayBuffer(lay); // byteLength === the exact L=0 layout total
|
||||
const lay = 72 + 8 * n + 8 * n + 4 * (n + 1) + 4 * 1 + 4 * n + 4 * (n + 1); // = 112: header + lat + lng + edgeStart[2] + nameOffset[1] + chRank[1] + shortcutStart[2]; no shortcut payload
|
||||
const bare = new ArrayBuffer(lay);
|
||||
const dv = new DataView(bare);
|
||||
dv.setUint32(0, GRAPH_MAGIC, true);
|
||||
dv.setUint32(4, GRAPH_VERSION, true);
|
||||
@@ -297,13 +302,12 @@ test("G11 decode rejection — landmarkCount 0 -> null even when the size matche
|
||||
dv.setUint32(12, 0, true); // edgeCount
|
||||
dv.setUint32(16, 0, true); // nameCount
|
||||
dv.setUint32(20, 0, true); // namePoolLen
|
||||
dv.setFloat32(64, 0.5, true); // a valid quantum…
|
||||
dv.setUint32(60, 0, true); // …but landmarkCount = 0
|
||||
assert.equal(decodeGraph(bare), null, "an exactly-sized landmark-less ARG2 -> null");
|
||||
// Monster header: L = 2,000,000 on a tiny buffer — the exact-size
|
||||
// arithmetic rejects BEFORE any Uint16Array allocation (a null return,
|
||||
dv.setUint32(60, 0, true); // chShortcutCount = 0
|
||||
assert.equal(decodeGraph(bare), null, "an exactly-sized CH-less ARG3 -> null");
|
||||
// Monster header: S = 2,000,000 on a tiny buffer — the exact-size
|
||||
// arithmetic rejects BEFORE any typed-array allocation (a null return,
|
||||
// never a RangeError).
|
||||
const monster = buf.slice(0);
|
||||
new DataView(monster).setUint32(60, 2_000_000, true);
|
||||
assert.equal(decodeGraph(monster), null, "monster landmarkCount on a tiny buffer -> null, no allocation");
|
||||
assert.equal(decodeGraph(monster), null, "monster chShortcutCount on a tiny buffer -> null, no allocation");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user