feat(08-02): ARG2 codec — one version, one decode path (G-suite GREEN)

graph.ts versioned IN PLACE (no second codec): GRAPH_MAGIC 0x41524732,
GRAPH_VERSION 2, HEADER_SIZE 72 (72%8==0 keeps f64 views aligned);
header gains landmarkCount u32 @60 (the old pad), landmarkQuantumS f32
@64, reserved u32 @68; the landmark suffix (u16 toTable, u16 fromTable,
u32 landmarkNodes) extends layoutFor's total arithmetic — exact-size
law covers the variable suffix BEFORE any view allocation (T-08-01).
D-01 fail-honest: ARG1 bytes / version!=2 / landmarkCount=0 / hostile
quantum / size mismatch all decode null (committed mini-graph-arg1.bin
is the standing rejection vector). GraphInput.landmarks REQUIRED and
loud-validated (T-08-04). RoadGraph gains the five landmark fields
(Plan 08-03's ALT potentials consume them).

Fixture regenerated as ARG2 through the SAME encode authority: the
builder gains the tiny real landmark stage (L=2, anchors [0,18],
quantum 0.1s, coverage 1.0 — identical bytes every re-run).

Rule-3 call-site updates forced by the required-landmarks law (missed
by the plan's files list): astar.test A3 + snap.test S3 synthetics
carry inert hand landmark blocks. Fixture regen pulled forward from
task 3 so this commit's own battery is green (74 passed, 0 failed).
This commit is contained in:
araharah-executor
2026-10-25 18:48:23 +08:00
parent 34d96b0cdb
commit 32a00b5b0b
5 changed files with 206 additions and 24 deletions
+146 -22
View File
@@ -6,46 +6,67 @@
* (the committed mini-graph fixture and plan 04's Overpass build script
* both write through it, so the layout has exactly one authority).
*
* PINNED CSR BINARY LAYOUT (little-endian; 06-01 — plans 04/07 depend on it):
* header: magic u32 (0x41524731 "ARG1") | version u32 (=1)
* PINNED CSR BINARY LAYOUT — ARG2 (little-endian; 06-01 pinned ARG1,
* plan 08-02 versioned it per phase-8 D-01; plans 04/07 depend on it):
* header: magic u32 (0x41524732 "ARG2") | version u32 (=2)
* | nodeCount u32 | edgeCount u32 | nameCount u32 | namePoolLen u32
* | bbox 4×f64 (minLat, minLng, maxLat, maxLng) | maxSpeedKmh f32
* | 4 pad bytes — the header is padded 60 -> 64 bytes so the f64
* node arrays start 8-byte aligned: zero-copy TypedArray views
* REQUIRE aligned offsets (unaligned Float64Array throws).
* | bbox 4×f64 (minLat, minLng, maxLat, maxLng) | maxSpeedKmh f32 @56
* | landmarkCount u32 @60 (>= 1 REQUIRED — see the D-01 law)
* | landmarkQuantumS f32 @64 (the ĉ unit; build default 0.1 s)
* | reserved u32 @68 (written 0; decode accepts any value)
* — 72 bytes total: 72 % 8 == 0 keeps the f64 node views
* 8-byte aligned; zero-copy TypedArray views REQUIRE aligned
* offsets (unaligned Float64Array throws).
* nodes: f64 lat[n] ++ f64 lng[n]
* offsets: u32 edgeStart[n+1] (CSR row pointers)
* edges: u32 edgeTarget[e] ++ u8 edgeFlags[e] (bit0: oneway-reverse)
* weights: f32 edgeSeconds[e] (aligned to 4 bytes)
* names: u32 edgeNameId[e] | u32 nameOffset[m+1] | u8 namePool (UTF-8)
* landmarks (the APPEND-ONLY ARG2 suffix; row-major [l*N + v]):
* u16 toTable[L*N] — quantized ĉ-distance v -> anchor l
* u16 fromTable[L*N] — quantized ĉ-distance anchor l -> v
* u32 landmarkNodes[L] — the anchors (audit + --check prints)
* 0xFFFF in a table cell is DATA (the unreachable sentinel,
* landmark.ts) — decode never validates it away.
*
* THE D-01 FAIL-HONEST LAW (one decode path, one search path): a buffer
* that is not EXACTLY an ARG2 graph decodes to null — ARG1 bytes (version
* 1), future versions, landmarkCount = 0 (no landmark-less ARG2 layout
* exists to silently degrade into), and every size mismatch. There is no
* v1 fallback branch: the Phase 6 router-pending state covers old binaries
* until one re-upload. (Router-pending coverage is exercised by the bot
* suite; the committed mini-graph-arg1.bin is the standing ARG1 rejection
* vector — plan 08-01.)
*
* edgeFlags bit0 ("oneway-reverse") marks the blocked mirror direction of a
* oneway segment: the edge stays in the CSR so snapping sees the segment
* geometry and its street name, but A* (astar.ts) never traverses it.
*
* T-06-03: a hostile/corrupt binary fails HONEST — decodeGraph validates
* magic, version, and the EXACT byte size implied by the header counts, and
* returns null on any mismatch. It never throws and never returns a
* half-built graph; plan 04's Worker turns null into the designed
* magic, version, landmarkCount, the quantum, and the EXACT byte size
* implied by the header counts (the size arithmetic covers the variable
* u16 suffix), and returns null on any mismatch. It never throws and never
* returns a half-built graph; plan 04's Worker turns null into the designed
* router-pending state.
* T-06-03b: the size check runs BEFORE any view allocation — a huge-count
* header on a small buffer rejects on arithmetic, not on allocation.
* T-06-03b / T-08-01: the size check runs BEFORE any view allocation —
* huge-count headers (including a monster landmarkCount) reject on
* arithmetic, not on allocation.
* T-06-05: pure module — no logging, no storage, no network; coordinates
* exist only in the caller's frame.
*/
/** Header magic "ARG1" (ArahaRah Graph v1). */
export const GRAPH_MAGIC = 0x41524731;
/** Header magic "ARG2" (ArahaRah Graph v2 — the landmark-carrying layout). */
export const GRAPH_MAGIC = 0x41524732;
/** Layout version this module reads and writes. */
export const GRAPH_VERSION = 1;
export const GRAPH_VERSION = 2;
/** edgeNameId sentinel: the edge carries no street name. */
export const UNNAMED_NAME_ID = 0xffffffff;
/**
* 60 field bytes + 4 alignment pad bytes. The pad keeps every f64 view
* 68 field bytes + 4 reserved bytes. 72 % 8 == 0 keeps every f64 view
* 8-byte aligned — the zero-copy law's mechanical prerequisite.
*/
const HEADER_SIZE = 64;
const HEADER_SIZE = 72;
/** The decoded graph: typed-array views over the caller's ArrayBuffer. */
export interface RoadGraph {
@@ -67,6 +88,16 @@ export interface RoadGraph {
nameAt(id: number): string | undefined;
bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number };
maxSpeedKmh: number;
/** Number of landmark anchors (ARG2: always >= 1 — decode refuses 0). */
landmarkCount: number;
/** The ĉ quantum in seconds the tables were computed at (f32 readback). */
landmarkQuantumS: number;
/** Row-major [l*N + v] quantized distance v -> anchor l; 0xFFFF = unreachable (DATA). */
landmarkTo: Uint16Array;
/** Row-major [l*N + v] quantized distance anchor l -> v; 0xFFFF = unreachable (DATA). */
landmarkFrom: Uint16Array;
/** The L anchor node ids (row l of both tables). */
landmarkNodes: Uint32Array;
}
/** One directed edge at encode time. */
@@ -81,6 +112,18 @@ export interface GraphEdgeInput {
nameId?: number;
}
/** The landmark-table block of the encodeGraph input (ARG2). */
export interface LandmarkTablesInput {
/** The ĉ quantum in seconds the tables were computed at (e.g. landmark.ts's LANDMARK_QUANTUM_S). */
quantumS: number;
/** The L anchor node ids; L >= 1 — an ARG2 always carries landmarks. */
nodes: ArrayLike<number>;
/** Row-major [l*N + v] to-anchors table, length exactly L*N. */
to: Uint16Array;
/** Row-major [l*N + v] from-anchors table, length exactly L*N. */
from: Uint16Array;
}
/** The encodeGraph input — the shape build scripts assemble. */
export interface GraphInput {
lat: ArrayLike<number>;
@@ -89,6 +132,8 @@ export interface GraphInput {
edges: readonly GraphEdgeInput[];
names: readonly string[];
maxSpeedKmh: number;
/** REQUIRED since ARG2 (D-01): the precomputed landmark tables. */
landmarks: LandmarkTablesInput;
}
/* Section offsets implied by the header counts (all little-endian). */
@@ -102,10 +147,19 @@ interface Layout {
edgeNameId: number;
nameOffset: number;
namePool: number;
toTable: number;
fromTable: number;
landmarkNodes: number;
total: number;
}
function layoutFor(nodeCount: number, edgeCount: number, nameCount: number, namePoolLen: number): Layout {
function layoutFor(
nodeCount: number,
edgeCount: number,
nameCount: number,
namePoolLen: number,
landmarkCount: number,
): Layout {
const lat = HEADER_SIZE;
const lng = lat + 8 * nodeCount;
const edgeStart = lng + 8 * nodeCount;
@@ -115,7 +169,27 @@ function layoutFor(nodeCount: number, edgeCount: number, nameCount: number, name
const edgeNameId = edgeSeconds + 4 * edgeCount;
const nameOffset = edgeNameId + 4 * edgeCount;
const namePool = nameOffset + 4 * (nameCount + 1);
return { lat, lng, edgeStart, edgeTarget, edgeFlags, edgeSeconds, edgeNameId, nameOffset, namePool, total: namePool + namePoolLen };
// The ARG2 landmark suffix — an append-only extension of the pinned ARG1
// section order. Encode and decode share THIS arithmetic (T-08-01: the
// exact-size law extends over the variable-size suffix).
const toTable = (namePool + namePoolLen + 3) & ~3; // align u16 pairs to 4 bytes
const fromTable = toTable + 2 * landmarkCount * nodeCount;
const landmarkNodes = fromTable + 2 * landmarkCount * nodeCount;
return {
lat,
lng,
edgeStart,
edgeTarget,
edgeFlags,
edgeSeconds,
edgeNameId,
nameOffset,
namePool,
toTable,
fromTable,
landmarkNodes,
total: landmarkNodes + 4 * landmarkCount,
};
}
/**
@@ -138,9 +212,19 @@ export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null {
maxLng: view.getFloat64(48, true),
};
const maxSpeedKmh = view.getFloat32(56, true);
// Exact byte-size check BEFORE any view allocation (T-06-03b): a hostile
// huge-count header rejects here on arithmetic alone.
const lay = layoutFor(nodeCount, edgeCount, nameCount, namePoolLen);
// ARG2 header fields (plan 08-02): landmarkCount @60 (the ARG1 pad),
// quantum @64, reserved @68 (accepted untouched — the field is unused).
const landmarkCount = view.getUint32(60, true);
// D-01/A5: an ARG2 always carries landmarks — a landmark-less layout
// would silently route with weak steering, and "no v1 fallback branch"
// holds at the content level too. Decode refuses, honestly.
if (landmarkCount === 0) return null;
const landmarkQuantumS = view.getFloat32(64, true);
if (!Number.isFinite(landmarkQuantumS) || landmarkQuantumS <= 0) return null; // hostile quantum -> null, never a divide-by-0 downstream
// Exact byte-size check BEFORE any view allocation (T-06-03b, extended
// over the u16 suffix per T-08-01): a hostile huge-count header —
// including a monster landmarkCount — rejects here on arithmetic alone.
const lay = layoutFor(nodeCount, edgeCount, nameCount, namePoolLen, landmarkCount);
if (buffer.byteLength !== lay.total) return null;
const lat = new Float64Array(buffer, lay.lat, nodeCount);
@@ -152,6 +236,9 @@ export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null {
const edgeNameId = new Uint32Array(buffer, lay.edgeNameId, edgeCount);
const nameOffset = new Uint32Array(buffer, lay.nameOffset, nameCount + 1);
const namePool = new Uint8Array(buffer, lay.namePool, namePoolLen);
const landmarkTo = new Uint16Array(buffer, lay.toTable, landmarkCount * nodeCount);
const landmarkFrom = new Uint16Array(buffer, lay.fromTable, landmarkCount * nodeCount);
const landmarkNodes = new Uint32Array(buffer, lay.landmarkNodes, landmarkCount);
const decoder = new TextDecoder();
const graph: RoadGraph = {
nodeCount,
@@ -173,6 +260,11 @@ export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null {
},
bbox,
maxSpeedKmh,
landmarkCount,
landmarkQuantumS,
landmarkTo,
landmarkFrom,
landmarkNodes,
};
return graph;
}
@@ -194,6 +286,29 @@ export function encodeGraph(input: GraphInput): ArrayBuffer {
if (!Number.isFinite(input.maxSpeedKmh) || input.maxSpeedKmh <= 0) {
fail("maxSpeedKmh must be a positive finite number");
}
// ARG2 landmark block (D-01): REQUIRED, loud-validated BEFORE any
// allocation work (T-08-04) — no silent clamp, no landmark-less encode.
const landmarks = input.landmarks;
if (!landmarks) fail("landmarks block is required (ARG2 has no landmark-less layout)");
const landmarkCount = landmarks.nodes.length;
if (!Number.isInteger(landmarkCount) || landmarkCount < 1) {
fail("landmarks.nodes must carry at least one anchor (ARG2 has no landmark-less layout)");
}
if (!Number.isFinite(landmarks.quantumS) || landmarks.quantumS <= 0) {
fail("landmarks.quantumS must be a positive finite number");
}
for (let i = 0; i < landmarkCount; i++) {
const a = landmarks.nodes[i]!;
if (!Number.isInteger(a) || a < 0 || a >= nodeCount) {
fail(`landmarks.nodes[${i}] (${a}) is not a node id in [0, ${nodeCount})`);
}
}
if (landmarks.to.length !== landmarkCount * nodeCount) {
fail(`landmarks.to must have length L*N = ${landmarkCount}*${nodeCount}, got ${landmarks.to.length}`);
}
if (landmarks.from.length !== landmarkCount * nodeCount) {
fail(`landmarks.from must have length L*N = ${landmarkCount}*${nodeCount}, got ${landmarks.from.length}`);
}
const nameCount = input.names.length;
for (const name of input.names) {
if (typeof name !== "string") fail("names must all be strings");
@@ -232,7 +347,7 @@ export function encodeGraph(input: GraphInput): ArrayBuffer {
for (const edge of input.edges) counts[edge.from + 1] = (counts[edge.from + 1] ?? 0) + 1;
for (let i = 0; i < nodeCount; i++) counts[i + 1] = counts[i + 1]! + counts[i]!;
const edgeCount = input.edges.length;
const lay = layoutFor(nodeCount, edgeCount, nameCount, namePoolLen);
const lay = layoutFor(nodeCount, edgeCount, nameCount, namePoolLen, landmarkCount);
const buffer = new ArrayBuffer(lay.total);
const view = new DataView(buffer);
const edgeTarget = new Uint32Array(buffer, lay.edgeTarget, edgeCount);
@@ -273,6 +388,9 @@ export function encodeGraph(input: GraphInput): ArrayBuffer {
view.setFloat64(40, maxLat, true);
view.setFloat64(48, maxLng, true);
view.setFloat32(56, input.maxSpeedKmh, true);
view.setUint32(60, landmarkCount, true);
view.setFloat32(64, landmarks.quantumS, true);
view.setUint32(68, 0, true); // reserved (decode accepts any value)
// CSR row pointers + node coordinates.
for (let i = 0; i <= nodeCount; i++) view.setUint32(lay.edgeStart + 4 * i, counts[i]!, true);
@@ -295,5 +413,11 @@ export function encodeGraph(input: GraphInput): ArrayBuffer {
pool.set(bytes, at);
at += bytes.length;
}
// ARG2 landmark suffix — bulk typed-array writes through the same
// view discipline as the u32/f32/f64 sections, so decode's zero-copy
// views read the bytes back exactly.
new Uint16Array(buffer, lay.toTable, landmarkCount * nodeCount).set(landmarks.to);
new Uint16Array(buffer, lay.fromTable, landmarkCount * nodeCount).set(landmarks.from);
new Uint32Array(buffer, lay.landmarkNodes, landmarkCount).set(landmarks.nodes);
return buffer;
}
+9
View File
@@ -160,6 +160,15 @@ 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),
},
}),
);
assert.ok(g, "synthetic graph decodes");
+43 -2
View File
@@ -16,6 +16,12 @@
* Usage: node routing/test/fixtures/build-mini-graph.mjs
* Exit: 0 on write; throws on any invariant violation (never writes).
*
* ARG2 (plan 08-02): the fixture carries a tiny real landmark stage —
* L=2 anchors via farthestPointAnchors + quantized c-metric tables via
* landmarkTables (the SAME pure module the real build script uses), passed
* through the ONE encodeGraph authority. Deterministic: identical output
* bytes on every re-run.
*
* Design (pinned by 06-01): a hand-built 5x5 grid in a ~0.002 deg x 0.002 deg
* bbox near KL center (lat 3.1500–3.1520, lng 101.6900–101.6920):
* - 25 nodes, 80 directed edge entries (20 H + 20 V segments, both ways)
@@ -28,6 +34,7 @@
import { writeFileSync } from "node:fs";
import { encodeGraph } from "../../src/graph.ts";
import { haversineM } from "../../src/geo.ts";
import { LANDMARK_QUANTUM_S, farthestPointAnchors, landmarkTables, landmarkCoverage } from "../../src/landmark.ts";
const GRID = 5;
const LAT0 = 3.15;
@@ -85,6 +92,40 @@ reverse.flags = 1;
if (edges.length !== 80) throw new Error(`expected 80 directed edges, built ${edges.length}`);
const bin = encodeGraph({ lat, lng, edges, names, maxSpeedKmh: SPEED_PERIMETER_KMH });
// LANDMARK STAGE (plan 08-02): the fixture regenerates as ARG2 THROUGH the
// one encode authority — the same farthestPointAnchors + landmarkTables the
// real build script (08-04) will run, at a tiny L. Fail-loud invariants
// before any write (the `edges.length !== 80` style).
const LANDMARK_COUNT = 2; // tiny L: the 5x5 grid needs exactly 2 well-spread anchors
const landmarkInput = { nodeCount: GRID * GRID, edges, quantumS: LANDMARK_QUANTUM_S };
const anchors = farthestPointAnchors(landmarkInput, LANDMARK_COUNT);
const { to, from } = landmarkTables(landmarkInput, anchors);
if (anchors.length !== LANDMARK_COUNT) throw new Error(`expected ${LANDMARK_COUNT} anchors, got ${anchors.length}`);
if (to.length !== LANDMARK_COUNT * GRID * GRID || from.length !== LANDMARK_COUNT * GRID * GRID) {
throw new Error("landmark tables are not L*N row-major");
}
for (let l = 0; l < anchors.length; l++) {
const a = anchors[l];
if (to[l * GRID * GRID + a] !== 0 || from[l * GRID * GRID + a] !== 0) {
throw new Error(`anchor ${l} (node ${a}) own cells are not 0 — table law broken`);
}
}
const coverage = landmarkCoverage(to, anchors, GRID * GRID);
if (coverage.some((c) => c !== 1)) {
throw new Error(`the grid is strongly connected — coverage must be 1.0, got ${coverage.join(", ")}`);
}
const bin = encodeGraph({
lat,
lng,
edges,
names,
maxSpeedKmh: SPEED_PERIMETER_KMH,
landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from },
});
writeFileSync(new URL("./mini-graph.bin", import.meta.url), Buffer.from(bin));
console.log(`mini-graph.bin: ${GRID * GRID} nodes, ${edges.length} edges, 3 names, ${bin.byteLength} B`);
console.log(
`mini-graph.bin: ${GRID * GRID} nodes, ${edges.length} edges, 3 names, ` +
`L=${LANDMARK_COUNT} landmarks (nodes ${[...anchors].join(",")}), quantum ${LANDMARK_QUANTUM_S}s, ` +
`coverage ${coverage.map((c) => c.toFixed(2)).join("/")}, ${bin.byteLength} B`,
);
Binary file not shown.
+8
View File
@@ -110,12 +110,20 @@ test("S3 nameId — a perimeter fix resolves 'Jalan Persiaran Mini'; an unnamed
assert.equal(g.nameAt(atNode1.nameId), "Jalan Persiaran Mini");
// Synthetic 2-node graph with ONE unnamed edge -> nameId null.
// ARG2 (plan 08-02): the landmark block is required by the one encode
// authority; values are inert steering data until ALT lands (08-03).
const bare = encodeGraph({
lat: [3.15, 3.1505],
lng: [101.69, 101.6905],
edges: [{ from: 0, to: 1, seconds: 4 }],
names: [],
maxSpeedKmh: 60,
landmarks: {
quantumS: 0.5,
nodes: [0],
to: Uint16Array.of(0, 30),
from: Uint16Array.of(0, 30),
},
});
const { decodeGraph, UNNAMED_NAME_ID } = await loadGraph();
const bg = decodeGraph(bare);