Files
arah2/routing/test/graph.test.mjs
T
araharah-executor 34d96b0cdb test(08-02): ARG2 codec G-suite — RED gate (G3/G5/G7/G8 re-pins + new G9-G11)
Re-pins: G3 consumes the frozen mini-graph-arg1.bin as the standing ARG1
rejection vector (D-01 one-path law) + v1/v3 tamper; G5's zero-copy flip
moves to byte 72 (the ARG2 header end); G7 expects the regenerated ARG2
fixture (landmarkCount >= 1, quantum 0.1, L*N tables, anchor self-cells
0); G8 re-pins the exported constants to 0x41524732/2.

New: G9 table round-trip (four landmark properties, sentinel-is-DATA,
zero-copy u16 views); G10 encode-validation matrix (9 named-throw cases,
T-08-04); G11 decode rejection (landmarkCount=0 refused even when the
byte size exactly matches the L=0 layout — D-01/A5 — plus the monster
landmarkCount=2e6 arithmetic rejection, T-08-01).
2026-10-25 18:46:00 +08:00

310 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/*
* graph.test.mjs — the CSR road-graph binary codec gates (G1–G11).
* ------------------------------------------------------------------
* 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.
*
* 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).
*
* G1 round-trip — encodeGraph → decodeGraph restores coords,
* targets, flags, weights, names, bbox 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
* 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)
* G5 zero-copy views — the views' .buffer IS the input buffer;
* 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
* 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)
*
* 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
// Lazy module load — missing module => per-case ERR_MODULE_NOT_FOUND (RED).
let graphPromise = null;
function loadGraph() {
if (!graphPromise) graphPromise = import("../src/graph.ts");
return graphPromise;
}
/** Decode helper: bytes -> exact ArrayBuffer copy (Buffer pools are larger). */
function toArrayBuffer(buf) {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
}
/* ————— synthetic 3-node graph (the round-trip vector) ————— */
function syntheticInput() {
return {
lat: [3.15, 3.1505, 3.151],
lng: [101.69, 101.6905, 101.691],
edges: [
{ from: 0, to: 1, seconds: 4, nameId: 0 },
{ from: 1, to: 0, seconds: 4 }, // unnamed (nameId -> UNNAMED_NAME_ID)
{ from: 1, to: 2, seconds: 8, flags: 1, nameId: 1 }, // oneway-reverse marker
],
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),
},
};
}
/* ————— G1–G8 ————— */
test("G1 round-trip — encodeGraph -> decodeGraph restores coords, targets, flags, weights, names, bbox exactly", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const input = syntheticInput();
const buf = encodeGraph(input);
assert.ok(buf instanceof ArrayBuffer, "encodeGraph returns an ArrayBuffer");
const g = decodeGraph(buf);
assert.ok(g, "a well-formed buffer decodes non-null");
assert.equal(g.nodeCount, 3);
assert.equal(g.edgeCount, 3);
assert.equal(g.nameCount, 2);
assert.deepEqual([...g.lat], input.lat, "lat round-trips exactly (f64)");
assert.deepEqual([...g.lng], input.lng, "lng round-trips exactly (f64)");
// edges grouped by `from` ascending: node0 -> [e0], node1 -> [e1, e2], node2 -> []
assert.deepEqual([...g.edgeStart], [0, 1, 3, 3], "CSR row pointers");
assert.deepEqual([...g.edgeTarget], [1, 0, 2], "edge targets round-trip");
assert.deepEqual([...g.edgeFlags], [0, 0, 1], "flags round-trip (bit0 oneway-reverse)");
assert.deepEqual([...g.edgeSeconds], [4, 4, 8], "weights round-trip (f32)");
assert.deepEqual([...g.edgeNameId], [0, 0xffffffff, 1], "name ids round-trip (unnamed sentinel)");
assert.deepEqual(
g.bbox,
{ minLat: 3.15, minLng: 101.69, maxLat: 3.151, maxLng: 101.691 },
"bbox derived from the nodes at encode time",
);
assert.equal(g.maxSpeedKmh, 60);
});
test("G2 bad magic -> null — a tampered header never half-decodes (T-06-03)", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const buf = encodeGraph(syntheticInput());
const tampered = buf.slice(0);
new DataView(tampered).setUint32(0, 0xdeadbeef, true);
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 () => {
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.
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");
});
test("G4 truncated + hostile-size buffer -> null — exact byte-size check BEFORE any view allocation (T-06-03b)", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const buf = encodeGraph(syntheticInput());
assert.equal(
decodeGraph(buf.slice(0, buf.byteLength - 1)),
null,
"one byte short -> null (truncated)",
);
assert.equal(decodeGraph(new ArrayBuffer(0)), null, "empty buffer -> null");
assert.equal(decodeGraph(new ArrayBuffer(16)), null, "sub-header buffer -> null");
// Hostile header: valid magic/version but a huge nodeCount on a tiny buffer —
// the size mismatch rejects it before any allocation (T-06-03b).
const hostile = new ArrayBuffer(72);
const dv = new DataView(hostile);
const { GRAPH_MAGIC, GRAPH_VERSION } = await loadGraph();
dv.setUint32(0, GRAPH_MAGIC, true);
dv.setUint32(4, GRAPH_VERSION, true);
dv.setUint32(8, 1_000_000, true); // declares a million nodes…
assert.equal(decodeGraph(hostile), null, "huge-count header on a tiny buffer -> null, no allocation");
});
test("G5 zero-copy views — the views' .buffer IS the input buffer; flipping an input byte flips the view", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const buf = encodeGraph(syntheticInput()).slice(0);
const g = decodeGraph(buf);
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");
// The padded header ends at byte 72 — the first lat byte. Flip it.
const dv = new DataView(buf);
const before = g.lat[0];
dv.setUint8(72, dv.getUint8(72) ^ 0xff);
assert.notEqual(g.lat[0], before, "mutating the input buffer flips the decoded view");
});
test("G6 name pool — nameAt(0) resolves the first pool name; the unnamed sentinel resolves undefined", async () => {
const { encodeGraph, decodeGraph, UNNAMED_NAME_ID } = await loadGraph();
const g = decodeGraph(encodeGraph(syntheticInput()));
assert.ok(g, "decode succeeds");
assert.equal(g.nameAt(0), "Jalan Mini 1", "nameId 0 -> the first pool name");
assert.equal(g.nameAt(1), "Jalan Mini 2");
assert.equal(g.nameAt(UNNAMED_NAME_ID), undefined, "UNNAMED_NAME_ID -> undefined");
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("G8 exported constants — GRAPH_MAGIC === 0x41524732 ('ARG2'), GRAPH_VERSION === 2", async () => {
const mod = await loadGraph();
assert.equal(mod.GRAPH_MAGIC, 0x41524732);
assert.equal(mod.GRAPH_VERSION, 2);
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 () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const buf = encodeGraph(syntheticInput()).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");
});
test("G10 encode validation — malformed landmark 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 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 }],
];
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 () => {
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.
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 dv = new DataView(bare);
dv.setUint32(0, GRAPH_MAGIC, true);
dv.setUint32(4, GRAPH_VERSION, true);
dv.setUint32(8, n, true);
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,
// 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");
});