From 32a00b5b0b086a9cfb8135b3a181779246d31ee3 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sun, 25 Oct 2026 18:48:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(08-02):=20ARG2=20codec=20=E2=80=94=20one?= =?UTF-8?q?=20version,=20one=20decode=20path=20(G-suite=20GREEN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- routing/src/graph.ts | 168 ++++++++++++++++++--- routing/test/astar.test.mjs | 9 ++ routing/test/fixtures/build-mini-graph.mjs | 45 +++++- routing/test/fixtures/mini-graph.bin | Bin 1668 -> 1884 bytes routing/test/snap.test.mjs | 8 + 5 files changed, 206 insertions(+), 24 deletions(-) diff --git a/routing/src/graph.ts b/routing/src/graph.ts index 2724307..1cb671f 100644 --- a/routing/src/graph.ts +++ b/routing/src/graph.ts @@ -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; + /** 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; @@ -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; } diff --git a/routing/test/astar.test.mjs b/routing/test/astar.test.mjs index fd9b58a..f00923f 100644 --- a/routing/test/astar.test.mjs +++ b/routing/test/astar.test.mjs @@ -160,6 +160,15 @@ test("A3 unreachable -> null — a disconnected pair resolves the honest miss", ], names: ["Isolated Road"], maxSpeedKmh: 60, + // ARG2 (plan 08-02): every encode carries a landmark block — inert + // steering data until ALT lands (08-03); hand values keep this case + // focused on the unreachable-pair law. Sentinels mark component 2. + landmarks: { + quantumS: 0.5, + nodes: [0], + to: Uint16Array.of(0, 30, 0xffff, 0xffff), + from: Uint16Array.of(0, 30, 0xffff, 0xffff), + }, }), ); assert.ok(g, "synthetic graph decodes"); diff --git a/routing/test/fixtures/build-mini-graph.mjs b/routing/test/fixtures/build-mini-graph.mjs index 85d0270..36969ff 100644 --- a/routing/test/fixtures/build-mini-graph.mjs +++ b/routing/test/fixtures/build-mini-graph.mjs @@ -16,6 +16,12 @@ * Usage: node routing/test/fixtures/build-mini-graph.mjs * Exit: 0 on write; throws on any invariant violation (never writes). * + * ARG2 (plan 08-02): the fixture carries a tiny real landmark stage — + * L=2 anchors via farthestPointAnchors + quantized c-metric tables via + * landmarkTables (the SAME pure module the real build script uses), passed + * through the ONE encodeGraph authority. Deterministic: identical output + * bytes on every re-run. + * * 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) @@ -28,6 +34,7 @@ import { writeFileSync } from "node:fs"; import { encodeGraph } from "../../src/graph.ts"; import { haversineM } from "../../src/geo.ts"; +import { LANDMARK_QUANTUM_S, farthestPointAnchors, landmarkTables, landmarkCoverage } from "../../src/landmark.ts"; const GRID = 5; const LAT0 = 3.15; @@ -85,6 +92,40 @@ 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 }); +// LANDMARK STAGE (plan 08-02): the fixture regenerates as ARG2 THROUGH the +// one encode authority — the same farthestPointAnchors + landmarkTables the +// real build script (08-04) will run, at a tiny L. Fail-loud invariants +// before any write (the `edges.length !== 80` style). +const LANDMARK_COUNT = 2; // tiny L: the 5x5 grid needs exactly 2 well-spread anchors +const landmarkInput = { nodeCount: GRID * GRID, edges, quantumS: LANDMARK_QUANTUM_S }; +const anchors = farthestPointAnchors(landmarkInput, LANDMARK_COUNT); +const { to, from } = landmarkTables(landmarkInput, anchors); +if (anchors.length !== LANDMARK_COUNT) throw new Error(`expected ${LANDMARK_COUNT} anchors, got ${anchors.length}`); +if (to.length !== LANDMARK_COUNT * GRID * GRID || from.length !== LANDMARK_COUNT * GRID * GRID) { + throw new Error("landmark tables are not L*N row-major"); +} +for (let l = 0; l < anchors.length; l++) { + const a = anchors[l]; + if (to[l * GRID * GRID + a] !== 0 || from[l * GRID * GRID + a] !== 0) { + throw new Error(`anchor ${l} (node ${a}) own cells are not 0 — table law broken`); + } +} +const coverage = landmarkCoverage(to, anchors, GRID * GRID); +if (coverage.some((c) => c !== 1)) { + throw new Error(`the grid is strongly connected — coverage must be 1.0, got ${coverage.join(", ")}`); +} + +const bin = encodeGraph({ + lat, + lng, + edges, + names, + maxSpeedKmh: SPEED_PERIMETER_KMH, + landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from }, +}); 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`); +console.log( + `mini-graph.bin: ${GRID * GRID} nodes, ${edges.length} edges, 3 names, ` + + `L=${LANDMARK_COUNT} landmarks (nodes ${[...anchors].join(",")}), quantum ${LANDMARK_QUANTUM_S}s, ` + + `coverage ${coverage.map((c) => c.toFixed(2)).join("/")}, ${bin.byteLength} B`, +); diff --git a/routing/test/fixtures/mini-graph.bin b/routing/test/fixtures/mini-graph.bin index cb12325de8c976fe383363cccfe6514b91bc5fef..d8c9fed54d5c745ea01113eb781383f58a9f2845 100644 GIT binary patch delta 236 zcmZqSy~D?9ZOC1&Snt#UOTo iOo!O96etI=<9Q%Z1I&`=Km`a(K-Q3E2?K)=&}RU)V>8|W delta 28 icmcb^*TTzc=pN+AIFZ$yg#idA1}IES;MiCo!v+9hiw2tj diff --git a/routing/test/snap.test.mjs b/routing/test/snap.test.mjs index 75634bc..c310edf 100644 --- a/routing/test/snap.test.mjs +++ b/routing/test/snap.test.mjs @@ -110,12 +110,20 @@ test("S3 nameId — a perimeter fix resolves 'Jalan Persiaran Mini'; an unnamed assert.equal(g.nameAt(atNode1.nameId), "Jalan Persiaran Mini"); // Synthetic 2-node graph with ONE unnamed edge -> nameId null. + // ARG2 (plan 08-02): the landmark block is required by the one encode + // authority; values are inert steering data until ALT lands (08-03). const bare = encodeGraph({ lat: [3.15, 3.1505], lng: [101.69, 101.6905], edges: [{ from: 0, to: 1, seconds: 4 }], names: [], maxSpeedKmh: 60, + landmarks: { + quantumS: 0.5, + nodes: [0], + to: Uint16Array.of(0, 30), + from: Uint16Array.of(0, 30), + }, }); const { decodeGraph, UNNAMED_NAME_ID } = await loadGraph(); const bg = decodeGraph(bare);