/* * 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; lng: ArrayLike; /** 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; }