#!/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"); });