Files

246 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 08-alt-landmark-preprocessing-routing-backend-v2
plan: 02
type: execute
wave: 2
depends_on: ["08-01"]
files_modified:
- 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
autonomous: true
requirements: [SC3, SC4]
must_haves:
truths:
- "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"
artifacts:
- path: "routing/src/landmark.ts"
provides: "farthestPointAnchors + landmarkTables — quantized ĉ-metric integer Dijkstra, 0xFFFF sentinels, ≤65,000 quantum assert"
- path: "routing/src/graph.ts"
provides: "ARG2 codec — 72-byte header, u16 suffix, version-bumped decode laws"
- path: "routing/test/graph.test.mjs"
provides: "G-suite extended: ARG2 roundtrip + tables, rejection matrix, G7/G8 re-pins"
- path: "routing/test/fixtures/mini-graph.bin"
provides: "committed ARG2 fixture with a tiny landmark set"
key_links:
- from: "routing/src/graph.ts decodeGraph"
to: "buffer.byteLength !== layoutFor(...).total"
via: "exact-size law extended to the suffix BEFORE view allocation"
pattern: "buffer\\.byteLength !== lay\\.total"
- from: "routing/test/graph.test.mjs"
to: "routing/test/fixtures/mini-graph-arg1.bin"
via: "committed ARG1 bytes decode to null (the G3-merged rejection case)"
pattern: "mini-graph-arg1"
- from: "routing/src/graph.ts RoadGraph"
to: "landmarkCount/landmarkQuantumS/landmarkTo/landmarkFrom/landmarkNodes"
via: "new typed-array fields consumed by Plan 08-03's ALT potentials"
pattern: "landmarkTo\\s*:\\s*Uint16Array"
---
<objective>
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).
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- The ARG1 codec being versioned. Extracted from routing/src/graph.ts 2026-10-25 (299 lines — read it in full first). -->
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.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>task 1: landmark.ts — the pure landmark compute core (RED→GREEN)</name>
<files>routing/src/landmark.ts, routing/test/landmark.test.mjs</files>
<read_first>
- .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)
</read_first>
<behavior>
- 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)
</behavior>
<action>
Create `routing/src/landmark.ts` — pure typed-array module (no I/O, no logging, no storage; zero deps). Exports:
```ts
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 <behavior> 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`.
</action>
<verify>
<automated>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"</automated>
</verify>
<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>
<done>Deterministic anchors + exact integer ĉ tables + sentinel/overflow laws proven on hand-computed vectors.</done>
</task>
<task type="auto" tdd="true">
<name>task 2: ARG2 codec in graph.ts (RED→GREEN)</name>
<files>routing/src/graph.ts, routing/test/graph.test.mjs</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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
</behavior>
<action>
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).
</action>
<verify>
<automated>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"</automated>
</verify>
<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>
<done>One codec, one version (2): ARG2 encodes/decodes with tables; every non-ARG2 input class decodes null before allocation; G7/G8 re-pinned.</done>
</task>
<task type="auto">
<name>task 3: Regenerate the fixture as ARG2; seal the G-suite + unaffected suites</name>
<files>routing/test/fixtures/build-mini-graph.mjs, routing/test/fixtures/mini-graph.bin</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>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"</automated>
</verify>
<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>
<done>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.</done>
</task>
</tasks>
<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>
<verification>
- `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.
</verification>
<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>
<output>
After completion, create `.planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-02-SUMMARY.md`
</output>