feat(08-02): ARG2 codec — one version, one decode path (G-suite GREEN)
graph.ts versioned IN PLACE (no second codec): GRAPH_MAGIC 0x41524732, GRAPH_VERSION 2, HEADER_SIZE 72 (72%8==0 keeps f64 views aligned); header gains landmarkCount u32 @60 (the old pad), landmarkQuantumS f32 @64, reserved u32 @68; the landmark suffix (u16 toTable, u16 fromTable, u32 landmarkNodes) extends layoutFor's total arithmetic — exact-size law covers the variable suffix BEFORE any view allocation (T-08-01). D-01 fail-honest: ARG1 bytes / version!=2 / landmarkCount=0 / hostile quantum / size mismatch all decode null (committed mini-graph-arg1.bin is the standing rejection vector). GraphInput.landmarks REQUIRED and loud-validated (T-08-04). RoadGraph gains the five landmark fields (Plan 08-03's ALT potentials consume them). Fixture regenerated as ARG2 through the SAME encode authority: the builder gains the tiny real landmark stage (L=2, anchors [0,18], quantum 0.1s, coverage 1.0 — identical bytes every re-run). Rule-3 call-site updates forced by the required-landmarks law (missed by the plan's files list): astar.test A3 + snap.test S3 synthetics carry inert hand landmark blocks. Fixture regen pulled forward from task 3 so this commit's own battery is green (74 passed, 0 failed).
This commit is contained in:
+146
-22
@@ -6,46 +6,67 @@
|
||||
* (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)
|
||||
* PINNED CSR BINARY LAYOUT — ARG2 (little-endian; 06-01 pinned ARG1,
|
||||
* plan 08-02 versioned it per phase-8 D-01; plans 04/07 depend on it):
|
||||
* header: magic u32 (0x41524732 "ARG2") | version u32 (=2)
|
||||
* | 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).
|
||||
* | bbox 4×f64 (minLat, minLng, maxLat, maxLng) | maxSpeedKmh f32 @56
|
||||
* | landmarkCount u32 @60 (>= 1 REQUIRED — see the D-01 law)
|
||||
* | landmarkQuantumS f32 @64 (the ĉ unit; build default 0.1 s)
|
||||
* | reserved u32 @68 (written 0; decode accepts any value)
|
||||
* — 72 bytes total: 72 % 8 == 0 keeps the f64 node views
|
||||
* 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)
|
||||
* landmarks (the APPEND-ONLY ARG2 suffix; row-major [l*N + v]):
|
||||
* u16 toTable[L*N] — quantized ĉ-distance v -> anchor l
|
||||
* u16 fromTable[L*N] — quantized ĉ-distance anchor l -> v
|
||||
* u32 landmarkNodes[L] — the anchors (audit + --check prints)
|
||||
* 0xFFFF in a table cell is DATA (the unreachable sentinel,
|
||||
* landmark.ts) — decode never validates it away.
|
||||
*
|
||||
* THE D-01 FAIL-HONEST LAW (one decode path, one search path): a buffer
|
||||
* that is not EXACTLY an ARG2 graph decodes to null — ARG1 bytes (version
|
||||
* 1), future versions, landmarkCount = 0 (no landmark-less ARG2 layout
|
||||
* exists to silently degrade into), and every size mismatch. There is no
|
||||
* v1 fallback branch: the Phase 6 router-pending state covers old binaries
|
||||
* until one re-upload. (Router-pending coverage is exercised by the bot
|
||||
* suite; the committed mini-graph-arg1.bin is the standing ARG1 rejection
|
||||
* vector — plan 08-01.)
|
||||
*
|
||||
* 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
|
||||
* magic, version, landmarkCount, the quantum, and the EXACT byte size
|
||||
* implied by the header counts (the size arithmetic covers the variable
|
||||
* u16 suffix), 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-03b / T-08-01: the size check runs BEFORE any view allocation —
|
||||
* huge-count headers (including a monster landmarkCount) reject 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;
|
||||
/** Header magic "ARG2" (ArahaRah Graph v2 — the landmark-carrying layout). */
|
||||
export const GRAPH_MAGIC = 0x41524732;
|
||||
/** Layout version this module reads and writes. */
|
||||
export const GRAPH_VERSION = 1;
|
||||
export const GRAPH_VERSION = 2;
|
||||
/** 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
|
||||
* 68 field bytes + 4 reserved bytes. 72 % 8 == 0 keeps every f64 view
|
||||
* 8-byte aligned — the zero-copy law's mechanical prerequisite.
|
||||
*/
|
||||
const HEADER_SIZE = 64;
|
||||
const HEADER_SIZE = 72;
|
||||
|
||||
/** The decoded graph: typed-array views over the caller's ArrayBuffer. */
|
||||
export interface RoadGraph {
|
||||
@@ -67,6 +88,16 @@ export interface RoadGraph {
|
||||
nameAt(id: number): string | undefined;
|
||||
bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number };
|
||||
maxSpeedKmh: number;
|
||||
/** Number of landmark anchors (ARG2: always >= 1 — decode refuses 0). */
|
||||
landmarkCount: number;
|
||||
/** The ĉ quantum in seconds the tables were computed at (f32 readback). */
|
||||
landmarkQuantumS: number;
|
||||
/** Row-major [l*N + v] quantized distance v -> anchor l; 0xFFFF = unreachable (DATA). */
|
||||
landmarkTo: Uint16Array;
|
||||
/** Row-major [l*N + v] quantized distance anchor l -> v; 0xFFFF = unreachable (DATA). */
|
||||
landmarkFrom: Uint16Array;
|
||||
/** The L anchor node ids (row l of both tables). */
|
||||
landmarkNodes: Uint32Array;
|
||||
}
|
||||
|
||||
/** One directed edge at encode time. */
|
||||
@@ -81,6 +112,18 @@ export interface GraphEdgeInput {
|
||||
nameId?: number;
|
||||
}
|
||||
|
||||
/** The landmark-table block of the encodeGraph input (ARG2). */
|
||||
export interface LandmarkTablesInput {
|
||||
/** The ĉ quantum in seconds the tables were computed at (e.g. landmark.ts's LANDMARK_QUANTUM_S). */
|
||||
quantumS: number;
|
||||
/** The L anchor node ids; L >= 1 — an ARG2 always carries landmarks. */
|
||||
nodes: ArrayLike<number>;
|
||||
/** Row-major [l*N + v] to-anchors table, length exactly L*N. */
|
||||
to: Uint16Array;
|
||||
/** Row-major [l*N + v] from-anchors table, length exactly L*N. */
|
||||
from: Uint16Array;
|
||||
}
|
||||
|
||||
/** The encodeGraph input — the shape build scripts assemble. */
|
||||
export interface GraphInput {
|
||||
lat: ArrayLike<number>;
|
||||
@@ -89,6 +132,8 @@ export interface GraphInput {
|
||||
edges: readonly GraphEdgeInput[];
|
||||
names: readonly string[];
|
||||
maxSpeedKmh: number;
|
||||
/** REQUIRED since ARG2 (D-01): the precomputed landmark tables. */
|
||||
landmarks: LandmarkTablesInput;
|
||||
}
|
||||
|
||||
/* Section offsets implied by the header counts (all little-endian). */
|
||||
@@ -102,10 +147,19 @@ interface Layout {
|
||||
edgeNameId: number;
|
||||
nameOffset: number;
|
||||
namePool: number;
|
||||
toTable: number;
|
||||
fromTable: number;
|
||||
landmarkNodes: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function layoutFor(nodeCount: number, edgeCount: number, nameCount: number, namePoolLen: number): Layout {
|
||||
function layoutFor(
|
||||
nodeCount: number,
|
||||
edgeCount: number,
|
||||
nameCount: number,
|
||||
namePoolLen: number,
|
||||
landmarkCount: number,
|
||||
): Layout {
|
||||
const lat = HEADER_SIZE;
|
||||
const lng = lat + 8 * nodeCount;
|
||||
const edgeStart = lng + 8 * nodeCount;
|
||||
@@ -115,7 +169,27 @@ function layoutFor(nodeCount: number, edgeCount: number, nameCount: number, name
|
||||
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 };
|
||||
// The ARG2 landmark suffix — an append-only extension of the pinned ARG1
|
||||
// section order. Encode and decode share THIS arithmetic (T-08-01: the
|
||||
// exact-size law extends over the variable-size suffix).
|
||||
const toTable = (namePool + namePoolLen + 3) & ~3; // align u16 pairs to 4 bytes
|
||||
const fromTable = toTable + 2 * landmarkCount * nodeCount;
|
||||
const landmarkNodes = fromTable + 2 * landmarkCount * nodeCount;
|
||||
return {
|
||||
lat,
|
||||
lng,
|
||||
edgeStart,
|
||||
edgeTarget,
|
||||
edgeFlags,
|
||||
edgeSeconds,
|
||||
edgeNameId,
|
||||
nameOffset,
|
||||
namePool,
|
||||
toTable,
|
||||
fromTable,
|
||||
landmarkNodes,
|
||||
total: landmarkNodes + 4 * landmarkCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,9 +212,19 @@ export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null {
|
||||
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);
|
||||
// ARG2 header fields (plan 08-02): landmarkCount @60 (the ARG1 pad),
|
||||
// quantum @64, reserved @68 (accepted untouched — the field is unused).
|
||||
const landmarkCount = view.getUint32(60, true);
|
||||
// D-01/A5: an ARG2 always carries landmarks — a landmark-less layout
|
||||
// would silently route with weak steering, and "no v1 fallback branch"
|
||||
// holds at the content level too. Decode refuses, honestly.
|
||||
if (landmarkCount === 0) return null;
|
||||
const landmarkQuantumS = view.getFloat32(64, true);
|
||||
if (!Number.isFinite(landmarkQuantumS) || landmarkQuantumS <= 0) return null; // hostile quantum -> null, never a divide-by-0 downstream
|
||||
// Exact byte-size check BEFORE any view allocation (T-06-03b, extended
|
||||
// over the u16 suffix per T-08-01): a hostile huge-count header —
|
||||
// including a monster landmarkCount — rejects here on arithmetic alone.
|
||||
const lay = layoutFor(nodeCount, edgeCount, nameCount, namePoolLen, landmarkCount);
|
||||
if (buffer.byteLength !== lay.total) return null;
|
||||
|
||||
const lat = new Float64Array(buffer, lay.lat, nodeCount);
|
||||
@@ -152,6 +236,9 @@ export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null {
|
||||
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 landmarkTo = new Uint16Array(buffer, lay.toTable, landmarkCount * nodeCount);
|
||||
const landmarkFrom = new Uint16Array(buffer, lay.fromTable, landmarkCount * nodeCount);
|
||||
const landmarkNodes = new Uint32Array(buffer, lay.landmarkNodes, landmarkCount);
|
||||
const decoder = new TextDecoder();
|
||||
const graph: RoadGraph = {
|
||||
nodeCount,
|
||||
@@ -173,6 +260,11 @@ export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null {
|
||||
},
|
||||
bbox,
|
||||
maxSpeedKmh,
|
||||
landmarkCount,
|
||||
landmarkQuantumS,
|
||||
landmarkTo,
|
||||
landmarkFrom,
|
||||
landmarkNodes,
|
||||
};
|
||||
return graph;
|
||||
}
|
||||
@@ -194,6 +286,29 @@ export function encodeGraph(input: GraphInput): ArrayBuffer {
|
||||
if (!Number.isFinite(input.maxSpeedKmh) || input.maxSpeedKmh <= 0) {
|
||||
fail("maxSpeedKmh must be a positive finite number");
|
||||
}
|
||||
// ARG2 landmark block (D-01): REQUIRED, loud-validated BEFORE any
|
||||
// allocation work (T-08-04) — no silent clamp, no landmark-less encode.
|
||||
const landmarks = input.landmarks;
|
||||
if (!landmarks) fail("landmarks block is required (ARG2 has no landmark-less layout)");
|
||||
const landmarkCount = landmarks.nodes.length;
|
||||
if (!Number.isInteger(landmarkCount) || landmarkCount < 1) {
|
||||
fail("landmarks.nodes must carry at least one anchor (ARG2 has no landmark-less layout)");
|
||||
}
|
||||
if (!Number.isFinite(landmarks.quantumS) || landmarks.quantumS <= 0) {
|
||||
fail("landmarks.quantumS must be a positive finite number");
|
||||
}
|
||||
for (let i = 0; i < landmarkCount; i++) {
|
||||
const a = landmarks.nodes[i]!;
|
||||
if (!Number.isInteger(a) || a < 0 || a >= nodeCount) {
|
||||
fail(`landmarks.nodes[${i}] (${a}) is not a node id in [0, ${nodeCount})`);
|
||||
}
|
||||
}
|
||||
if (landmarks.to.length !== landmarkCount * nodeCount) {
|
||||
fail(`landmarks.to must have length L*N = ${landmarkCount}*${nodeCount}, got ${landmarks.to.length}`);
|
||||
}
|
||||
if (landmarks.from.length !== landmarkCount * nodeCount) {
|
||||
fail(`landmarks.from must have length L*N = ${landmarkCount}*${nodeCount}, got ${landmarks.from.length}`);
|
||||
}
|
||||
const nameCount = input.names.length;
|
||||
for (const name of input.names) {
|
||||
if (typeof name !== "string") fail("names must all be strings");
|
||||
@@ -232,7 +347,7 @@ export function encodeGraph(input: GraphInput): ArrayBuffer {
|
||||
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 lay = layoutFor(nodeCount, edgeCount, nameCount, namePoolLen, landmarkCount);
|
||||
const buffer = new ArrayBuffer(lay.total);
|
||||
const view = new DataView(buffer);
|
||||
const edgeTarget = new Uint32Array(buffer, lay.edgeTarget, edgeCount);
|
||||
@@ -273,6 +388,9 @@ export function encodeGraph(input: GraphInput): ArrayBuffer {
|
||||
view.setFloat64(40, maxLat, true);
|
||||
view.setFloat64(48, maxLng, true);
|
||||
view.setFloat32(56, input.maxSpeedKmh, true);
|
||||
view.setUint32(60, landmarkCount, true);
|
||||
view.setFloat32(64, landmarks.quantumS, true);
|
||||
view.setUint32(68, 0, true); // reserved (decode accepts any value)
|
||||
|
||||
// CSR row pointers + node coordinates.
|
||||
for (let i = 0; i <= nodeCount; i++) view.setUint32(lay.edgeStart + 4 * i, counts[i]!, true);
|
||||
@@ -295,5 +413,11 @@ export function encodeGraph(input: GraphInput): ArrayBuffer {
|
||||
pool.set(bytes, at);
|
||||
at += bytes.length;
|
||||
}
|
||||
// ARG2 landmark suffix — bulk typed-array writes through the same
|
||||
// view discipline as the u32/f32/f64 sections, so decode's zero-copy
|
||||
// views read the bytes back exactly.
|
||||
new Uint16Array(buffer, lay.toTable, landmarkCount * nodeCount).set(landmarks.to);
|
||||
new Uint16Array(buffer, lay.fromTable, landmarkCount * nodeCount).set(landmarks.from);
|
||||
new Uint32Array(buffer, lay.landmarkNodes, landmarkCount).set(landmarks.nodes);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user