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:
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user