diff --git a/routing/src/graph.ts b/routing/src/graph.ts new file mode 100644 index 0000000..2724307 --- /dev/null +++ b/routing/src/graph.ts @@ -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; + 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; +} diff --git a/routing/src/types.ts b/routing/src/types.ts index f1340fc..cb38a68 100644 --- a/routing/src/types.ts +++ b/routing/src/types.ts @@ -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"; } /** diff --git a/routing/test/fixtures/build-mini-graph.mjs b/routing/test/fixtures/build-mini-graph.mjs new file mode 100644 index 0000000..1e30d83 --- /dev/null +++ b/routing/test/fixtures/build-mini-graph.mjs @@ -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`); diff --git a/routing/test/fixtures/mini-graph.bin b/routing/test/fixtures/mini-graph.bin new file mode 100644 index 0000000..cb12325 Binary files /dev/null and b/routing/test/fixtures/mini-graph.bin differ