- graph.ts: ARG3 layout (magic 0x41524733, version 3, chShortcutCount u32 @60, reserved @64/@68) with the CH suffix chRank ++ shortcut CSR; fail-honest decode (ARG1/ARG2 -> null, chShortcutCount 0 -> null, exact-size before any allocation, 4-byte alignment load-bearing); loud ch-block encode validation - ch-query.ts: routeCH — bidirectional uphill search (strict rank filter, forward originals+shortcuts, backward reverse views incl. shortcuts), edge-ref meet probe, iterative via-unpacking (edge-aware: a parallel original edge is never expanded as a shortcut; DERIVE prefers the cheaper of original/shortcut), seconds re-summed from the unpacked original-edge path. STOP RULE: min(topF, topB) >= mu — the classical sum rule is unsound under the rank filter (Q1 caught it: 36 vs 24) - astar.ts: routeAstar validates + delegates every request to routeCH; ALT/BH internals, clean-column memo, landmark tokens DELETED (D-04); classifyTurn/deriveSteps untouched - landmark.ts + landmark.test.mjs deleted (D-05); index.ts additively exports contractGraph + CH constants + routeCH - snap.test.mjs: S3's encode moved to the ARG3 ch block (3-node star — a 2-node graph can carry no valid shortcut); committed-fixture S-cases suspended until task 3 (Rule 3: same pattern as G7/A1-A6) - routing suite: 86 passed, 0 failed; typecheck clean
314 lines
17 KiB
JavaScript
314 lines
17 KiB
JavaScript
#!/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 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 + 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 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, 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)
|
||
* 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 — 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 — 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)
|
||
*/
|
||
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) ————— */
|
||
|
||
/**
|
||
* 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, 1),
|
||
shortcutTarget: Uint32Array.of(2),
|
||
shortcutSeconds: Float32Array.of(12),
|
||
shortcutVia: Uint32Array.of(1),
|
||
};
|
||
}
|
||
|
||
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,
|
||
// 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, 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);
|
||
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);
|
||
// 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 () => {
|
||
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, 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-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 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 () => {
|
||
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");
|
||
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];
|
||
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 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 === 0x41524733 ('ARG3'), GRAPH_VERSION === 3", async () => {
|
||
const mod = await loadGraph();
|
||
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 CH fields restore exactly; the CH arrays are zero-copy views", async () => {
|
||
const { encodeGraph, decodeGraph } = await loadGraph();
|
||
const ch = syntheticCh();
|
||
const buf = encodeGraph({ ...syntheticInput(), ch }).slice(0);
|
||
const g = decodeGraph(buf);
|
||
assert.ok(g, "decode succeeds");
|
||
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 ch input throws NAMED encodeGraph: errors (T-08-04, no silent clamp)", async () => {
|
||
const { encodeGraph } = await loadGraph();
|
||
const base = syntheticInput();
|
||
const mangle = (over) => ({ ...base, ch: { ...base.ch, ...over } });
|
||
const cases = [
|
||
["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 — 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, "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 + 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);
|
||
dv.setUint32(8, n, true);
|
||
dv.setUint32(12, 0, true); // edgeCount
|
||
dv.setUint32(16, 0, true); // nameCount
|
||
dv.setUint32(20, 0, true); // namePoolLen
|
||
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 chShortcutCount on a tiny buffer -> null, no allocation");
|
||
});
|