Files

22 KiB
Raw Permalink Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
08-alt-landmark-preprocessing-routing-backend-v2 02 execute 2
08-01
routing/src/landmark.ts
routing/src/graph.ts
routing/test/graph.test.mjs
routing/test/fixtures/build-mini-graph.mjs
routing/test/fixtures/mini-graph.bin
bot/test/webhook.test.mjs
bot/test/guidance-cron.test.mjs
true
SC3
SC4
truths artifacts key_links
decodeGraph accepts EXACTLY an ARG2 graph (magic 0x41524732, version 2, landmarkCount ≥ 1, exact byte size) and returns zero-copy typed-array views including both u16 landmark-table families; everything else decodes to null (D-01 fail-honest — ARG1 fixture bytes, corrupt magic/version, landmarkCount=0, hostile oversized count)
encodeGraph remains the ONE authority: GraphInput gains a required landmarks block, loud-fails on invalid input, and the ARG2 layout is its exact inverse (round-trip law)
The exact-size check runs BEFORE any view allocation, extended arithmetically over the variable-size landmark suffix (T-06-03b law, T-08-01)
The committed mini-graph fixture is regenerated as ARG2 through the same encode path; its former ARG1 bytes (mini-graph-arg1.bin from Plan 08-01) decode to null
Landmark anchor selection + table Dijkstras exist as one pure, typed-array module (landmark.ts) shared by the fixture builder and the build script — no duplicated integer-Dijkstra implementations
path provides
routing/src/landmark.ts farthestPointAnchors + landmarkTables — quantized ĉ-metric integer Dijkstra, 0xFFFF sentinels, ≤65,000 quantum assert
path provides
routing/src/graph.ts ARG2 codec — 72-byte header, u16 suffix, version-bumped decode laws
path provides
routing/test/graph.test.mjs G-suite extended: ARG2 roundtrip + tables, rejection matrix, G7/G8 re-pins
path provides
routing/test/fixtures/mini-graph.bin committed ARG2 fixture with a tiny landmark set
from to via pattern
routing/src/graph.ts decodeGraph buffer.byteLength !== layoutFor(...).total exact-size law extended to the suffix BEFORE view allocation buffer.byteLength !== lay.total
from to via pattern
routing/test/graph.test.mjs routing/test/fixtures/mini-graph-arg1.bin committed ARG1 bytes decode to null (the G3-merged rejection case) mini-graph-arg1
from to via pattern
routing/src/graph.ts RoadGraph landmarkCount/landmarkQuantumS/landmarkTo/landmarkFrom/landmarkNodes new typed-array fields consumed by Plan 08-03's ALT potentials landmarkTo\s*:\s*Uint16Array
The ARG2 codec: version the pinned CSR layout per D-01 (append-only suffix, one decode path, one search path — the old v1 decode is removed, not hidden), with the landmark-table compute core as a single pure module and the committed fixture regenerated through the same encode authority.

Purpose: the binary this plan defines is what the build (08-04), the ALT potentials (08-03), and the real-graph campaign (08-05) all consume. Output: routing/src/landmark.ts, ARG2 routing/src/graph.ts, extended G-suite, regenerated mini-graph.bin (+ frozen mini-graph-arg1.bin as the rejection vector).

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md @.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md @.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md

Current header (64 B): magic u32 @0 = 0x41524731; version u32 @4 = 1; nodeCount @8; edgeCount @12; nameCount @16; namePoolLen @20; bbox 4×f64 @24..55; maxSpeedKmh f32 @56; pad @60..63. NOTE: the RESEARCH doc's "@52 maxSpeedKmh" is a doc drift — the CODE truth is @56 (view.getFloat32(56, true)). ARG2 places its new fields where the pad was.

Sections (relative order unchanged): lat f64[N], lng f64[N], edgeStart u32[N+1], edgeTarget u32[E], edgeFlags u8[E], edgeSeconds f32[E] (4-byte aligned start), edgeNameId u32[E], nameOffset u32[names+1], namePool u8[pool].

Existing exports/consts: GRAPH_MAGIC = 0x41524731, GRAPH_VERSION = 1, UNNAMED_NAME_ID = 0xffffffff, decodeGraph(buffer): RoadGraph | null, encodeGraph(input: GraphInput): ArrayBuffer (throws via fail() on malformed input), private layoutFor(...): Layout { lat, lng, ..., total }.

Decode laws carried forward: buffer.byteLength < HEADER_SIZE → null; magic/version mismatch → null; buffer.byteLength !== lay.total → null BEFORE any view allocation.

task 1: landmark.ts — the pure landmark compute core (RED→GREEN) routing/src/landmark.ts, routing/test/landmark.test.mjs - .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md (Pattern 2 — the ĉ-metric surrogate; the closed math: floor-quantize EDGE WEIGHTS once, exact integer Dijkstra, exact admissibility + Lipschitz consistency; u16/quantum range law — assert ≤65,000 quanta, sentinel 0xFFFF) - .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md (astar.ts A1 test-local Dijkstra shape — the traversal law: edgeFlags bit0 never relaxed) - routing/test/astar.test.mjs lines 69–91 (the proven brute-force Dijkstra to mirror) - routing/src/graph.ts lines 51–105 (RoadGraph/GraphEdgeInput field shapes this module consumes) - farthestPointAnchors on a known tiny graph returns L deterministic, degree-≥2-filtered, well-spread anchors; identical bytes of input ⇒ identical anchors (no Math.random anywhere) - landmarkTables with quantum 0.1: a two-edge path 3.05 s + 3.05 s … floor(30500/1000s units) — verify: edge seconds 2.55 → ĉ=25 quanta; table distance over 2 edges = 50 quanta exactly (integer arithmetic, no float drift) - unreachable node pairs → 0xFFFF sentinel in the row; the landmark's own cell = 0 - any distance label > 65,000 quanta → module throws (build-time loud; message names the offending distance and the fix: raise landmarkQuantumS) - reversed vs forward rows differ on an oneway graph (asymmetry is representable) Create `routing/src/landmark.ts` — pure typed-array module (no I/O, no logging, no storage; zero deps). Exports:
export const LANDMARK_QUANTUM_S = 0.1;      // the ĉ unit (header field default; doc'd fork knob)
export const LANDMARK_MAX_QUANTA = 65000;   // u16 headroom below the 0xFFFF sentinel
export interface LandmarkInput {
  nodeCount: number;
  edges: Array<{ from: number; to: number; seconds: number; flags: number }>; // flags bit0 = oneway-reverse: relaxed by NO direction
  quantumS: number;                          // named-callers pass LANDMARK_QUANTUM_S
}
export const LANDMARK_SENTINEL = 0xffff;

/** Deterministic farthest-point selection over the ĉ metric. */
export function farthestPointAnchors(input: LandmarkInput, count: number, opts?: { coverageFraction?: number }): Uint32Array;

/** Integer ĉ-metric Dijkstras: returns { to: Uint16Array (L·N), from: Uint16Array (L·N) } (row-major: [ℓ*N + v]). */
export function landmarkTables(input: LandmarkInput, anchors: Uint32Array): { to: Uint16Array; from: Uint16Array };

/** Per-anchor coverage audit (fraction of nodes with non-sentinel to-entry), for the build's assert. */
export function landmarkCoverage(to: Uint16Array, anchors: Uint32Array, nodeCount: number): number[];

Mechanics (concrete): build forward + reversed CSR internally (nodeCount+1 row pointers; reversed skips nothing — flags semantics: bit0 edges are traversed by the REVERSED direction only in the reverse set... mirror exactly the A1 test-local Dijkstra law: an edge u→v with flags bit0 CLEAR is relaxed in the forward pass FROM v in the reversed graph, and NEVER in the unreversed mirror — the keyword: oneway-reverse edges are traversable only backwards). ĉ(u,v) = Math.floor(seconds / quantumS) (floor keeps the surrogate ≤ the live edge). Integer Dijkstra with a binary heap over Int32 quanta labels; unvisited-finite-only relaxation; 0xFFFF frozen as unreachable. Coverage via a per-anchor reachable-count / nodeCount. Throwing path: any final label or intermediate > LANDMARK_MAX_QUANTA → throw new Error("landmarkTables: quantized distance <d> exceeds <MAX> (raise landmarkQuantumS)”). Determinism: anchor greed starts at the first degree-≥2 candidate by node id; ties broken by lower node id; the greedy loop reuses each picked anchor's forward pass to maintain the per-node min-distance field (≈2L total passes, not 3L).

RED: write routing/test/landmark.test.mjs covering the cases (hand-computed expectations on a 5-node path graph + the 5×5-grid mini graph shape from the fixture builder) BEFORE implementing; run RED; then implement; then GREEN + npm run routing:typecheck. export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && npm run routing:test 2>&1 | tail -3 && npm run routing:typecheck && echo "LANDMARK-CORE-GREEN" <acceptance_criteria> - routing/test/landmark.test.mjs exists, committed, all cases passing (runner reports 0 failed). - grep -c "Math.random" routing/src/landmark.ts === 0 (determinism law). - grep -c "0xffff" routing/src/landmark.ts ≥ 2 (sentinel used, not invented per-callsite). - grep -c "Math.floor" routing/src/landmark.ts ≥ 1 (the ĉ floor rule, exact placement documented in Pattern 2). - typecheck clean; no imports outside node builtins/types in the module. </acceptance_criteria> Deterministic anchors + exact integer ĉ tables + sentinel/overflow laws proven on hand-computed vectors.

task 2: ARG2 codec in graph.ts (RED→GREEN) routing/src/graph.ts, routing/test/graph.test.mjs - routing/src/graph.ts IN FULL (299 lines — you are versioning it in place) - .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-RESEARCH.md ("ARG2 Binary Layout" section — but note the doc's maxSpeedKmh@52 is DRIFT: code truth is @56; use the code truth) - .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md (graph.ts section — layoutFor/fail/decode/encode patterns to extend mechanically) - .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-CONTEXT.md (D-01 — one decode path, one search path, no v1 fallback branch) - routing/test/graph.test.mjs IN FULL (191 lines — G1–G8 cases and tatper-vector mechanics you are extending; G3 merges into the ARG1-fixture rejection, G8 re-pins) - G2 (extended): wrong magic (0xdeadbeef) on a valid ARG2 buffer → null, never a throw - G3 (re-pinned, merged with the version law): version field = 1 (Use the committed mini-graph-arg1.bin bytes) → null; version = 3 → null - NEW: landmarkCount = 0 with otherwise-valid header+body → null (A5 resolution, documented choice: no silent haversine-fallback layout can exist under D-01's "no v1 fallback branch" law) - NEW: monster header (landmarkCount = 2,000,000) on a tiny buffer → null by arithmetic BEFORE any Uint16Array allocation (exact-size law covers the suffix: total = … + 2·L·N·2 + 4·L (+pad-to-4)) - NEW: exact round-trip — encodeGraph(synthetic-with-landmarks) → decodeGraph → all 4 table properties: landmarkCount === L, tables row-major length L·N, landmarkNodes[i] matches input, landmarkQuantumS === input quantum - NEW: encodeGraph rejects invalid landmark input via fail(): landmarkCount 0 / row length ≠ L·N / node id ≥ N / non-finite or non-positive quantum → throw named `encodeGraph:` errors (no silent clamp) - G7 (re-pinned): the regenerated committed ARG2 fixture decodes; counts + bbox + oneway flag + speed classes intact AND landmarkCount ≥ 1 - G8 (re-pinned): GRAPH_MAGIC / GRAPH_VERSION exported constants are 0x41524732 / 2 Version `routing/src/graph.ts` in place (no second codec, anti-pattern "Second encodeGraph"):
  1. GRAPH_MAGIC = 0x41524732 ("ARG2"); GRAPH_VERSION = 2; HEADER_SIZE = 72 (comment: 72 % 8 == 0 keeps f64 views 8-byte aligned).
  2. Layout: after the ARG1 field order — landmarkCount u32 @60 (the old pad), landmarkQuantumS f32 @64, reserved u32 @68 (written 0, asserted-decode-0 OPTIONAL — recommend accepting any value there, the field is unused). Sections start @72 (ARG1 relative structure preserved, each offset shifted +8).
  3. Suffix in layoutFor: const toTable = align4(namePool + namePoolLen); const fromTable = toTable + 2*L*N; const landmarkNodes = fromTable + 2*L*N; const total = landmarkNodes + 4*L; (u16 × dual families × L×N bytes + u32 anchors; alignment pad before toTable via & ~3; decode/encode use identical arithmetic — single layoutFor gains landmarkCount + quantum params or reads stored fields).
  4. RoadGraph gains fields: landmarkCount: number; landmarkQuantumS: number; landmarkTo: Uint16Array; landmarkFrom: Uint16Array; landmarkNodes: Uint32Array;
  5. decodeGraph: read L @60 and quantum @64; if (L === 0) return null (comment cites D-01/A5); extend the exact-size check to the suffix BEFORE allocation; allocate u16/u32 zero-copy views; magic/version laws unchanged in shape (v1 bytes now fail the version check — THE D-01 one-path law; comment cites the router-pending coverage).
  6. GraphInput gains REQUIRED landmarks: { quantumS: number; nodes: ArrayLike<number>; to: Uint16Array; from: Uint16Array } — encode loud-validates lengths (L = nodes.length; to/from lengths === L·N; every node id < nodeCount; quantumS finite > 0; fail() otherwise). Bulk writes: follow the file's existing section-write pattern for the u16/u32 views so decode's zero-copy views read back exactly (endianness discipline identical to the existing f32/f64 sections).
  7. RED: extend routing/test/graph.test.mjs first (new cases appended with the doc-index comment updated; keep the case-harness loadGraph() lazy-import pattern), run RED, then implement GREEN.

Doc-comment update at the module head: ARG2 layout table (72-byte header field offsets, suffix order), the D-01 fail-honest law, the sentinel (0xFFFF is DATA — never validated away). export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && npm run routing:test 2>&1 | tail -3 && npm run routing:typecheck && echo "ARG2-CODEC-GREEN" <acceptance_criteria> - npm run routing:test green INCLUDING the new G-cases (runner output 0 failed); the G-case doc-index header lists them. - tamper-vector grep: grep -c "setUint32(4" routing/test/graph.test.mjs ≥ 1 (version tamper present) and the landmarkCount=0 + oversized-L cases exist (grep -c "landmarkCount" ≥ 4). - grep -n "0x41524731" routing/src/graph.ts returns ZERO code hits (may appear only in a comment naming the retired magic); 0x41524732 is THE constant. - Header-72 arithmetic: grep HEADER_SIZE = 72 ≥ 1; grep -n "getFloat32(56" routing/src/graph.ts still exactly 1 (the untouched ARG1 field). - typecheck clean; RoadGraph's five new fields grep-findable. </acceptance_criteria> One codec, one version (2): ARG2 encodes/decodes with tables; every non-ARG2 input class decodes null before allocation; G7/G8 re-pinned.

task 3: Regenerate the fixture as ARG2; seal the G-suite + unaffected suites routing/test/fixtures/build-mini-graph.mjs, routing/test/fixtures/mini-graph.bin - routing/test/fixtures/build-mini-graph.mjs IN FULL (85 lines) - routing/src/landmark.ts exports (task 1 of this plan) - .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-PATTERNS.md (build-mini-graph section — tiny L stage, fail-loud invariants, old-bytes-kept note) Extend `build-mini-graph.mjs`: after the 80-edge assembly and BEFORE encodeGraph, add the landmark stage — `LANDMARK_QUANTUM_S` from `../../src/landmark.ts`; `const anchors = farthestPointAnchors({ nodeCount: GRID*GRID, edges, quantumS: LANDMARK_QUANTUM_S }, 2)` (tiny L = 2, deterministic); `const { to, from } = landmarkTables(...)`; hand-assert invariants fail-loud before write (style of the existing `if (edges.length !== 80) throw` — e.g. to.length === 2*25 and to[anchor0*N + anchor0] === 0). Pass `landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from }` into the SAME single encodeGraph call; write `mini-graph.bin` (unchanged path/writeshape). Print the landmark stats in the script's summary line (L=2, quantum, coverage).

Then update the ONLY remaining encodeGraph v1-shaped runtime call sites so the FULL battery is green in THIS plan (the required-landmarks law makes them fail loudly otherwise): bot/test/webhook.test.mjs (both corridor builders, encodeGraph calls at lines ~499 and ~509) and bot/test/guidance-cron.test.mjs (~252) — each computes tiny tables via farthestPointAnchors + landmarkTables from @araharah/routing's ../../src/landmark.ts and passes the landmarks block. (Call-site updating, NOT a second encode function — anti-pattern respected.)

Then re-run the full verification battery: the committed fixture is now ARG2 — every existing test that consumes mini-graph.bin must stay green (G7 re-pin, snap suite, A-suite via astar.test's fixture load, loader-env). Verify mini-graph-arg1.bin still decodes to null via the new G3 case (task 2 wired it). NOTE: routeAstar still runs haversine potentials here (ALT lands in Plan 08-03) — that coexistence is deliberate; A-suite greenness at this commit proves the codec change alone is behavior-neutral to the search. export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" && node routing/test/fixtures/build-mini-graph.mjs && npm run routing:test 2>&1 | tail -3 && npm test -w bot 2>&1 | tail -2 && npm run routing:typecheck && npm run bot:typecheck && echo "FULL-BATTERY-GREEN" <acceptance_criteria> - node routing/test/fixtures/build-mini-graph.mjs exits 0 and prints the landmark stats; mini-graph.bin regenerated (git diff shows content change, same path). - git status shows mini-graph-arg1.bin unchanged bytes from Plan 08-01 (cmp vs its committed version passes — it is NOT regenerated). - npm run routing:test AND npm test -w bot AND both typechecks fully green at this commit (the corridor/cron encodeGraph call sites now pass the landmarks block). - grep law: grep -Lc "landmarms\|landmarks" bot/test/webhook.test.mjs bot/test/guidance-cron.test.mjs shows both files contain landmarks: (grep -c "landmarks: {" ≥ 1 in each). </acceptance_criteria> Committed fixture is ARG2 through the one encode authority with a tiny landmark set; the frozen ARG1 vector stays byte-identical; the full routing + bot battery is green.

<threat_model>

Trust Boundaries

Boundary Description
R2 binary → decodeGraph the graph binary is HOSTILE INPUT at the Worker boundary (V5); the codec is the gate
build input → encodeGraph maintainer-time JSON/graph input; loud failure is the correct posture (T-06-03 shape)

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-08-01 Tampering/DoS decodeGraph mitigate exact-size arithmetic extended over the u16 suffix BEFORE any view allocation — a monster landmarkCount header on a tiny buffer rejects on arithmetic (grep-able test proves it); landmarkCount=0 rejected (no silent-degradation layout)
T-08-02 Tampering decodeGraph version law mitigate ARG1 bytes → null → router-pending (D-01; one decode path, no v1 branch); committed mini-graph-arg1.bin is the standing rejection vector
T-08-03 Information disclosure codec module accept pure module law preserved (no logging/storage/network); T-06-05 unchanged
T-08-04 DoS encodeGraph mitigate resource-exhaustion malformed input fails loudly with named encodeGraph: errors before any allocation work
</threat_model>
- `npm run routing:test` fully green (G + landmark cases). - `npm run routing:typecheck` clean. - `mini-graph-arg1.bin` committed and byte-stable; `mini-graph.bin` regenerated as ARG2 (G7 pin green). - Bot-suite encodeGraph call sites are updated in THIS plan (webhook corridor builders + guidance cron); `npm test -w bot` is green at this commit — routeAstar still runs haversine potentials here (ALT lands in Plan 08-03), so greenness proves the codec change alone is behavior-neutral to search.

<success_criteria> ARG2 is the single, fail-honest, exact-size-validated layout; the landmarkTables/farthestPointAnchors core exists once (pure, deterministic, integer-exact); every synthetic/future graph encodes landmark tables through the one authority; nothing else about decode or search behavior changed at this commit (A-suite green on haversine potentials over the ARG2 fixture). </success_criteria>

After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-02-SUMMARY.md`