feat(06-01): CSR graph codec + committed mini-graph fixture + RouteStep.kind (GREEN)

- graph.ts: decodeGraph/encodeGraph over the pinned little-endian CSR
  layout; header magic+version+exact byte-size validation BEFORE any view
  allocation (T-06-03/T-06-03b fail-honest null); zero-copy typed-array
  views; street-name pool with UNNAMED_NAME_ID sentinel
- header padded 60 -> 64 bytes so f64 views are 8-byte aligned — unaligned
  Float64Array throws RangeError, making plan-literal offsets incompatible
  with the plan's own zero-copy law (documented deviation; field
  order/content/endian unchanged; consumers inherit via encodeGraph)
- build-mini-graph.mjs + committed mini-graph.bin: hand-built 5x5 grid,
  25 nodes, 80 directed edges, 3 named streets, exactly ONE oneway pair,
  two speed classes (60/30 km/h) — the A* ground truth (G7 pins it)
- types.ts: RouteStep gains optional kind (8 turn kinds) — Engine A
  populates it (06-01); fixture-world steps stay empty arrays (D-10)
This commit is contained in:
araharah-executor
2026-10-24 12:07:17 +08:00
parent 53740944b8
commit 1ce35907d5
4 changed files with 397 additions and 0 deletions
+299
View File
@@ -0,0 +1,299 @@
/*
* graph.ts — the CSR road-graph binary codec (D-01 engine A data plane).
* ------------------------------------------------------------------------
* decodeGraph turns the R2-hosted graph binary into typed-array views with
* ZERO per-node JS objects (D-01); encodeGraph is its build-time inverse
* (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)
* | 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).
* 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)
*
* 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
* 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-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;
/** Layout version this module reads and writes. */
export const GRAPH_VERSION = 1;
/** 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
* 8-byte aligned — the zero-copy law's mechanical prerequisite.
*/
const HEADER_SIZE = 64;
/** The decoded graph: typed-array views over the caller's ArrayBuffer. */
export interface RoadGraph {
nodeCount: number;
edgeCount: number;
nameCount: number;
lat: Float64Array;
lng: Float64Array;
/** CSR row pointers — edgeStart[i]..edgeStart[i+1] are node i's edges. */
edgeStart: Uint32Array;
edgeTarget: Uint32Array;
/** bit0 set = oneway-reverse (never traversed by A*; visible to snapping). */
edgeFlags: Uint8Array;
/** Speed-derived travel time per edge, seconds. */
edgeSeconds: Float32Array;
/** Index into the name table; UNNAMED_NAME_ID = no street name. */
edgeNameId: Uint32Array;
/** Street name for a name-table id; undefined for the unnamed sentinel. */
nameAt(id: number): string | undefined;
bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number };
maxSpeedKmh: number;
}
/** One directed edge at encode time. */
export interface GraphEdgeInput {
from: number;
to: number;
/** Travel time in seconds (speed-derived at build). */
seconds: number;
/** bit0 = oneway-reverse. */
flags?: number;
/** Name-table index; omit for an unnamed edge. */
nameId?: number;
}
/** The encodeGraph input — the shape build scripts assemble. */
export interface GraphInput {
lat: ArrayLike<number>;
lng: ArrayLike<number>;
/** Any order — encodeGraph groups them into CSR rows itself. */
edges: readonly GraphEdgeInput[];
names: readonly string[];
maxSpeedKmh: number;
}
/* Section offsets implied by the header counts (all little-endian). */
interface Layout {
lat: number;
lng: number;
edgeStart: number;
edgeTarget: number;
edgeFlags: number;
edgeSeconds: number;
edgeNameId: number;
nameOffset: number;
namePool: number;
total: number;
}
function layoutFor(nodeCount: number, edgeCount: number, nameCount: number, namePoolLen: number): Layout {
const lat = HEADER_SIZE;
const lng = lat + 8 * nodeCount;
const edgeStart = lng + 8 * nodeCount;
const edgeTarget = edgeStart + 4 * (nodeCount + 1);
const edgeFlags = edgeTarget + 4 * edgeCount;
const edgeSeconds = (edgeFlags + edgeCount + 3) & ~3; // align f32 to 4 bytes
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 };
}
/**
* Decode the CSR binary into zero-copy typed-array views, or null when the
* buffer is not exactly a GRAPH_VERSION graph (T-06-03 fail-honest).
*/
export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null {
if (buffer.byteLength < HEADER_SIZE) return null;
const view = new DataView(buffer);
if (view.getUint32(0, true) !== GRAPH_MAGIC) return null;
if (view.getUint32(4, true) !== GRAPH_VERSION) return null;
const nodeCount = view.getUint32(8, true);
const edgeCount = view.getUint32(12, true);
const nameCount = view.getUint32(16, true);
const namePoolLen = view.getUint32(20, true);
const bbox = {
minLat: view.getFloat64(24, true),
minLng: view.getFloat64(32, true),
maxLat: view.getFloat64(40, true),
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);
if (buffer.byteLength !== lay.total) return null;
const lat = new Float64Array(buffer, lay.lat, nodeCount);
const lng = new Float64Array(buffer, lay.lng, nodeCount);
const edgeStart = new Uint32Array(buffer, lay.edgeStart, nodeCount + 1);
const edgeTarget = new Uint32Array(buffer, lay.edgeTarget, edgeCount);
const edgeFlags = new Uint8Array(buffer, lay.edgeFlags, edgeCount);
const edgeSeconds = new Float32Array(buffer, lay.edgeSeconds, edgeCount);
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 decoder = new TextDecoder();
const graph: RoadGraph = {
nodeCount,
edgeCount,
nameCount,
lat,
lng,
edgeStart,
edgeTarget,
edgeFlags,
edgeSeconds,
edgeNameId,
nameAt(id: number): string | undefined {
if (!Number.isInteger(id) || id < 0 || id >= nameCount) return undefined;
const start = nameOffset[id]!;
const end = nameOffset[id + 1]!;
if (end < start) return undefined;
return decoder.decode(namePool.subarray(start, end));
},
bbox,
maxSpeedKmh,
};
return graph;
}
function fail(message: string): never {
throw new Error(`encodeGraph: ${message}`);
}
/**
* Encode a graph into the pinned CSR binary. Throws on malformed input —
* encode is the build-time path (loud failure is correct there; the
* fail-honest-null law belongs to decodeGraph).
*/
export function encodeGraph(input: GraphInput): ArrayBuffer {
const nodeCount = input.lat.length;
if (nodeCount < 1 || input.lng.length !== nodeCount) {
fail("lat/lng must be non-empty and the same length");
}
if (!Number.isFinite(input.maxSpeedKmh) || input.maxSpeedKmh <= 0) {
fail("maxSpeedKmh must be a positive finite number");
}
const nameCount = input.names.length;
for (const name of input.names) {
if (typeof name !== "string") fail("names must all be strings");
}
for (let i = 0; i < nodeCount; i++) {
if (!Number.isFinite(input.lat[i]!) || !Number.isFinite(input.lng[i]!)) {
fail(`node ${i} has a non-finite coordinate`);
}
}
for (const edge of input.edges) {
if (!Number.isInteger(edge.from) || edge.from < 0 || edge.from >= nodeCount) {
fail(`edge from-node ${edge.from} out of range`);
}
if (!Number.isInteger(edge.to) || edge.to < 0 || edge.to >= nodeCount) {
fail(`edge to-node ${edge.to} out of range`);
}
if (!Number.isFinite(edge.seconds) || edge.seconds < 0) {
fail(`edge ${edge.from}->${edge.to} has a non-finite/negative weight`);
}
if (edge.flags !== undefined && (!Number.isInteger(edge.flags) || edge.flags < 0 || edge.flags > 255)) {
fail(`edge ${edge.from}->${edge.to} has malformed flags`);
}
if (edge.nameId !== undefined && (!Number.isInteger(edge.nameId) || edge.nameId < 0 || edge.nameId >= nameCount)) {
fail(`edge ${edge.from}->${edge.to} nameId ${edge.nameId} out of range`);
}
}
// Name pool: UTF-8 bytes, non-null-terminated, offset table m+1 entries.
const encoder = new TextEncoder();
const encodedNames = input.names.map((name) => encoder.encode(name));
let namePoolLen = 0;
for (const bytes of encodedNames) namePoolLen += bytes.length;
// Edges grouped by `from` ascending (stable within a node): counting sort.
const counts = new Uint32Array(nodeCount + 1);
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 buffer = new ArrayBuffer(lay.total);
const view = new DataView(buffer);
const edgeTarget = new Uint32Array(buffer, lay.edgeTarget, edgeCount);
const edgeFlags = new Uint8Array(buffer, lay.edgeFlags, edgeCount);
const edgeSeconds = new Float32Array(buffer, lay.edgeSeconds, edgeCount);
const edgeNameId = new Uint32Array(buffer, lay.edgeNameId, edgeCount);
const cursor = new Uint32Array(nodeCount);
for (const edge of input.edges) {
const pos = cursor[edge.from]! + counts[edge.from]!;
cursor[edge.from] = cursor[edge.from]! + 1;
edgeTarget[pos] = edge.to;
edgeFlags[pos] = edge.flags ?? 0;
edgeSeconds[pos] = edge.seconds;
edgeNameId[pos] = edge.nameId ?? UNNAMED_NAME_ID;
}
// Header.
view.setUint32(0, GRAPH_MAGIC, true);
view.setUint32(4, GRAPH_VERSION, true);
view.setUint32(8, nodeCount, true);
view.setUint32(12, edgeCount, true);
view.setUint32(16, nameCount, true);
view.setUint32(20, namePoolLen, true);
let minLat = Infinity;
let minLng = Infinity;
let maxLat = -Infinity;
let maxLng = -Infinity;
for (let i = 0; i < nodeCount; i++) {
const la = input.lat[i]!;
const ln = input.lng[i]!;
if (la < minLat) minLat = la;
if (la > maxLat) maxLat = la;
if (ln < minLng) minLng = ln;
if (ln > maxLng) maxLng = ln;
}
view.setFloat64(24, minLat, true);
view.setFloat64(32, minLng, true);
view.setFloat64(40, maxLat, true);
view.setFloat64(48, maxLng, true);
view.setFloat32(56, input.maxSpeedKmh, true);
// CSR row pointers + node coordinates.
for (let i = 0; i <= nodeCount; i++) view.setUint32(lay.edgeStart + 4 * i, counts[i]!, true);
const latOut = new Float64Array(buffer, lay.lat, nodeCount);
const lngOut = new Float64Array(buffer, lay.lng, nodeCount);
for (let i = 0; i < nodeCount; i++) {
latOut[i] = input.lat[i]!;
lngOut[i] = input.lng[i]!;
}
// Name table: ABSOLUTE pool offsets (m entries) + total length (m+1th).
let acc = 0;
for (let i = 0; i < nameCount; i++) {
view.setUint32(lay.nameOffset + 4 * i, acc, true);
acc += encodedNames[i]!.byteLength;
}
view.setUint32(lay.nameOffset + 4 * nameCount, namePoolLen, true);
const pool = new Uint8Array(buffer, lay.namePool, namePoolLen);
let at = 0;
for (const bytes of encodedNames) {
pool.set(bytes, at);
at += bytes.length;
}
return buffer;
}
+13
View File
@@ -50,6 +50,19 @@ export interface RouteStep {
name?: string;
/** polyline6-encoded step geometry (backend #2/#3 populate this). */
geometry?: string;
/**
* Maneuver kind — Engine A populates this (06-01); fixture-world steps
* stay empty arrays (D-10).
*/
kind?:
| "depart"
| "straight"
| "left"
| "right"
| "slight-left"
| "slight-right"
| "uturn"
| "arrive";
}
/**
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
/*
* build-mini-graph.mjs — builds the committed mini-graph.bin fixture.
* ----------------------------------------------------------------------
* A MAINTAINER-TIME REPO TOOL: regenerates routing/test/fixtures/
* mini-graph.bin — the A* correctness ground truth (plan 06-01) and the
* snap/astar suites' shared fixture. NOT a runtime dependency; nothing in
* bot/src/, web/src/, or routing/src/ imports it. Tests consume the
* COMMITTED binary; only a deliberate fixture redesign re-runs this.
*
* Usage: node routing/test/fixtures/build-mini-graph.mjs
* Exit: 0 on write; throws on any invariant violation (never writes).
*
* 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)
* - three named streets: "Jalan Mini 1" (interior E-W rows),
* "Jalan Mini 2" (interior N-S columns), "Jalan Persiaran Mini" (perimeter)
* - exactly ONE oneway pair: 2 -> 3 traversable, 3 -> 2 flagged bit0
* - two speed classes: perimeter 60 km/h, interior 30 km/h
* (weights = segment meters / (kmh / 3.6), meters via haversineM)
*/
import { writeFileSync } from "node:fs";
import { encodeGraph } from "../../src/graph.ts";
import { haversineM } from "../../src/geo.ts";
const GRID = 5;
const LAT0 = 3.15;
const LNG0 = 101.69;
const STEP_DEG = 0.0005;
/** The single oneway pair (grid ids: row 0, cols 2->3). */
const ONEWAY_U = 2;
const ONEWAY_V = 3;
const NAME_MINI_1 = 0; // interior E-W rows
const NAME_MINI_2 = 1; // interior N-S columns
const NAME_PERIMETER = 2;
const names = ["Jalan Mini 1", "Jalan Mini 2", "Jalan Persiaran Mini"];
const SPEED_PERIMETER_KMH = 60;
const SPEED_INTERIOR_KMH = 30;
const lat = [];
const lng = [];
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
// 1e-6 integer grid then divide — exact decimal coordinates (the codec's
// grid law); naive `3.15 + r * 0.0005` accumulates float error.
lat.push((3150000 + r * 500) / 1e6);
lng.push((101690000 + c * 500) / 1e6);
}
}
const id = (r, c) => r * GRID + c;
const edges = [];
function addEdge(u, v, nameId) {
const kmh = nameId === NAME_PERIMETER ? SPEED_PERIMETER_KMH : SPEED_INTERIOR_KMH;
const meters = haversineM({ lat: lat[u], lng: lng[u] }, { lat: lat[v], lng: lng[v] });
edges.push({ from: u, to: v, seconds: meters / (kmh / 3.6), nameId });
}
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID - 1; c++) {
const nameId = r === 0 || r === GRID - 1 ? NAME_PERIMETER : NAME_MINI_1;
addEdge(id(r, c), id(r, c + 1), nameId);
addEdge(id(r, c + 1), id(r, c), nameId);
}
}
for (let c = 0; c < GRID; c++) {
for (let r = 0; r < GRID - 1; r++) {
const nameId = c === 0 || c === GRID - 1 ? NAME_PERIMETER : NAME_MINI_2;
addEdge(id(r, c), id(r + 1, c), nameId);
addEdge(id(r + 1, c), id(r, c), nameId);
}
}
// Exactly ONE oneway: the reverse mirror of U->V carries flags bit0.
const reverse = edges.find((e) => e.from === ONEWAY_V && e.to === ONEWAY_U);
if (!reverse) throw new Error("oneway reverse edge not found — grid invariant broken");
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 });
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`);
Binary file not shown.