Files
arah2/routing/src/graph.ts
T
araharah-executor 1ce35907d5 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)
2026-10-24 12:07:17 +08:00

300 lines
12 KiB
TypeScript
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.
/*
* 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;
}