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