Files
arah2/routing/test/graph.test.mjs
T
araharah-executor 06624bb688 feat(08.1-01): ARG3 codec + uphill CH query — ALT deleted, one search path (GREEN)
- 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
2026-10-26 07:15:28 +08:00

314 lines
17 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 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");
});