Compare commits

11 Commits
Author SHA1 Message Date
araharah-executor bcab4b9999 feat(08.1-02): re-pin measurement gates for ARG3 and extend the rollout locks (GREEN)
- The optimality gate's decode-failure message names ARG3 and the rebuild
  command (node routing/scripts/build-road-graph.mjs); the frozen D-03
  constants (OPTIMALITY_EPSILON_PCT = 0.1, REQUIRED_BASELINE_ARG_VERSION
  = 1) and the immutable alt-baseline.json record are untouched.
- The oracle sweep's graph-missing (SKIP) and decode-failure wording names
  the ARG3 binary; the seeded 200-pair symmetric-compare constants and the
  three violation classes are unchanged.
- RO1/RO2 ban the ARG3 magic 0x41524733 in the upload script's and the
  Worker's comment-stripped code: the one-path laws now cover the CURRENT
  format, not just the retired ones. The Worker keeps exactly one
  decodeGraph call, no GRAPH_VERSION, no magic, no === 2.
- measure-route-cpu.mjs is untouched here by design: its LADDER HISTORY
  gains the CH rung only once the measured numbers exist (task 3).
2026-10-26 07:48:28 +08:00
araharah-executor 68bbe80894 test(08.1-02): pin ARG3-honest gate wording and frozen measurement constants (RED)
MG1/MG2 fail against the current scripts: the optimality gate and the
oracle sweep still describe the binary-under-test in ARG2-era terms and
their decode-failure messages do not name ARG3 or the rebuild command.
MG3 pins the CPU gate's budget/sample constants exactly-once (already
true — stays green).
2026-10-26 07:47:30 +08:00
araharah-executor 58a5d406a3 feat(08.1-02): replace the ALT build stage with measured CH preprocessing (GREEN)
- The build no longer imports landmark.ts (deleted in 08.1-01) and has no
  --landmarks knob: the compacted directed edges feed contractGraph (the
  ONE CH construction authority) and the tables encode through the single
  encodeGraph authority as the ch: block (never landmarks:).
- Any contraction/encode throw names the CH preprocessing stage and exits 1
  BEFORE any write — collect-then-write keeps the previous binary intact
  (T-08.1-10).
- The build prints the measured CH stats block (D-08/D-09): contracted
  nodes, shortcuts + ratio per node, witness searches + settle-budget hits,
  chRank bytes, shortcut bytes, wall-clock over contraction + encode, and
  total binary bytes. No ARG3 payload ceiling exists (D-08) — the enforced
  1-80 MB D-01 envelope is unchanged and still fails before any write.
- The stale ARG2-era 30 MB forecast advisory is retired with the layout it
  forecasted; the measurement itself is the record.
- --check becomes a pure decode+facts tool for any valid ARG3 (the
  committed mini fixture included): prints chShortcutCount, shortcut
  ratio, chRank length; the envelope appears as an advisory line — the
  enforced gates stay in the build path.
2026-10-26 07:46:41 +08:00
araharah-executor 61e2bfe070 test(08.1-02): add failing maintainer-build CH pins (RED)
BS1-BS5 pin plan 02 task 1's behavior before it exists: the build script
must be landmark-free in stripped code, run ONE contractGraph stage into
the ch: encode input, print the full measured CH stats block (D-08/D-09),
keep the 1-80 MB D-01 envelope single and unchanged, and decode the
committed ARG3 fixture green via --check with CH facts. BS1/BS2/BS3/BS5
fail against the current landmark-era script (which cannot even load —
landmark.ts was deleted in 08.1-01).
2026-10-26 07:43:16 +08:00
araharah-executor 4ca7a71d15 feat(08.1-01): rebuild all fixtures and bot graph builders through the ARG3 path
- mini-graph-arg2.bin: the frozen ARG2-era bytes preserved once as the
  D-12 decode-rejection vector (never regenerated — the arg1 precedent)
- build-mini-graph.mjs: landmark stage out, CH stage in — contractGraph
  over the 25-node grid (41 shortcuts, 1.64/node, 0 budget hits) with
  fail-loud invariants: total-order rank, grid shortcut band, upward
  shortcuts, overlay-exact constituent weights (f64 replay, f32 compare),
  strong connectivity; deterministic ARG3 bytes
- graph.test.mjs: committed-fixture G4 added (ARG3 magic/version, 25
  nodes, 80 edges, rank 25, >=1 shortcut, ARG2 vector -> null); G7
  restored (full ARG3 pin)
- astar.test.mjs: A1-A6 restored — the 625-pair Dijkstra equivalence,
  oneway law, deriveSteps, depart/arrive, classifyTurn all green against
  the CH query on the committed ARG3 fixture
- snap.test.mjs: S1-S6 restored on the ARG3 fixture; S3's synthetic
  graph rebuilt as the ARG3 ch-block star
- webhook.test.mjs: landmarkBlock -> chBlock (contractGraph); corridor
  gains the fast nonstop chord (a pure line starves to zero shortcuts);
  sparse star keeps node ids 0-hub; RT9's split carrier is now two
  disconnected decodable stars (the honest A\* miss)
- guidance-cron.test.mjs: GC6b's 2-node graph becomes the lowest-id-mid
  3-node star (a 2-node graph cannot carry a valid shortcut)
- routing 87 passed / bot 126 passed; routing+bot+web typechecks green
2026-10-26 07:30:15 +08:00
araharah-executor 06624bb688 feat(08.1-01): ARG3 codec + uphill CH query — ALT deleted, one search path (GREEN)
- graph.ts: ARG3 layout (magic 0x41524733, version 3, chShortcutCount
  u32 @60, reserved @64/@68) with the CH suffix chRank ++ shortcut CSR;
  fail-honest decode (ARG1/ARG2 -> null, chShortcutCount 0 -> null,
  exact-size before any allocation, 4-byte alignment load-bearing);
  loud ch-block encode validation
- ch-query.ts: routeCH — bidirectional uphill search (strict rank filter,
  forward originals+shortcuts, backward reverse views incl. shortcuts),
  edge-ref meet probe, iterative via-unpacking (edge-aware: a parallel
  original edge is never expanded as a shortcut; DERIVE prefers the
  cheaper of original/shortcut), seconds re-summed from the unpacked
  original-edge path. STOP RULE: min(topF, topB) >= mu — the classical
  sum rule is unsound under the rank filter (Q1 caught it: 36 vs 24)
- astar.ts: routeAstar validates + delegates every request to routeCH;
  ALT/BH internals, clean-column memo, landmark tokens DELETED (D-04);
  classifyTurn/deriveSteps untouched
- landmark.ts + landmark.test.mjs deleted (D-05); index.ts additively
  exports contractGraph + CH constants + routeCH
- snap.test.mjs: S3's encode moved to the ARG3 ch block (3-node star —
  a 2-node graph can carry no valid shortcut); committed-fixture S-cases
  suspended until task 3 (Rule 3: same pattern as G7/A1-A6)
- routing suite: 86 passed, 0 failed; typecheck clean
2026-10-26 07:15:28 +08:00
araharah-executor d3f0b4577f test(08.1-01): re-pin codec + search suites for ARG3 and the CH query (RED)
- graph.test.mjs: G1-G11 re-pinned for the ARG3 layout (chShortcutCount
  @60, CH suffix); G7 suspended until task 3 regenerates the fixture
- astar.test.mjs: committed-fixture cases A1/A2/A4/A5/A6 suspended;
  A3 re-pinned on an in-memory ch block; A8 extended to ch-query.ts;
  A11 becomes the D-04 one-search-path pin; ALT-era A9/A10/A12/A13 fold
- ch-query.test.mjs: Q1-Q5 (625-pair equivalence, directed oneway law,
  original-edge seconds, island nulls, structural deletion) + G1-G3
  in-memory ARG3 pins; all fixtures through contractGraph + encodeGraph
2026-10-26 06:48:24 +08:00
araharah-executor f75dc9e8c6 feat(08.1-01): implement CH contraction core — ordering, contraction, overlay witness, CSR (GREEN)
- contractGraph: greedy edgeDifference + λ·deletedNeighbours priority,
  lazy PQ with id tie-breaks, rank = contraction position (total order)
- overlay-only witness search: contracted node + candidate absent,
  bounded by candidate distance and max(60, 4·localDegree) settles
- directed shortcut emission (ascending from, then to), via midpoints,
  f32 CSR; dominated-insert guard; explosion tripwire fails loud at 4×N
- deterministic: no PRNG/map-order; identical input -> identical output
- 25-node grid: 41 shortcuts (1.64/node), 0 budget hits
2026-10-26 06:39:09 +08:00
araharah-executor a3e2c3253f test(08.1-01): add failing CH construction core tests (RED)
- C1 total order / C2 shortcut weights / C3 upward shortcuts
- C4 hand-computed 3-node fixture (literal rank + shortcut set)
- C5 overlay-only witness law (contracted node cannot witness)
- C6 explosion tripwire (hand-traced 42-shortcut cascade on 9 nodes)
- C7 determinism + bit0 oneway law
2026-10-26 06:29:32 +08:00
araharah-executor 67026b9483 docs(08.1): fix plan task ownership and broken verify gates
- 08.1-01 task 2: re-pin graph.test.mjs in-memory G-cases (G1/G3/G8/G9/G10/G11)
  and astar.test.mjs in-memory/ALT cases; suspend only committed-fixture G7 and
  A1/A2/A4/A5/A6 (the runner auto-discovers all suites and parses no argv, so
  task 2's verify could never exit 0 otherwise)
- 08.1-01 task 3: own un-suspension of G7 and A1/A2/A4/A5/A6 after the ARG3
  fixture regeneration
- 08.1-01 task 1: drop the cosmetic --test-name-pattern flag (ignored by the
  hand-rolled runner; full suite runs instead)
- 08.1-02 task 2: use grep -cF for the rollout decodeGraph call-site gate
  (BRE form exited 2 with 'Unmatched ( or \\(')
- 08.1-02 task 1: replace the no-op '|| test $? -eq 1' landmark gate with a
  'test "$(...)" -eq 0' count assertion that can actually fail
2026-10-26 00:54:01 +08:00
araharah-executor 5033618ab1 docs(08.1): create phase plans for contraction hierarchies routing backend v3 2026-10-26 00:36:57 +08:00
26 changed files with 3771 additions and 2187 deletions
+6 -4
View File
@@ -296,13 +296,15 @@ Notes:
### Phase 08.1: Contraction Hierarchies (Routing Backend v3) — the lever that closes SC-1: build-time node ordering + contraction + shortcut edges, simple uphill bidirectional query, optimality by construction (INSERTED)
**Goal:** [Urgent work - to be planned]
**Requirements**: TBD
**Goal:** Build-time node ordering + contraction + shortcut edges over the KL road graph, a simple uphill bidirectional query replacing the ALT-steered bidirectional A* as THE one search path, and optimality by construction — so cross-metro routes finally route inside the free-plan CPU budget and the c3 degradation class retires with measured evidence (or the record shrinks honestly if the measurement says otherwise).
**Requirements**: ROADMAP-08.1-GOAL, D-01..D-13 (phase-scoped decisions; no v1 REQ ids added)
**Depends on:** Phase 8
**Plans:** 5/5 plans complete
**Plans:** 3 plans
Plans:
- [ ] TBD (run /gsd-plan-phase 08.1 to break down)
- [ ] 08.1-01-PLAN.md — Pure CH construction core + ARG3 codec + uphill bidirectional CH query as the sole search path; ALT deleted; ARG3 fixtures and bot graph builders rebuilt (D-04/D-05/D-07/D-10/D-11/D-12)
- [ ] 08.1-02-PLAN.md — CH production build stage + measured ARG3 build + unchanged D-13 optimality/oracle gates + local CPU verdict and conditional D-03 drift-guard re-anchor (D-01/D-02/D-03/D-08/D-09/D-13)
- [ ] 08.1-03-PLAN.md — Public ARG3 fork contract + measured c3 retire-or-shrink record + direction-split experiment closed as superseded (D-01/D-03/D-06/D-08/D-09/D-10/D-13)
### Phase 9: Routing Extras (Toll / Speed / Share / Voice)
**Goal**: The REQUIREMENTS extras go live on the engine-A stack.
@@ -0,0 +1,437 @@
---
phase: 08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- routing/src/contraction.ts
- routing/src/ch-query.ts
- routing/src/graph.ts
- routing/src/astar.ts
- routing/src/index.ts
- routing/src/landmark.ts
- routing/test/contraction.test.mjs
- routing/test/ch-query.test.mjs
- routing/test/graph.test.mjs
- routing/test/astar.test.mjs
- routing/test/landmark.test.mjs
- routing/test/fixtures/build-mini-graph.mjs
- routing/test/fixtures/mini-graph.bin
- routing/test/fixtures/mini-graph-arg2.bin
- bot/test/webhook.test.mjs
- bot/test/guidance-cron.test.mjs
autonomous: true
requirements:
- ROADMAP-08.1-GOAL
- D-04
- D-05
- D-07
- D-10
- D-11
- D-12
must_haves:
truths:
- "The routing package builds a deterministic contraction hierarchy with a strict total rank, valid upward shortcut edges, and an overlay-only witness search."
- "Every route request uses one bidirectional uphill CH query; the old ALT-steered bidirectional A* internals and landmark tables are absent from runtime code and binary output."
- "ARG3 is a little-endian typed-array layout that decodes only exact ARG3 bytes; ARG1 and ARG2 bytes decode to null and reach the existing router-pending state."
- "The reported route seconds are summed from the unpacked original-edge path, and every route remains optimal against the Dijkstra oracle."
- "The committed mini-graph fixture and bot test graphs are produced through the same ARG3 encode path as the production graph."
artifacts:
- path: "routing/src/contraction.ts"
provides: "Pure deterministic CH construction core: ordering, contraction, overlay witness search, shortcut emission"
- path: "routing/src/ch-query.ts"
provides: "Pure uphill bidirectional CH query, shortcut unpacking, and original-edge cost recomputation"
- path: "routing/src/graph.ts"
provides: "ARG3 codec authority: rank + shortcut suffix, exact-size and alignment validation"
- path: "routing/test/contraction.test.mjs"
provides: "Construction invariants, hand fixture, overlay witness law, tripwire, determinism"
- path: "routing/test/ch-query.test.mjs"
provides: "Directed oneway, original-edge seconds, one-path, and deleted-ALT structural pins"
- path: "routing/test/fixtures/mini-graph-arg2.bin"
provides: "Immutable ARG2 decode-rejection vector"
key_links:
- from: "routing/src/contraction.ts"
to: "routing/src/graph.ts"
via: "ContractionResult rank + shortcut CSR encoded through GraphInput.ch"
pattern: "contractGraph|encodeGraph"
- from: "routing/src/ch-query.ts"
to: "routing/src/graph.ts"
via: "zero-copy RoadGraph chRank and shortcut CSR views"
pattern: "chRank|shortcutStart"
- from: "routing/src/astar.ts"
to: "routing/src/ch-query.ts"
via: "routeAstar delegates every request to the uphill CH query"
pattern: "routeCH|ch-query"
---
<objective>
Implement the routing-backend-v3 data plane and its single search path: build-time contraction with shortcut edges, ARG3 binary persistence, and one uphill bidirectional CH query used by every request.
Purpose: This is the mechanism that closes SC-1. The hierarchy prunes the request-time search structurally, while D-04/D-05 remove the ALT engine and its 28.5 MB landmark suffix rather than leaving a dormant fallback.
Output: Pure CH construction/query modules, ARG3 codec, CH-backed routeAstar, ARG3 fixtures, and updated routing/bot test suites.
</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/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-CONTEXT.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-RESEARCH.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-PATTERNS.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-VALIDATION.md
@routing/src/graph.ts
@routing/src/astar.ts
@routing/src/landmark.ts
@routing/src/index.ts
@routing/test/graph.test.mjs
@routing/test/astar.test.mjs
@routing/test/fixtures/build-mini-graph.mjs
@routing/test/rollout.test.mjs
</context>
<interfaces>
Existing contracts to preserve:
```typescript
// routing/src/astar.ts
export interface AstarResult {
nodes: number[];
seconds: number;
distanceM: number;
}
export function routeAstar(graph: RoadGraph, originNode: number, goalNode: number): AstarResult | null;
export function deriveSteps(graph: RoadGraph, nodes: number[]): RouteStep[];
export function classifyTurn(prevBearingDeg: number, nextBearingDeg: number): GuidanceTurnKind;
```
New internal contracts to implement:
```typescript
// routing/src/contraction.ts
export interface ContractionInput {
nodeCount: number;
edges: Array<{ from: number; to: number; seconds: number; flags: number }>;
}
export interface ContractionResult {
rank: Uint32Array;
shortcutStart: Uint32Array;
shortcutTarget: Uint32Array;
shortcutSeconds: Float32Array;
shortcutVia: Uint32Array;
stats: {
shortcutCount: number;
ratioPerNode: number;
witnessSearches: number;
witnessBudgetHits: number;
};
}
export const CH_LAMBDA_DELETED_NEIGHBOURS = 1;
export const CH_WPS_MAX_SETTLED = 60;
export const CH_WPS_SETTLED_PER_DEGREE = 4;
export const CH_SHORTCUT_EXPLOSION_RATIO = 4;
export function contractGraph(input: ContractionInput): ContractionResult;
// routing/src/graph.ts
export interface ChTablesInput {
rank: Uint32Array;
shortcutStart: Uint32Array;
shortcutTarget: Uint32Array;
shortcutSeconds: Float32Array;
shortcutVia: Uint32Array;
}
export interface RoadGraph {
chShortcutCount: number;
chRank: Uint32Array;
shortcutStart: Uint32Array;
shortcutTarget: Uint32Array;
shortcutSeconds: Float32Array;
shortcutVia: Uint32Array;
}
```
ARG3 fork contract to implement exactly:
```text
header 72 bytes, little-endian:
0 magic u32 = 0x41524733 ("ARG3")
4 version u32 = 3
8 nodeCount u32
12 edgeCount u32
16 nameCount u32
20 namePoolLen u32
24 bbox 4 x f64
56 maxSpeedKmh f32
60 chShortcutCount u32 (0 -> decode null)
64 reserved0 u32
68 reserved1 u32
sections:
lat[N] ++ lng[N] ++ edgeStart[N+1] ++ edgeTarget[E] ++ edgeFlags[E]
++ edgeSeconds[E] ++ edgeNameId[E] ++ nameOffset[M+1] ++ namePool
++ chRank[N] ++ shortcutStart[N+1] ++ shortcutTarget[S]
++ shortcutSeconds[S] ++ shortcutVia[S]
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>task 1: build the pure CH construction core with RED-first invariants</name>
<files>routing/src/contraction.ts, routing/test/contraction.test.mjs</files>
<read_first>
- routing/src/landmark.ts
- routing/src/graph.ts
- routing/test/landmark.test.mjs
- .planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-RESEARCH.md
- .planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-PATTERNS.md
</read_first>
<behavior>
- Case C1: contractGraph over a 5-node directed fixture returns distinct ranks covering exactly [0, nodeCount).
- Case C2: every emitted shortcut's seconds equal the sum of the weights of the two edges it replaced.
- Case C3: every shortcut is upward, with rank(from) > rank(via) and rank(to) > rank(via).
- Case C4: a hand-computed 3-4 node fixture yields its literal expected contraction order and shortcut set.
- Case C5: the witness search sees only the overlay graph; a previously contracted node cannot witness a shortcut.
- Case C6: the shortcut-explosion tripwire fails loud above CH_SHORTCUT_EXPLOSION_RATIO * nodeCount and passes for a healthy graph.
- Case C7: two calls on identical input produce byte-identical rank and shortcut arrays.
</behavior>
<action>
Write `routing/test/contraction.test.mjs` first using the existing lazy-import harness, `node:assert/strict`, and hand-built vectors. Then create `routing/src/contraction.ts` as a pure module with zero value imports, no I/O, no logging, and no storage.
Implement these exact constants and exports:
- `CH_LAMBDA_DELETED_NEIGHBOURS = 1`
- `CH_WPS_MAX_SETTLED = 60`
- `CH_WPS_SETTLED_PER_DEGREE = 4`
- `CH_SHORTCUT_EXPLOSION_RATIO = 4`
- `ContractionInput { nodeCount, edges: Array<{from,to,seconds,flags}> }`
- `ContractionResult { rank, shortcutStart, shortcutTarget, shortcutSeconds, shortcutVia, stats }`
- `contractGraph(input): ContractionResult`
Implement the bottom-up CH algorithm exactly:
1. Validate node ids and finite non-negative edge seconds before constructing CSR views. An edge with `flags & 1` is blocked in every direction and must never be relaxed.
2. Compute priority as `edgeDifference(u) + CH_LAMBDA_DELETED_NEIGHBOURS * deletedNeighbours(u)`. Use a lazy binary priority queue. When a popped priority is stale, recompute it and requeue if it improved. Break priority ties by the lower node id.
3. Assign `rank[u]` as the strictly increasing contraction position, never as the priority value. Thus rank is a total order over all nodes.
4. For each node `u` being contracted, enumerate overlay in-neighbours `a` and overlay out-neighbours `b`. The candidate shortcut is directed `a -> b`, with seconds `w(a,u) + w(u,b)` and `via = u`.
5. Run each witness search on the overlay graph with `u` removed and all previously contracted nodes absent. Bound it by both the candidate distance and `max(CH_WPS_MAX_SETTLED, CH_WPS_SETTLED_PER_DEGREE * localDegree)`. If the witness search is bounded out or finds no path within the candidate distance, emit the shortcut; bounded witness searches may add superfluous shortcuts but must not remove a required one.
6. Remove `u` and its incident overlay edges before the next witness search. Emit shortcuts in deterministic order: ascending `from`, then ascending `to`.
7. Build shortcut CSR arrays where `shortcutStart` has length `nodeCount + 1` and every source's shortcuts are contiguous.
8. Fail loud if `shortcutCount > CH_SHORTCUT_EXPLOSION_RATIO * nodeCount`, with a message naming the ratio and the measured count.
9. Populate `stats` with `shortcutCount`, `ratioPerNode = shortcutCount / nodeCount`, `witnessSearches`, and `witnessBudgetHits`.
Determinism is mandatory: no PRNG, no `Date`, no Map iteration order, no reliance on object key order. Identical input arrays must yield identical output arrays. Do not use any external CH library and do not add a dependency.
</action>
<verify>
<automated>npm run routing:test &amp;&amp; npm run routing:typecheck</automated>
</verify>
<acceptance_criteria>
- `routing/src/contraction.ts` contains `export function contractGraph` and all four `CH_*` constants with the exact values 1, 60, 4, and 4.
- `routing/test/contraction.test.mjs` contains cases whose names include C1, C2, C3, C4, C5, C6, and C7.
- `npm run routing:test` exits 0 with no FAIL lines for the C-series cases.
- `npm run routing:typecheck` exits 0.
- `grep -v '^[[:space:]]*\*' routing/src/contraction.ts | grep -v '^[[:space:]]*//' | grep -c 'Date\|Math.random'` equals 0.
</acceptance_criteria>
<verify_note>
The routing runner (`routing/test/runner.mjs`) auto-discovers every `*.test.mjs` in `routing/test/` and parses NO argv — a `--test-name-pattern` flag is silently dropped and the FULL suite runs. This task's verify therefore runs the whole routing suite (82+ cases) rather than only the C-series. That is strictly stronger than a filtered run, and it must be green in full: at this point in the plan the only suites affected are the pre-existing ones plus the new `contraction.test.mjs`.
</verify_note>
<done>CH construction is deterministic, rank is a strict total order, shortcut weights and upwardness are proven, the overlay-only witness law is pinned, and the explosion tripwire fails loud.</done>
</task>
<task type="auto" tdd="true">
<name>task 2: implement ARG3 codec persistence and the uphill bidirectional CH query</name>
<files>routing/src/graph.ts, routing/src/ch-query.ts, routing/src/astar.ts, routing/src/index.ts, routing/src/landmark.ts, routing/test/ch-query.test.mjs, routing/test/astar.test.mjs, routing/test/landmark.test.mjs, routing/test/graph.test.mjs</files>
<read_first>
- routing/src/graph.ts
- routing/src/astar.ts
- routing/src/landmark.ts
- routing/src/index.ts
- routing/test/graph.test.mjs
- routing/test/astar.test.mjs
- routing/test/rollout.test.mjs
- .planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-RESEARCH.md
</read_first>
<behavior>
- Case Q1: the CH query equals brute-force Dijkstra for every pair of the 25-node mini-graph, with path endpoints equal to the requested origin and goal.
- Case Q2: a directed ring/chord/spur fixture matches the Dijkstra oracle in both directions for every pair and never traverses a bit0 edge.
- Case Q3: the returned seconds equal a fresh sum over the returned original-edge path and never equal a sum of shortcut weights.
- Case Q4: an unreachable pair returns null.
- Case Q5: comment-stripped `astar.ts` contains none of `landmarkTo`, `landmarkFrom`, `cleanColumnsFor`, `LANDMARK_SENTINEL`; `routing/src/landmark.ts` and `routing/test/landmark.test.mjs` are absent; the index exports no landmark symbols.
- Case G1: an in-memory ARG3 buffer round-trips rank and shortcut CSR exactly through zero-copy views.
- Case G2: in-memory ARG1 bytes and ARG2 bytes both decode to null; a version-2 tamper decodes to null.
- Case G3: in-memory truncated, hostile-count, and `chShortcutCount = 0` buffers decode to null before any typed-array allocation.
- The committed-fixture decode pin (G7) is SUSPENDED in this task and re-pinned in task 3, after the fixture is regenerated through the ARG3 encode path. G7 is the ONLY case in `graph.test.mjs` that reads a committed fixture; every other G-case is in-memory and is re-pinned HERE.
</behavior>
<action>
Replace the ARG2 codec in `routing/src/graph.ts` with the ARG3 codec described in the interfaces block. Use `GRAPH_MAGIC = 0x41524733` and `GRAPH_VERSION = 3`. Keep `HEADER_SIZE = 72` so f64 views remain 8-byte aligned. Replace landmark header fields with `chShortcutCount` u32 @60 and reserved u32 @64/@68. Remove `landmarkCount`, `landmarkQuantumS`, `landmarkTo`, `landmarkFrom`, and `landmarkNodes` from `RoadGraph`, `LandmarkTablesInput`, and `GraphInput`; replace them with `ChTablesInput` and `GraphInput.ch`.
In `layoutFor`, calculate the CH suffix in this order and use 4-byte alignment before every u32/f32 section: `chRank = (namePool + namePoolLen + 3) & ~3`, then `shortcutStart = chRank + 4*nodeCount`, `shortcutTarget = shortcutStart + 4*(nodeCount+1)`, `shortcutSeconds = shortcutTarget + 4*shortcutCount`, `shortcutVia = shortcutSeconds + 4*shortcutCount`, `total = shortcutVia + 4*shortcutCount`. Run the exact-size arithmetic check before allocating any view. `chShortcutCount === 0` must return null even when the byte size matches. `decodeGraph` must never throw for malformed bytes and must never return a half-built graph.
Create `routing/src/ch-query.ts` with the pure uphill bidirectional CH query. Its public result shape must be `{ nodes: number[]; seconds: number; distanceM: number }`. The forward search relaxes only edges whose target rank is strictly greater than the source rank. The backward search relaxes only incoming edges whose source rank is strictly greater than the settled node's rank. Use separate forward and backward edge access; never reuse the forward shortcut list in the backward search. Apply the shared traversal law in every scan: `if ((edgeFlags[e] & 1) !== 0) continue;`.
The stop rule is `if (forwardHeap.peekKey() + backwardHeap.peekKey() >= mu) break;`. Alternating the smaller frontier first is only a work-balance knob and must not affect optimality. Use the edge-based meet probe already present in astar.ts. After reconstructing the parent chains, recursively unpack every shortcut through its `via` field until only original edges remain. THEN compute `seconds` by summing `graph.edgeSeconds[findTraversableEdge(...)]` over consecutive original-edge pairs, and compute `distanceM` by summing haversine over that same unpacked path. Shortcut weights steer the search only; they never enter reported seconds.
Rewrite `routing/src/astar.ts` so `routeAstar` validates origin/goal, handles origin === goal, and delegates to the CH query for every other request. Preserve `AstarResult`, `deriveSteps`, `classifyTurn`, and all exported turn constants exactly. Delete the ALT machinery: `LANDMARK_SENTINEL_U16`, `cleanColumnsFor`, the `cleanColumnGraphs` memo, `metersTo`, `bound`, `potential`, the ALT/BH search body, and every landmark-table reference. Per D-04 there is exactly one search path.
Update `routing/src/index.ts` to export the existing public API and additively export `contractGraph`, `ContractionInput`, `ContractionResult`, and the four `CH_*` constants if the query implementation needs them. Export no landmark symbols.
Delete `routing/src/landmark.ts` and `routing/test/landmark.test.mjs` per D-05. Add the ARG3 codec cases G1-G3 to `routing/test/ch-query.test.mjs` using in-memory encode/decode vectors only; do not read or assert the committed mini-graph in this task, because that fixture is still ARG2 until task 3 regenerates it.
RE-PIN `routing/test/graph.test.mjs` IN THIS TASK. The codec flips to ARG3 here, so every in-memory G-case must be re-pinned here or the suite cannot go green. The routing runner auto-discovers ALL suites and parses NO argv, so `npm run routing:test` runs the whole `routing/test/` directory — there is no way to exclude `graph.test.mjs` from this task's verify.
`routing/test/graph.test.mjs` re-pinning, case by case:
- `syntheticInput()` (currently around line 74) — replace the `landmarks: {...}` block with a `ch:` block built from `contractGraph` over the same 3 edges, or with a hand-built CH block (rank of length 3, `shortcutStart` of length 4, matching target/seconds/via arrays). The fixture must produce a graph with at least one shortcut so the round-trip is meaningful.
- G1 (around line 100) — assert the CH fields round-trip: `chRank` length equals nodeCount, `shortcutStart` length equals nodeCount+1, `shortcutTarget`/`shortcutSeconds`/`shortcutVia` lengths equal `chShortcutCount`, and the values match the input exactly. Keep the existing coord/target/flag/weight/name/bbox assertions unchanged.
- G2 (around line 126) — unchanged: tampered magic still decodes to null.
- G3 (around line 134) — keep the ARG1 fixture rejection. The version tamper must change: version 1 still decodes to null, but the "version 3 tamper" case must become a version 4 tamper (the new `GRAPH_VERSION` is 3, so a buffer stamped 4 must be refused). A version-2 tamper may be added as well.
- G4 (around line 150) — keep the truncated/empty/sub-header/hostile-count assertions. The hostile header must stamp `GRAPH_MAGIC` and the NEW `GRAPH_VERSION` (3).
- G5 (around line 171) — unchanged: zero-copy views over the input buffer. Add `chRank.buffer === buf` as a CH zero-copy assertion.
- G6 (around line 185) — unchanged: name pool behavior.
- G7 (around line 195) — SUSPEND in this task. This is the ONLY case that reads the committed `routing/test/fixtures/mini-graph.bin`, which is still ARG2 until task 3 regenerates it. Replace its body with a `test.skip`-style no-op that documents the deferral (for example a case named `G7 committed ARG3 fixture — SUSPENDED until task 3 regenerates mini-graph.bin` whose body asserts `true`). Do NOT delete the case and do NOT change the committed fixture in this task. Task 3 re-pins G7 in place.
- G8 (around line 237) — re-pin the constants: `GRAPH_MAGIC === 0x41524733` ('ARG3') and `GRAPH_VERSION === 3`. `UNNAMED_NAME_ID` stays `0xffffffff`.
- G9 (around line 244) — re-pin the table round-trip to the CH tables: assert `chRank` restores exactly, `shortcutStart` restores exactly, `shortcutTarget`/`shortcutSeconds`/`shortcutVia` restore exactly, `chShortcutCount` matches, and the CH arrays are zero-copy views over the input buffer.
- G10 (around line 260) — re-pin the encode-validation cases to CH inputs: a missing `ch` block, a `chRank` whose length differs from nodeCount, a `shortcutStart` whose length differs from nodeCount+1, a `shortcutTarget` whose length differs from `chShortcutCount`, a `shortcutVia` entry outside `[0, nodeCount)`, a non-finite `shortcutSeconds` value, and a `chShortcutCount` that disagrees with the arrays. Each must throw an `Error` whose message starts with `encodeGraph: `.
- G11 (around line 280) — re-pin the decode rejection: `chShortcutCount = 0` in the header decodes to null even when the byte size matches (byte 60 is now `chShortcutCount`, not `landmarkCount`), and a monster `chShortcutCount` on a tiny buffer decodes to null by arithmetic before any allocation. Recompute the "exactly-sized" buffer arithmetic for the ARG3 layout (the L=0 landmark layout no longer applies — the equivalent is a buffer whose byte length matches an ARG3 layout with `chShortcutCount = 0`).
- The file's header comment block (around lines 1-51) must be rewritten to describe the ARG3 layout (72-byte header, `chShortcutCount` u32 @60, reserved u32 @64/@68, the CH suffix of `chRank` ++ `shortcutStart` ++ `shortcutTarget` ++ `shortcutSeconds` ++ `shortcutVia`) and the G1-G11 case list. Remove every reference to the landmark tables and to `0x41524732`.
RE-PIN `routing/test/astar.test.mjs` IN THIS TASK. Its cases split the same way:
- `loadMiniGraph()` (around line 92) reads the committed `routing/test/fixtures/mini-graph.bin`, which is still ARG2 in this task. The cases that use it are A1 (around line 187), A2 (around line 216), A4 (around line 274), A5 (around line 309), and A6 (around line 328). SUSPEND those five cases in this task with a documented `test.skip`-style no-op (the fixture is still ARG2 until task 3 regenerates it). Do NOT delete them and do NOT change the committed fixture in this task — task 3 re-pins them in place.
- A3 (around line 239) is in-memory: replace its `landmarks: {...}` block with a `ch:` block and keep the unreachable-pair law.
- A7 (around line 356) and A8 (around line 378) are structural: keep them, but extend A8 to assert the CH path (no `new Map(` in `astar.ts`/`ch-query.ts`).
- A9 (around line 478), A10 (around line 535), A12 (around line 401), and A13 (around line 612) are ALT-specific: per D-04/D-05 they are folded into the CH query suite (`routing/test/ch-query.test.mjs`) rather than retained. Their island fixture (around line 134) imports `../src/landmark.ts` and builds a `landmarks:` block — rewrite it to build a `ch:` block through `contractGraph`, and re-point the surviving assertions at the CH query. A11 (around line 561) asserts the ALT clean-column law in `astar.ts` source: replace it with the CH-law equivalent (the uphill rank filter exists in `ch-query.ts`, and `astar.ts` contains no landmark token).
The committed-fixture ARG3 decode pin (the G4 case added in task 3) and the un-suspension of G7 / A1 / A2 / A4 / A5 / A6 all belong to task 3, after the fixture is regenerated through the ARG3 encode path and the ARG2 vector is preserved.
</action>
<verify>
<automated>npm run routing:test &amp;&amp; npm run routing:typecheck</automated>
</verify>
<acceptance_criteria>
- `routing/src/graph.ts` contains `GRAPH_MAGIC = 0x41524733` and `GRAPH_VERSION = 3` and contains no `landmarkTo`, `landmarkFrom`, or `landmarkCount`.
- `routing/src/ch-query.ts` exists and contains the upward-rank filter, the `peekKey()` stop rule, and shortcut unpacking through `via`.
- `routing/src/astar.ts` comment-stripped code contains none of `landmarkTo`, `landmarkFrom`, `cleanColumnsFor`, `LANDMARK_SENTINEL`.
- `routing/src/landmark.ts` and `routing/test/landmark.test.mjs` do not exist.
- `routing/test/graph.test.mjs` contains no `0x41524732`, no `landmarkCount`, and no `landmarkTo`/`landmarkFrom`/`landmarkQuantumS`; its G7 case is a documented suspended no-op.
- `routing/test/astar.test.mjs` contains no import of `../src/landmark.ts` and no `landmarks:` block; its A1/A2/A4/A5/A6 cases are documented suspended no-ops.
- `npm run routing:test` exits 0 and `npm run routing:typecheck` exits 0.
- `grep -v '^[[:space:]]*\*' routing/src/astar.ts | grep -v '^[[:space:]]*//' | grep -c 'landmarkTo\|landmarkFrom\|cleanColumnsFor\|LANDMARK_SENTINEL'` equals 0.
</acceptance_criteria>
<verify_note>
The routing runner auto-discovers every `*.test.mjs` in `routing/test/` and parses NO argv — `npm run routing:test` is the WHOLE routing suite, and `graph.test.mjs` / `astar.test.mjs` cannot be excluded from it. That is why their in-memory cases are re-pinned in THIS task and only their committed-fixture-dependent cases are suspended (G7; A1/A2/A4/A5/A6) until task 3 regenerates `mini-graph.bin` as ARG3.
</verify_note>
<done>ARG3 is the sole binary format, the CH uphill query is the sole search path, ALT code and tables are gone, and reported seconds come from the unpacked original-edge path.</done>
</task>
<task type="auto" tdd="true">
<name>task 3: rebuild all fixtures and bot graph builders through the ARG3 path</name>
<files>routing/test/fixtures/build-mini-graph.mjs, routing/test/fixtures/mini-graph.bin, routing/test/fixtures/mini-graph-arg2.bin, routing/test/graph.test.mjs, routing/test/astar.test.mjs, bot/test/webhook.test.mjs, bot/test/guidance-cron.test.mjs</files>
<read_first>
- routing/test/fixtures/build-mini-graph.mjs
- routing/test/fixtures/mini-graph.bin
- routing/test/graph.test.mjs
- routing/test/astar.test.mjs
- bot/test/webhook.test.mjs
- bot/test/guidance-cron.test.mjs
- routing/src/contraction.ts
- routing/src/graph.ts
- .planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-PATTERNS.md
</read_first>
<behavior>
- The committed mini-graph fixture decodes as ARG3 with 25 nodes, 80 edges, and a valid CH section.
- Case G4: the committed ARG3 mini-graph has 25 nodes, 80 edges, a rank of length 25, and at least one shortcut.
- The preserved ARG2 fixture decodes to null.
- The G7 case in `routing/test/graph.test.mjs` is un-suspended and asserts the committed ARG3 mini-graph.
- The A1/A2/A4/A5/A6 cases in `routing/test/astar.test.mjs` are un-suspended and run green against the committed ARG3 mini-graph.
- Every bot test graph encodes and decodes through the ARG3 `ch` block and produces the same route behavior as before.
</behavior>
<action>
Before regenerating the mini-graph, copy the current committed `routing/test/fixtures/mini-graph.bin` bytes exactly once to `routing/test/fixtures/mini-graph-arg2.bin`. Add a provenance comment to `build-mini-graph.mjs` stating that `mini-graph-arg2.bin` holds the frozen ARG2-era bytes preserved as the ARG2 decode-rejection vector and is never regenerated by the script, matching the existing `mini-graph-arg1.bin` precedent.
Replace the landmark stage in `routing/test/fixtures/build-mini-graph.mjs` with a CH stage. Import `contractGraph` from `../../src/contraction.ts`, call it with the 25-node grid's directed edges, and pass its result as `ch:` to `encodeGraph`. Before writing, fail loud unless: `new Set(rank).size === 25`; every rank is in [0,25); the shortcut count is within the expected grid band; every `via` is a node id in [0,25); every shortcut's seconds equal its two constituent edge weights; and the graph remains strongly connected.
Update `bot/test/webhook.test.mjs`: replace the `landmark.ts` import and `landmarkBlock()` with a `chBlock(nodeCount, edges)` helper that computes `contractGraph` and returns the CH block. Replace every `landmarks: { ... }` argument to `encodeGraph` with `ch: { ... }`. There are three graph builders in this file; all must use the ARG3 path.
Update `bot/test/guidance-cron.test.mjs`: replace the pinned-anchor 2-node graph builder with a `contractGraph` call and encode with `ch:`. The 2-node graph must contract successfully; no anchor pin is allowed.
Run `node routing/test/fixtures/build-mini-graph.mjs` to regenerate `mini-graph.bin` as ARG3 and confirm the printed node/edge/shortcut/byte counts. After regeneration succeeds and the ARG2 rejection vector is preserved, re-pin `routing/test/graph.test.mjs` for ARG3 and add the committed-fixture G4 case: it must read `routing/test/fixtures/mini-graph.bin`, assert ARG3 magic/version, 25 nodes, 80 edges, rank length 25, and at least one shortcut, and assert that `routing/test/fixtures/mini-graph-arg2.bin` decodes to null. Keep this graph.test.mjs G4 pin in this task so fixture regeneration, ARG2 preservation, and the committed-fixture assertion are owned atomically.
UN-SUSPEND the committed-fixture cases that task 2 parked, now that `mini-graph.bin` is ARG3:
- `routing/test/graph.test.mjs` G7 — replace the suspended no-op with the real case: read `routing/test/fixtures/mini-graph.bin`, assert it decodes non-null, assert 25 nodes, 80 edges, 3 names, maxSpeed 60, exactly ONE oneway flag, the hand-built grid bbox, both speed classes present, `chRank` length 25, and at least one shortcut. Delete the ARG2 landmark assertions (landmarkCount, landmarkQuantumS, landmarkTo/landmarkFrom lengths, the anchor-cell loop). Also assert that `routing/test/fixtures/mini-graph-arg2.bin` decodes to null.
- `routing/test/astar.test.mjs` A1, A2, A4, A5, A6 — replace each suspended no-op with its real body. `loadMiniGraph()` now decodes an ARG3 graph; A1's 625-pair exhaustive Dijkstra equivalence, A2's oneway law, A4's `deriveSteps` L-path, A5's depart/arrange law, and A6's `classifyTurn` pins must all be restored and green. If any assertion depended on an ALT-only field, replace it with the CH equivalent (for example `chRank`/`chShortcutCount` instead of `landmarkCount`).
</action>
<verify>
<automated>node routing/test/fixtures/build-mini-graph.mjs &amp;&amp; npm run routing:test &amp;&amp; npm test -w bot &amp;&amp; npm run bot:typecheck</automated>
</verify>
<acceptance_criteria>
- `routing/test/fixtures/mini-graph-arg2.bin` exists and `node -e "const fs=require('fs');const b=fs.readFileSync('routing/test/fixtures/mini-graph-arg2.bin');console.log(b.readUInt32LE(4))"` prints `2`.
- `node -e "const fs=require('fs');const b=fs.readFileSync('routing/test/fixtures/mini-graph.bin');console.log(b.readUInt32LE(0).toString(16),b.readUInt32LE(4))"` prints `41524733 3`.
- `routing/test/graph.test.mjs` contains a G4 case that reads the committed ARG3 mini-graph, asserts 25 nodes, 80 edges, rank length 25, at least one shortcut, and asserts the preserved ARG2 fixture decodes to null.
- `routing/test/graph.test.mjs` contains no suspended/skipped case: grep for `skip`/`SUSPENDED` in that file returns 0 matches, and G7 asserts the committed ARG3 mini-graph.
- `routing/test/astar.test.mjs` contains no suspended/skipped case: grep for `skip`/`SUSPENDED` in that file returns 0 matches, and A1/A2/A4/A5/A6 run against the committed ARG3 mini-graph.
- `grep -v '^[[:space:]]*\*' bot/test/webhook.test.mjs | grep -v '^[[:space:]]*//' | grep -c 'landmark'` equals 0.
- `grep -v '^[[:space:]]*\*' bot/test/guidance-cron.test.mjs | grep -v '^[[:space:]]*//' | grep -c 'landmark'` equals 0.
- `npm run routing:test`, `npm test -w bot`, and `npm run bot:typecheck` exit 0.
</acceptance_criteria>
<done>Every committed and bot test graph is ARG3-shaped, the old ARG2 bytes survive only as an immutable rejection vector, and the routing and bot suites stay green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| R2 object -> Worker decoder | The R2-held road-graph bytes are untrusted to the decoder: corruption, truncation, hostile counts, or misalignment must produce null, never a throw or half-built graph. |
| Request -> Worker -> CH query | Untrusted route parameters reach the search only after the existing Bearer claim and bbox validation; the CH query must not broaden that surface. |
| Build pipeline -> binary | The Overpass-derived graph is transformed by build-time contraction into the persisted hierarchy; a stale hierarchy against changed topology is a build-artifact discipline problem. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08.1-01 | Tampering / DoS | `decodeGraph` ARG3 header and section arithmetic | mitigate | `layoutFor` computes every offset in one place; exact-size validation runs before any typed-array allocation; `chShortcutCount === 0` returns null; malformed bytes return null rather than throwing. Pinned by graph.test.mjs G3/G4/G11 successors. |
| T-08.1-02 | DoS | Misaligned `Float32Array`/`Uint32Array` CH views | mitigate | Keep `HEADER_SIZE = 72` for 8-byte f64 alignment and align the CH suffix with `(namePool + namePoolLen + 3) & ~3`; G1 round-trip and hostile-buffer tests decode the committed ARG3 fixture without RangeError. |
| T-08.1-03 | Tampering | Directed shortcut confusion across oneway edges | mitigate | Shortcuts are directed only; forward and backward searches use separate edge access; bit0 edges are never relaxed in forward, backward, or witness scans; the directed ring/chord/spur sweep compares both directions with the Dijkstra oracle. |
| T-08.1-04 | Tampering | Stale CH hierarchy after topology or weight changes | mitigate | ARG3 is rebuilt and re-uploaded as one whole artifact under the unchanged key; `docs/ROAD-GRAPH.md` records that the hierarchy is valid only for the topology and weights it was built against. |
| T-08.1-05 | DoS | Unbounded build-time witness search | mitigate | Witness search is bounded by candidate distance and `max(CH_WPS_MAX_SETTLED, CH_WPS_SETTLED_PER_DEGREE * localDegree)`; build prints witness-search and budget-hit counts. |
| T-08.1-06 | Tampering / DoS | Shortcut explosion inflating binary and query cost | mitigate | `CH_SHORTCUT_EXPLOSION_RATIO = 4` fails the build loud; the existing 1-80 MB envelope remains enforced; no fixed payload ceiling is invented. |
| T-08.1-07 | Information Disclosure | Coordinates, route payloads, or user identity in logs | accept | The phase adds no logging and changes no `/api/route` response shape; existing category-only log law and Bearer-claim identity remain unchanged. No new log statement may be introduced in routing modules. |
| T-08.1-08 | Spoofing / Tampering | Dormant ALT path or second decoder producing a different answer | mitigate | D-04/D-05 deletion is pinned structurally: no ALT tokens in astar.ts, landmark.ts absent, no landmark exports, one decode path in the Worker; routeAstar delegates every request to the CH query. |
| T-08.1-09 | DoS | CPU exhaustion on the request path | mitigate | The uphill query uses the `peekKey()` stop rule and returns null for unreachable pairs; the local CPU gate and tightened drift guard in Plan 02 are the enforcement point. |
</threat_model>
<verification>
Run the full automated suite after this plan:
```bash
npm run routing:test && npm test -w bot && npm run routing:typecheck && npm run bot:typecheck && npm run web:typecheck
```
Also verify structural deletion and the one-path law:
```bash
test ! -e routing/src/landmark.ts && test ! -e routing/test/landmark.test.mjs
grep -v '^[[:space:]]*\*' routing/src/astar.ts | grep -v '^[[:space:]]*//' | grep -c 'landmarkTo\|landmarkFrom\|cleanColumnsFor\|LANDMARK_SENTINEL'
grep -c 'decodeGraph(' bot/src/routing/worker-router.ts
```
The committed-fixture cases suspended in task 2 (G7; A1/A2/A4/A5/A6) must be un-suspended by task 3 — no case in the plan may be left skipped at the end:
```bash
grep -c 'SUSPENDED\|skip' routing/test/graph.test.mjs
grep -c 'SUSPENDED\|skip' routing/test/astar.test.mjs
```
Expected: routing and bot suites green; all typechecks green; structural ALT count 0; Worker decodeGraph count 1; both suspension counts 0.
</verification>
<success_criteria>
- CH construction and query behavior is proven by the routing unit suite, including a hand-computed fixture, overlay witness law, oneway direction, and original-edge seconds.
- ARG3 is the only encoded/decoded format; ARG1 and ARG2 decode to null through the existing fail-honest path.
- `routeAstar` is the sole exported search and is CH-backed for every request; no ALT runtime code or landmark payload remains.
- The committed mini-graph and both bot graph builders are ARG3-shaped and produced through the same encode path.
- `npm run routing:test`, `npm test -w bot`, and all three typechecks pass.
</success_criteria>
<output>
After completion, create `.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-01-SUMMARY.md`
</output>
@@ -0,0 +1,311 @@
---
phase: 08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl
plan: 02
type: execute
wave: 2
depends_on:
- 08.1-01
files_modified:
- routing/scripts/build-road-graph.mjs
- routing/scripts/measure-route-cpu.mjs
- routing/scripts/measure-optimality-gate.mjs
- routing/scripts/measure-astar-oracle-sweep.mjs
- scripts/lib/budget-gates.mjs
- scripts/test/budget-audit.test.mjs
- routing/test/rollout.test.mjs
autonomous: true
requirements:
- D-01
- D-02
- D-03
- D-08
- D-09
- D-13
must_haves:
truths:
- "The production graph build runs CH ordering, contraction, and shortcut encoding and prints measured bytes, shortcut ratio, witness counts, and wall-clock without inventing a fixed payload ceiling."
- "The existing optimality gate and 200-pair seeded Dijkstra-oracle sweep run unchanged against the ARG3 binary and report zero optimality violations."
- "The local route-CPU gate prints the CH p95 against the unchanged 8 ms product budget and is the only SC-1 closing measurement."
- "The drift guard is re-anchored to approximately two times the measured CH p95 only when the c3 class retires, and the constant plus its audit mirror move together."
- "The Worker and upload path remain format-agnostic: one decode call, no version branch, no format token in the upload script."
artifacts:
- path: "routing/scripts/build-road-graph.mjs"
provides: "Build-time CH preprocessing stage and measured ARG3 statistics print"
- path: "routing/scripts/measure-route-cpu.mjs"
provides: "Unchanged local 25-route and extended 100-route CH CPU measurement gates"
- path: "scripts/lib/budget-gates.mjs"
provides: "CPU verdict gate with D-03 drift-guard constant and CH regime wording"
- path: "scripts/test/budget-audit.test.mjs"
provides: "CPU3 mirror vector pinned to the CH verdict"
key_links:
- from: "routing/scripts/build-road-graph.mjs"
to: "routing/src/contraction.ts"
via: "contractGraph over the compacted directed edges, then encodeGraph ch block"
pattern: "contractGraph|encodeGraph"
- from: "routing/scripts/measure-route-cpu.mjs"
to: "routing/src/astar.ts"
via: "routeAstar now delegates to the CH query"
pattern: "routeAstar|astar"
- from: "scripts/lib/budget-gates.mjs"
to: "scripts/test/budget-audit.test.mjs"
via: "ROUTE_CPU_DRIFT_GUARD_MS and its CPU3 evidence mirror"
pattern: "ROUTE_CPU_DRIFT_GUARD_MS|CPU3"
---
<objective>
Wire the ARG3 binary into the real build and measurement campaign, then close SC-1 from measured local evidence with an honest retire-or-shrink verdict.
Purpose: The CH implementation is only useful if the production binary carries the hierarchy and the same evidence class that failed Phase 8 measures whether it succeeded. D-01 forbids retiring the c3 class on failing numbers; D-02 forbids substituting a deployed probe.
Output: CH build stage, measured ARG3 build, re-run optimality/oracle/CPU gates, and the tightened or deliberately unchanged drift guard.
</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/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-CONTEXT.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-RESEARCH.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-PATTERNS.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-VALIDATION.md
@routing/scripts/build-road-graph.mjs
@routing/scripts/measure-route-cpu.mjs
@routing/scripts/measure-optimality-gate.mjs
@routing/scripts/measure-astar-oracle-sweep.mjs
@scripts/lib/budget-gates.mjs
@scripts/test/budget-audit.test.mjs
@routing/test/rollout.test.mjs
</context>
<interfaces>
Contracts consumed from Plan 01:
```typescript
// routing/src/contraction.ts
export function contractGraph(input: ContractionInput): ContractionResult;
// ContractionResult.stats: { shortcutCount, ratioPerNode, witnessSearches, witnessBudgetHits }
```
Measurement constants to preserve or re-anchor:
```javascript
// routing/scripts/measure-route-cpu.mjs
const P95_BUDGET_MS = 8; // MUST remain exactly 8 and appear exactly once
const ROUTE_COUNT = 25;
const EXTENDED_PAIR_COUNT = 100;
// routing/scripts/measure-optimality-gate.mjs
const OPTIMALITY_EPSILON_PCT = 0.1;
const REQUIRED_BASELINE_ARG_VERSION = 1; // the frozen record is immutable
// routing/scripts/measure-astar-oracle-sweep.mjs
const SWEEP_PAIR_COUNT = 200;
const SWEEP_SEED = 0x08f1;
const ORACLE_EPSILON_S = 1e-6;
// scripts/lib/budget-gates.mjs
export const P95_BUDGET_MS = 8;
export const ROUTE_CPU_DRIFT_GUARD_MS = <ceil(2 * measured CH p95)>; // only if c3 retires
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>task 1: replace the ALT build stage with measured CH preprocessing</name>
<files>routing/scripts/build-road-graph.mjs</files>
<read_first>
- routing/scripts/build-road-graph.mjs
- routing/src/contraction.ts
- routing/src/graph.ts
- routing/test/fixtures/build-mini-graph.mjs
- .planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-RESEARCH.md
</read_first>
<behavior>
- The build no longer imports landmark.ts and has no --landmarks option.
- The build calls contractGraph and encodes the CH block through encodeGraph.
- The build prints contracted node count, shortcut count, ratio per node, witness search count, witness budget hits, CH-section bytes, total bytes, and wall-clock.
- The existing 1-80 MB envelope gate still fails before any write; no new fixed payload ceiling exists.
</behavior>
<action>
Remove the `--landmarks` argument parsing, `LANDMARK_COUNT_MIN`, `LANDMARK_COUNT_MAX`, `NODES_PER_LANDMARK`, `LANDMARK_MIN_COVERAGE_FRACTION`, and `LANDMARK_COUNT_OVERRIDE_MAX` constants and their usage.
Remove the ALT preprocessing block and replace it with one CH stage. Import `contractGraph` from `../src/contraction.ts`. Project the compacted directed edges into `{ from, to, seconds, flags }` and call `contractGraph({ nodeCount: cLat.length, edges })`. If `contractGraph` throws, fail with a message naming the stage and the thrown reason, then exit 1 before any write. Pass the resulting CH data to `encodeGraph` through the `ch:` input field, never a `landmarks:` field.
Measure the build wall-clock with `process.hrtime.bigint()` around the CH stage and the encode. Print a `CH stats:` block containing exactly these labeled measurements:
- `contracted nodes: <N>`
- `shortcuts: <S> (<ratio> per node)`
- `witness searches: <W> (<H> hit the settle budget)`
- `chRank bytes: <4*N>`
- `shortcut bytes: <12*S>`
- `build wall-clock: <ms> ms`
- `total binary: <MB> (<bytes> B)`
Do not add an ARG3 byte ceiling. Keep the existing enforced `MIN_BINARY_BYTES` floor and `MAX_BINARY_BYTES` ceiling unchanged. Keep collect-then-write semantics: any gate failure exits 1 with the previous binary untouched.
Update `--check <file>` so it prints CH facts instead of landmark facts: nodeCount, edgeCount, nameCount, maxSpeedKmh, bbox, bytes, chShortcutCount, shortcut count/ratio, and rank length. It must not import landmark.ts.
Update the script header's usage text to remove `--landmarks` and describe the CH stage.
</action>
<verify>
<automated>node --check routing/scripts/build-road-graph.mjs &amp;&amp; test "$(grep -v '^[[:space:]]*\*' routing/scripts/build-road-graph.mjs | grep -v '^[[:space:]]*//' | grep -c 'landmark\|--landmarks')" -eq 0</automated>
</verify>
<verify_note>
The landmark-absence gate uses `test "$(... grep -c ...)" -eq 0` rather than `grep -c ... || test $? -eq 1`. The `||` form is a no-op gate: `grep -c` exits 0 when it FINDS matches (it prints the count), so `|| test $? -eq 1` never fires and the overall command exits 0 even with every landmark token still present. The `test ... -eq 0` form fails when the count is non-zero — verified: it exits 1 against the current file (39 landmark matches) and exits 0 against a clean file.
</verify_note>
<acceptance_criteria>
- `grep -v '^[[:space:]]*\*' routing/scripts/build-road-graph.mjs | grep -v '^[[:space:]]*//' | grep -c 'landmark'` equals 0.
- `grep -v '^[[:space:]]*\*' routing/scripts/build-road-graph.mjs | grep -v '^[[:space:]]*//' | grep -c 'contractGraph'` is at least 1.
- `grep -v '^[[:space:]]*\*' routing/scripts/build-road-graph.mjs | grep -v '^[[:space:]]*//' | grep -c 'build wall-clock\|witness searches\|shortcuts:'` is at least 1.
- `node routing/scripts/build-road-graph.mjs --check routing/test/fixtures/mini-graph.bin` exits 0 and prints `chShortcutCount`.
</acceptance_criteria>
<done>The production build emits ARG3 through the one CH module and prints every D-08/D-09 measurement without a payload ceiling.</done>
</task>
<task type="auto" tdd="true">
<name>task 2: re-pin the existing gates for ARG3 and preserve the one-path rollout locks</name>
<files>routing/scripts/measure-optimality-gate.mjs, routing/scripts/measure-astar-oracle-sweep.mjs, routing/scripts/measure-route-cpu.mjs, routing/test/rollout.test.mjs</files>
<read_first>
- routing/scripts/measure-optimality-gate.mjs
- routing/scripts/measure-astar-oracle-sweep.mjs
- routing/scripts/measure-route-cpu.mjs
- routing/test/rollout.test.mjs
- routing/src/astar.ts
- .planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-VALIDATION.md
</read_first>
<behavior>
- The optimality gate still rejects a baseline whose argVersion is not 1 and still mirrors the record's 0.1 percent epsilon.
- The oracle sweep still runs 200 compared pairs with seed 0x08f1 and epsilon 1e-6, classifying SUBOPTIMAL, ORACLE-MISMATCH, and class breaks.
- The CPU gate still measures 25 routes plus an optional 100-route extended run against P95_BUDGET_MS 8 and prints p95.
- The upload script and Worker remain format-agnostic, and the ARG3 magic is added to their banned-token lists.
</behavior>
<action>
In `routing/scripts/measure-optimality-gate.mjs`, keep `OPTIMALITY_EPSILON_PCT = 0.1` and `REQUIRED_BASELINE_ARG_VERSION = 1` unchanged. Change only the decode-failure message so it says the current binary must be ARG3 and names the rebuild command `node routing/scripts/build-road-graph.mjs`. Do not edit `routing/test/fixtures/alt-baseline.json`; it is immutable.
In `routing/scripts/measure-astar-oracle-sweep.mjs`, keep `SWEEP_PAIR_COUNT = 200`, `SWEEP_SEED = 0x08f1`, `SWEEP_OVERSAMPLE_FACTOR = 3`, `SWEEP_MIN_PAIR_KM = 12`, and `ORACLE_EPSILON_S = 1e-6` unchanged. Update only the graph-missing and decode-failure wording to name ARG3. Keep the symmetric comparison and the three violation classes.
In `routing/scripts/measure-route-cpu.mjs`, keep `P95_BUDGET_MS = 8`, `ROUTE_COUNT = 25`, `SAMPLE_SEED = 0x06d4`, `EXTENDED_PAIR_COUNT = 100`, and `EXTENDED_SEED = 0x08a1` unchanged. Update the LADDER HISTORY comment with a CH rung line once the measured CH numbers are available. Do not change the measurement logic or the warm-up law. The `P95_BUDGET_MS = 8` string must remain present exactly once in this file.
In `routing/test/rollout.test.mjs`, add the ARG3 magic `0x41524733` to RO1's banned-token list and to RO2's banned-token list. Keep the Worker pinned to exactly one `decodeGraph` call, no `GRAPH_VERSION`, no graph magic, and no `=== 2`. Keep the upload script's banned-token list free of format awareness.
</action>
<verify>
<automated>npm run routing:test &amp;&amp; test "$(grep -c 'P95_BUDGET_MS = 8' routing/scripts/measure-route-cpu.mjs)" -eq 1 &amp;&amp; test "$(grep -cF 'const calls = code.match(/decodeGraph\(/g)' routing/test/rollout.test.mjs)" -eq 1</automated>
</verify>
<verify_note>
The rollout assertion uses `grep -cF` (fixed-string) on purpose. The BRE form `grep -c 'const calls = code.match(/decodeGraph\(/g)'` is a group-open and exits 2 with `grep: Unmatched ( or \(` — the gate would fail on a correct implementation. `grep -cF` treats the pattern as a literal string and returns 1 on the current file (the one decodeGraph call site in `routing/test/rollout.test.mjs`), so `-eq 1` is a real assertion.
</verify_note>
<acceptance_criteria>
- `grep -c 'P95_BUDGET_MS = 8' routing/scripts/measure-route-cpu.mjs` equals 1.
- `grep -c 'SWEEP_PAIR_COUNT = 200' routing/scripts/measure-astar-oracle-sweep.mjs` equals 1.
- `grep -c 'SWEEP_SEED = 0x08f1' routing/scripts/measure-astar-oracle-sweep.mjs` equals 1.
- `grep -c 'ORACLE_EPSILON_S = 1e-6' routing/scripts/measure-astar-oracle-sweep.mjs` equals 1.
- `grep -c 'REQUIRED_BASELINE_ARG_VERSION = 1' routing/scripts/measure-optimality-gate.mjs` equals 1.
- `grep -c '0x41524733' routing/test/rollout.test.mjs` is at least 2.
- `npm run routing:test` exits 0.
</acceptance_criteria>
<done>The D-13 gates remain unchanged in substance, the failure messages honestly name ARG3, and the Worker/upload one-path laws cover the new magic.</done>
</task>
<task type="auto">
<name>task 3: run the maintainer measurement campaign and re-anchor the drift guard only on a green verdict</name>
<files>scripts/lib/budget-gates.mjs, scripts/test/budget-audit.test.mjs</files>
<read_first>
- scripts/lib/budget-gates.mjs
- scripts/test/budget-audit.test.mjs
- routing/scripts/measure-route-cpu.mjs
- routing/scripts/measure-optimality-gate.mjs
- routing/scripts/measure-astar-oracle-sweep.mjs
- docs/BUDGETS.md
- docs/KNOWN-LIMITATIONS.md
</read_first>
<behavior>
- The built ARG3 binary decodes and the optimality gate reports zero D03A and zero D03B violations.
- The 200-pair oracle sweep reports 0 SUBOPTIMAL, 0 ORACLE-MISMATCH, and 0 class breaks.
- The local CPU gate prints the CH p95 for both the 25-route and 100-route extended samples.
- If measured CH p95 is below 8 ms, the drift guard becomes ceil(2 * measured p95) and the CPU3 mirror moves in the same commit.
- If measured CH p95 is at or above 8 ms, the c3 class remains, the drift guard is not tightened, and the evidence says the class stays.
</behavior>
<action>
Run the maintainer campaign in this exact order and preserve the printed output in the plan summary:
1. `node routing/scripts/build-road-graph.mjs`
2. `node routing/scripts/measure-optimality-gate.mjs`
3. `node routing/scripts/measure-astar-oracle-sweep.mjs`
4. `node routing/scripts/measure-route-cpu.mjs`
5. `node routing/scripts/measure-route-cpu.mjs --extended`
6. `node scripts/test/budget-audit.test.mjs`
The local gate is the sole SC-1 evidence per D-02. Do not add a deployed-Worker CPU probe. Do not change `P95_BUDGET_MS = 8`; do not change `OPTIMALITY_EPSILON_PCT = 0.1`; do not change `ORACLE_EPSILON_S = 1e-6`; do not loosen any gate to manufacture a pass.
Apply D-01/D-03 conditionally on the printed numbers:
- If the 25-route CH p95 is strictly below 8 ms, the c3 class retires. Set `ROUTE_CPU_DRIFT_GUARD_MS` in `scripts/lib/budget-gates.mjs` to `Math.ceil(2 * measuredChP95Ms)` as a literal integer, and update its provenance comment to name the measured CH p95, the date, and the host. In the same commit, update the CPU3 vector in `scripts/test/budget-audit.test.mjs`: it must assert the CH verdict wording and must no longer require the literal `EXECUTED in Phase 8` phrase. Keep the `p95 <number> ms`, `8 ms`, `10 ms free-plan ceiling`, and `GATE FAIL` assertions when the child still exits 1 for an over-budget regression.
- If the 25-route CH p95 is at or above 8 ms, keep `ROUTE_CPU_DRIFT_GUARD_MS` at its current value and keep the c3 class. Rewrite only the budget-gates verdict prose so it names the executed-and-measured CH result and states that the class remains because retirement fires only on a passing measurement. Update the CPU3 mirror regex in the same commit to the new wording.
In both branches, the three-branch verdict structure in `runCpuLadderGate` stays: uncharted regression beyond the drift guard, over-budget-within-band, and within-budget. Keep the honest NOT-RUN behavior for a missing binary.
</action>
<verify>
<automated>node routing/scripts/measure-optimality-gate.mjs &amp;&amp; node routing/scripts/measure-astar-oracle-sweep.mjs &amp;&amp; node scripts/test/budget-audit.test.mjs</automated>
</verify>
<acceptance_criteria>
- `node routing/scripts/measure-optimality-gate.mjs` prints `GATE PASS` and `0 violation(s)` for both D03A and D03B.
- `node routing/scripts/measure-astar-oracle-sweep.mjs` prints `GATE PASS` and `0 SUBOPTIMAL`, `0 ORACLE-MISMATCH`, `0 class-breaks`.
- `node routing/scripts/measure-route-cpu.mjs` prints a `p95: <number> ms` line; the number is recorded verbatim in the plan summary.
- `grep -c 'P95_BUDGET_MS = 8' scripts/lib/budget-gates.mjs` equals 1.
- `node scripts/test/budget-audit.test.mjs` exits 0.
- The summary states explicitly whether the c3 class retired and, if it did, the exact new `ROUTE_CPU_DRIFT_GUARD_MS` value; if it did not, the summary states that the class remains.
</acceptance_criteria>
<done>The measured CH verdict exists, the same local evidence class decides SC-1, and the drift guard plus its mirror move together only when the numbers justify retirement.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Overpass response -> build pipeline | External map data enters the graph builder; malformed or hostile data must fail the build before a binary is written. |
| Built ARG3 binary -> measurement gates | The gitignored local binary is the artifact under test; a corrupt or stale binary must make gates fail honestly rather than pass. |
| Gate output -> budget verdict | Measured p95 and guard constants decide whether the c3 class retires; a mistaken or stale number could retire the class on failing evidence. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08.1-10 | Tampering / DoS | Overpass-derived graph entering contraction | mitigate | The existing build validates extracted ways, node ids, finite coordinates, and non-empty graphs before contraction; any `contractGraph` throw fails before collect-then-write, leaving the previous binary untouched. |
| T-08.1-11 | Tampering | Stale or corrupt local binary passing a gate | mitigate | `decodeGraph` returns null for non-ARG3 bytes and every measurement script fails on null; the gates never substitute a missing measurement for a pass. |
| T-08.1-12 | Repudiation | Retiring c3 on failing numbers | mitigate | D-01 is encoded in the conditional action: the guard is re-anchored only when the printed 25-route p95 is strictly below 8 ms; the measured value and verdict must be quoted in the plan summary and later record surfaces. |
| T-08.1-13 | Tampering | Constant-mirror drift between guard and audit vector | mitigate | `ROUTE_CPU_DRIFT_GUARD_MS` and the CPU3 evidence mirror are changed in the same commit; CPU2 still pins `P95_BUDGET_MS = 8` exactly once. |
| T-08.1-14 | DoS | Unbounded CH build consuming the maintainer machine | mitigate | Witness searches are bounded by distance and settle count; the build prints wall-clock and budget-hit counts; D-09 treats an over-run as an honest measurement, not a hidden failure. |
| T-08.1-15 | DoS | Search CPU regression after the re-anchor | mitigate | The tightened drift guard is approximately 2x the measured CH p95, so a regression that consumes the headroom fails the route-cpu gate; the 8 ms product budget remains the binding pass bar. |
</threat_model>
<verification>
Run the full suite and the maintainer campaign:
```bash
npm run routing:test && npm test -w bot && npm run routing:typecheck && npm run bot:typecheck && npm run web:typecheck
node scripts/test/budget-audit.test.mjs
node routing/scripts/build-road-graph.mjs
node routing/scripts/measure-optimality-gate.mjs
node routing/scripts/measure-astar-oracle-sweep.mjs
node routing/scripts/measure-route-cpu.mjs
node routing/scripts/measure-route-cpu.mjs --extended
```
Expected: every suite green; optimality and oracle gates green; both CPU p95 values printed and recorded; the drift guard either tightened to ceil(2 x measured p95) with its mirror updated, or deliberately unchanged with the class remaining.
</verification>
<success_criteria>
- The real build produces ARG3 with measured CH statistics and no invented byte ceiling.
- The D-03 optimality gate and 200-pair oracle sweep pass against the ARG3 binary without threshold changes.
- The local CPU gate prints the CH p95 for both sample widths.
- The c3 class is retired only when the measured p95 is below 8 ms; otherwise it remains with an honest record.
- The drift guard and its audit mirror agree, and the Worker/upload path remains format-agnostic.
</success_criteria>
<output>
After completion, create `.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-02-SUMMARY.md`
</output>
@@ -0,0 +1,204 @@
---
phase: 08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl
plan: 03
type: execute
wave: 3
depends_on:
- 08.1-02
files_modified:
- docs/ROAD-GRAPH.md
- docs/BUDGETS.md
- docs/KNOWN-LIMITATIONS.md
- docs/ALT-DIRECTION-SPLIT.md
- routing/README.md
autonomous: true
requirements:
- D-01
- D-03
- D-06
- D-08
- D-09
- D-10
- D-13
must_haves:
truths:
- "The public ARG3 fork contract is documented field-by-field so a browser/webview consumer can decode the same asset without Node-specific assumptions."
- "The budget and known-limitations records state the measured CH verdict and never claim success when the measurement failed."
- "The direction-split potentials experiment is closed as superseded while its measured facts remain intact."
- "The repository's routing module map describes the CH data plane and contains no active ALT engine claim."
artifacts:
- path: "docs/ROAD-GRAPH.md"
provides: "ARG3 header/section contract, CH build facts, and CPU ladder record"
- path: "docs/BUDGETS.md"
provides: "Measured CH CPU verdict and retire-or-shrink c3 record"
- path: "docs/KNOWN-LIMITATIONS.md"
provides: "Honest cross-metro status after the CH measurement"
- path: "docs/ALT-DIRECTION-SPLIT.md"
provides: "Superseded closing header with historical measured facts preserved"
key_links:
- from: "docs/ROAD-GRAPH.md"
to: "routing/src/graph.ts"
via: "documented ARG3 header offsets and section order match the codec authority"
pattern: "ARG3|chShortcutCount|chRank"
- from: "docs/BUDGETS.md"
to: "routing/scripts/measure-route-cpu.mjs"
via: "quoted measured p95 and 8 ms budget come from the local gate output"
pattern: "p95|8 ms"
- from: "docs/ALT-DIRECTION-SPLIT.md"
to: "docs/BUDGETS.md"
via: "historical ALT facts retained while the post-CH verdict lives in the budget record"
pattern: "Phase 8|superseded"
---
<objective>
Close the phase with an honest public record: the ARG3 fork contract, the measured CH build and CPU facts, the retire-or-shrink c3 verdict, and the superseded direction-split experiment.
Purpose: The phase's value depends on truthful engineering records. A passing implementation with a stale or optimistic document would preserve the exact failure mode Phase 8 exposed.
Output: Updated road-graph contract, budgets, known limitations, ALT experiment closure, and routing module map.
</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/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-CONTEXT.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-RESEARCH.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-PATTERNS.md
@.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-VALIDATION.md
@docs/ROAD-GRAPH.md
@docs/BUDGETS.md
@docs/KNOWN-LIMITATIONS.md
@docs/ALT-DIRECTION-SPLIT.md
@routing/README.md
</context>
<tasks>
<task type="auto">
<name>task 1: document the ARG3 fork contract and measured build facts</name>
<files>docs/ROAD-GRAPH.md, routing/README.md</files>
<read_first>
- docs/ROAD-GRAPH.md
- routing/README.md
- routing/src/graph.ts
- routing/scripts/build-road-graph.mjs
- routing/scripts/measure-route-cpu.mjs
- .planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-RESEARCH.md
</read_first>
<action>
In `docs/ROAD-GRAPH.md`, replace the ARG2 layout section with an ARG3 section that is the public fork contract required by D-10. Document these exact facts:
- magic `0x41524733` ("ARG3"), version 3, header size 72 bytes, little-endian only.
- The header field table with byte offsets: magic @0 u32, version @4 u32, nodeCount @8 u32, edgeCount @12 u32, nameCount @16 u32, namePoolLen @20 u32, bbox @24 as four f64 values, maxSpeedKmh @56 f32, chShortcutCount @60 u32, reserved0 @64 u32, reserved1 @68 u32.
- The section order: lat[N] ++ lng[N] ++ edgeStart[N+1] ++ edgeTarget[E] ++ edgeFlags[E] ++ edgeSeconds[E] ++ edgeNameId[E] ++ nameOffset[M+1] ++ namePool ++ chRank[N] ++ shortcutStart[N+1] ++ shortcutTarget[S] ++ shortcutSeconds[S] ++ shortcutVia[S].
- The alignment law: header 72 bytes keeps f64 views 8-byte aligned; every u32/f32 section starts at a 4-byte boundary using `(offset + 3) & ~3`.
- The decode law: only exact ARG3 bytes decode; `chShortcutCount === 0`, any size mismatch, and ARG1/ARG2 bytes return null and route to the designed router-pending state. There is no version fallback branch.
- The semantics of each CH field: `chRank[u]` is the contraction position (0 contracted first, a strict total order); `shortcutStart` is the shortcut CSR row pointer array; `shortcutTarget` is the shortcut head; `shortcutSeconds` is the shortcut's f32 seconds; `shortcutVia` is the contracted midpoint used to unpack the shortcut recursively.
- The phone-first browser-decodability statement: little-endian typed arrays only, no Node-specific alignment assumptions, so Phase 11's webview can decode this same asset.
- The build-artifact discipline: the hierarchy is valid only for the exact topology and edge weights it was built against; a graph edit requires a full rebuild and re-upload under the unchanged key `kl-roadgraph.v1.bin`.
In the same file, update the CPU ladder table and key-facts table to include the CH rung with the measured values from Plan 02: contracted node count, shortcut count and ratio per node, witness search count and budget hits, CH-section bytes, total ARG3 bytes, build wall-clock, 25-route p95, and extended 100-route p95. State the verdict as measured: retired below 8 ms, or the c3 class remains. Do not describe an unmeasured or projected number as measured.
In `routing/README.md`, update the module map to list `contraction.ts` as the build-time CH core and `ch-query.ts` as the request-time uphill query, remove `landmark.ts` if present, and describe `graph.ts` as the ARG3 codec. Do not add a new dependency or install instruction.
</action>
<verify>
<automated>grep -q 'ARG3' docs/ROAD-GRAPH.md &amp;&amp; grep -q 'chShortcutCount' docs/ROAD-GRAPH.md &amp;&amp; grep -q 'shortcutVia' docs/ROAD-GRAPH.md &amp;&amp; grep -q 'contraction.ts' routing/README.md &amp;&amp; ! grep -q 'landmark.ts' routing/README.md</automated>
</verify>
<acceptance_criteria>
- `docs/ROAD-GRAPH.md` contains `ARG3`, `0x41524733`, `chShortcutCount`, `chRank`, `shortcutStart`, `shortcutTarget`, `shortcutSeconds`, `shortcutVia`, and `72`.
- `docs/ROAD-GRAPH.md` contains the byte offset `60` for `chShortcutCount` and `68` for `reserved1`.
- `docs/ROAD-GRAPH.md` contains the measured p95 values copied from Plan 02's recorded gate output.
- `routing/README.md` contains `contraction.ts` and `ch-query.ts` and contains no `landmark.ts`.
</acceptance_criteria>
<done>A browser-oriented fork contract fully describes ARG3, and the routing module map reflects the CH data plane truthfully.</done>
</task>
<task type="auto">
<name>task 2: record the measured c3 verdict and close the direction-split experiment</name>
<files>docs/BUDGETS.md, docs/KNOWN-LIMITATIONS.md, docs/ALT-DIRECTION-SPLIT.md</files>
<read_first>
- docs/BUDGETS.md
- docs/KNOWN-LIMITATIONS.md
- docs/ALT-DIRECTION-SPLIT.md
- docs/ROAD-GRAPH.md
- .planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-CONTEXT.md
- .planning/phases/08-alt-landmark-preprocessing-routing-backend-v2/08-05-SUMMARY.md
</read_first>
<action>
Update `docs/BUDGETS.md` section c3 using the measured outputs from Plan 02. The new record must include:
- The CH build facts: contracted nodes, shortcut count, ratio per node, witness searches and budget hits, CH-section bytes, total ARG3 bytes, and build wall-clock.
- The CH CPU facts: the 25-route p95 and the extended 100-route p95 exactly as printed by the local gate, alongside the unchanged 8 ms product budget and the 10 ms free-plan ceiling.
- The verdict in D-01's exact terms: if p95 is below 8 ms, write that the c3 class RETIRED because the measured CH p95 landed under the 8 ms budget; if p95 is at or above 8 ms, write that the c3 class REMAINS and that the $0 lever ladder is documented as a dead end, not a failure.
- The new drift guard value if the class retired, described as approximately 2x the measured CH p95. If it did not retire, state that the previous guard remains.
- The optimality evidence: D-03 gate green within ±0.1% and the 200-pair oracle sweep with zero SUBOPTIMAL, zero ORACLE-MISMATCH, and zero class breaks.
- The local-only evidence scope per D-02, including the documented host variance, and no claim of a deployed-Worker measurement.
Update `docs/KNOWN-LIMITATIONS.md` so the cross-metro section reflects the measured CH state: remove the active ALT roadmap pointer, replace it with the CH verdict, and state whether cross-metro routing is now within budget, still in the c3 honest-degradation class, or otherwise limited by the measured result. Keep the existing honest-limitations tone; do not claim a traffic-confidence, lane-guidance, or turn-restriction capability.
Update `docs/ALT-DIRECTION-SPLIT.md` with a closing header stating: `Status: SUPERSEDED by Phase 08.1 Contraction Hierarchies (2026-10-26)` and a short paragraph explaining that CH closes SC-1 at a lower proof burden because its optimality needs no new meet-rule proof. Preserve the entire measured-facts table and the one-directional-Lipschitz law below the header; the facts remain the historical record of why CH was chosen. Do not delete the existing analysis.
Also update the ALT roadmap-pointer row in `docs/KNOWN-LIMITATIONS.md` and any ALT phase pointer in `docs/BUDGETS.md` so no document tells the reader that ALT is the active engine or that the direction-split experiment is still open.
</action>
<verify>
<automated>grep -q 'SUPERSEDED' docs/ALT-DIRECTION-SPLIT.md &amp;&amp; grep -q 'CH' docs/BUDGETS.md &amp;&amp; grep -q 'CH' docs/KNOWN-LIMITATIONS.md &amp;&amp; ! grep -qi 'direction-split potentials.*still open' docs/KNOWN-LIMITATIONS.md</automated>
</verify>
<acceptance_criteria>
- `docs/ALT-DIRECTION-SPLIT.md` contains `SUPERSEDED by Phase 08.1 Contraction Hierarchies` and still contains its original measured-facts table rows.
- `docs/BUDGETS.md` contains the measured CH p95 value copied from Plan 02, the literal `8 ms`, and the words `CH` and `ARG3`.
- `docs/BUDGETS.md` states either `RETIRED` or `REMAINS` for the c3 class, matching Plan 02's measured verdict.
- `docs/KNOWN-LIMITATIONS.md` contains `CH` and no longer presents ALT as the active engine.
- `grep -c 'Contraction Hierarchies' docs/ALT-DIRECTION-SPLIT.md` is at least 1.
</acceptance_criteria>
<done>The records carry the measured CH truth, the c3 verdict matches the gate output, and the direction-split experiment is closed without erasing its historical facts.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Public fork contract -> external implementer | docs/ROAD-GRAPH.md is the interface a fork or Phase 11 browser decoder relies on; an inaccurate offset table produces silent misdecoding. |
| Measurement output -> published engineering record | Numbers copied into docs are the evidence for SC-1 closure; paraphrase or stale values could retire the c3 class dishonestly. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08.1-16 | Tampering | Public ARG3 header/section documentation | mitigate | The documented offsets are copied from `routing/src/graph.ts`, and acceptance criteria require the exact offsets 0, 4, 8, 12, 16, 20, 24, 56, 60, 64, 68 plus the full section order and the alignment law. |
| T-08.1-17 | Repudiation | Published budget claim differing from the measured gate output | mitigate | The plan requires copying the exact printed p95, byte, and wall-clock values from Plan 02's campaign; acceptance criteria require the literal `8 ms` and an explicit RETIRED/REMAINS verdict. |
| T-08.1-18 | Tampering | Erosion of the one-directional-Lipschitz historical facts | accept | D-06 intentionally preserves the historical measured facts; the risk is editorial, not technical. The closing header is additive and the existing analysis must remain intact. |
| T-08.1-19 | Information Disclosure | Public docs exposing user location | accept | The documents contain only road-network geometry, build statistics, and public bbox-derived measurements; no user coordinates, chat ids, or route payloads are added. |
</threat_model>
<verification>
Manually read and compare the four record surfaces against the Plan 02 measurement output, then run:
```bash
grep -q 'ARG3' docs/ROAD-GRAPH.md
grep -q 'chShortcutCount' docs/ROAD-GRAPH.md
grep -q 'SUPERSEDED' docs/ALT-DIRECTION-SPLIT.md
grep -q 'CH' docs/BUDGETS.md
grep -q 'CH' docs/KNOWN-LIMITATIONS.md
npm run routing:test
```
Expected: all greps succeed and the routing suite remains green.
</verification>
<success_criteria>
- The ARG3 header and section contract is documented exactly and is browser-decodable by construction.
- The build bytes, shortcut ratio, wall-clock, and both CPU p95 values in the docs match the measured Plan 02 output verbatim.
- The c3 class record says RETIRED or REMAINS exactly as the measurement dictates.
- The direction-split experiment is marked superseded with its measured facts intact.
- No document claims ALT is the active engine or that a deployed-Worker CPU probe was performed.
</success_criteria>
<output>
After completion, create `.planning/phases/08.1-contraction-hierarchies-routing-backend-v3-the-lever-that-cl/08.1-03-SUMMARY.md`
</output>
+15 -12
View File
@@ -49,7 +49,7 @@ const { strings } = await import("../src/i18n/strings.ts");
const { encodePolyline6 } = await import("@araharah/routing/src/polyline.ts");
const { deriveCumulativeDistanceM, haversineM } = await import("@araharah/routing/src/geo.ts");
const { encodeGraph, decodeGraph } = await import("@araharah/routing/src/graph.ts");
const { LANDMARK_QUANTUM_S, landmarkTables } = await import("@araharah/routing/src/landmark.ts");
const { contractGraph } = await import("@araharah/routing/src/contraction.ts");
/* ————— geometry helpers (1e-6-grid-exact construction, the codec's grid law) ————— */
@@ -250,23 +250,26 @@ test("GC6b unnamed matched edge → cardStreetUnnamed ('UNNAMED ROAD') — still
// A purpose-built unnamed way through the same encodeGraph contract
// (names: [] → every edge carries the UNNAMED_NAME_ID sentinel).
const meters = haversineM({ lat: 3.15, lng: 101.69 }, { lat: 3.1505, lng: 101.69 });
const edges = [
// A 3-node star through the same encodeGraph contract (names: [] →
// every edge carries the UNNAMED_NAME_ID sentinel). ARG3 (Phase 08.1):
// the ch block comes from the ONE contraction core, and the MID owns
// the LOWEST id — the priority tie contracts lowest-id-first, so a mid
// with id 1 would starve to zero shortcuts and an ARG3 with no
// shortcuts is not a road graph. Geometry: north leg (0->1), east leg
// (0->2) — the turn at the shared mid vertex.
const starEdges = [
{ from: 0, to: 1, seconds: meters / (30 / 3.6) },
{ from: 1, to: 0, seconds: meters / (30 / 3.6) },
{ from: 0, to: 2, seconds: meters / (30 / 3.6) },
{ from: 2, to: 0, seconds: meters / (30 / 3.6) },
];
// A 2-node graph has no degree>=2 farthest-point candidate (the selection
// filter), so the anchor pins directly — the tables still come from the
// ONE integer-Dijkstra core (08-02), through the ONE encode authority.
const landmarkInput = { nodeCount: 2, edges, quantumS: LANDMARK_QUANTUM_S };
const anchors = Uint32Array.of(0);
const { to, from } = landmarkTables(landmarkInput, anchors);
const buf = encodeGraph({
lat: [3.15, 3.1505],
lng: [101.69, 101.69],
edges,
lat: [3.15, 3.1505, 3.15],
lng: [101.69, 101.69, 101.6905],
edges: starEdges,
names: [],
maxSpeedKmh: 30,
landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from },
ch: contractGraph({ nodeCount: 3, edges: starEdges }),
});
const graph = decodeGraph(buf);
assert.notEqual(graph, null);
+70 -20
View File
@@ -472,31 +472,37 @@ test("GW11 PUT /api/guidance → 405", async () => {
const { encodeGraph } = await import("../../routing/src/graph.ts");
const { haversineM } = await import("../../routing/src/geo.ts");
const { LANDMARK_QUANTUM_S, farthestPointAnchors, landmarkTables } = await import(
"../../routing/src/landmark.ts"
);
const { contractGraph } = await import("../../routing/src/contraction.ts");
/** Tiny REAL landmark block through the ONE preprocessing core (08-02):
* farthest-point anchor + integer ĉ tables — computed, never faked.
* `{ pin }` pins the anchor for graphs with no degree>=2 candidate (the
* selection filter's law — e.g. a single-segment graph); the tables still
* come from the ONE integer-Dijkstra core. */
function landmarkBlock(nodeCount, edges, { pin } = {}) {
const input = { nodeCount, edges, quantumS: LANDMARK_QUANTUM_S };
const anchors = pin !== undefined ? Uint32Array.of(pin) : farthestPointAnchors(input, 1);
if (anchors.length !== 1) throw new Error("graph has no degree-2 anchor candidate");
const { to, from } = landmarkTables(input, anchors);
return { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from };
/** The ARG3 ch block through the ONE contraction core (Phase 08.1):
* computed, never faked. A shape that contracts to ZERO shortcuts is a
* builder bug — an ARG3 with chShortcutCount = 0 is not a road graph
* (the decode law refuses it), so fail loud here instead of shipping an
* undecodable test binary. */
function chBlock(nodeCount, edges) {
const ch = contractGraph({ nodeCount, edges });
if (ch.stats.shortcutCount === 0) {
throw new Error("graph contracted to zero shortcuts — ARG3 has no CH-less layout");
}
return ch;
}
/** The committed 06-01 ground truth (25 nodes around KL center) via fs. */
const MINI_GRAPH = readFileSync(new URL("../../routing/test/fixtures/mini-graph.bin", import.meta.url));
/** Public gazetteer landmarks only — no user coordinate enters a test (D-12). */
/** Public gazetteer POIs only — no user coordinate enters a test (D-12). */
const KL_TOWER = { lat: 3.1528, lng: 101.7038 };
const KL_SENTRAL = { lat: 3.1343, lng: 101.6869 };
/** 8-node straight corridor kl-tower → suria-klcc (public coords), two-way. */
/** 8-node straight corridor kl-tower → suria-klcc (public coords), two-way.
* ARG3 (Phase 08.1): the corridor gains ONE fast nonstop chord (0<->7,
* 120 km/h vs the 60 km/h local road) — a pure line contracts to ZERO
* shortcuts (endpoints starve the interior on the priority tie) and a
* shortcut-less ARG3 is not a road graph. The chord gives the contraction
* a real hierarchy; the split variant keeps the gap AND drops the chord,
* so its two line components stay disconnected (RT9's A* miss) — the
* resulting binary decodes to null under the CH-less law, which flows to
* the same 200-null contract. */
function corridorGraphBuffer({ gap = false } = {}) {
const n = 8;
const lat = [];
@@ -512,26 +518,40 @@ function corridorGraphBuffer({ gap = false } = {}) {
const seconds = meters / (60 / 3.6);
edges.push({ from: i, to: i + 1, seconds, nameId: 0 }, { from: i + 1, to: i, seconds, nameId: 0 });
}
if (!gap) {
const meters = haversineM({ lat: lat[0], lng: lng[0] }, { lat: lat[n - 1], lng: lng[n - 1] });
const seconds = meters / (120 / 3.6);
edges.push({ from: 0, to: n - 1, seconds, nameId: 0 }, { from: n - 1, to: 0, seconds, nameId: 0 });
}
return encodeGraph({
lat,
lng,
edges,
names: ["Jalan Koridor Ujian"],
maxSpeedKmh: 60,
landmarks: { ...landmarkBlock(n, edges) },
ch: chBlock(n, edges),
});
}
/** Nodes cluster at two far-apart cells with a nodeless middle: an in-bbox
* origin between them is the snap-miss carrier (3x3-neighborhood law). */
* origin between them is the snap-miss carrier (3x3-neighborhood law).
* ARG3: node 0 is the star's hub (two unnamed legs, ids kept so the
* contraction's lowest-id tie-break contracts the MID first — a star
* whose mid id is higher starves to zero shortcuts) — the node set is
* unchanged from the ARG2-era fixture, keeping RT8's snap-miss geometry
* exact. */
function sparseGraphBuffer() {
const lat = [3.15, 3.1501, 3.1515];
const lng = [101.69, 101.6905, 101.7];
const meters = haversineM({ lat: lat[0], lng: lng[0] }, { lat: lat[1], lng: lng[1] });
const seconds = meters / (50 / 3.6);
const meters2 = haversineM({ lat: lat[0], lng: lng[0] }, { lat: lat[2], lng: lng[2] });
const seconds2 = meters2 / (50 / 3.6);
const edges = [
{ from: 0, to: 1, seconds, nameId: 0 },
{ from: 1, to: 0, seconds, nameId: 0 },
{ from: 0, to: 2, seconds: seconds2, nameId: 0 },
{ from: 2, to: 0, seconds: seconds2, nameId: 0 },
];
return encodeGraph({
lat,
@@ -539,12 +559,42 @@ function sparseGraphBuffer() {
edges,
names: ["Jalan Sparse Ujian"],
maxSpeedKmh: 50,
landmarks: { ...landmarkBlock(lat.length, edges, { pin: 0 }) },
ch: chBlock(lat.length, edges),
});
}
/** RT9's disconnected carrier: two independent 3-node stars — each
* decodable (real shortcuts) but the component A stars around kl-tower
* and the component B stars around kl-sentra never touch, so the route
* is the honest A* miss (NOT a decode refusal). */
function splitGraphBuffer() {
const lat = [3.1528, 3.1533, 3.1523, 3.1343, 3.1348, 3.1338];
const lng = [101.7038, 101.7043, 101.7033, 101.6869, 101.6874, 101.6864];
const edges = [];
const star = (mid, a, b) => {
const m1 = haversineM({ lat: lat[mid], lng: lng[mid] }, { lat: lat[a], lng: lng[a] });
const m2 = haversineM({ lat: lat[mid], lng: lng[mid] }, { lat: lat[b], lng: lng[b] });
edges.push(
{ from: mid, to: a, seconds: m1 / (60 / 3.6), nameId: 0 },
{ from: a, to: mid, seconds: m1 / (60 / 3.6), nameId: 0 },
{ from: mid, to: b, seconds: m2 / (60 / 3.6), nameId: 0 },
{ from: b, to: mid, seconds: m2 / (60 / 3.6), nameId: 0 },
);
};
star(0, 1, 2); // component A around kl-tower
star(3, 4, 5); // component B around kl-sentra — no edge bridges the two
return encodeGraph({
lat,
lng,
edges,
names: ["Jalan Split Ujian"],
maxSpeedKmh: 60,
ch: chBlock(lat.length, edges),
});
}
const CORRIDOR_BUF = corridorGraphBuffer();
const SPLIT_BUF = corridorGraphBuffer({ gap: true });
const SPLIT_BUF = splitGraphBuffer();
const SPARSE_BUF = sparseGraphBuffer();
/** An R2 fake over one fixed binary; counts + records its gets (the memo probe).
+111 -189
View File
@@ -18,24 +18,24 @@
* Pitfall 1: an unfiltered highway=* graph is the classic CPU-budget kill).
* 3. Ways → nodes (deduplicated by rounded coordinate) → directed edges with
* oneway flags, speed-derived seconds, and street-name pool ids.
* 4. THE ALT PREPROCESSING STAGE (Phase 8, ladder rung c2): the compacted
* edges feed the ONE landmark module (../src/landmark.ts — this script
* quantizes nothing and implements nothing itself): farthestPointAnchors
* picks L = clamp(round(N/13000), 16, 32) road-network anchors (D-02
* scaling rule; anchors are NOT POIs), landmarkTables runs the integer
* ĉ-metric Dijkstras, and landmarkCoverage gates every anchor at
* ≥ LANDMARK_MIN_COVERAGE_FRACTION node reach — any gate failure exits 1
* before a byte is written.
* 4. THE CH PREPROCESSING STAGE (Phase 08.1): the compacted edges feed the
* ONE contraction module (../src/contraction.ts — this script quantizes
* nothing and implements nothing itself): contractGraph runs the
* deterministic ordering, the overlay-only bounded witness search, and
* the directed shortcut emission. Any throw names the stage and its
* reason and exits 1 BEFORE a byte is written.
* 5. encodeGraph (../src/graph.ts — the layout's SINGLE authority) produces
* the pinned ARG2 CSR binary from the edge set + the landmark block.
* COLLECT-THEN-WRITE: routing/data/kl-roadgraph.v1.bin is written ONLY
* after the encode and the stats gate both pass — any failure exits 1
* with the previous binary untouched.
* 6. THE STATS GATE prints nodeCount/edgeCount/nameCount/bbox/landmark
* facts/binary size and enforces the D-01 envelope: size ≤
* MAX_BINARY_BYTES or exit 1. A result below the expected 30–80 MB band
* prints an ADVISORY warning (A2's forecast) — the ENFORCED floor is the
* 1 MB honesty check in --verify, never the forecast band.
* the pinned ARG3 CSR binary from the edge set + the CH tables (the `ch:`
* input — rank + shortcut CSR with unpacking vias). COLLECT-THEN-WRITE:
* routing/data/kl-roadgraph.v1.bin is written ONLY after the encode and
* the stats gate both pass — any failure exits 1 with the previous
* binary untouched.
* 6. THE STATS GATE prints nodeCount/edgeCount/bbox/CH facts (contracted
* nodes, shortcuts + ratio, witness searches + budget hits, CH-section
* bytes, build wall-clock, total bytes — D-08/D-09: MEASURED, never
* hoped) and enforces the D-01 envelope: outside 1-80 MB → exit 1
* without writing. There is NO fixed ARG3 payload ceiling (D-08): the
* build ships whatever contraction produces, measured.
*
* The binary is a BUILD ARTIFACT, not committed: routing/data/.gitignore
* ignores *.bin — forks build + upload their own (ODbL-derived data stays out
@@ -45,19 +45,21 @@
*
* Usage:
* node routing/scripts/build-road-graph.mjs
* # full build: fetch → landmarks → encode → gate → write
* # (network fetch; minutes on the KL bbox)
* node routing/scripts/build-road-graph.mjs --landmarks <n>
* # full build with an explicit landmark count — the D-02
* # MEASURED knob (raise L within/at the top of the rule for a
* # CPU-gate retry; hard-capped at 128, never a table bomb)
* # full build: fetch → contract (CH) → encode → gate → write
* # (network fetch; the CH stage is Dijkstra-heavy —
* # D-09: minutes, not hours, and the wall-clock is
* # printed either way)
* node routing/scripts/build-road-graph.mjs --check <file>
* # decode an existing binary with decodeGraph, print stats
* # incl. the landmark facts, exit 0/1 — NO network
* # decode an existing ARG3 binary with decodeGraph, print the
* # stats incl. the CH facts, exit 0/1 — NO network. --check is
* # a decode+facts tool for ANY valid ARG3 (the committed test
* # fixture included): the envelope appears as an ADVISORY line;
* # the ENFORCED 1-80 MB gates run in the build path, before
* # any write.
*
* Exit codes: 0 built and gated (or checked green); 1 pre-flight failure,
* fetch failure, landmark coverage/quantum-gate failure, envelope-gate
* failure, or --check decode failure.
* fetch failure, CH contraction/encode failure, envelope-gate failure, or
* --check decode failure.
*/
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
@@ -76,20 +78,17 @@ const USER_AGENT = "araharah-roadgraph/1.0 (build-time KL road graph; repo: arah
/** D-01 envelope ceiling — a larger binary must NOT be written (shrink the
* class filter or the bbox instead). */
const MAX_BINARY_BYTES = 80 * 1024 * 1024;
/** A2's honest-build forecast floor — crossing it downward is an ADVISORY
* (verify the class filter), never a gate. */
const EXPECTED_MIN_BYTES = 30 * 1024 * 1024;
/** How far the computed bbox extends beyond the committed corpus extremes. */
const BBOX_MARGIN_DEG = 0.05;
/** Node dedup granularity: 1e-6 deg ≈ 0.11 m — far under GPS noise. */
const COORD_ROUND_DECIMALS = 6;
/** drivable classes ONLY (06-RESEARCH Pitfall 1): footway/cycleway/service/
* path are deliberately absent — a walking path must never enter a road A*.
* The *_link classes are LOAD-BEARING (measured 2026-10-24): they are the
* interchange ramps tying motorways/trunk/primary into the surface network.
* Without them the graph FRAGMENTS — 7.7% of nodes sat in severed motorway
* islands and golden corpus pairs resolved "unreachable" because an endpoint
* snapped onto an isolated fragment.
* path are deliberately absent — a walking path must never enter a road
* search. The *_link classes are LOAD-BEARING (measured 2026-10-24): they
* are the interchange ramps tying motorway/trunk/primary into the surface
* network. Without them the graph FRAGMENTS — 7.7% of nodes sat in severed
* motorway islands and golden corpus pairs resolved "unreachable" because
* an endpoint snapped onto an isolated fragment.
*
* A1 MITIGATION LADDER HISTORY (measure-route-cpu.mjs is the arbiter):
* 2026-10-24 full 13-class linked graph (311k nodes): p95 38.4 ms, ALL
@@ -99,14 +98,17 @@ const COORD_ROUND_DECIMALS = 6;
* UNREACHABLE — the dropped classes were the fine mesh tying landmarks
* to the arterial network. REJECTED on correctness (D-03: an honest
* router-pending for half the product's destinations is not a smaller
* graph, it is a broken one). REVERTED to the full 13-class set; the
* CPU gate is carried by ladder step (b) — bidirectional A* in
* routing/src/astar.ts — and the recorded verdict in the plan summary.
* graph, it is a broken one). REVERTED to the full 13-class set.
* Ladder step (b) — bidirectional A*: sound, no worse, better on short
* product routes; kept until superseded.
* Ladder step (c) — Workers Paid — breaks the $0 law and requires user
* sign-off; it is NOT applied in this script.
* Ladder rung (c2) — EXECUTED in this script's preprocessing stage (Phase 8):
* build-time landmark tables + ALT steering; the verdict lives in
* measure-route-cpu.mjs's history and docs/ROAD-GRAPH.md. */
* Ladder rung (c2) — EXECUTED in Phase 8 (build-time ALT tables): the
* CPU verdict measured over budget; the c3 degradation class stayed.
* Ladder lever (Phase 08.1) — Contraction Hierarchies: THIS script's CH
* preprocessing stage (step 4 below) replaces steering with structure.
* The verdict lives in measure-route-cpu.mjs's history and the budget
* record — the class retires only on a passing measurement (D-01). */
const ACTIVE_CLASSES = new Set([
"motorway",
"trunk",
@@ -141,51 +143,19 @@ const LINK_CLASS_SPEED_KMH = {
secondary_link: 40,
tertiary_link: 40,
};
/** ENFORCED floor — an at/below-1 MB "KL road graph" is a broken build (D-01). */
/** ENFORCED floor — an at/below-1 MB "KL road graph" is a broken build (D-01).
* The floor and the ceiling above are the ONLY byte gates: the ARG3 payload
* itself is measured-first (D-08) — no pre-committed CH payload ceiling. */
const MIN_BINARY_BYTES = 1 * 1024 * 1024;
/** D-02 scaling rule: the landmark count is NODE-COUNT-PROPORTIONAL, never a
* fixed generous constant — per-landmark table bytes scale with N (~1.19
* MB/landmark at KL's 311k nodes), so over-provisioning explodes at Malaysia
* scale exactly where ALT's advantage itself narrows (that tier is
* Contraction Hierarchies' job, still deferred). L = clamp(round(N/13,000),
* 16, 32) → ~24 at KL scale; a future Malaysia-wide build re-derives the
* count from the same rule at its own scale. */
const LANDMARK_COUNT_MIN = 16;
const LANDMARK_COUNT_MAX = 32;
const NODES_PER_LANDMARK = 13000; // 311,325 / 13,000 ≈ 24 at KL scale
/** Build-time coverage gate: every anchor's ĉ-Dijkstra must reach at least
* this fraction of nodes (the measured KL giant SCC covers ~0.98 — the
* headroom is honest). The SAME fraction gates ANY --landmarks value: the
* override cannot buy a weaker gate (T-08-11). */
const LANDMARK_MIN_COVERAGE_FRACTION = 0.9;
/** --landmarks hard cap — the knob exists for measured retries within/at the
* top of the D-02 rule, not for unbounded table bombs (T-08-10). The 80 MB
* envelope gate fires before any write regardless. */
const LANDMARK_COUNT_OVERRIDE_MAX = 128;
const mb = (b) => `${(b / (1024 * 1024)).toFixed(1)} MB`;
function fail(message) {
console.error(`build-road-graph: ${message}`);
process.exit(1);
}
/* ————— --landmarks override (the D-02 measured knob; parsed BEFORE --check) ————— */
const landmarksIdx = process.argv.indexOf("--landmarks");
let landmarksOverride = null;
if (landmarksIdx !== -1) {
const raw = process.argv[landmarksIdx + 1];
const n = raw === undefined ? NaN : Number(raw);
if (!Number.isInteger(n) || n < 1) {
fail(`--landmarks requires a positive integer count, got ${raw === undefined ? "(missing value)" : `"${raw}"`}`);
}
if (n > LANDMARK_COUNT_OVERRIDE_MAX) {
fail(`--landmarks ${n} exceeds the hard cap ${LANDMARK_COUNT_OVERRIDE_MAX} — the knob exists for measured ` +
`retries within/at the top of the D-02 rule, not for unbounded table bombs`);
}
landmarksOverride = n;
}
/* ————— --check mode: decode + stats, no network ————— */
/* ————— --check mode: decode + CH facts, no network ————— */
const checkIdx = process.argv.indexOf("--check");
if (checkIdx !== -1) {
@@ -195,22 +165,10 @@ if (checkIdx !== -1) {
const { decodeGraph } = await import("../src/graph.ts");
const buffer = readFileSync(file);
const graph = decodeGraph(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
if (graph === null) fail(`--check: ${file} is NOT a valid GRAPH_VERSION CSR binary (decodeGraph null)`);
if (graph === null) fail(`--check: ${file} is NOT a valid ARG3 CSR binary (decodeGraph null)`);
const size = statSync(file).size;
// ARG2 landmark facts (Phase 8): decoded straight off the binary — the
// SAME ONE module computes the coverage audit here; NO network.
const { LANDMARK_SENTINEL, landmarkCoverage } = await import("../src/landmark.ts");
let toSentinels = 0;
for (let i = 0; i < graph.landmarkTo.length; i++) {
if (graph.landmarkTo[i] === LANDMARK_SENTINEL) toSentinels += 1;
}
let fromSentinels = 0;
for (let i = 0; i < graph.landmarkFrom.length; i++) {
if (graph.landmarkFrom[i] === LANDMARK_SENTINEL) fromSentinels += 1;
}
const coverage = landmarkCoverage(graph.landmarkTo, graph.landmarkNodes, graph.nodeCount);
const minCoverage = Math.min(...coverage);
const meanCoverage = coverage.reduce((a, b) => a + b, 0) / coverage.length;
// ARG3 CH facts (Phase 08.1): read straight off the decoded views — the
// SAME ONE codec module decoded the bytes; NO network.
console.log(
`--check ${file}\n` +
` nodeCount: ${graph.nodeCount}\n` +
@@ -219,14 +177,17 @@ if (checkIdx !== -1) {
` maxSpeedKmh: ${graph.maxSpeedKmh}\n` +
` bbox: [${graph.bbox.minLat}, ${graph.bbox.minLng}, ${graph.bbox.maxLat}, ${graph.bbox.maxLng}]\n` +
` bytes: ${size}\n` +
` landmarkCount: ${graph.landmarkCount}\n` +
` landmarkQuantumS: ${graph.landmarkQuantumS.toFixed(3)}\n` +
` landmarkNodes: ${[...graph.landmarkNodes].join(", ")}\n` +
` landmark coverage min/mean: ${minCoverage.toFixed(3)} / ${meanCoverage.toFixed(3)}\n` +
` landmark sentinel cells: ${toSentinels} to / ${fromSentinels} from`,
` chShortcutCount: ${graph.chShortcutCount}\n` +
` shortcut ratio: ${(graph.chShortcutCount / graph.nodeCount).toFixed(2)} per node\n` +
` chRank length: ${graph.chRank.length}`,
);
if (size > MAX_BINARY_BYTES) fail(`--check: ${size} bytes exceeds the ${MAX_BINARY_BYTES}-byte D-01 envelope`);
if (size <= MIN_BINARY_BYTES) fail(`--check: ${size} bytes is at/below the ${MIN_BINARY_BYTES}-byte floor — not a real road graph`);
// Advisory only (see the usage note): --check serves any valid ARG3, the
// committed test fixture included. The ENFORCED gates run in the build path.
if (size > MAX_BINARY_BYTES || size <= MIN_BINARY_BYTES) {
console.log(
` envelope advisory: ${mb(size)} sits outside the 1-80 MB D-01 envelope a built KL metro graph lands in (advisory only)`,
);
}
process.exit(0);
}
@@ -372,7 +333,7 @@ for (const way of ways) {
// forward direction: blocked when the way is reverse-only
rawEdges.push({ from, to, seconds, flags: reverseOnly ? 1 : 0, nameId });
// reverse direction: blocked when the way is forward-only (bit0 set —
// stays visible to snapping, never traversed by A*)
// stays visible to snapping, never traversed by the search)
rawEdges.push({ from: to, to: from, seconds, flags: forwardOnly ? 1 : 0, nameId });
}
from = to;
@@ -380,7 +341,8 @@ for (const way of ways) {
}
/* Degree-0 cleanup: a node that lost every segment (collapsed self-loops) is
* dead weight — compact the id space so A* and the snap grid never scan it. */
* dead weight — compact the id space so the search and the snap grid never
* scan it. */
const degree = new Uint32Array(latOut.length);
for (const edge of rawEdges) {
degree[edge.from] += 1;
@@ -402,86 +364,53 @@ const edges = rawEdges
const droppedNodes = latOut.length - cLat.length;
if (cLat.length === 0 || edges.length === 0) fail("the extract produced an empty graph — refusing to write");
/* ————— ALT preprocessing stage (Phase 8 rung c2): landmark tables through the ONE module —————
/* ————— CH preprocessing stage (Phase 08.1): the hierarchy through the ONE module —————
*
* This script quantizes nothing and implements nothing itself: the graph's
* edges feed ../src/landmark.ts (farthestPointAnchors + landmarkTables +
* landmarkCoverage) — there is no second integer-Dijkstra implementation here
* (single-authority law). Gates run BEFORE any write:
* - quantum ceiling: a landmarkTables throw means the ĉ metric overflowed
* the u16 domain → raise LANDMARK_QUANTUM_S in landmark.ts, never clamp
* (Pitfall 5);
* - coverage: every anchor reaches ≥ LANDMARK_MIN_COVERAGE_FRACTION of the
* graph — a fragmented extract fails honestly, it is never forced through.
* edges feed ../src/contraction.ts (contractGraph — ordering + contraction +
* the overlay-only bounded witness search + shortcut CSR emission). There is
* no second CH implementation here (single-authority law). The stage and the
* encode share one wall-clock measurement (D-09: the number prints either
* way — an over-run is an honest measurement, not a hidden failure), and any
* throw names the stage and exits 1 BEFORE a byte is written (T-08.1-10:
* the previous binary survives every build failure — collect-then-write).
*/
const { LANDMARK_QUANTUM_S, LANDMARK_SENTINEL, farthestPointAnchors, landmarkTables, landmarkCoverage } =
await import("../src/landmark.ts");
// D-02 scaling rule: the count derives from THIS graph's node count; a
// --landmarks override (validated at argv time) replaces the rule wholesale.
const ruleRaw = Math.round(cLat.length / NODES_PER_LANDMARK);
const ruleCount = Math.min(LANDMARK_COUNT_MAX, Math.max(LANDMARK_COUNT_MIN, ruleRaw));
const landmarkCount = landmarksOverride ?? ruleCount;
console.log(
`landmarks: rule round(${cLat.length} / ${NODES_PER_LANDMARK}) = ${ruleRaw} → clamped ${ruleCount}` +
(landmarksOverride !== null ? `; --landmarks override → L = ${landmarkCount}` : `; L = ${landmarkCount}`),
);
// The module's input shape — projected ONCE (no mutation of the encode-side
// edge array; encodeGraph re-groups edges itself).
const lmEdges = edges.map((e) => ({ from: e.from, to: e.to, seconds: e.seconds, flags: e.flags }));
const lmInput = { nodeCount: cLat.length, edges: lmEdges, quantumS: LANDMARK_QUANTUM_S };
const anchors = farthestPointAnchors(lmInput, landmarkCount);
if (anchors.length === 0) {
fail("farthestPointAnchors found no degree>=2 candidate — the extract has no junction-ish node (degenerate/fragmented graph)");
}
if (anchors.length < landmarkCount) {
fail(`farthestPointAnchors returned only ${anchors.length} of ${landmarkCount} requested anchors (candidates exhausted) — ` +
`refusing to encode a weaker ALT than the rule asked for; investigate the extract before rebuilding`);
}
let to;
let from;
try {
({ to, from } = landmarkTables(lmInput, anchors));
} catch (err) {
fail(`landmark tables exceeded the u16 quantum ceiling (${err.message}) — raise LANDMARK_QUANTUM_S in ` +
`routing/src/landmark.ts and rebuild (Pitfall 5: raise the quantum, never clamp)`);
}
// Coverage gate (research Pattern 3, layer 1): the SAME fraction for ANY L —
// the override cannot lower it (T-08-11).
const coverage = landmarkCoverage(to, anchors, cLat.length);
const minCoverage = Math.min(...coverage);
if (minCoverage < LANDMARK_MIN_COVERAGE_FRACTION) {
const worst = coverage.indexOf(minCoverage);
fail(
`landmark coverage gate FAILED at L=${landmarkCount}: anchor ${worst} (node ${anchors[worst]}) reaches only ` +
`${(minCoverage * 100).toFixed(1)}% of nodes (gate: ≥ ${(LANDMARK_MIN_COVERAGE_FRACTION * 100).toFixed(0)}%). ` +
`The KL extract is expected to be ONE giant SCC covering ~98% of nodes (2026-10-24 build fact) — ` +
`a miss this large means a fragmented extract; investigate the fetch, do NOT lower the gate.`,
);
}
const meanCoverage = coverage.reduce((a, b) => a + b, 0) / coverage.length;
// Sentinel audit — cheap honesty; --check mirrors it from the written bytes.
let toSentinels = 0;
for (let i = 0; i < to.length; i++) if (to[i] === LANDMARK_SENTINEL) toSentinels += 1;
let fromSentinels = 0;
for (let i = 0; i < from.length; i++) if (from[i] === LANDMARK_SENTINEL) fromSentinels += 1;
/* ————— encode (the layout's single authority) + stats gate ————— */
const { contractGraph } = await import("../src/contraction.ts");
const names = [...nameIds.keys()];
const buffer = encodeGraph({
// The module's input shape — projected ONCE (no nameId: the CH overlay runs
// on { from, to, seconds, flags }; encodeGraph re-groups the full edges itself).
const chEdges = edges.map((e) => ({ from: e.from, to: e.to, seconds: e.seconds, flags: e.flags }));
const buildT0 = process.hrtime.bigint();
let ch;
let buffer;
try {
ch = contractGraph({ nodeCount: cLat.length, edges: chEdges });
buffer = encodeGraph({
lat: cLat,
lng: cLng,
edges,
names,
maxSpeedKmh: maxSeenKmh,
landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from },
});
ch: {
rank: ch.rank,
shortcutStart: ch.shortcutStart,
shortcutTarget: ch.shortcutTarget,
shortcutSeconds: ch.shortcutSeconds,
shortcutVia: ch.shortcutVia,
},
});
} catch (err) {
fail(`CH preprocessing stage failed: ${err.message}`);
}
const buildMs = Number(process.hrtime.bigint() - buildT0) / 1e6;
const bytes = buffer.byteLength;
const mb = (b) => `${(b / (1024 * 1024)).toFixed(1)} MB`;
/* ————— stats print (measured, BEFORE the gates — a gate refusal still
* leaves every number on the terminal) + the D-01 envelope gate ————— */
console.log(
`graph stats:\n` +
` nodeCount: ${cLat.length}${droppedNodes > 0 ? ` (${droppedNodes} degree-0 dropped)` : ""}\n` +
@@ -493,16 +422,17 @@ console.log(
` binary size: ${mb(bytes)} (${bytes} B)`,
);
// The landmark facts print BEFORE the size gates — the gate numbers exist
// outside the terminal even when a gate then refuses the write.
const tableBytes = 2 * landmarkCount * cLat.length * 2 + 4 * landmarkCount;
// The CH facts (D-08/D-09): shortcut economy, witness behavior, section
// bytes, wall-clock, total bytes — the measured payload, never a forecast.
console.log(
`landmark stats (ALT preprocessing through the ONE module):\n` +
` L: ${landmarkCount} (D-02 rule ${ruleCount}${landmarksOverride !== null ? `, --landmarks override ${landmarksOverride}` : ""})\n` +
` quantum: ${LANDMARK_QUANTUM_S} s (the ĉ unit)\n` +
` coverage min/mean: ${minCoverage.toFixed(3)} / ${meanCoverage.toFixed(3)} (gate ≥ ${LANDMARK_MIN_COVERAGE_FRACTION})\n` +
` table bytes: ${tableBytes} (${mb(tableBytes)} = 2 families × L·N u16 + L u32 anchors)\n` +
` sentinel cells: ${toSentinels} to / ${fromSentinels} from`,
`CH stats (contraction through the ONE module):\n` +
` contracted nodes: ${cLat.length}\n` +
` shortcuts: ${ch.stats.shortcutCount} (${ch.stats.ratioPerNode.toFixed(2)} per node)\n` +
` witness searches: ${ch.stats.witnessSearches} (${ch.stats.witnessBudgetHits} hit the settle budget)\n` +
` chRank bytes: ${4 * cLat.length}\n` +
` shortcut bytes: ${12 * ch.stats.shortcutCount}\n` +
` build wall-clock: ${buildMs.toFixed(0)} ms\n` +
` total binary: ${mb(bytes)} (${bytes} B)`,
);
// THE ENFORCED GATE (D-01): outside the 1-80 MB envelope → exit 1 WITHOUT writing.
@@ -514,19 +444,11 @@ if (bytes <= MIN_BINARY_BYTES) {
fail(`binary is ${bytes} bytes — at/below the ${MIN_BINARY_BYTES}-byte floor; a real metro graph is never this small. ` +
`Verify the Overpass query actually returned ways, then rebuild.`);
}
// THE ADVISORY BAND (A2's 18–50 MB forecast vs D-01's 30–80 MB expectation):
// a small graph is a review prompt, not a failure — the enforced gate stays.
if (bytes < EXPECTED_MIN_BYTES) {
console.log(
`WARNING: ${mb(bytes)} is below the expected 30–80 MB band — verify the drivable-class filter ` +
`(A2 forecast: 18–50 MB for KL metro). Advisory only; the enforced gate is 1–80 MB.`,
);
}
/* ————— collect-then-write (the committed/previous binary survives failures) ————— */
mkdirSync(dirname(OUT_PATH), { recursive: true });
writeFileSync(OUT_PATH, Buffer.from(buffer));
console.log(`wrote ${OUT_PATH} — ${mb(bytes)}, ${cLat.length} nodes, ${edges.length} edges`);
console.log(`wrote ${OUT_PATH} — ${mb(bytes)}, ${cLat.length} nodes, ${edges.length} edges, ${ch.stats.shortcutCount} shortcuts`);
console.log("next: node routing/scripts/upload-road-graph.mjs # push the binary to your R2 bucket");
process.exit(0);
@@ -268,14 +268,16 @@ async function main() {
// environment fact, not a failure.
if (!existsSync(GRAPH_PATH)) {
console.log(
`SKIP: no graph at ${GRAPH_PATH} — the real-graph optimality pin needs the gitignored binary; ` +
`SKIP: no graph at ${GRAPH_PATH} — the real-graph optimality pin needs the gitignored ARG3 binary; ` +
`build it first: node routing/scripts/build-road-graph.mjs`,
);
process.exit(0);
}
const buffer = readFileSync(GRAPH_PATH);
const graph = decodeGraph(toArrayBuffer(buffer));
if (graph === null) fail(`${GRAPH_PATH} does not decode (fail-honest null) — rebuild the binary`);
if (graph === null) {
fail(`${GRAPH_PATH} does not decode (fail-honest null — the current binary must be ARG3) — rebuild it: node routing/scripts/build-road-graph.mjs`);
}
const rawPairs = generateCrossMetroPairs(graph.bbox, {
count: SWEEP_PAIR_COUNT * SWEEP_OVERSAMPLE_FACTOR,
+5 -4
View File
@@ -23,8 +23,9 @@
* Inputs:
* - the COMMITTED record routing/test/fixtures/alt-baseline.json (IMMUTABLE
* — the recorder refuses overwrite; delete by explicit git revert only);
* - the LOCAL rebuilt binary routing/data/kl-roadgraph.v1.bin (ARG2 — the
* storage NAME stays v1; the header version is the format truth).
* - the LOCAL rebuilt binary routing/data/kl-roadgraph.v1.bin (ARG3 since
* Phase 08.1 — the storage NAME stays v1; the header version is the
* format truth).
*
* Constant-mirror law: this script's OPTIMALITY_EPSILON_PCT is asserted EQUAL
* to the record's epsilon block BEFORE any measurement — a record edit alone
@@ -88,7 +89,7 @@ function toArrayBuffer(buf) {
* "unsnapped" — a record-routed pair whose snap now misses (a D03B class
* break — the pair no longer routes at all).
*
* @param graph decoded RoadGraph (the current ARG2 build)
* @param graph decoded RoadGraph (the current ARG3 build)
* @param record parsed alt-baseline.json (the frozen pre-ALT record)
* @param opts { snap, astar } — the live modules (injected like the corpus core)
* @returns {{ checks: { D03A: check, D03B: check }, rows: Array, reSnapped: number, pass: boolean }}
@@ -270,7 +271,7 @@ function main() {
const graph = decodeGraph(toArrayBuffer(readFileSync(GRAPH_PATH)));
if (graph === null) {
fail(
`${GRAPH_PATH} does not decode (fail-honest null — still ARG1 or corrupt) — rebuild the ARG2 binary: node routing/scripts/build-road-graph.mjs`,
`${GRAPH_PATH} does not decode (fail-honest null — the current binary must be ARG3) — rebuild it: node routing/scripts/build-road-graph.mjs`,
);
}
+37 -507
View File
@@ -1,35 +1,35 @@
/*
* astar.ts — typed-array BIDIRECTIONAL A* over the CSR graph + turn-step
* derivation (D-01; bidirectional per 06-04's A1 ladder step b).
* astar.ts — the engine-A router surface: CH-backed routeAstar + turn-step
* derivation (Phase 08.1: D-04's ONE search path).
* ----------------------------------------------------------------------------
* The engine-A router core: routeAstar finds the minimum-seconds path
* between two graph nodes. The search is BIDIRECTIONAL (BH, Ikeda
* potentials — see routeAstar's doc) with ALT landmark steering (plan
* 08-03: h_f/h_b = max(ALT triangle bounds, haversine) over the ARG2
* landmark tables — see routeAstar's ALT paragraph) because the free-plan
* 10 ms CPU budget is the design constraint and the measured
* unidirectional p95 on the real KL graph was 3-5x over it. The hot loops
* remain allocation-free: binary
* heaps of node indices over preallocated typed arrays, typed-array
* distances/closed sets/chains, lazy on-demand potentials, no Map, no
* objects per node. Optimality is not presumed — astar.test.mjs A1
* exhaustively proves equivalence to brute-force Dijkstra on the fixture
* (625 pairs), and the oneway/unreachable laws have dedicated cases.
* routeAstar is the exported search the Worker calls (the RouteResult wire
* contract is frozen). Since Phase 08.1 it DELEGATES every request to the
* uphill bidirectional CH query (ch-query.ts's routeCH) — the hierarchy
* prunes the search structurally, so there is no heuristic, no potential,
* and no second engine: the ALT-steered bidirectional A* internals were
* DELETED (D-04), not left dormant, and the landmark tables left the
* binary with them (D-05). Optimality is by construction and is proven,
* not presumed — the committed suites run the exhaustive 625-pair
* Dijkstra-equivalence sweep and the directed oneway ring against this
* exact export.
*
* Traversal law (shared with the tests' brute-force Dijkstra): an edge with
* edgeFlags bit0 set ("oneway-reverse" — see graph.ts) is NEVER relaxed.
* Snapping (snap.ts) ignores the flag; only A* enforces direction.
* Traversal law (shared with contraction.ts and the tests' brute-force
* Dijkstra): an edge with edgeFlags bit0 set ("oneway-reverse" —
* graph.ts) is NEVER traversed. Snapping (snap.ts) ignores the flag;
* only the search enforces direction.
*
* classifyTurn is the ONE authority for "what is a turn" — plan 07's cron
* projection imports this exact classifier. Convention: compass bearings
* increase clockwise (N=0, E=90), so a NEGATIVE bearing delta is a left
* turn.
*
* deriveSteps walks a node path and splits it into maneuver steps at
* bearings that leave "straight" or at street-name changes; steps carry
* kind/name/distanceM and polyline6 geometry (RouteStep.kind — the field
* this phase added to types.ts; fixture-world steps stay empty arrays,
* D-10).
* deriveSteps walks an unpacked ORIGINAL-edge node path and splits it
* into maneuver steps at bearings that leave "straight" or at street-name
* changes; steps carry kind/name/distanceM and polyline6 geometry
* (RouteStep.kind — the field phase 6 added to types.ts; fixture-world
* steps stay empty arrays, D-10). A path that still contains a shortcut
* hop fails LOUD here (findTraversableEdge misses) — the unpacking must
* have completed upstream (ch-query.ts's seconds law).
*
* Pure: no I/O, no logging, no storage (T-06-05).
*/
@@ -38,6 +38,7 @@ import type { RoadGraph } from "./graph.ts";
import { haversineM } from "./geo.ts";
import { encodePolyline6 } from "./polyline.ts";
import type { RoutePoint, RouteStep } from "./types.ts";
import { routeCH, findTraversableEdge } from "./ch-query.ts";
/** Maneuver kinds — structurally the RouteStep.kind union (one authority). */
export type GuidanceTurnKind = NonNullable<RouteStep["kind"]>;
@@ -50,19 +51,9 @@ export const TURN_SLIGHT_MAX_DEG = 60;
export const TURN_UTURN_MIN_DEG = 150;
const DEG_TO_RAD = Math.PI / 180;
/** Same mean-earth sphere as geo.ts haversineM (6 371 000 m). */
const EARTH_RADIUS_M = 6_371_000;
/** The u16 sentinel cell in an ARG2 landmark table column (research
* Pattern 3's DATA value: the node cannot reach the anchor). Always
* compared RAW — u16, before any float conversion — and only at SETUP
* scope (cleanColumnsFor / the per-search constants): never inline inside
* the hot bound() scan, where a per-node compare+skip would vary the
* active landmark family set per NODE and break the max-of-families
* cross-edge consistency (the RC-1 bug — debug alt-potential-4x-slowdown). */
const LANDMARK_SENTINEL_U16 = 0xffff;
export interface AstarResult {
/** Node path, origin first, goal last. */
/** Node path, origin first, goal last (original edges only — unpacked). */
nodes: number[];
/** Total travel seconds (sum of traversed edgeSeconds). */
seconds: number;
@@ -70,472 +61,19 @@ export interface AstarResult {
distanceM: number;
}
/* ————— binary min-heap of node indices over preallocated typed arrays ————— */
const HEAP_INITIAL_CAPACITY = 1024;
class NodeHeap {
private keys: Float64Array;
private nodes: Uint32Array;
size = 0;
constructor() {
this.keys = new Float64Array(HEAP_INITIAL_CAPACITY);
this.nodes = new Uint32Array(HEAP_INITIAL_CAPACITY);
}
push(key: number, node: number): void {
if (this.size === this.keys.length) this.grow();
let i = this.size;
this.size += 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.keys[parent]! <= key) break;
this.keys[i] = this.keys[parent]!;
this.nodes[i] = this.nodes[parent]!;
i = parent;
}
this.keys[i] = key;
this.nodes[i] = node;
}
/** The min key without popping; Infinity when empty (the BH stop rule reads this). */
peekKey(): number {
return this.size === 0 ? Number.POSITIVE_INFINITY : this.keys[0]!;
}
/** Returns the min-key node, or -1 when empty. Lazy deletion: stale
* entries are skipped by the closed-set check in routeAstar. */
pop(): number {
if (this.size === 0) return -1;
const top = this.nodes[0]!;
this.size -= 1;
const lastKey = this.keys[this.size]!;
const lastNode = this.nodes[this.size]!;
let i = 0;
for (;;) {
const left = 2 * i + 1;
if (left >= this.size) break;
const right = left + 1;
const child = right < this.size && this.keys[right]! < this.keys[left]! ? right : left;
if (this.keys[child]! >= lastKey) break;
this.keys[i] = this.keys[child]!;
this.nodes[i] = this.nodes[child]!;
i = child;
}
if (this.size > 0) {
this.keys[i] = lastKey;
this.nodes[i] = lastNode;
}
return top;
}
private grow(): void {
const keys = new Float64Array(this.keys.length * 2);
keys.set(this.keys);
const nodes = new Uint32Array(this.nodes.length * 2);
nodes.set(this.nodes);
this.keys = keys;
this.nodes = nodes;
}
}
/* ————— A* — bidirectional (BH, Ikeda potentials) ————— */
/** Inline haversine from a graph node to a fixed (lat, lng), in METERS. */
function metersTo(graph: RoadGraph, v: number, lat2: number, lng2: number): number {
const lat1 = graph.lat[v]! * DEG_TO_RAD;
const lat2r = lat2 * DEG_TO_RAD;
const dLat = lat2r - lat1;
const dLng = (lng2 - graph.lng[v]!) * DEG_TO_RAD;
const sinLat = Math.sin(dLat / 2);
const sinLng = Math.sin(dLng / 2);
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2r) * sinLng * sinLng;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
}
/* ————— reverse adjacency (the backward search's in-edge scan) ————— */
/**
* The reverse CSR (in-edges per node), built ONCE per RoadGraph and
* memoized in a module-scope WeakMap — the snap-grid LAW 1 pattern. The
* backward search cannot iterate a node's in-edges out of a source-grouped
* CSR (that was the 06-04 GREEN bug the A1/A2 gates caught: out-edges read
* as in-edges fabricate phantom arcs and traverse oneway mirrors); this
* index makes them addressable without touching the pinned binary layout.
* One-time build is O(V+E) per isolate, paid by the first route (the CPU
* gate's warm-up covers it), amortized across the isolate's requests.
*/
interface ReverseAdj {
/** rowStart[u]..rowStart[u+1] index u's IN-edges inside targets/edgeIds. */
rowStart: Uint32Array;
/** The in-edge's SOURCE node (v, for an edge v -> u). */
targets: Uint32Array;
/** The edge's index in the forward CSR (weights/flags/seconds lookup). */
edgeIds: Uint32Array;
}
const reverseGraphs = new WeakMap<RoadGraph, ReverseAdj>();
function reverseFor(graph: RoadGraph): ReverseAdj {
const memoized = reverseGraphs.get(graph);
if (memoized !== undefined) return memoized;
const n = graph.nodeCount;
const edgeCount = graph.edgeCount;
const inDegree = new Uint32Array(n + 1);
for (let e = 0; e < edgeCount; e++) inDegree[graph.edgeTarget[e]! + 1] = inDegree[graph.edgeTarget[e]! + 1]! + 1;
for (let i = 0; i < n; i++) inDegree[i + 1] = inDegree[i + 1]! + inDegree[i]!;
const targets = new Uint32Array(edgeCount);
const edgeIds = new Uint32Array(edgeCount);
const cursor = inDegree.slice(0, n);
for (let u = 0; u < n; u++) {
for (let e = graph.edgeStart[u]!; e < graph.edgeStart[u + 1]!; e++) {
const v = graph.edgeTarget[e]!; // the edge u -> v is an IN-edge of v
const pos = cursor[v]!;
cursor[v] = pos + 1;
targets[pos] = u;
edgeIds[pos] = e;
}
}
const adj: ReverseAdj = { rowStart: inDegree, targets, edgeIds };
reverseGraphs.set(graph, adj);
return adj;
}
/* ————— clean-to-columns memo (the F1 containment law) ————— */
/**
* Which landmark toTable columns are CLEAN — zero 0xFFFF sentinel cells —
* built ONCE per RoadGraph and memoized in a module-scope WeakMap (the
* reverseFor precedent): O(L*n) sequential row scans, paid by the first
* route (the CPU gate's warm-up covers it), amortized across the isolate's
* requests (the bot's per-isolate graph memo shares one decoded object).
*
* WHY (the F1 fix, debug session alt-potential-4x-slowdown): the ALT bound
* is a max over per-landmark families, and the shared Ikeda average
* p = (h_f − h_b)/2 is consistent only when every family inside the max is
* active at BOTH ends of EVERY edge the search relaxes. A sentinel cell
* anywhere in column ℓ means some node would silently drop family ℓ while
* its neighbours keep it — a per-node ℓ-mask, the exact cross-edge
* inconsistency that made shipped routeAstar return suboptimal real-graph
* routes (1/194 fresh seeded pairs vs the Dijkstra oracle; worst
* family-variance 1,026 s on a single edge). So cleanliness is decided
* ONCE per graph: any sentinel cell ⇒ dirty ⇒ the landmark drops for the
* WHOLE search. On the current binary every column is dirty (the
* 6,285-node disconnected island cannot reach any anchor — 24/24), the
* per-search active list is empty, and the potential degrades to exactly
* the haversine bound: sound by the same proof, at the pre-ALT CPU band.
* A future clean-column binary (strongly connected, anchors inside the
* SCC) re-arms ALT with no code change.
*/
const cleanColumnGraphs = new WeakMap<RoadGraph, Uint8Array>();
function cleanColumnsFor(graph: RoadGraph): Uint8Array {
const memoized = cleanColumnGraphs.get(graph);
if (memoized !== undefined) return memoized;
const n = graph.nodeCount;
const landmarkCount = graph.landmarkCount;
const clean = new Uint8Array(landmarkCount).fill(1);
for (let l = 0; l < landmarkCount; l++) {
const base = l * n;
for (let v = 0; v < n; v++) {
if (graph.landmarkTo[base + v]! === LANDMARK_SENTINEL_U16) {
clean[l] = 0;
break; // one sentinel cell is enough — the column is dirty
}
}
}
cleanColumnGraphs.set(graph, clean);
return clean;
}
/**
* Minimum-seconds path origin -> goal over the graph, or null when the pair
* is unreachable (the honest miss). Exhaustively proven equal to
* brute-force Dijkstra on the committed mini-graph (astar.test.mjs A1).
*
* BIDIRECTIONAL (A1 ladder step b, 06-04): the free-plan 10 ms CPU ceiling
* demands it — measured on the real 311k-node KL graph, the unidirectional
* search's weak max-speed heuristic balloons on 20-30 km routes (p95 38 ms).
* BH construction (Ikeda et al.): with directional heuristics
* h_f(v)=hav(v,goal)/maxSpeed and h_b(v)=hav(v,origin)/maxSpeed (each
* consistent on its own search graph) and the opposite potentials
* p_f(v) = (h_f(v) - h_b(v))/2, p_b(v) = -p_f(v),
* the reductions
* forward, edge u->v: c + p_f(u) - p_f(v)
* backward, in-edge v->u of settled u: c + p_b(u) - p_b(v)
* are NON-NEGATIVE (each feasibility is the sum of the two consistencies,
* each <= c), and the meet keys satisfy
* d_f(v) + d_b(v) = real(o->g through v) - p_f(o) + p_f(g)
* — a v-independent constant — so stopping when top_f + top_b >= mu (the
* best meeting cost seen) settles the goal PROVABLY optimally, at roughly
* half the explored radius per side. Alternation balance is a work knob,
* not a correctness knob (the stop rule carries optimality); A1's
* exhaustive 625-pair Dijkstra-equivalence sweep is the standing proof,
* re-run on every pass.
*
* ALT (plan 08-03, amended 08-05): the h_f/h_b bodies are max(ALT triangle
* bounds, haversine) over the ARG2 landmark tables (graph.landmarkTo,
* quantized at graph.landmarkQuantumS) — the toTable families ONLY. The
* tables come from landmark.ts's integer ĉ-metric Dijkstras —
* floor-quantized EDGE WEIGHTS make the stored distances an exact metric,
* so the triangle bounds derived from them are exactly admissible and
* exactly consistent against the real f32 seconds (no admissibility
* epsilon). 08-05 amendment (the D-03 gate's catch): the fromTable
* families d̂(ℓ,t) − d̂(ℓ,v) are one-directional Lipschitz — their
* u-side bound needs the REVERSE edge (d̂(ℓ,u) ≤ d̂(ℓ,v) + ĉ(v→u)),
* which a oneway does not provide — and the shared Ikeda average
* p = (h_f − h_b)/2 is only consistent when EVERY family inside h_f/h_b
* is two-sided. Mixing them returned suboptimal real-graph routes
* (x038: 1234.6s vs 1214.8s optimum). The toTable bound d̂(v,ℓ) − d̂(t,ℓ)
* rides stored traversable edges in BOTH directions (u→v→ℓ, v→u→ℓ) —
* it alone joins the haversine. Sentinel laws (research Pattern 3, F1
* amended — debug session alt-potential-4x-slowdown): the containment law
* is CLEAN COLUMNS ONLY — a to-column holding ANY 0xFFFF sentinel cell
* (2.02% of cells on the current binary: the 6,285-node island cannot
* reach any anchor) is dirty, and its landmark drops for the WHOLE search,
* decided once per graph (cleanColumnsFor) and scoped per search (the
* active list); a goal/origin-side sentinel constant drops its ℓ too (a
* retained belt — a clean column cannot produce one). NO per-node sentinel
* skip exists: an earlier per-node cell-skip varied the active family set
* per NODE — the max-of-families lost cross-edge consistency and the
* search returned suboptimal real-graph routes (1/194 fresh pairs vs the
* Dijkstra oracle). With no clean column (the current binary, 24/24
* dirty) the active list is empty and every bound degrades to exactly the
* haversine — admissible + consistent everywhere, the designed sound
* fallback. The survey's (πf−πr)/2 combination is what the
* p_f below already implements — ONLY the h_f/h_b bodies changed; the
* reductions, meet probes, stop rule, and reconstruction are the proven
* BH skeleton, and A1's sweep re-run under ALT (with A9/A10/A12/A13's
* island, skip, oneway, and mixed-column seals) is the standing optimality
* proof.
* Minimum-seconds path origin -> goal over the graph, or null when the
* pair is unreachable (the honest miss). Validates the endpoints, answers
* the trivial same-node pair directly, and delegates EVERY other request
* to the CH query — D-11's one search path: a 500 m hop and a cross-metro
* run run the same proven search.
*/
export function routeAstar(graph: RoadGraph, originNode: number, goalNode: number): AstarResult | null {
const n = graph.nodeCount;
if (!Number.isInteger(originNode) || originNode < 0 || originNode >= n) return null;
if (!Number.isInteger(goalNode) || goalNode < 0 || goalNode >= n) return null;
if (originNode === goalNode) return { nodes: [originNode], seconds: 0, distanceM: 0 };
const maxSpeedMps = graph.maxSpeedKmh / 3.6;
const heuristicScale = maxSpeedMps > 0 ? 1 / maxSpeedMps : 1;
const goalLat = graph.lat[goalNode]!;
const goalLng = graph.lng[goalNode]!;
const origLat = graph.lat[originNode]!;
const origLng = graph.lng[originNode]!;
// ALT per-search constants (plan 08-03, amended 08-05, F1-amended — the
// debug session alt-potential-4x-slowdown): the ACTIVE landmark list is
// the CLEAN to-columns only (cleanColumnsFor) — a column holding any
// sentinel cell is dirty and its landmark drops for the WHOLE search.
// The goal/origin-side to-table cells are still hoisted ONCE per search,
// kept in QUANTA units with a 0xFFFF sentinel written as NaN, and a
// sentinel constant drops that landmark too (a retained belt: a clean
// column cannot produce one). Skip decisions live ONLY here, at search
// scope — never a per-node ℓ-mask mid-max, which would vary the landmark
// set inside the max and break the max-of-Lipschitz consistency argument
// (the exact mechanism that returned 1/194 suboptimal real-graph routes
// before F1). L >= 1 is a decodeGraph guarantee (ARG2 refuses
// landmarkCount 0 — D-01), so there is no L===0 branch and no dead
// fallback path: one search path — an empty active list degenerates to
// the pure haversine bound inside bound().
//
// 08-05 AMENDMENT — the fromTable families are GONE, and that is the fix:
// the D-03 optimality gate caught routeAstar returning suboptimal real-graph
// routes (pair x038: 1234.6s vs the 1214.8s Dijkstra optimum; x045: 1475.5s
// vs 1460.6s). Root cause: a fromTable bound d̂(ℓ,t) − d̂(ℓ,v) is Lipschitz
// across an edge ONLY in the direction the anchor's reach confirms —
// d̂(ℓ,u) ≤ d̂(ℓ,v) + ĉ(v→u) needs the REVERSE edge, which a oneway does
// not have (KL: the anchor reached a node 126.8 s faster than its own 1.49 s
// upstream neighbour). The toTable bound d̂(v,ℓ) − d̂(t,ℓ) is two-sided:
// u→v→ℓ AND v→u→ℓ both ride stored traversable edges. The Ikeda average
// p = (h_f − h_b)/2 is shared by BOTH search directions, so every family
// inside h_f/h_b must be two-sided-Lipschitz — only the toTable pair is.
// haversine is two-sided (its chord ≤ road cost in both directions). The
// fixture sweep never caught this: bidirectional synthetic edges have no
// one-way reach asymmetry.
const L = graph.landmarkCount;
const Q = graph.landmarkQuantumS;
const clean = cleanColumnsFor(graph);
let activeCount = 0;
for (let l = 0; l < L; l++) if (clean[l]! === 1) activeCount += 1;
const activeL = new Int32Array(activeCount); // the clean to-columns, search-scoped
for (let l = 0, k = 0; l < L; l++) if (clean[l]! === 1) activeL[k++] = l;
const goalToU = new Float64Array(L);
const origToU = new Float64Array(L);
for (let l = 0; l < L; l++) {
const base = l * n;
const gTo = graph.landmarkTo[base + goalNode]!;
goalToU[l] = gTo === LANDMARK_SENTINEL_U16 ? Number.NaN : gTo;
const oTo = graph.landmarkTo[base + originNode]!;
origToU[l] = oTo === LANDMARK_SENTINEL_U16 ? Number.NaN : oTo;
}
// Directional heuristics + opposite potentials, lazy per node — only
// explored nodes ever pay, and the hot loops stay allocation-free.
const hfCache = new Float64Array(n).fill(Number.NaN); // h_f(v) = max(hav(v, goal) * scale, ALT bounds)
const hbCache = new Float64Array(n).fill(Number.NaN); // h_b(v) = max(hav(v, origin) * scale, ALT bounds)
const pf = new Float64Array(n); // p_f(v) = (h_f - h_b)/2; p_b = -p_f — valid once both caches are set
// One directional bound: the max-rule (research Pattern 1) — the sharper
// of the haversine bound and the best ALT triangle bound toward the target.
// TWO families per landmark ℓ — the toTable pair (amended 08-05, see the
// per-search comment above for why the fromTable pair is excluded):
// family A toward goal: d(v,t) >= d(v,ℓ) − d(t,ℓ) (this call)
// family A toward origin: d(v,o) >= d(v,ℓ) − d(o,ℓ) (the hb call site)
// evaluated in quanta and converted to seconds ONCE at the end. The scan
// runs the ACTIVE list only (clean columns — F1), and a clean column has
// NO sentinel cell by definition, so there is NO per-node branch: the
// active family set is IDENTICAL at both ends of every relaxed edge —
// exactly the cross-edge consistency the shared Ikeda average needs. The
// retained constant-table NaN check drops a landmark whose goal/origin-
// side cell is the sentinel (belt — unreachable for active columns). An
// empty active list (no clean column — the current real binary) skips
// the scan entirely and degrades to EXACTLY the pure haversine bound. A
// family value may sit below zero (v closer to ℓ than t is) — legal: p_f
// already goes negative when h_b > h_f, the BH meet-sum law is algebraic
// in its sign, and the max with the non-negative haversine bound keeps
// h_f/h_b >= 0 anyway.
function bound(v: number, lat2: number, lng2: number, constTo: Float64Array): number {
let bestTo = Number.NEGATIVE_INFINITY;
for (let k = 0; k < activeL.length; k++) {
const l = activeL[k]!;
const tv = graph.landmarkTo[l * n + v]!;
const cTo = constTo[l]!;
if (!Number.isNaN(cTo)) bestTo = Math.max(bestTo, tv - cTo); // family A: d(v,ℓ) − d(t,ℓ)
}
const hav = metersTo(graph, v, lat2, lng2) * heuristicScale;
return bestTo > Number.NEGATIVE_INFINITY ? Math.max(hav, bestTo * Q) : hav;
}
function potential(v: number): number {
let hf = hfCache[v]!;
if (Number.isNaN(hf)) {
hf = bound(v, goalLat, goalLng, goalToU);
hfCache[v] = hf;
}
let hb = hbCache[v]!;
if (Number.isNaN(hb)) {
hb = bound(v, origLat, origLng, origToU);
hbCache[v] = hb;
}
const value = (hf - hb) / 2;
pf[v] = value;
return value;
}
// Reduced distances (BH keys), chain arrays, closed sets, one heap per side.
const df = new Float64Array(n).fill(Number.POSITIVE_INFINITY); // reduced origin->v
const db = new Float64Array(n).fill(Number.POSITIVE_INFINITY); // reduced v->goal
const fPrev = new Int32Array(n).fill(-1); // predecessor on the origin->v chain
const bNext = new Int32Array(n).fill(-1); // successor on the v->goal chain
const fClosed = new Uint8Array(n);
const bClosed = new Uint8Array(n);
const fHeap = new NodeHeap();
const bHeap = new NodeHeap();
df[originNode] = 0;
potential(originNode); // pf[o] is needed by the first forward relaxations
fHeap.push(0, originNode); // key = the REDUCED distance itself (the heuristic lives in the reduction)
db[goalNode] = 0;
potential(goalNode); // p_b(g) likewise for the first backward relaxations
bHeap.push(0, goalNode);
let mu = Number.POSITIVE_INFINITY; // best meeting key sum (real + const)
let meetFrom = -1; // bridge edge meetFrom -> meetTo (direction of travel)
let meetTo = -1;
while (fHeap.size > 0 && bHeap.size > 0) {
// THE BH STOP RULE: with consistent potentials, no future meeting can
// beat mu once both frontiers' minima sum past it.
if (fHeap.peekKey() + bHeap.peekKey() >= mu) break;
// BALANCED ALTERNATION: expand the side with the smaller frontier.
// (Measured 06-04: comparing raw tops starves the backward side — its
// keys start near the full route cost while the forward side's start
// near zero, so `top_f <= top_b` ran the forward search almost alone
// (19501:3 settles) and landed 4x SLOWER than unidirectional. Heap
// size keeps both rings growing; correctness lives entirely in the
// stop rule — alternation order is a work-balancing knob only.)
const expandForward = fHeap.size <= bHeap.size;
const u = (expandForward ? fHeap : bHeap).pop()!;
if (expandForward ? fClosed[u]! !== 0 : bClosed[u]! !== 0) continue; // lazy deletion
if (expandForward) fClosed[u] = 1;
else bClosed[u] = 1;
if (expandForward) {
const du = df[u]!;
const pfu = pf[u]!;
for (let e = graph.edgeStart[u]!; e < graph.edgeStart[u + 1]!; e++) {
if ((graph.edgeFlags[e]! & 1) !== 0) continue; // oneway-reverse: never traversed
const v = graph.edgeTarget[e]!;
const pv = potential(v);
const candidate = du + graph.edgeSeconds[e]! + pfu - pv;
if (candidate < df[v]!) {
df[v] = candidate;
fPrev[v] = u;
fHeap.push(candidate, v);
}
// Meet probe: v already reached backward? (candidate uses v's BEST d_b)
if (db[v]! < Number.POSITIVE_INFINITY) {
const meet = candidate + db[v]!;
if (meet < mu) {
mu = meet;
meetFrom = u;
meetTo = v;
}
}
}
} else {
const rev = reverseFor(graph);
const du = db[u]!;
const pfu = pf[u]!;
const end = rev.rowStart[u + 1]!;
for (let r = rev.rowStart[u]!; r < end; r++) {
const e = rev.edgeIds[r]!; // the edge v -> u
if ((graph.edgeFlags[e]! & 1) !== 0) continue; // oneway-reverse: never traversed
const v = rev.targets[r]!;
const pvv = potential(v);
const candidate = du + graph.edgeSeconds[e]! - pfu + pvv;
if (candidate < db[v]!) {
db[v] = candidate;
bNext[v] = u;
bHeap.push(candidate, v);
}
if (df[v]! < Number.POSITIVE_INFINITY) {
const meet = candidate + df[v]!;
if (meet < mu) {
mu = meet;
meetFrom = v; // the path travels v -> u here
meetTo = u;
}
}
}
}
}
if (meetFrom < 0) return null; // never met — unreachable (honest miss)
// Reconstruct: origin ->(fPrev)-> meetFrom -[bridge]-> meetTo ->(bNext)-> goal.
const fPart: number[] = [];
for (let x = meetFrom; x >= 0; x = fPrev[x]!) fPart.push(x);
fPart.reverse();
const bPart: number[] = [];
for (let y = meetTo; y >= 0; y = bNext[y]!) bPart.push(y);
const path = fPart.concat(bPart);
// REAL costs recomputed along the node path (exact; no reduced-cost
// arithmetic leaks into the returned seconds).
let seconds = 0;
let distanceM = 0;
for (let i = 1; i < path.length; i++) {
const a = path[i - 1]!;
const b = path[i]!;
const from = { lat: graph.lat[a]!, lng: graph.lng[a]! };
const to = { lat: graph.lat[b]!, lng: graph.lng[b]! };
distanceM += haversineM(from, to);
const edge = findTraversableEdge(graph, a, b);
seconds += edge >= 0 ? graph.edgeSeconds[edge]! : 0; // -1 only on a corrupt chain — loud in deriveSteps
}
return { nodes: path, seconds, distanceM };
return routeCH(graph, originNode, goalNode);
}
/* ————— turn classification (the one authority; plan 07 imports this) ————— */
@@ -568,14 +106,6 @@ function initialBearingDeg(a: RoutePoint, b: RoutePoint): number {
return (Math.atan2(y, x) / DEG_TO_RAD + 360) % 360;
}
/** The traversable (bit0 clear) edge u -> v, or -1. */
function findTraversableEdge(graph: RoadGraph, u: number, v: number): number {
for (let e = graph.edgeStart[u]!; e < graph.edgeStart[u + 1]!; e++) {
if (graph.edgeTarget[e]! === v && (graph.edgeFlags[e]! & 1) === 0) return e;
}
return -1;
}
interface StepAccumulator {
kind: GuidanceTurnKind;
name: string | undefined;
@@ -584,12 +114,12 @@ interface StepAccumulator {
}
/**
* Derive maneuver steps from a node path: split where the bearing leaves
* "straight" or the street name changes. Step 0 is always "depart"; a
* terminal "arrive" step anchors the goal node. Each step carries its
* street name (omitted when unnamed), haversine meters, and polyline6
* geometry (the RouteStep.kind field this phase added to types.ts —
* fixture-world steps stay empty arrays, D-10).
* Derive maneuver steps from an UNPACKED original-edge node path: split
* where the bearing leaves "straight" or the street name changes. Step 0
* is always "depart"; a terminal "arrive" step anchors the goal node.
* Each step carries its street name (omitted when unnamed), haversine
* meters, and polyline6 geometry (the RouteStep.kind field phase 6 added
* to types.ts — fixture-world steps stay empty arrays, D-10).
*
* Throws on a malformed path (a non-traversable consecutive pair) — a
* caller bug is loud, unlike a route miss which stays null.
+464
View File
@@ -0,0 +1,464 @@
/*
* ch-query.ts — the uphill bidirectional CH query + shortcut unpacking
* (Phase 08.1, plan 01; D-04/D-11: THE one search path).
* ----------------------------------------------------------------------------
* routeCH finds the minimum-seconds path between two graph nodes over the
* ARG3 hierarchy: two Dijkstra searches that only ever CLIMB — the forward
* search from the origin relaxes only edges (original or shortcut) whose
* target has strictly higher rank; the backward search from the goal
* relaxes only IN-edges whose source has strictly higher rank. The two
* searches meet at the highest-ranked node of a shortest path, which is
* why optimality holds BY CONSTRUCTION — no heuristic, no potential, no
* landmark tables (D-04: the ALT machinery is deleted, not dormant).
*
* THE STOP RULE carries optimality: once the two frontiers' minima sum
* past mu (the best meeting cost seen), no future meeting can beat it.
* Expanding the smaller frontier first is a work-balance knob only —
* the same law astar.ts's BH documented, and the same edge-based meet
* probe A1's 625-pair sweep already validated.
*
* TRAVERSAL LAW (shared with contraction.ts and the tests' brute-force
* Dijkstra): an edge with edgeFlags bit0 set ("oneway-reverse", graph.ts)
* is NEVER relaxed — not in the forward scan, not in the backward scan.
* Shortcuts are DIRECTED only and the backward search reads its own
* reverse view — never the forward shortcut list (the oneway law's
* structural form: research Pitfall 2).
*
* THE SECONDS LAW (research Pitfall 3): after the meet, the raw node
* chain is recursively UNPACKED through the shortcuts' via field until
* only original edges remain, and ONLY THEN are seconds re-summed from
* graph.edgeSeconds over the unpacked path (and distanceM from
* haversineM over the same nodes). Shortcut weights steer the search
* only; they never enter reported seconds — which keeps the result
* bit-comparable to the Dijkstra oracle and the D-13 gates exactly
* satisfiable. deriveSteps (astar.ts) throws on any non-traversable hop,
* so an incompletely-unpacked path fails loud downstream.
*
* The reverse views (in-edges per node, originals and shortcuts) are
* built ONCE per RoadGraph and memoized in a module-scope WeakMap — the
* snap-grid LAW 1 / reverseFor precedent. One-time build is O(V+E) per
* isolate, paid by the first route (the CPU gate's warm-up covers it).
*
* Pure: no I/O, no logging, no storage (T-06-05).
*/
import type { RoadGraph } from "./graph.ts";
import { haversineM } from "./geo.ts";
/** The CH query result — the SAME shape astar.ts's AstarResult has always
* had, so the RouteResult wire contract and the worker-router call site
* are untouched (D-11: no product-surface change). */
export interface ChResult {
/** Node path, origin first, goal last — shortcuts already unpacked. */
nodes: number[];
/** Total travel seconds (sum of traversed original edgeSeconds). */
seconds: number;
/** Meters over the node path (haversineM post-hoc — the plan's law). */
distanceM: number;
}
/* ————— binary min-heap of node indices over preallocated typed arrays ————— */
const HEAP_INITIAL_CAPACITY = 1024;
class NodeHeap {
private keys: Float64Array;
private nodes: Uint32Array;
size = 0;
constructor() {
this.keys = new Float64Array(HEAP_INITIAL_CAPACITY);
this.nodes = new Uint32Array(HEAP_INITIAL_CAPACITY);
}
push(key: number, node: number): void {
if (this.size === this.keys.length) this.grow();
let i = this.size;
this.size += 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.keys[parent]! <= key) break;
this.keys[i] = this.keys[parent]!;
this.nodes[i] = this.nodes[parent]!;
i = parent;
}
this.keys[i] = key;
this.nodes[i] = node;
}
/** The min key without popping; Infinity when empty (the stop rule reads this). */
peekKey(): number {
return this.size === 0 ? Number.POSITIVE_INFINITY : this.keys[0]!;
}
/** Returns the min-key node, or -1 when empty. Lazy deletion: stale
* entries are skipped by the closed-set check in routeCH. */
pop(): number {
if (this.size === 0) return -1;
const top = this.nodes[0]!;
this.size -= 1;
const lastKey = this.keys[this.size]!;
const lastNode = this.nodes[this.size]!;
let i = 0;
for (;;) {
const left = 2 * i + 1;
if (left >= this.size) break;
const right = left + 1;
const child = right < this.size && this.keys[right]! < this.keys[left]! ? right : left;
if (this.keys[child]! >= lastKey) break;
this.keys[i] = this.keys[child]!;
this.nodes[i] = this.nodes[child]!;
i = child;
}
if (this.size > 0) {
this.keys[i] = lastKey;
this.nodes[i] = lastNode;
}
return top;
}
private grow(): void {
const keys = new Float64Array(this.keys.length * 2);
keys.set(this.keys);
this.keys = keys;
const nodes = new Uint32Array(this.nodes.length * 2);
nodes.set(this.nodes);
this.nodes = nodes;
}
}
/* ————— reverse views (the backward search's in-edge scans) ————— */
/**
* A source-grouped CSR cannot answer "node u's in-edges" — reading
* out-edges as in-edges fabricates phantom arcs and traverses oneway
* mirrors (the 06-04 GREEN bug the A1/A2 gates caught). Both reverse
* views (original edges and shortcuts) are built ONCE per RoadGraph and
* memoized in a module-scope WeakMap.
*/
interface ReverseView {
/** rowStart[u]..rowStart[u+1] index u's IN-edges inside sources/edgeIds. */
rowStart: Uint32Array;
/** The in-edge's SOURCE node (v, for an edge v -> u). */
sources: Uint32Array;
/** The edge's index in the FORWARD CSR (seconds/flags lookup). */
edgeIds: Uint32Array;
}
interface ReversePair {
original: ReverseView;
shortcut: ReverseView;
}
const reverseGraphs = new WeakMap<RoadGraph, ReversePair>();
function buildReverse(rowOf: (u: number) => number, targetOf: (e: number) => number, edgeCount: number, n: number, listEnd: (u: number) => number): ReverseView {
const inDegree = new Uint32Array(n + 1);
for (let e = 0; e < edgeCount; e++) inDegree[targetOf(e) + 1] = inDegree[targetOf(e) + 1]! + 1;
for (let i = 0; i < n; i++) inDegree[i + 1] = inDegree[i + 1]! + inDegree[i]!;
const sources = new Uint32Array(edgeCount);
const edgeIds = new Uint32Array(edgeCount);
const cursor = inDegree.slice(0, n);
for (let u = 0; u < n; u++) {
const end = listEnd(u);
for (let e = rowOf(u); e < end; e++) {
const v = targetOf(e); // the edge u -> v is an IN-edge of v
const pos = cursor[v]!;
cursor[v] = pos + 1;
sources[pos] = u;
edgeIds[pos] = e;
}
}
return { rowStart: inDegree, sources, edgeIds };
}
function reverseFor(graph: RoadGraph): ReversePair {
const memoized = reverseGraphs.get(graph);
if (memoized !== undefined) return memoized;
const n = graph.nodeCount;
const original = buildReverse(
(u) => graph.edgeStart[u]!,
(e) => graph.edgeTarget[e]!,
graph.edgeCount,
n,
(u) => graph.edgeStart[u + 1]!,
);
const shortcut = buildReverse(
(u) => graph.shortcutStart[u]!,
(e) => graph.shortcutTarget[e]!,
graph.chShortcutCount,
n,
(u) => graph.shortcutStart[u + 1]!,
);
const pair: ReversePair = { original, shortcut };
reverseGraphs.set(graph, pair);
return pair;
}
/* ————— the ONE search path ————— */
/** Bit 30 marks a shortcut index inside an edge ref (original edges stay
* their plain CSR index; shortcut counts are far below 2^30). */
const SHORTCUT_REF_BIT = 0x40000000;
/**
* Minimum-seconds path origin -> goal over the CH hierarchy, or null when
* the pair is unreachable (the honest miss). Exhaustively proven equal to
* brute-force Dijkstra on the fixture grid (ch-query.test.mjs Q1's 625
* pairs) and the directed oneway ring (Q2) — the standing optimality
* proof, re-run on every pass.
*/
export function routeCH(graph: RoadGraph, originNode: number, goalNode: number): ChResult | null {
const n = graph.nodeCount;
if (!Number.isInteger(originNode) || originNode < 0 || originNode >= n) return null;
if (!Number.isInteger(goalNode) || goalNode < 0 || goalNode >= n) return null;
if (originNode === goalNode) return { nodes: [originNode], seconds: 0, distanceM: 0 };
const rank = graph.chRank;
// Reduced distances (heap keys), chains, closed sets, one heap per side.
// The chain edges are REFS, not just predecessors: a parallel original
// edge and shortcut can share endpoints, and the unpacker must know
// which one the search traversed (bit 30 marks a shortcut index).
const df = new Float64Array(n).fill(Number.POSITIVE_INFINITY); // origin->v over upward edges
const db = new Float64Array(n).fill(Number.POSITIVE_INFINITY); // v->goal over upward in-edges
const fPrev = new Int32Array(n).fill(-1); // predecessor on the origin->v chain
const bNext = new Int32Array(n).fill(-1); // successor on the v->goal chain
const fPrevEdge = new Int32Array(n).fill(-1); // the edge ref fPrev[x]->x (bit30 = shortcut)
const bNextEdge = new Int32Array(n).fill(-1); // the edge ref x->bNext[x] (bit30 = shortcut)
const fClosed = new Uint8Array(n);
const bClosed = new Uint8Array(n);
const fHeap = new NodeHeap();
const bHeap = new NodeHeap();
df[originNode] = 0;
fHeap.push(0, originNode);
db[goalNode] = 0;
bHeap.push(0, goalNode);
let mu = Number.POSITIVE_INFINITY; // best meeting key sum (real cost through the meet edge)
let meetFrom = -1; // bridge edge meetFrom -> meetTo (direction of travel)
let meetTo = -1;
let meetEdgeRef = -1; // the bridge edge's ref (bit30 = shortcut)
const rev = reverseFor(graph);
// The loop runs while EITHER frontier has work: a side whose heap
// emptied can no longer relax, but the other side's remaining settles
// still probe meets against its settled labels (the origin/goal at a
// rank-extremum case — the meet can be recorded from the live side
// alone). Alternation below is a work knob, never a correctness knob.
for (;;) {
// THE STOP RULE: a future meet is recorded only by relaxing an edge
// from a not-yet-settled node, so its value is at least the popping
// side's next key — once the SMALLER frontier top has reached mu, no
// future meeting can improve it and the search stops. (The classical
// bidirectional "topF + topB >= mu" sum rule is UNSOUND here: the
// rank filter means neither top bounds the optimal path's far side,
// so the sum can cross mu before the peak-node probe fires — Q1's
// 625-pair sweep catches exactly that.)
if (Math.min(fHeap.peekKey(), bHeap.peekKey()) >= mu) break;
if (fHeap.size === 0 && bHeap.size === 0) break;
let expandForward: boolean;
if (bHeap.size === 0) expandForward = true;
else if (fHeap.size === 0) expandForward = false;
else expandForward = fHeap.size <= bHeap.size;
const u = (expandForward ? fHeap : bHeap).pop()!;
if (expandForward ? fClosed[u]! !== 0 : bClosed[u]! !== 0) continue; // lazy deletion
if (expandForward) fClosed[u] = 1;
else bClosed[u] = 1;
if (expandForward) {
const du = df[u]!;
const rankU = rank[u]!;
// Original out-edges: bit0 never traversed; strictly uphill only.
let end = graph.edgeStart[u + 1]!;
for (let e = graph.edgeStart[u]!; e < end; e++) {
if ((graph.edgeFlags[e]! & 1) !== 0) continue; // oneway-reverse: never traversed
const v = graph.edgeTarget[e]!;
if (rank[v]! <= rankU) continue; // the uphill filter
const candidate = du + graph.edgeSeconds[e]!;
if (candidate < df[v]!) {
df[v] = candidate;
fPrev[v] = u;
fPrevEdge[v] = e; // a plain original edge ref
fHeap.push(candidate, v);
}
// Edge-based meet probe: v already reached backward?
if (db[v]! < Number.POSITIVE_INFINITY) {
const meet = candidate + db[v]!;
if (meet < mu) {
mu = meet;
meetFrom = u;
meetTo = v;
meetEdgeRef = e;
}
}
}
// Shortcut out-edges (same two laws: uphill only; shortcuts are
// directed — the backward search NEVER reuses this list).
end = graph.shortcutStart[u + 1]!;
for (let e = graph.shortcutStart[u]!; e < end; e++) {
const v = graph.shortcutTarget[e]!;
if (rank[v]! <= rankU) continue; // the uphill filter
const candidate = du + graph.shortcutSeconds[e]!;
if (candidate < df[v]!) {
df[v] = candidate;
fPrev[v] = u;
fPrevEdge[v] = e | SHORTCUT_REF_BIT;
fHeap.push(candidate, v);
}
if (db[v]! < Number.POSITIVE_INFINITY) {
const meet = candidate + db[v]!;
if (meet < mu) {
mu = meet;
meetFrom = u;
meetTo = v;
meetEdgeRef = e | SHORTCUT_REF_BIT;
}
}
}
} else {
const du = db[u]!;
const rankU = rank[u]!;
// Original IN-edges from the reverse view: relax v->u when its bit0
// is clear and v is strictly higher-ranked.
let end = rev.original.rowStart[u + 1]!;
for (let r = rev.original.rowStart[u]!; r < end; r++) {
const e = rev.original.edgeIds[r]!; // the edge v -> u
if ((graph.edgeFlags[e]! & 1) !== 0) continue; // oneway-reverse: never traversed
const v = rev.original.sources[r]!;
if (rank[v]! <= rankU) continue; // the uphill filter
const candidate = du + graph.edgeSeconds[e]!;
if (candidate < db[v]!) {
db[v] = candidate;
bNext[v] = u;
bNextEdge[v] = e; // a plain original edge ref
bHeap.push(candidate, v);
}
if (df[v]! < Number.POSITIVE_INFINITY) {
const meet = candidate + df[v]!;
if (meet < mu) {
mu = meet;
meetFrom = v; // the path travels v -> u here
meetTo = u;
meetEdgeRef = e;
}
}
}
// Shortcut IN-edges from the SHORTCUT reverse view — a separate
// structure, never the forward shortcut list.
end = rev.shortcut.rowStart[u + 1]!;
for (let r = rev.shortcut.rowStart[u]!; r < end; r++) {
const e = rev.shortcut.edgeIds[r]!; // the shortcut v -> u
const v = rev.shortcut.sources[r]!;
if (rank[v]! <= rankU) continue; // the uphill filter
const candidate = du + graph.shortcutSeconds[e]!;
if (candidate < db[v]!) {
db[v] = candidate;
bNext[v] = u;
bNextEdge[v] = e | SHORTCUT_REF_BIT;
bHeap.push(candidate, v);
}
if (df[v]! < Number.POSITIVE_INFINITY) {
const meet = candidate + df[v]!;
if (meet < mu) {
mu = meet;
meetFrom = v; // the path travels v -> u here
meetTo = u;
meetEdgeRef = e | SHORTCUT_REF_BIT;
}
}
}
}
}
if (meetFrom < 0) return null; // never met — unreachable (honest miss)
// Reconstruct as EDGE hops (node + ref), not just nodes: a parallel
// original edge and shortcut can share endpoints, so the ref is what
// tells the unpacker which one the search traversed.
// origin ->(fPrev)-> meetFrom -[bridge]-> meetTo ->(bNext)-> goal.
const hops: Array<{ from: number; ref: number }> = [];
for (let x = meetFrom; x !== originNode; x = fPrev[x]!) {
hops.push({ from: fPrev[x]!, ref: fPrevEdge[x]! });
}
hops.reverse();
hops.push({ from: meetFrom, ref: meetEdgeRef });
for (let y = meetTo; y !== goalNode; y = bNext[y]!) {
hops.push({ from: y, ref: bNextEdge[y]! });
}
// Unpack every shortcut hop through its via until only original edges
// remain — iterative (an explicit stack) so hierarchy depth can never
// overflow the JS call stack on long routes. A known-original ref
// (plain CSR index) is kept as-is; a marked ref expands by its stored
// via; an inner hop (ref -1) re-derives from the shortcut CSR — inside
// an expansion the shortcut IS the path the label used.
const DERIVE = -1;
const unpacked: number[] = [];
const stack: Array<{ a: number; b: number; ref: number }> = [];
for (let i = hops.length - 1; i >= 0; i--) {
stack.push({ a: hops[i]!.from, b: i + 1 < hops.length ? hops[i + 1]!.from : goalNode, ref: hops[i]!.ref });
}
while (stack.length > 0) {
const hop = stack.pop()!;
let via = -1;
if (hop.ref === DERIVE) {
// Inner hop: take the CHEAPER of the original edge and a parallel
// shortcut — a shortcut inserted by an earlier contraction can sit
// ABOVE a direct original edge (the dominated-insert guard only
// bounds the pair at its own insertion), and preferring the
// shortcut blindly would re-sum a costlier path than the label.
const eOrig = findTraversableEdge(graph, hop.a, hop.b);
const origW = eOrig >= 0 ? graph.edgeSeconds[eOrig]! : Number.POSITIVE_INFINITY;
let scIdx = -1;
const sEnd = graph.shortcutStart[hop.a + 1]!;
for (let s = graph.shortcutStart[hop.a]!; s < sEnd; s++) {
if (graph.shortcutTarget[s] === hop.b) {
scIdx = s;
break;
}
}
if (scIdx >= 0 && graph.shortcutSeconds[scIdx]! < origW) {
via = graph.shortcutVia[scIdx]!;
} else {
via = -1; // original edge (or no edge — the re-sum flags it loud)
}
} else if (hop.ref & SHORTCUT_REF_BIT) {
via = graph.shortcutVia[hop.ref & ~SHORTCUT_REF_BIT]!;
} // else: a plain original edge ref — keep as-is
if (via < 0) {
unpacked.push(hop.a); // an original edge: keep its tail node
continue;
}
// The shortcut a->b expands to a->via then via->b (push reversed so
// a->via pops first — LIFO order preserves travel order).
stack.push({ a: via, b: hop.b, ref: DERIVE });
stack.push({ a: hop.a, b: via, ref: DERIVE });
}
unpacked.push(goalNode);
// REAL costs recomputed along the unpacked original-edge path (exact;
// no shortcut arithmetic leaks into the returned seconds — Pitfall 3).
let seconds = 0;
let distanceM = 0;
for (let i = 1; i < unpacked.length; i++) {
const a = unpacked[i - 1]!;
const b = unpacked[i]!;
const from = { lat: graph.lat[a]!, lng: graph.lng[a]! };
const to = { lat: graph.lat[b]!, lng: graph.lng[b]! };
distanceM += haversineM(from, to);
const edge = findTraversableEdge(graph, a, b);
seconds += edge >= 0 ? graph.edgeSeconds[edge]! : 0; // -1 only on a corrupt chain — loud in deriveSteps
}
return { nodes: unpacked, seconds, distanceM };
}
/** The traversable (bit0 clear) original edge u -> v, or -1. Shared with
* astar.ts's step derivation — one lookup, one authority. */
export function findTraversableEdge(graph: RoadGraph, u: number, v: number): number {
for (let e = graph.edgeStart[u]!; e < graph.edgeStart[u + 1]!; e++) {
if (graph.edgeTarget[e]! === v && (graph.edgeFlags[e]! & 1) === 0) return e;
}
return -1;
}
+476
View File
@@ -0,0 +1,476 @@
/*
* contraction.ts — the CH construction core (Phase 08.1, plan 01).
* ----------------------------------------------------------------------------
* One pure, typed-array module for everything build-time about the
* contraction hierarchy: deterministic node ordering (greedy
* edge-difference priority with the deleted-neighbour uniformity term,
* lazy binary PQ, ties to the lower node id), node contraction with an
* OVERLAY-ONLY witness search, and directed shortcut emission in CSR
* form. The fixture builder (test/fixtures/build-mini-graph.mjs), the
* real build script (scripts/build-road-graph.mjs, plan 02) and the
* graph.test.mjs ARG3 re-pin ALL compute their `ch:` blocks through
* this export — there is no second CH implementation anywhere
* (single-authority law).
*
* THE WEIGHT DOMAIN: CH runs on the REAL f32 seconds — no quantization.
* The hierarchy must preserve the exact edge weights the query reads
* back, because the query's reported seconds are re-summed from the
* unpacked original edges (ch-query.ts); a shortcut's stored weight is
* the f64 sum of its two constituents rounded once to f32 at CSR
* assembly — it steers the search only, it never enters reported
* seconds.
*
* TRAVERSAL LAW (mirrors astar.ts / landmark.ts verbatim — the A1
* precedent): an edge with bit0 set ("oneway-reverse", graph.ts) is
* NEVER relaxed — it never enters the overlay, so no witness search and
* no neighbour enumeration can traverse it, in either direction.
*
* THE OVERLAY LAW (research Pitfall 1 — the #1 correctness trap): the
* witness search sees ONLY the overlay graph — the node being
* contracted and every previously contracted node are absent (their
* incident edges are dead). A witness found through a contracted node
* does not exist in the reduced graph; honoring it would silently drop
* a required shortcut and break distance preservation. Bounding the
* search (candidate distance + settle budget) may add superfluous
* shortcuts but never removes a required one — correctness survives
* budget misses by construction (arXiv:1402.0402 §3).
*
* DETERMINISM: no randomness of any kind — no PRNG, no map-iteration
* order, no reliance on object key order. The priority PQ breaks ties
* by the lower node id, neighbour lists are sorted by node id, and
* identical input arrays give byte-identical output arrays (C7).
*
* Pure module: no I/O, no logging, no storage, zero value imports
* (T-06-05).
*/
/** The priority function's uniformity weight — the reference default:
* p(u) = edgeDifference(u) + LAMBDA * deletedNeighbours(u). */
export const CH_LAMBDA_DELETED_NEIGHBOURS = 1;
/** The witness search's settle-budget floor (density-scaled above it). */
export const CH_WPS_MAX_SETTLED = 60;
/** The witness search's settle-budget scaling per unit of local degree. */
export const CH_WPS_SETTLED_PER_DEGREE = 4;
/** The shortcut-explosion tripwire: shortcuts per node — a build above
* 4×N fails loud (T-08.1-06: a blown hierarchy inflates the binary and
* every query; the ratio names the diagnosis). */
export const CH_SHORTCUT_EXPLOSION_RATIO = 4;
/** The contraction compute input — the shape build scripts assemble. */
export interface ContractionInput {
nodeCount: number;
/**
* Directed edges in any order. flags bit0 = oneway-reverse: relaxed by
* NO direction (mirrors the CSR's edgeFlags; see the traversal law).
*/
edges: Array<{ from: number; to: number; seconds: number; flags: number }>;
}
/** The CH output: the contraction rank + the shortcut set in CSR form. */
export interface ContractionResult {
/** rank[u] = u's position in the contraction order (0 = contracted
* first). A STRICT total order — the query's strict uphill
* comparisons depend on it (Pitfall 9). */
rank: Uint32Array;
/** CSR row pointers over the shortcut set — shortcutStart[u]..
* shortcutStart[u+1] are u's out-shortcuts, length nodeCount + 1. */
shortcutStart: Uint32Array;
shortcutTarget: Uint32Array;
/** f32 round of w(from,via) + w(via,to) — steering weight only. */
shortcutSeconds: Float32Array;
/** The contracted midpoint of each shortcut — REQUIRED for the
* query's recursive unpacking. */
shortcutVia: Uint32Array;
/** Build statistics (printed by the build script). */
stats: {
shortcutCount: number;
/** shortcuts per node — the explosion tripwire's input. */
ratioPerNode: number;
/** Witness searches run (priority evaluations + contractions). */
witnessSearches: number;
/** Witness searches bounded out by the settle budget. */
witnessBudgetHits: number;
};
}
function fail(context: string, message: string): never {
throw new Error(`${context}: ${message}`);
}
/* ————— overlay graph: one edge object shared by both adjacency lists ————— */
interface OverlayEdge {
from: number;
to: number;
seconds: number;
alive: boolean;
}
/** Deterministic binary min-heap of (key, node) — ties break to the
* LOWER node id at every level, so pop order is a pure function of the
* key/node multiset (the determinism law). */
class IdHeap {
private keys: Float64Array;
private nodes: Uint32Array;
size = 0;
constructor(capacity = 1024) {
this.keys = new Float64Array(capacity);
this.nodes = new Uint32Array(capacity);
}
private less(i: number, j: number): boolean {
const ki = this.keys[i]!;
const kj = this.keys[j]!;
return ki < kj || (ki === kj && this.nodes[i]! < this.nodes[j]!);
}
private swap(i: number, j: number): void {
const k = this.keys[i]!;
this.keys[i] = this.keys[j]!;
this.keys[j] = k;
const n = this.nodes[i]!;
this.nodes[i] = this.nodes[j]!;
this.nodes[j] = n;
}
push(key: number, node: number): void {
if (this.size === this.keys.length) this.grow();
let i = this.size;
this.size += 1;
this.keys[i] = key;
this.nodes[i] = node;
while (i > 0) {
const parent = (i - 1) >> 1;
if (!this.less(i, parent)) break;
this.swap(i, parent);
i = parent;
}
}
/** The min (key, node), or null when empty. */
pop(): { key: number; node: number } | null {
if (this.size === 0) return null;
const key = this.keys[0]!;
const node = this.nodes[0]!;
this.size -= 1;
if (this.size > 0) {
this.keys[0] = this.keys[this.size]!;
this.nodes[0] = this.nodes[this.size]!;
let i = 0;
for (;;) {
const left = 2 * i + 1;
if (left >= this.size) break;
const right = left + 1;
const child = right < this.size && this.less(right, left) ? right : left;
if (!this.less(child, i)) break;
this.swap(i, child);
i = child;
}
}
return { key, node };
}
private grow(): void {
const keys = new Float64Array(this.keys.length * 2);
keys.set(this.keys);
this.keys = keys;
const nodes = new Uint32Array(this.nodes.length * 2);
nodes.set(this.nodes);
this.nodes = nodes;
}
}
/** A neighbour (distinct node) with its minimum alive edge weight. */
interface Neighbour {
node: number;
weight: number;
}
/**
* Distinct alive neighbours of the list's owner, ascending by node id,
* each carrying the MINIMUM alive edge weight (parallel edges collapse
* to the cheapest). `reverse=false` reads an out-adjacency (the
* neighbour is e.to); `reverse=true` reads an in-adjacency (the
* neighbour is e.from — the two lists share edge objects). Deterministic:
* collect, sort by (node, weight), keep the first per node.
*/
function collectNeighbours(adj: OverlayEdge[], reverse: boolean): Neighbour[] {
const pairs: Neighbour[] = [];
for (const e of adj) {
if (!e.alive) continue;
pairs.push({ node: reverse ? e.from : e.to, weight: e.seconds });
}
pairs.sort((a, b) => a.node - b.node || a.weight - b.weight);
const out: Neighbour[] = [];
for (const p of pairs) {
if (out.length > 0 && out[out.length - 1]!.node === p.node) continue;
out.push(p);
}
return out;
}
/** Count alive edges in an adjacency list (the incident-edge term). */
function aliveCount(adj: OverlayEdge[]): number {
let n = 0;
for (const e of adj) if (e.alive) n += 1;
return n;
}
/**
* Build the contraction hierarchy for the whole graph: ordering,
* contraction, witness-bounded shortcut emission.
*
* Loud on malformed input (node ids out of range, non-finite/negative
* seconds, non-positive nodeCount) and on the shortcut-explosion
* tripwire — build-time failure is correct there; the fail-honest-null
* law belongs to decodeGraph.
*/
export function contractGraph(input: ContractionInput): ContractionResult {
const context = "contraction";
const n = input.nodeCount;
if (!Number.isInteger(n) || n < 1) fail(context, "nodeCount must be a positive integer");
// 1. Validate BEFORE any CSR work (the plan's step order): node ids in
// range, finite non-negative seconds. Self-loops are skipped — no
// cheapest path ever uses one and no shortcut pair can come from it.
const edgeCount = input.edges.length;
const overlay: OverlayEdge[] = [];
const outAdj: OverlayEdge[][] = new Array(n);
const inAdj: OverlayEdge[][] = new Array(n);
for (let i = 0; i < n; i++) {
outAdj[i] = [];
inAdj[i] = [];
}
for (let e = 0; e < edgeCount; e++) {
const edge = input.edges[e]!;
if (!Number.isInteger(edge.from) || edge.from < 0 || edge.from >= n) {
fail(context, `edge ${e} from-node ${edge.from} out of range`);
}
if (!Number.isInteger(edge.to) || edge.to < 0 || edge.to >= n) {
fail(context, `edge ${e} to-node ${edge.to} out of range`);
}
if (!Number.isFinite(edge.seconds) || edge.seconds < 0) {
fail(context, `edge ${edge.from}->${edge.to} has a non-finite/negative weight`);
}
if ((edge.flags & 1) !== 0) continue; // oneway-reverse: NEVER relaxed — not in the overlay
if (edge.from === edge.to) continue; // self-loop: no path can improve through it
const oe: OverlayEdge = { from: edge.from, to: edge.to, seconds: edge.seconds, alive: true };
overlay.push(oe);
outAdj[edge.from]!.push(oe);
inAdj[edge.to]!.push(oe);
}
// Original (input-graph) neighbour sets — the deleted-neighbour term
// counts neighbours ALREADY CONTRACTED regardless of overlay liveness
// (a contracted neighbour's edges are dead, so the overlay alone
// cannot answer it).
const origNeighbours: Array<Set<number>> = new Array(n);
for (let u = 0; u < n; u++) origNeighbours[u] = new Set();
for (const oe of overlay) {
origNeighbours[oe.from]!.add(oe.to);
origNeighbours[oe.to]!.add(oe.from);
}
// Witness search state: generation-stamped distances (no refills).
const dist = new Float64Array(n);
const visitGen = new Int32Array(n);
let gen = 0;
/**
* Bounded Dijkstra a -> b over ALIVE out-edges (the overlay: contracted
* nodes are unreachable because every incident edge is dead). Returns
* "witness" when b settles within `maxDist`; "budget" when the settle
* budget bound the search first; "none" otherwise (heap exhausted or
* the distance bound exceeded — no path within maxDist exists).
* `forbidden` is the node being contracted/evaluated: its edges are
* treated as absent even while they are still alive in the overlay —
* the search runs "with u removed" in BOTH call scopes (priority and
* contraction), so the trivial two-hop path THROUGH u can never
* witness u's own pairs.
*/
function witness(
a: number,
b: number,
budgetSettles: number,
maxDist: number,
forbidden: number,
): "witness" | "budget" | "none" {
gen += 1;
const heap = new IdHeap();
dist[a] = 0;
visitGen[a] = gen;
heap.push(0, a);
let settled = 0;
for (;;) {
const top = heap.pop();
if (top === null) return "none"; // exhausted: no path within maxDist
const u = top.node;
if (visitGen[u] === -gen) continue; // already settled (lazy deletion)
if (top.key > maxDist) return "none"; // distance bound: everything left is worse
visitGen[u] = -gen; // settle
settled += 1;
if (u === b) return "witness"; // settled within the candidate distance
if (settled >= budgetSettles) return "budget"; // bounded out — superfluous shortcut is SAFE
for (const e of outAdj[u]!) {
if (!e.alive) continue;
const v = e.to;
if (v === forbidden) continue; // the contracting node is REMOVED
if (visitGen[v] === -gen) continue;
const nd = top.key + e.seconds;
if (visitGen[v] !== gen || nd < dist[v]!) {
visitGen[v] = gen;
dist[v] = nd;
heap.push(nd, v);
}
}
}
}
/** Settle budget for contracting u: the density-scaled plan formula. */
function budgetFor(localDegree: number): number {
return Math.max(CH_WPS_MAX_SETTLED, CH_WPS_SETTLED_PER_DEGREE * localDegree);
}
const contracted = new Uint8Array(n);
const rank = new Uint32Array(n).fill(0xffffffff);
let witnessSearches = 0;
let witnessBudgetHits = 0;
/**
* priority(u) = edgeDifference(u) + LAMBDA * deletedNeighbours(u).
* edgeDifference runs the REAL witness searches (the honest cost of
* contracting u now) — recomputed fresh at every pop against the
* current overlay (the lazy-update law: a popped priority that
* IMPROVED is requeued; otherwise the node contracts at its popped
* key). Deterministic: neighbour lists are id-sorted, the witness
* heap ties break to the lower id.
*/
function priorityOf(u: number): number {
const inN = collectNeighbours(inAdj[u]!, true);
const outN = collectNeighbours(outAdj[u]!, false);
const incident = aliveCount(inAdj[u]!) + aliveCount(outAdj[u]!);
let added = 0;
for (const a of inN) {
for (const b of outN) {
if (a.node === b.node) continue;
const candidate = a.weight + b.weight;
witnessSearches += 1;
const r = witness(a.node, b.node, budgetFor(inN.length + outN.length), candidate, u);
if (r === "budget") witnessBudgetHits += 1;
if (r !== "witness") added += 1;
}
}
let deleted = 0;
for (const v of origNeighbours[u]!) if (contracted[v] === 1) deleted += 1;
return added - incident + CH_LAMBDA_DELETED_NEIGHBOURS * deleted;
}
// The lazy priority queue: every node enters once; stale pops recompute
// and requeue on improvement (ties re-pop in id order — no loops: the
// recomputed value is not lower than itself).
const pq = new IdHeap(n > 0 ? n : 1);
for (let u = 0; u < n; u++) pq.push(priorityOf(u), u);
const shortcuts: Array<{ from: number; to: number; seconds: number; via: number }> = [];
let nextRank = 0;
for (;;) {
const top = pq.pop();
if (top === null) break;
const u = top.node;
if (contracted[u] === 1) continue; // defensive: no re-push ever happens
const fresh = priorityOf(u);
if (fresh < top.key) {
pq.push(fresh, u); // the priority IMPROVED — requeue (lazy update)
continue;
}
// 3. rank = the strictly increasing contraction position (never the
// priority value) — a total order over all nodes.
contracted[u] = 1;
rank[u] = nextRank;
nextRank += 1;
// Neighbourhood BEFORE the removal (weights are the pre-kill minima).
const inN = collectNeighbours(inAdj[u]!, true);
const outN = collectNeighbours(outAdj[u]!, false);
// 6. Remove u and its incident overlay edges FIRST — every witness
// search below runs with u absent (the overlay law), and every
// previously contracted node is absent because its edges died at
// its own contraction.
for (const e of outAdj[u]!) e.alive = false;
for (const e of inAdj[u]!) e.alive = false;
// 4./5. Candidate shortcuts a -> b, deterministic order: ascending a
// (in-neighbours), then ascending b (out-neighbours).
const budget = budgetFor(inN.length + outN.length);
for (const a of inN) {
for (const b of outN) {
if (a.node === b.node) continue;
const candidate = a.weight + b.weight;
witnessSearches += 1;
const r = witness(a.node, b.node, budget, candidate, u);
if (r === "budget") witnessBudgetHits += 1;
if (r === "witness") continue;
// Dominated-insert guard: an alive a->b edge at or under the
// candidate already preserves the distance — emitting a parallel
// shortcut would only bloat the CSR. (The witness search itself
// usually catches this; the guard covers the bounded-out case.)
let dominated = false;
for (const e of outAdj[a.node]!) {
if (e.alive && e.to === b.node && e.seconds <= candidate) {
dominated = true;
break;
}
}
if (dominated) continue;
const se: OverlayEdge = { from: a.node, to: b.node, seconds: candidate, alive: true };
outAdj[a.node]!.push(se);
inAdj[b.node]!.push(se);
shortcuts.push({ from: a.node, to: b.node, seconds: candidate, via: u });
}
}
}
// 8. The explosion tripwire: fail loud naming the ratio and the count.
if (shortcuts.length > CH_SHORTCUT_EXPLOSION_RATIO * n) {
fail(
context,
`shortcut explosion — ${shortcuts.length} shortcuts exceeds ` +
`${CH_SHORTCUT_EXPLOSION_RATIO}x ${n} nodes ` +
`(check contraction order / witness budget)`,
);
}
// 7. CSR assembly: shortcuts were emitted ascending by (from, to); the
// counting sort preserves that within each row.
const shortcutCount = shortcuts.length;
const shortcutStart = new Uint32Array(n + 1);
const shortcutTarget = new Uint32Array(shortcutCount);
const shortcutSeconds = new Float32Array(shortcutCount);
const shortcutVia = new Uint32Array(shortcutCount);
for (const s of shortcuts) shortcutStart[s.from + 1] = shortcutStart[s.from + 1]! + 1;
for (let i = 0; i < n; i++) shortcutStart[i + 1] = shortcutStart[i + 1]! + shortcutStart[i]!;
const cursor = shortcutStart.slice(0, n);
for (const s of shortcuts) {
const pos = cursor[s.from]!;
cursor[s.from] = pos + 1;
shortcutTarget[pos] = s.to;
shortcutSeconds[pos] = s.seconds; // the single f64 -> f32 rounding
shortcutVia[pos] = s.via;
}
// 9. Stats.
return {
rank,
shortcutStart,
shortcutTarget,
shortcutSeconds,
shortcutVia,
stats: {
shortcutCount,
ratioPerNode: shortcutCount / n,
witnessSearches,
witnessBudgetHits,
},
};
}
+157 -119
View File
@@ -1,64 +1,75 @@
/*
* graph.ts — the CSR road-graph binary codec (D-01 engine A data plane).
* graph.ts — the CSR road-graph binary codec (the 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).
* (the committed mini-graph fixture, the bot suites' purpose-built graphs
* and plan 02's Overpass build script all write through it, so the layout
* has exactly one authority).
*
* 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)
* PINNED CSR BINARY LAYOUT — ARG3 (little-endian; 06-01 pinned ARG1,
* plan 08-02 versioned ARG2 per phase-8 D-01, Phase 08.1 re-pins ARG3
* per D-07/D-10; plans 04/07 depend on the base sections):
* header: magic u32 (0x41524733 "ARG3") | version u32 (=3)
* | nodeCount u32 | edgeCount u32 | nameCount u32 | namePoolLen u32
* | 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)
* | chShortcutCount u32 @60 (0 -> decode null — see the A5 law)
* | reserved0 u32 @64 (written 0; decode accepts any value)
* | reserved1 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).
* offsets (unaligned Float64Array throws RangeError).
* 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.
* hierarchy (the ARG3 CH suffix — D-10 phone-first: plain little-endian
* typed arrays, no Node-specific alignment):
* u32 chRank[n] (the contraction rank — a total
* order; 0 = contracted first)
* u32 shortcutStart[n+1] (shortcut CSR row pointers)
* u32 shortcutTarget[S]
* f32 shortcutSeconds[S] (steering weights only — the
* query re-sums original edges)
* u32 shortcutVia[S] (the contracted midpoint —
* REQUIRED for unpacking)
* The CH suffix starts at the first 4-byte boundary after the name
* pool: (namePoolEnd + 3) & ~3 — a misaligned Float32Array view would
* THROW at decode (a crash, not a null), so the alignment arithmetic
* is load-bearing (T-08.1-02).
*
* 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.)
* THE FAIL-HONEST LAW (one decode path, one search path): a buffer that
* is not EXACTLY an ARG3 graph decodes to null — ARG1 bytes (version 1),
* ARG2 bytes (version 2), future versions, chShortcutCount = 0 (an ARG3
* with no hierarchy is not a road graph — there is no CH-less layout to
* silently degrade into), and every size mismatch. There is no fallback
* branch: the Phase 6 router-pending state covers old binaries until one
* re-upload (D-07). (Router-pending coverage is exercised by the bot
* suite; the committed mini-graph-arg1.bin — and, after task 3,
* mini-graph-arg2.bin — are the standing rejection vectors, D-12.)
*
* 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.
* geometry and its street name, but the search (ch-query.ts) never
* traverses it.
*
* T-06-03: a hostile/corrupt binary fails HONEST — decodeGraph validates
* 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 / T-08-01: the size check runs BEFORE any view allocation —
* huge-count headers (including a monster landmarkCount) reject on
* magic, version, chShortcutCount, and the EXACT byte size implied by the
* header counts (the arithmetic covers the whole CH suffix), and returns
* null on any mismatch. It never throws and never returns a half-built
* graph; the Worker turns null into the designed router-pending state.
* T-06-03b / T-08.1-01: the size check runs BEFORE any view allocation —
* huge-count headers (including a monster chShortcutCount) 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 "ARG2" (ArahaRah Graph v2 — the landmark-carrying layout). */
export const GRAPH_MAGIC = 0x41524732;
/** Header magic "ARG3" (ArahaRah Graph v3 — the CH-carrying layout). */
export const GRAPH_MAGIC = 0x41524733;
/** Layout version this module reads and writes. */
export const GRAPH_VERSION = 2;
export const GRAPH_VERSION = 3;
/** edgeNameId sentinel: the edge carries no street name. */
export const UNNAMED_NAME_ID = 0xffffffff;
@@ -78,7 +89,7 @@ export interface RoadGraph {
/** 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). */
/** bit0 set = oneway-reverse (never traversed by the search; visible to snapping). */
edgeFlags: Uint8Array;
/** Speed-derived travel time per edge, seconds. */
edgeSeconds: Float32Array;
@@ -88,16 +99,17 @@ 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;
/** Number of CH shortcuts S (ARG3: always >= 1 — decode refuses 0). */
chShortcutCount: number;
/** The contraction rank — a strict total order over the nodes. */
chRank: Uint32Array;
/** CSR row pointers over the shortcuts — shortcutStart[u]..[u+1] are u's out-shortcuts. */
shortcutStart: Uint32Array;
shortcutTarget: Uint32Array;
/** Shortcut steering weights, seconds (never reported — the query re-sums). */
shortcutSeconds: Float32Array;
/** The contracted midpoint of each shortcut (the unpacking via). */
shortcutVia: Uint32Array;
}
/** One directed edge at encode time. */
@@ -112,16 +124,16 @@ 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 CH-table block of the encodeGraph input (ARG3). */
export interface ChTablesInput {
/** The contraction rank, length exactly nodeCount — a total order. */
rank: Uint32Array;
/** Shortcut CSR row pointers, length exactly nodeCount + 1. */
shortcutStart: Uint32Array;
shortcutTarget: Uint32Array;
shortcutSeconds: Float32Array;
/** The unpacking midpoints — every entry a node id in [0, nodeCount). */
shortcutVia: Uint32Array;
}
/** The encodeGraph input — the shape build scripts assemble. */
@@ -132,8 +144,8 @@ export interface GraphInput {
edges: readonly GraphEdgeInput[];
names: readonly string[];
maxSpeedKmh: number;
/** REQUIRED since ARG2 (D-01): the precomputed landmark tables. */
landmarks: LandmarkTablesInput;
/** REQUIRED since ARG3 (D-07): the contracted hierarchy tables. */
ch: ChTablesInput;
}
/* Section offsets implied by the header counts (all little-endian). */
@@ -147,9 +159,11 @@ interface Layout {
edgeNameId: number;
nameOffset: number;
namePool: number;
toTable: number;
fromTable: number;
landmarkNodes: number;
chRank: number;
shortcutStart: number;
shortcutTarget: number;
shortcutSeconds: number;
shortcutVia: number;
total: number;
}
@@ -158,7 +172,7 @@ function layoutFor(
edgeCount: number,
nameCount: number,
namePoolLen: number,
landmarkCount: number,
shortcutCount: number,
): Layout {
const lat = HEADER_SIZE;
const lng = lat + 8 * nodeCount;
@@ -169,12 +183,14 @@ function layoutFor(
const edgeNameId = edgeSeconds + 4 * edgeCount;
const nameOffset = edgeNameId + 4 * edgeCount;
const namePool = nameOffset + 4 * (nameCount + 1);
// 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;
// The ARG3 CH suffix — encode and decode share THIS arithmetic
// (T-08.1-01: the exact-size law extends over the whole suffix;
// T-08.1-02: the alignment keeps every u32/f32 view 4-byte aligned).
const chRank = (namePool + namePoolLen + 3) & ~3; // align u32 to 4 bytes
const shortcutStart = chRank + 4 * nodeCount;
const shortcutTarget = shortcutStart + 4 * (nodeCount + 1);
const shortcutSeconds = shortcutTarget + 4 * shortcutCount;
const shortcutVia = shortcutSeconds + 4 * shortcutCount;
return {
lat,
lng,
@@ -185,10 +201,12 @@ function layoutFor(
edgeNameId,
nameOffset,
namePool,
toTable,
fromTable,
landmarkNodes,
total: landmarkNodes + 4 * landmarkCount,
chRank,
shortcutStart,
shortcutTarget,
shortcutSeconds,
shortcutVia,
total: shortcutVia + 4 * shortcutCount,
};
}
@@ -212,19 +230,19 @@ export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null {
maxLng: view.getFloat64(48, true),
};
const maxSpeedKmh = view.getFloat32(56, true);
// 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
// ARG3 header field (Phase 08.1): chShortcutCount @60 (the ARG2
// landmarkCount slot), reserved @64/@68 (accepted untouched — unused).
const chShortcutCount = view.getUint32(60, true);
// A5: an ARG3 always carries its hierarchy — a CH-less layout would
// silently route on a search that cannot reach its meet node, and "no
// fallback branch" holds at the content level too. Decode refuses,
// honestly.
if (chShortcutCount === 0) return null;
// 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);
// over the CH suffix per T-08.1-01): a hostile huge-count header —
// including a monster chShortcutCount — rejects here on arithmetic
// alone.
const lay = layoutFor(nodeCount, edgeCount, nameCount, namePoolLen, chShortcutCount);
if (buffer.byteLength !== lay.total) return null;
const lat = new Float64Array(buffer, lay.lat, nodeCount);
@@ -236,9 +254,11 @@ 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 chRank = new Uint32Array(buffer, lay.chRank, nodeCount);
const shortcutStart = new Uint32Array(buffer, lay.shortcutStart, nodeCount + 1);
const shortcutTarget = new Uint32Array(buffer, lay.shortcutTarget, chShortcutCount);
const shortcutSeconds = new Float32Array(buffer, lay.shortcutSeconds, chShortcutCount);
const shortcutVia = new Uint32Array(buffer, lay.shortcutVia, chShortcutCount);
const decoder = new TextDecoder();
const graph: RoadGraph = {
nodeCount,
@@ -260,11 +280,12 @@ export function decodeGraph(buffer: ArrayBuffer): RoadGraph | null {
},
bbox,
maxSpeedKmh,
landmarkCount,
landmarkQuantumS,
landmarkTo,
landmarkFrom,
landmarkNodes,
chShortcutCount,
chRank,
shortcutStart,
shortcutTarget,
shortcutSeconds,
shortcutVia,
};
return graph;
}
@@ -286,28 +307,43 @@ 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)");
// ARG3 CH block (D-07): REQUIRED, loud-validated BEFORE any allocation
// work (T-08-04's law carried forward) — no silent clamp, no CH-less encode.
const ch = input.ch;
if (!ch) fail("ch block is required (ARG3 has no CH-less layout)");
if (!ch.rank || ch.rank.length !== nodeCount) {
fail(`ch.rank must have length nodeCount = ${nodeCount}, got ${ch.rank ? ch.rank.length : "undefined"}`);
}
if (!Number.isFinite(landmarks.quantumS) || landmarks.quantumS <= 0) {
fail("landmarks.quantumS must be a positive finite number");
if (!ch.shortcutStart || ch.shortcutStart.length !== nodeCount + 1) {
fail(
`ch.shortcutStart must have length nodeCount + 1 = ${nodeCount + 1}, got ` +
`${ch.shortcutStart ? ch.shortcutStart.length : "undefined"}`,
);
}
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})`);
const shortcutCount = ch.shortcutTarget ? ch.shortcutTarget.length : -1;
if (!Number.isInteger(shortcutCount) || shortcutCount < 1) {
fail("ch.shortcutTarget must carry at least one shortcut (ARG3 has no CH-less layout)");
}
if (!ch.shortcutSeconds || ch.shortcutSeconds.length !== shortcutCount) {
fail(`ch.shortcutSeconds must have length S = ${shortcutCount}, got ${ch.shortcutSeconds ? ch.shortcutSeconds.length : "undefined"}`);
}
if (landmarks.to.length !== landmarkCount * nodeCount) {
fail(`landmarks.to must have length L*N = ${landmarkCount}*${nodeCount}, got ${landmarks.to.length}`);
if (!ch.shortcutVia || ch.shortcutVia.length !== shortcutCount) {
fail(`ch.shortcutVia must have length S = ${shortcutCount}, got ${ch.shortcutVia ? ch.shortcutVia.length : "undefined"}`);
}
if (ch.shortcutStart[nodeCount] !== shortcutCount) {
fail(
`ch.shortcutStart[${nodeCount}] (${ch.shortcutStart[nodeCount]}) must equal the shortcut count S = ${shortcutCount}`,
);
}
for (let s = 0; s < shortcutCount; s++) {
const via = ch.shortcutVia[s]!;
if (!Number.isInteger(via) || via < 0 || via >= nodeCount) {
fail(`ch.shortcutVia[${s}] (${via}) is not a node id in [0, ${nodeCount})`);
}
const seconds = ch.shortcutSeconds[s]!;
if (!Number.isFinite(seconds) || seconds < 0) {
fail(`ch.shortcutSeconds[${s}] (${seconds}) is not a finite non-negative weight`);
}
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) {
@@ -347,7 +383,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, landmarkCount);
const lay = layoutFor(nodeCount, edgeCount, nameCount, namePoolLen, shortcutCount);
const buffer = new ArrayBuffer(lay.total);
const view = new DataView(buffer);
const edgeTarget = new Uint32Array(buffer, lay.edgeTarget, edgeCount);
@@ -388,9 +424,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)
view.setUint32(60, shortcutCount, true);
view.setUint32(64, 0, true); // reserved0 (decode accepts any value)
view.setUint32(68, 0, true); // reserved1 (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);
@@ -413,11 +449,13 @@ 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);
// ARG3 CH 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 Uint32Array(buffer, lay.chRank, nodeCount).set(ch.rank);
new Uint32Array(buffer, lay.shortcutStart, nodeCount + 1).set(ch.shortcutStart);
new Uint32Array(buffer, lay.shortcutTarget, shortcutCount).set(ch.shortcutTarget);
new Float32Array(buffer, lay.shortcutSeconds, shortcutCount).set(ch.shortcutSeconds);
new Uint32Array(buffer, lay.shortcutVia, shortcutCount).set(ch.shortcutVia);
return buffer;
}
+22 -8
View File
@@ -46,14 +46,6 @@ export {
} from "./fixture-source.ts";
export { createHttpRoutingSource } from "./http-source.ts";
export type { HttpRoutingSourceOptions } from "./http-source.ts";
export type { RoadGraph, GraphEdgeInput, GraphInput } from "./graph.ts";
export {
decodeGraph,
encodeGraph,
GRAPH_MAGIC,
GRAPH_VERSION,
UNNAMED_NAME_ID,
} from "./graph.ts";
export type { SnapResult } from "./snap.ts";
export { snapToGraph, SNAP_BBOX_MARGIN_DEG } from "./snap.ts";
export type { AstarResult, GuidanceTurnKind } from "./astar.ts";
@@ -65,3 +57,25 @@ export {
TURN_SLIGHT_MAX_DEG,
TURN_UTURN_MIN_DEG,
} from "./astar.ts";
// Phase 08.1 (D-04/D-11): the CH construction core + the one search path
// are public surface now — the build scripts and the query's own suite
// import through this index. No landmark symbols exist (D-05 deleted the
// ALT machinery outright).
export type { RoadGraph, GraphEdgeInput, GraphInput, ChTablesInput } from "./graph.ts";
export type { ContractionInput, ContractionResult } from "./contraction.ts";
export type { ChResult } from "./ch-query.ts";
export {
decodeGraph,
encodeGraph,
GRAPH_MAGIC,
GRAPH_VERSION,
UNNAMED_NAME_ID,
} from "./graph.ts";
export {
contractGraph,
CH_LAMBDA_DELETED_NEIGHBOURS,
CH_WPS_MAX_SETTLED,
CH_WPS_SETTLED_PER_DEGREE,
CH_SHORTCUT_EXPLOSION_RATIO,
} from "./contraction.ts";
export { routeCH } from "./ch-query.ts";
-410
View File
@@ -1,410 +0,0 @@
/*
* landmark.ts — the ALT landmark preprocessing core (plan 08-02, D-02).
* ----------------------------------------------------------------------------
* One pure, typed-array module for everything integer about landmarks:
* deterministic farthest-point anchor selection + the per-anchor distance
* tables, both over the ĉ metric. The fixture builder
* (test/fixtures/build-mini-graph.mjs), the real build script
* (scripts/build-road-graph.mjs) and the bot suites ALL compute their
* landmark blocks through these exports — there is no second
* integer-Dijkstra implementation anywhere (single-authority law).
*
* THE ĉ METRIC (research Pattern 2 — quantization WITHOUT an epsilon):
* edge weights are floor-quantized ONCE, ĉ(u,v) = floor(seconds/quantumS)
* in fixed 0.1 s units, and the landmark Dijkstras run over that integer
* metric in EXACT arithmetic. Stored distances are never rounded; because
* ĉ <= c edge-wise and Dijkstra distances are 1-Lipschitz in their own
* edges, the triangle bounds Plan 08-03 derives from these tables are
* exactly admissible and exactly consistent against the real f32 seconds.
*
* TRAVERSAL LAW (mirrors astar.ts verbatim — the A1/625-pair precedent):
* an edge with bit0 set ("oneway-reverse", graph.ts) is NEVER relaxed —
* not in the forward pass, and not in the reversed pass either (astar.ts's
* backward search skips bit0 in-edges at line 321). The reversed pass
* relaxes a stored edge u->v FROM v (toward u) exactly when its bit0 is
* clear, which is what makes to-row / from-row asymmetry representable.
*
* RANGE LAW: 0xFFFF is DATA (the unreachable sentinel), so any label
* beyond LANDMARK_MAX_QUANTA (65 000 — headroom below the sentinel)
* throws at build time, naming the distance and the fix
* (raise landmarkQuantumS). Nothing is clamped, ever.
*
* DETERMINISM: no randomness of any kind — no PRNG, no Date, no
* map-iteration order. Anchor greed starts at the first degree>=2
* candidate by node id, ties break to the lower node id, and identical
* input bytes give identical anchors.
*
* Pure module: no I/O, no logging, no storage, zero imports (T-06-05).
*/
/** The ĉ unit in seconds — the header field default, a documented fork knob. */
export const LANDMARK_QUANTUM_S = 0.1;
/** u16 headroom below the 0xFFFF sentinel; any label past it fails loud. */
export const LANDMARK_MAX_QUANTA = 65000;
/** Stored sentinel for "unreachable" — DATA, never validated away. The
* all-ones u16 pattern 0xffff is every table cell's out-of-domain value;
* consumers check against this constant, never a per-callsite literal. */
export const LANDMARK_SENTINEL = 0xffff;
/** The i32 weight-domain ceiling: qWeight is an Int32Array, so a quantized
* edge at or past 2^31 would WRAP NEGATIVE on store — silently bypassing
* the LANDMARK_MAX_QUANTA throw in dijkstraPass (a negative contribution
* never exceeds it), poisoning the Dijkstras, and at finish() a wrapped
* −1 label would BECOME the 0xFFFF sentinel. Any raw seconds/quantumS
* quotient at or past this ceiling throws at prepare time, BEFORE the
* store — nothing is clamped, ever. (Exactly 2^31−1 already cannot
* survive dijkstraPass's ceiling either, so the strict `>=` loses
* nothing and fails one boundary earlier, at the table-build entry.) */
const LANDMARK_I32_WEIGHT_CEILING = 0x7fffffff;
/** The landmark compute input — the shape build scripts assemble. */
export interface LandmarkInput {
nodeCount: number;
/**
* Directed edges in any order. flags bit0 = oneway-reverse: relaxed by
* NO direction (mirrors the CSR's edgeFlags; see the traversal law).
*/
edges: Array<{ from: number; to: number; seconds: number; flags: number }>;
/** The ĉ quantum in seconds; named callers pass LANDMARK_QUANTUM_S. */
quantumS: number;
}
/** Internal "unvisited" distance — outside the u16 domain on purpose. */
const UNVISITED = 0x7fffffff;
function fail(context: string, message: string): never {
throw new Error(`${context}: ${message}`);
}
/* ————— integer CSR (both directions) over the quantized weights ————— */
interface Prepared {
n: number;
/** Per-input-edge quantized weight ĉ (integer quanta). */
qWeight: Int32Array;
/** Per-input-edge traversal-relevant flags (bit0 only). */
flags: Uint8Array;
/** Per-input-edge endpoints (the reverse pass relaxes toward source). */
target: Uint32Array;
source: Uint32Array;
/** Forward CSR: row u lists u's out-edges (input-edge indices). */
fwdRow: Uint32Array;
fwdEdge: Uint32Array;
/** Reverse CSR: row v lists v's in-edges (input-edge indices). */
revRow: Uint32Array;
revEdge: Uint32Array;
}
function prepare(input: LandmarkInput): Prepared {
const context = "landmark";
const n = input.nodeCount;
if (!Number.isInteger(n) || n < 1) fail(context, "nodeCount must be a positive integer");
if (!Number.isFinite(input.quantumS) || input.quantumS <= 0) {
fail(context, "quantumS must be a positive finite number");
}
const edgeCount = input.edges.length;
const qWeight = new Int32Array(edgeCount);
const flags = new Uint8Array(edgeCount);
const target = new Uint32Array(edgeCount);
const source = new Uint32Array(edgeCount);
const fwdRow = new Uint32Array(n + 1);
const revRow = new Uint32Array(n + 1);
for (let e = 0; e < edgeCount; e++) {
const edge = input.edges[e]!;
if (!Number.isInteger(edge.from) || edge.from < 0 || edge.from >= n) {
fail(context, `edge ${e} from-node ${edge.from} out of range`);
}
if (!Number.isInteger(edge.to) || edge.to < 0 || edge.to >= n) {
fail(context, `edge ${e} to-node ${edge.to} out of range`);
}
if (!Number.isFinite(edge.seconds) || edge.seconds < 0) {
fail(context, `edge ${edge.from}->${edge.to} has a non-finite/negative weight`);
}
// THE ĉ RULE (Pattern 2): floor keeps the surrogate at or below the
// live edge — the exactness hinge for admissibility downstream.
const q = Math.floor(edge.seconds / input.quantumS);
// RANGE LAW at the storage boundary (08-review WR-04): the quotient is
// validated BEFORE the Int32Array store — at or past the i32 ceiling
// the store wraps NEGATIVE, silently bypassing the dijkstraPass ceiling
// (a negative contribution never exceeds LANDMARK_MAX_QUANTA) and
// corrupting the tables. This is a per-edge check at the table-build
// entry: the search hot path stays allocation-free and validation-free.
if (q >= LANDMARK_I32_WEIGHT_CEILING) {
fail(
context,
`edge ${edge.from}->${edge.to} quantizes to ${q} quanta, at or past the i32 weight ceiling ${LANDMARK_I32_WEIGHT_CEILING} (raise landmarkQuantumS)`,
);
}
qWeight[e] = q;
flags[e] = edge.flags & 1;
target[e] = edge.to;
source[e] = edge.from;
fwdRow[edge.from + 1] = fwdRow[edge.from + 1]! + 1;
revRow[edge.to + 1] = revRow[edge.to + 1]! + 1;
}
for (let i = 0; i < n; i++) {
fwdRow[i + 1] = fwdRow[i + 1]! + fwdRow[i]!;
revRow[i + 1] = revRow[i + 1]! + revRow[i]!;
}
const fwdEdge = new Uint32Array(edgeCount);
const revEdge = new Uint32Array(edgeCount);
const fwdCursor = fwdRow.slice(0, n);
const revCursor = revRow.slice(0, n);
for (let e = 0; e < edgeCount; e++) {
const fu = source[e]!;
const fp = fwdCursor[fu]!;
fwdEdge[fp] = e;
fwdCursor[fu] = fp + 1;
const rv = target[e]!;
const rp = revCursor[rv]!;
revEdge[rp] = e;
revCursor[rv] = rp + 1;
}
return { n, qWeight, flags, target, source, fwdRow, fwdEdge, revRow, revEdge };
}
/* ————— binary min-heap of (integer key, node) over preallocated arrays ————— */
class IntHeap {
private keys = new Int32Array(1024);
private nodes = new Uint32Array(1024);
size = 0;
push(key: number, node: number): void {
if (this.size === this.keys.length) this.grow();
let i = this.size;
this.size += 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.keys[parent]! <= key) break;
this.keys[i] = this.keys[parent]!;
this.nodes[i] = this.nodes[parent]!;
i = parent;
}
this.keys[i] = key;
this.nodes[i] = node;
}
peekKey(): number {
return this.size === 0 ? UNVISITED : this.keys[0]!;
}
/** Min-key node, or -1 when empty. Lazy deletion: stale entries are
* skipped by the dist check in dijkstraPass (the astar.ts shape). */
pop(): number {
if (this.size === 0) return -1;
const top = this.nodes[0]!;
this.size -= 1;
const lastKey = this.keys[this.size]!;
const lastNode = this.nodes[this.size]!;
let i = 0;
for (;;) {
const left = 2 * i + 1;
if (left >= this.size) break;
const right = left + 1;
const child = right < this.size && this.keys[right]! < this.keys[left]! ? right : left;
if (this.keys[child]! >= lastKey) break;
this.keys[i] = this.keys[child]!;
this.nodes[i] = this.nodes[child]!;
i = child;
}
if (this.size > 0) {
this.keys[i] = lastKey;
this.nodes[i] = lastNode;
}
return top;
}
private grow(): void {
const keys = new Int32Array(this.keys.length * 2);
keys.set(this.keys);
this.keys = keys;
const nodes = new Uint32Array(this.nodes.length * 2);
nodes.set(this.nodes);
this.nodes = nodes;
}
}
/* ————— the ONE integer Dijkstra (both directions share it) ————— */
/**
* Exact integer Dijkstra from `anchor` over the ĉ metric. `reverse=false`
* relaxes stored out-edges (d̂(anchor, v)); `reverse=true` relaxes stored
* in-edges from their head (d̂(v, anchor)) — the astar.ts backward-search
* law: an in-edge with bit0 set is skipped, never traversed.
* Returns per-node labels; UNVISITED means unreachable. Any improving
* label beyond LANDMARK_MAX_QUANTA throws (context names the entry point).
*/
function dijkstraPass(p: Prepared, reverse: boolean, anchor: number, context: string): Int32Array {
const n = p.n;
const dist = new Int32Array(n).fill(UNVISITED);
const heap = new IntHeap();
dist[anchor] = 0;
heap.push(0, anchor);
const row = reverse ? p.revRow : p.fwdRow;
const list = reverse ? p.revEdge : p.fwdEdge;
while (heap.size > 0) {
const d = heap.peekKey();
const u = heap.pop();
if (d > dist[u]!) continue; // stale heap entry (lazy deletion)
const end = row[u + 1]!;
for (let r = row[u]!; r < end; r++) {
const e = list[r]!;
if ((p.flags[e]! & 1) !== 0) continue; // oneway-reverse: never relaxed, either direction
const v = reverse ? p.source[e]! : p.target[e]!;
const nd = d + p.qWeight[e]!;
if (nd < dist[v]!) {
if (nd > LANDMARK_MAX_QUANTA) {
fail(
context,
`quantized distance ${nd} exceeds ${LANDMARK_MAX_QUANTA} (raise landmarkQuantumS)`,
);
}
dist[v] = nd;
heap.push(nd, v);
}
}
}
return dist;
}
/** u16 finish: unreachable -> the 0xFFFF sentinel; finite labels are
* already <= LANDMARK_MAX_QUANTA (the pass throws beyond it). */
function finish(d: number): number {
return d === UNVISITED ? LANDMARK_SENTINEL : d;
}
/* ————— public surface ————— */
/**
* Deterministic farthest-point anchor selection over the ĉ metric
* (D-02's locked approach family). Candidates are nodes with stored
* out-degree >= 2 (blocked mirrors included — the node still sits on the
* road). Greed: the first anchor is the first candidate by node id; each
* next anchor maximizes the per-node min-distance to the already-picked
* set (ties -> lower node id). Each pick's FORWARD pass is reused to
* maintain that min-distance field (one pass per pick, not per candidate).
*
* `opts.coverageFraction` (0..1): stop early once the fraction of nodes
* within finite ĉ-distance of the picked set reaches it — a documented
* knob that may return FEWER than `count` anchors. Without it, exactly
* `min(count, candidates)` anchors return (early-stop when candidates run
* out). Returns an empty array when no degree>=2 candidate exists.
*/
export function farthestPointAnchors(
input: LandmarkInput,
count: number,
opts?: { coverageFraction?: number },
): Uint32Array {
const context = "farthestPointAnchors";
if (!Number.isInteger(count) || count < 0) fail(context, "count must be a non-negative integer");
const coverageFraction = opts?.coverageFraction;
if (
coverageFraction !== undefined &&
(!Number.isFinite(coverageFraction) || coverageFraction < 0 || coverageFraction > 1)
) {
fail(context, "coverageFraction must be a number in [0, 1]");
}
const p = prepare(input);
const n = p.n;
const capacity = Math.min(count, n);
const anchors = new Uint32Array(capacity);
if (capacity === 0) return anchors;
const degree = (u: number): number => p.fwdRow[u + 1]! - p.fwdRow[u]!;
let first = -1;
for (let u = 0; u < n; u++) {
if (degree(u) >= 2) {
first = u;
break;
}
}
if (first < 0) return anchors.slice(0, 0); // no junction-ish candidate exists
const minDist = new Int32Array(n).fill(UNVISITED);
const isPicked = new Uint8Array(n);
let picked = 0;
let current = first;
// Each loop turn folds ONE pass (the just-picked anchor's forward pass)
// into the min-distance field, then greedily picks the next anchor.
let dist = dijkstraPass(p, false, current, context);
for (;;) {
let covered = 0;
for (let v = 0; v < n; v++) {
const d = dist[v]!;
if (d < minDist[v]!) minDist[v] = d;
if (minDist[v]! !== UNVISITED) covered++;
}
anchors[picked] = current;
isPicked[current] = 1;
picked += 1;
if (picked === capacity) break;
if (coverageFraction !== undefined && covered / n >= coverageFraction) break;
let next = -1;
let best = -1;
for (let v = 0; v < n; v++) {
const d = minDist[v]!;
if (d === UNVISITED || isPicked[v] !== 0 || degree(v) < 2) continue;
if (d > best) {
best = d;
next = v; // strict > keeps the FIRST (lowest-id) node on ties
}
}
if (next < 0) break; // candidates exhausted
current = next;
dist = dijkstraPass(p, false, current, context);
}
return anchors.slice(0, picked);
}
/**
* Integer ĉ-metric Dijkstras per anchor. Returns row-major tables of
* L·N u16 cells ([ℓ*N + v]):
* to[ℓ*N+v] = d̂(v, ℓ) — the REVERSED-graph pass from ℓ
* from[ℓ*N+v] = d̂(ℓ, v) — the forward pass from ℓ
* with 0xFFFF wherever v cannot reach (resp. be reached from) ℓ. Throws
* on an out-of-range anchor or any label beyond LANDMARK_MAX_QUANTA.
*/
export function landmarkTables(
input: LandmarkInput,
anchors: Uint32Array,
): { to: Uint16Array; from: Uint16Array } {
const context = "landmarkTables";
const p = prepare(input);
const n = p.n;
const l = anchors.length;
for (let i = 0; i < l; i++) {
const a = anchors[i]!;
if (!Number.isInteger(a) || a < 0 || a >= n) fail(context, `anchor ${a} out of range`);
}
const to = new Uint16Array(l * n);
const from = new Uint16Array(l * n);
for (let i = 0; i < l; i++) {
const anchor = anchors[i]!;
const distTo = dijkstraPass(p, true, anchor, context);
const distFrom = dijkstraPass(p, false, anchor, context);
const base = i * n;
for (let v = 0; v < n; v++) {
to[base + v] = finish(distTo[v]!);
from[base + v] = finish(distFrom[v]!);
}
}
return { to, from };
}
/**
* Per-anchor coverage audit: the fraction of nodes whose to-row cell is
* NOT the sentinel — the build script's per-anchor assert feed
* (research Pattern 3, layer 1).
*/
export function landmarkCoverage(to: Uint16Array, anchors: Uint32Array, nodeCount: number): number[] {
const coverage: number[] = [];
for (let i = 0; i < anchors.length; i++) {
let reached = 0;
const base = i * nodeCount;
for (let v = 0; v < nodeCount; v++) {
if (to[base + v] !== LANDMARK_SENTINEL) reached++;
}
coverage.push(reached / nodeCount);
}
return coverage;
}
+126 -487
View File
@@ -1,68 +1,42 @@
#!/usr/bin/env node
/*
* astar.test.mjs — typed-array A* + turn classifier + step derivation (A1–A13).
* -----------------------------------------------------------------------------
* astar.test.mjs — the search surface + turn classifier + step derivation.
* ----------------------------------------------------------------------------
* RED first (plan 06-01 task 3): behavior cases fail while
* routing/src/astar.ts does not exist (lazy-import pattern).
*
* The correctness gate RESEARCH mandates: A* must equal brute-force
* Dijkstra's optimal cost for EVERY (origin, goal) pair of the mini-graph —
* the exhaustive 25x25 equivalence sweep (A1). The test-local Dijkstra uses
* plain arrays (fine in tests) and the SAME traversal law as routeAstar:
* oneway-reverse edges (edgeFlags bit0) are never relaxed.
* Phase 08.1 re-pin (plan 01 tasks 2-3): routeAstar is CH-backed — the ALT
* machinery is DELETED (D-04/D-05: one search path, no dormant engine).
* The exhaustive equivalence law (A1's 625-pair sweep) and the oneway /
* steps / classifier laws carry over UNCHANGED in intent; the
* committed-fixture cases (A1/A2/A4/A5/A6) were held out in task 2 while
* mini-graph.bin was still ARG2 and are restored here (task 3
* regenerated the fixture through the ARG3 encode path). The in-memory
* and structural cases were re-pinned in task 2:
*
* A3 unreachable -> null — honest miss (synthetic disconnected
* graph, ch block through the ONE authority)
* A7 public surface — index.ts exposes the engine-A exports;
* createHttpRoutingSource is LIVE
* A8 structural — no `new Map(` in astar.ts AND ch-query.ts
* (allocation-free hot loop: typed arrays only)
* A11 structural (CH law) — the uphill rank filter + shortcut
* unpacking exist in ch-query.ts CODE;
* astar.ts contains NO landmark token
* (D-04's one-search-path pin)
* A1 exhaustive equivalence — 625 pairs, cost equality + path endpoints
* A2 oneway respected — the pair behind the flagged edge routes the
* long way (matching Dijkstra); the open
* direction is direct
* A3 unreachable -> null — honest miss (synthetic disconnected graph)
* A2 oneway respected — the flagged edge is never traversed; the
* open direction is direct
* A4 deriveSteps L-path — 90-deg bearing changes classify left/right;
* steps carry name/distanceM/polyline6
* A5 depart + arrive always — straight paths still start "depart" and end
* "arrive"
* A6 classifyTurn pins — sign convention: compass bearings increase
* clockwise, so negative delta = left
* (NOTE: the plan's "classifyTurn(0, 40) ->
* slight-left" contradicts its own
* (0,-90)->left / (0,90)->right cases; the
* compass-consistent reading is pinned here)
* A7 public surface — index.ts exposes the engine-A exports;
* createHttpRoutingSource is LIVE (re-pinned
* by 06-04, the plan that added it — the
* original pin named this plan as the owner)
* A8 structural — no `new Map(` in astar.ts (allocation-free
* hot loop: typed arrays only)
* A9 ALT island (08-03, F1) — two-component synthetic, anchors HAND-PINNED
* inside component A only: every to-column
* holds a sentinel (B nodes reach no anchor),
* so the F1 clean-columns law drops EVERY
* landmark search-scoped and the WHOLE
* fixture runs the haversine fallback —
* every pair still Dijkstra-equal, cross
* pairs resolve null
* A10 ALT search-scoped skip — origin AND goal inside the anchorless
* component: with all columns dirty the
* landmarks drop at SEARCH scope (the active
* list is empty — never a per-node l-mask),
* results still Dijkstra-equal
* A11 structural (ALT, F1) — comment-stripped astar.ts consults the
* ARG2 landmark tables (landmarkTo/
* landmarkQuantumS in CODE, not comments) AND
* the F1 clean-column machinery (per-graph
* memo + per-search active list; the
* per-node sentinel compare is BANNED);
* landmarkFrom still absent; `new Map(`
* still absent
* A5 depart + arrive always — straight paths still start "depart" and
* end "arrive"
* A6 classifyTurn pins — sign convention: negative delta = left
*
* A12 oneway ring (08-05) — directed ring + spur, STRONGLY connected:
* every to-column is CLEAN, so the fixture
* runs its FULL active list — the ALT-active
* directed-asymmetry seal; every pair
* Dijkstra-equal
* A13 mixed columns (F1) — one-way-bottleneck twin rings, anchors in
* BOTH parts: some columns clean, some dirty
* — the active list is a strict SUBSET and
* every pair stays Dijkstra-equal
* The A9/A10/A12/A13 ALT-era cases (island / search-scoped drop / oneway
* ring / mixed columns) folded into ch-query.test.mjs: their surviving
* content is CH-law Dijkstra equivalence over directed fixtures — the
* ALT-specific table assertions are gone with the tables (D-05).
*
* Run: npm run routing:test (exit 0 = all green)
*/
@@ -96,7 +70,8 @@ async function loadMiniGraph() {
return g;
}
/** Brute-force O(n^2) Dijkstra — plain arrays, SAME traversal law as A*. */
/** Brute-force O(n^2) Dijkstra — plain arrays, SAME traversal law as CH. */
function dijkstra(g, origin, goal) {
const dist = new Array(g.nodeCount).fill(Infinity);
const done = new Array(g.nodeCount).fill(false);
@@ -121,68 +96,109 @@ function dijkstra(g, origin, goal) {
}
}
/* ————— A9/A10 island fixture (plan 08-03) ————— */
/* ————— A3 — the in-memory unreachable-pair law ————— */
/**
* Two-component synthetic: component A is a 3x3 grid (nodes 0-8), component
* B a separate 4-node path (nodes 9-12), far apart. `anchors` are HAND-PINNED
* by the caller (never derived — A9 pins them inside A only, so every
* component-B node is the 0xFFFF sentinel in BOTH table families). Tables
* come from the ONE landmark module (landmark.ts); encoding through the ONE
* graph authority (encodeGraph) — the same path the real build uses.
*/
async function buildIslandFixture(anchors) {
test("A3 unreachable -> null — a disconnected pair resolves the honest miss", async () => {
const { routeAstar } = await loadAstar();
const { encodeGraph, decodeGraph } = await loadGraph();
const { LANDMARK_QUANTUM_S, landmarkTables } = await import("../src/landmark.ts");
const lat = [];
const lng = [];
// Component A: 3x3 grid near (3.15, 101.69); integer grid / 1e6 (the
// codec's exact-decimal law — no float accumulation).
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
lat.push((3150000 + r * 500) / 1e6);
lng.push((101690000 + c * 500) / 1e6);
}
}
// Component B: a straight 4-node path near (3.20, 101.75) — a different
// metro entirely as far as the haversine fallback is concerned.
for (let i = 0; i < 4; i++) {
lat.push((3200000 + i * 500) / 1e6);
lng.push((101750000 + i * 500) / 1e6);
}
const edges = [];
const link = (u, v) => {
// 10 s per hop — quantizes to exactly 100 quanta at the 0.1 s unit.
edges.push({ from: u, to: v, seconds: 10, flags: 0, nameId: 0 });
edges.push({ from: v, to: u, seconds: 10, flags: 0, nameId: 0 });
};
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
if (c < 2) link(r * 3 + c, r * 3 + c + 1);
if (r < 2) link(r * 3 + c, (r + 1) * 3 + c);
}
}
for (let i = 0; i < 3; i++) link(9 + i, 10 + i);
const { to, from } = landmarkTables(
{ nodeCount: 13, edges, quantumS: LANDMARK_QUANTUM_S },
anchors,
);
// Two disconnected components: 0<->1 and 2<->3. The ch block is
// hand-built (the plan's option B): contractGraph over two disjoint
// mirrors emits no shortcut, and an ARG3 with chShortcutCount = 0 is
// not a road graph (decode refuses — G11), so the block carries one
// harmless shortcut (0->1, via 3, weight 4 — equal to the direct edge,
// rank-upward: rank(from)=1 > rank(via)=0, rank(to)=2 > 0).
const g = decodeGraph(
encodeGraph({
lat,
lng,
edges,
names: ["Island Road"],
lat: [3.15, 3.1505, 3.16, 3.1605],
lng: [101.69, 101.6905, 101.69, 101.6905],
edges: [
{ from: 0, to: 1, seconds: 4, nameId: 0 },
{ from: 1, to: 0, seconds: 4, nameId: 0 },
{ from: 2, to: 3, seconds: 4, nameId: 0 },
{ from: 3, to: 2, seconds: 4, nameId: 0 },
],
names: ["Isolated Road"],
maxSpeedKmh: 60,
landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from },
ch: {
rank: Uint32Array.of(1, 2, 3, 0),
shortcutStart: Uint32Array.of(0, 1, 1, 1, 1),
shortcutTarget: Uint32Array.of(1),
shortcutSeconds: Float32Array.of(4),
shortcutVia: Uint32Array.of(3),
},
}),
);
assert.ok(g, "the island synthetic decodes");
assert.equal(g.nodeCount, 13, "13 nodes: 9 in the anchored grid + 4 on the anchorless path");
return g;
}
assert.ok(g, "synthetic graph decodes");
assert.equal(dijkstra(g, 0, 2), Infinity, "Dijkstra agrees: unreachable");
assert.equal(routeAstar(g, 0, 2), null, "unreachable -> null (never a fabricated path)");
const within = routeAstar(g, 0, 1);
assert.ok(within, "the connected pair routes");
assert.deepEqual(within.nodes, [0, 1]);
assert.ok(Math.abs(within.seconds - 4) < 1e-6, "the hop costs its edge weight");
});
/* ————— A1–A11 ————— */
/* ————— A7/A8/A11 — the public surface + structural CH laws ————— */
test("A7 public surface — index.ts exposes engine A + the engine-a HTTP source (re-pinned 06-04)", async () => {
const mod = await import("../src/index.ts");
for (const name of [
"decodeGraph",
"encodeGraph",
"GRAPH_MAGIC",
"GRAPH_VERSION",
"snapToGraph",
"SNAP_BBOX_MARGIN_DEG",
"routeAstar",
"deriveSteps",
"classifyTurn",
"TURN_STRAIGHT_MAX_DEG",
"TURN_SLIGHT_MAX_DEG",
"TURN_UTURN_MIN_DEG",
"createHttpRoutingSource",
"contractGraph",
]) {
assert.notEqual(mod[name], undefined, `index.ts must export ${name}`);
}
assert.equal(typeof mod.createHttpRoutingSource, "function", "backend #2's client seam is live (D-01, 06-04)");
});
test("A8 structural — no `new Map(` in astar.ts AND ch-query.ts (typed arrays only in the hot loop)", () => {
for (const file of ["astar.ts", "ch-query.ts"]) {
const src = readFileSync(path.join(here, "..", "src", file), "utf8");
const code = src
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
assert.ok(!code.includes("new Map("), `${file}: no Map allocation — the hot loop is typed arrays`);
}
});
test("A11 structural (CH law) — the uphill rank filter + unpacking live in ch-query.ts; astar.ts has NO landmark token (D-04)", () => {
// astar.ts is a thin CH delegation + turn/step derivation: the
// comment-stripped code must contain NONE of the deleted ALT
// machinery — a dormant second engine would violate D-04's one-path law.
const astarSrc = readFileSync(path.join(here, "..", "src", "astar.ts"), "utf8");
const astarCode = astarSrc
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
for (const token of ["landmarkTo", "landmarkFrom", "cleanColumnsFor", "LANDMARK_SENTINEL"]) {
assert.ok(!astarCode.includes(token), `comment-stripped astar.ts must NOT contain ${token}`);
}
assert.ok(astarCode.includes("routeCH"), "routeAstar delegates to the CH query in code");
// The CH machinery itself lives in ch-query.ts — the uphill rank filter
// (chRank reads), the shortcut CSR, and the via-unpacking are CODE, not
// comments (a comment-only mention would strip away and fail this pin).
const chSrc = readFileSync(path.join(here, "..", "src", "ch-query.ts"), "utf8");
const chCode = chSrc
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
for (const token of ["chRank", "shortcutVia", "shortcutStart", "edgeFlags"]) {
assert.ok(chCode.includes(token), `comment-stripped ch-query.ts must reference ${token} in code`);
}
assert.ok(chCode.includes("peekKey"), "the bidirectional stop rule reads the heap minima (peekKey)");
assert.ok(chCode.includes("landmark") === false, "ch-query.ts carries no landmark machinery either");
});
/* ————— suspended committed-fixture cases (task 3 un-suspends) ————— */
test("A1 exhaustive equivalence — A* matches brute-force Dijkstra on EVERY 25x25 node pair", async () => {
const { routeAstar } = await loadAstar();
@@ -236,41 +252,6 @@ test("A2 oneway respected — the flagged edge is never traversed; the open dire
assert.ok(Math.abs(with_flow.seconds - dijkstra(g, 2, 3)) < 1e-6);
});
test("A3 unreachable -> null — a disconnected pair resolves the honest miss", async () => {
const { routeAstar } = await loadAstar();
const { encodeGraph, decodeGraph } = await loadGraph();
// Two disconnected components: 0<->1 and 2<->3.
const g = decodeGraph(
encodeGraph({
lat: [3.15, 3.1505, 3.16, 3.1605],
lng: [101.69, 101.6905, 101.69, 101.6905],
edges: [
{ from: 0, to: 1, seconds: 4, nameId: 0 },
{ from: 1, to: 0, seconds: 4, nameId: 0 },
{ from: 2, to: 3, seconds: 4, nameId: 0 },
{ from: 3, to: 2, seconds: 4, nameId: 0 },
],
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");
assert.equal(dijkstra(g, 0, 2), Infinity, "Dijkstra agrees: unreachable");
assert.equal(routeAstar(g, 0, 2), null, "unreachable -> null (never a fabricated path)");
const within = routeAstar(g, 0, 1);
assert.ok(within, "the connected pair routes");
assert.deepEqual(within.nodes, [0, 1]);
});
test("A4 deriveSteps L-path — 90-deg turns classify, steps carry name/distance/polyline6", async () => {
const { routeAstar, deriveSteps } = await loadAstar();
const { decodePolyline6 } = await import("../src/polyline.ts");
@@ -352,345 +333,3 @@ test("A6 classifyTurn pins — compass convention: negative bearing delta = left
assert.equal(classifyTurn(350, 10), "straight", "20 deg across north is straight");
assert.equal(classifyTurn(350, 80), "right", "90 deg across north is a right");
});
test("A7 public surface — index.ts exposes engine A + the engine-a HTTP source (re-pinned 06-04)", async () => {
const mod = await import("../src/index.ts");
for (const name of [
"decodeGraph",
"encodeGraph",
"GRAPH_MAGIC",
"GRAPH_VERSION",
"snapToGraph",
"SNAP_BBOX_MARGIN_DEG",
"routeAstar",
"deriveSteps",
"classifyTurn",
"TURN_STRAIGHT_MAX_DEG",
"TURN_SLIGHT_MAX_DEG",
"TURN_UTURN_MIN_DEG",
"createHttpRoutingSource",
]) {
assert.notEqual(mod[name], undefined, `index.ts must export ${name}`);
}
assert.equal(typeof mod.createHttpRoutingSource, "function", "backend #2's client seam is live (D-01, 06-04)");
});
test("A8 structural — no `new Map(` in astar.ts (typed arrays only in the hot loop)", () => {
const src = readFileSync(path.join(here, "..", "src", "astar.ts"), "utf8");
const code = src
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
assert.ok(!code.includes("new Map("), "no Map allocation — the hot loop is typed arrays");
});
/* ————— A12: oneway ring under ALT (plan 08-05) ————— */
/**
* A directed 8-node ring (10 s one-way hops; every reverse direction stored
* with bit0 — never relaxed) + a bidirectional chord (0<->4, 15 s) + a
* one-way-in spur (8->3: nothing reaches 8, so its fromTable cells are the
* 0xFFFF sentinel while its toTable cells are finite). This is the fixture
* shape the 08-05 consistency fix is ABOUT: directed reach asymmetry (the
* anchor reaches a node far faster than its upstream neighbour) is exactly
* what made the four-family potential inconsistent on the real graph — the
* D-03 gate caught it as x038/x045 suboptimality. Under the amended
* toTable-only potential EVERY pair must stay Dijkstra-equal. The fixture
* is STRONGLY connected, so under the F1 clean-columns law it runs its
* FULL active list — the ALT-active counterpart to A9's all-dirty island.
*/
test("A12 oneway ring under ALT — directed reach asymmetry stays Dijkstra-equal on every pair", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const { routeAstar } = await loadAstar();
const { LANDMARK_QUANTUM_S, landmarkTables } = await import("../src/landmark.ts");
const lat = [];
const lng = [];
// Ring + chord + spur laid out around (3.10, 101.70), integer / 1e6.
for (let i = 0; i < 8; i++) {
lat.push((3100000 + Math.round(400 * Math.sin((i / 8) * 2 * Math.PI))) / 1e6);
lng.push((101700000 + Math.round(400 * Math.cos((i / 8) * 2 * Math.PI))) / 1e6);
}
lat.push(3095000 / 1e6); // node 8: the spur head, off-ring
lng.push(101705000 / 1e6);
const edges = [];
for (let i = 0; i < 8; i++) {
const next = (i + 1) % 8;
edges.push({ from: i, to: next, seconds: 10, flags: 0, nameId: 0 }); // the one-way hop
edges.push({ from: next, to: i, seconds: 10, flags: 1, nameId: 0 }); // bit0: stored, never relaxed
}
edges.push({ from: 0, to: 4, seconds: 15, flags: 0, nameId: 0 }); // bidirectional chord
edges.push({ from: 4, to: 0, seconds: 15, flags: 0, nameId: 0 });
edges.push({ from: 8, to: 3, seconds: 10, flags: 0, nameId: 0 }); // one-way-in spur
const anchors = new Uint32Array([0, 4]); // hand-pinned, in the ring's SCC
const { to, from } = landmarkTables(
{ nodeCount: 9, edges, quantumS: LANDMARK_QUANTUM_S },
anchors,
);
const g = decodeGraph(
encodeGraph({
lat,
lng,
edges,
names: ["Ring Road"],
maxSpeedKmh: 60,
landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from },
}),
);
assert.ok(g, "the oneway-ring synthetic decodes");
assert.equal(g.nodeCount, 9, "9 nodes: ring + spur head");
// The F1 ALT-active seal: the fixture is STRONGLY connected — the ring
// reaches itself, and the spur head rides 8->3 into it — so every
// to-column is sentinel-FREE. The clean-columns law therefore keeps the
// FULL active list here: the directed reach asymmetry lives inside the
// triangle-exact tables (never inside a per-node skip), which is exactly
// why the sweep below stays Dijkstra-equal.
for (let l = 0; l < g.landmarkCount; l++) {
for (let v = 0; v < g.nodeCount; v++) {
assert.notEqual(
g.landmarkTo[l * g.nodeCount + v],
0xffff,
`column ${l} is clean: node ${v} reaches anchor ${g.landmarkNodes[l]}`,
);
}
}
let pairs = 0;
let reachable = 0;
for (let o = 0; o < g.nodeCount; o++) {
for (let t = 0; t < g.nodeCount; t++) {
pairs++;
const optimal = dijkstra(g, o, t);
const result = routeAstar(g, o, t);
if (optimal === Infinity) {
assert.equal(result, null, `pair ${o}->${t}: unreachable must resolve null`);
continue;
}
reachable++;
assert.ok(result, `pair ${o}->${t}: reachable must route`);
assert.ok(
Math.abs(result.seconds - optimal) < 1e-6,
`pair ${o}->${t}: A* cost ${result.seconds} vs Dijkstra ${optimal}`,
);
}
}
assert.equal(pairs, 81, "the full 9x9 sweep ran");
assert.ok(reachable > 60, `the ring is densely reachable (${reachable} pairs)`);
});
test("A9 ALT island — anchors pinned in component A only; EVERY pair still Dijkstra-equal", async () => {
const { routeAstar } = await loadAstar();
const g = await buildIslandFixture(Uint32Array.of(0, 8));
const n = g.nodeCount;
// Fixture law (the island precondition): every component-B node is the
// 0xFFFF sentinel in BOTH families for every landmark — 0xFFFF is DATA.
for (let l = 0; l < g.landmarkCount; l++) {
for (let v = 9; v < n; v++) {
assert.equal(g.landmarkTo[l * n + v], 0xffff, `B node ${v} cannot reach anchor ${l}`);
assert.equal(g.landmarkFrom[l * n + v], 0xffff, `anchor ${l} cannot reach B node ${v}`);
}
}
// The F1 containment corollary (debug alt-potential-4x-slowdown): every
// to-column holds a B-node sentinel, so EVERY column is dirty — the same
// shape as the current real binary (24/24 dirty). The clean-columns law
// drops every landmark search-scoped; the WHOLE fixture (A-internal pairs
// included) runs the haversine fallback — the designed sound degradation,
// never a per-node skip.
for (let l = 0; l < g.landmarkCount; l++) {
let clean = true;
for (let v = 0; v < n; v++) {
if (g.landmarkTo[l * n + v] === 0xffff) {
clean = false;
break;
}
}
assert.equal(clean, false, `column ${l} is dirty (a B-node sentinel sits in it)`);
}
// The sweep: with every column dirty, ALL pairs — A-internal included —
// route on the haversine fallback, cross pairs resolve the honest miss.
// Seconds-only assertions — never node-path equality (D-03's tie law).
let pairs = 0;
let crossNulls = 0;
for (let o = 0; o < n; o++) {
for (let t = 0; t < n; t++) {
pairs++;
const optimal = dijkstra(g, o, t);
const result = routeAstar(g, o, t);
const inA = o < 9 && t < 9;
const inB = o >= 9 && t >= 9;
if (!inA && !inB) {
assert.equal(optimal, Infinity, `cross pair ${o}->${t}: Dijkstra agrees unreachable`);
assert.equal(result, null, `cross pair ${o}->${t}: resolves null, never a fabricated path`);
crossNulls++;
continue;
}
assert.ok(result, `pair ${o}->${t} (${inA ? "A" : "B"}-internal) routes`);
assert.ok(
Math.abs(result.seconds - optimal) < 1e-6,
`pair ${o}->${t}: ALT-steered cost ${result.seconds} vs Dijkstra ${optimal}`,
);
}
}
assert.equal(pairs, 169, "the full 13x13 sweep ran");
assert.equal(crossNulls, 72, "every cross-component pair is a null");
});
test("A10 ALT search-scoped skip — origin AND goal in the anchorless component stays optimal", async () => {
const { routeAstar } = await loadAstar();
const g = await buildIslandFixture(Uint32Array.of(0, 8));
const n = g.nodeCount;
// With origin and goal inside B (9..12), every l's goal/origin-side
// constant is the 0xFFFF sentinel — and every COLUMN is dirty anyway
// (A9's corollary), so the F1 active list is empty and the landmarks
// drop at SEARCH scope (never a per-node l-mask mid-max): the run is
// pure-haversine. The containment law: results stay exactly optimal
// regardless.
let pairs = 0;
for (let o = 9; o < n; o++) {
for (let t = 9; t < n; t++) {
pairs++;
const optimal = dijkstra(g, o, t);
const result = routeAstar(g, o, t);
assert.ok(result, `anchorless pair ${o}->${t} routes (pure-haversine search)`);
assert.ok(
Math.abs(result.seconds - optimal) < 1e-6,
`pair ${o}->${t}: cost ${result.seconds} vs Dijkstra ${optimal}`,
);
}
}
assert.equal(pairs, 16, "the 4x4 anchorless sweep ran");
});
test("A11 structural (ALT, F1) — astar.ts consults the landmark tables + the clean-column law in CODE, not comments", () => {
const src = readFileSync(path.join(here, "..", "src", "astar.ts"), "utf8");
const code = src
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
// The potential provably consults Plan 08-02's ARG2 table fields — a
// comment-only mention would strip away and fail this pin.
for (const token of ["landmarkTo", "landmarkQuantumS"]) {
assert.ok(code.includes(token), `comment-stripped astar.ts must reference ${token} in code`);
}
// 08-05 amendment pin: the fromTable families are EXCLUDED from the
// potential — their bounds are one-directional Lipschitz (the u-side term
// needs the reverse edge; a oneway does not have one), which broke the
// shared Ikeda average's consistency and returned suboptimal real-graph
// routes (the D-03 gate's x038/x045 catch). Re-adding landmarkFrom to the
// search trips this pin and re-opens that bug.
assert.ok(!code.includes("landmarkFrom"), "comment-stripped astar.ts must NOT consult landmarkFrom (08-05 consistency law)");
// The F1 shape (debug alt-potential-4x-slowdown): the per-graph
// clean-column memo and the per-search active list exist in CODE — dirty
// columns drop landmarks at SEARCH scope ("clean columns only"), the
// containment law that keeps the max-of-families cross-edge consistent.
assert.ok(code.includes("cleanColumnsFor"), "the per-graph clean-column memo must exist in code (F1)");
assert.ok(code.includes("activeL"), "the per-search active-landmark list must exist in code (F1)");
// NO per-node sentinel branch survives anywhere: the RC-1 bug's exact
// shape was an inline per-cell compare+skip inside bound(), which varied
// the active family set PER NODE and broke the bound across edges
// leaving the disconnected island (1/194 fresh pairs suboptimal). Every
// sentinel compare must ride the one named constant, at setup scope
// only — the literal survives only in its declaration.
assert.ok(!code.includes("=== 0xffff"), "no inline sentinel compare — the per-node skip shape is banned (F1)");
assert.ok(code.includes("LANDMARK_SENTINEL_U16"), "the sentinel reads through the one named constant");
// Raw u16 sentinel spelling — one spelling of the sentinel (0xffff in
// code, never the decimal 65535).
assert.ok(code.includes("0xffff"), "the sentinel is spelled 0xffff (u16, before float conversion)");
assert.ok(!code.includes("65535"), "one spelling of the sentinel: 0xffff");
// A8's law is unchanged under ALT: the hot loop stays typed arrays.
assert.ok(!code.includes("new Map("), "no Map allocation — the hot loop is typed arrays");
});
/* ————— A13: mixed clean/dirty columns under the F1 law ————— */
/**
* Two directed 4-rings joined by a ONE-WAY bridge (x0 -> y0; nothing
* returns): the Y-anchor's to-column is CLEAN — every node reaches y0 (the
* X side rides x0->y0, the Y side goes around its ring) — while the
* X-anchor's column is DIRTY (the Y side can never reach back). This is
* the branch neither A9 (all-dirty) nor A12 (all-clean) exercises: the F1
* active list is a strict SUBSET of the landmarks, and the shared Ikeda
* average stays consistent because every family inside it is a clean
* (two-sided) column. EVERY pair must stay Dijkstra-equal.
*/
test("A13 mixed columns — a one-way bridge leaves some columns clean, some dirty; the subset active list stays optimal", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const { routeAstar } = await loadAstar();
const { LANDMARK_QUANTUM_S, landmarkTables } = await import("../src/landmark.ts");
const lat = [];
const lng = [];
// X ring: nodes 0-3 around (3.12, 101.68); Y ring: nodes 4-7 around
// (3.12, 101.72) — ~4 km apart, integer / 1e6 grid (the codec's
// exact-decimal law).
for (let i = 0; i < 4; i++) {
lat.push((3120000 + Math.round(300 * Math.sin((i / 4) * 2 * Math.PI))) / 1e6);
lng.push((101680000 + Math.round(300 * Math.cos((i / 4) * 2 * Math.PI))) / 1e6);
}
for (let i = 0; i < 4; i++) {
lat.push((3120000 + Math.round(300 * Math.sin((i / 4) * 2 * Math.PI))) / 1e6);
lng.push((101720000 + Math.round(300 * Math.cos((i / 4) * 2 * Math.PI))) / 1e6);
}
const edges = [];
const ring = (base) => {
for (let i = 0; i < 4; i++) {
edges.push({ from: base + i, to: base + ((i + 1) % 4), seconds: 10, flags: 0, nameId: 0 }); // the one-way hop
edges.push({ from: base + ((i + 1) % 4), to: base + i, seconds: 10, flags: 1, nameId: 0 }); // bit0: stored, never relaxed
}
};
ring(0); // X: one-way ring
ring(4); // Y: one-way ring
edges.push({ from: 0, to: 4, seconds: 10, flags: 0, nameId: 0 }); // the one-way bridge x0 -> y0
const anchors = new Uint32Array([0, 4]); // hand-pinned: one anchor per part
const { to, from } = landmarkTables(
{ nodeCount: 8, edges, quantumS: LANDMARK_QUANTUM_S },
anchors,
);
const g = decodeGraph(
encodeGraph({
lat,
lng,
edges,
names: ["Bridge Road"],
maxSpeedKmh: 60,
landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from },
}),
);
assert.ok(g, "the twin-ring synthetic decodes");
assert.equal(g.nodeCount, 8, "8 nodes: two directed rings");
assert.equal(g.landmarkNodes[0], 0, "landmark 0 is x0");
assert.equal(g.landmarkNodes[1], 4, "landmark 1 is y0");
// The fixture's MIXED law: the X-anchor to-column is dirty (Y-node
// sentinels — no way back across the bridge), the Y-anchor to-column is
// clean (every node reaches y0). The active list must therefore be the
// strict subset {y0}.
let xColumnClean = true;
let yColumnClean = true;
for (let v = 0; v < g.nodeCount; v++) {
if (g.landmarkTo[v] === 0xffff) xColumnClean = false; // column 0: anchor x0
if (g.landmarkTo[g.nodeCount + v] === 0xffff) yColumnClean = false; // column 1: anchor y0
}
assert.equal(xColumnClean, false, "the X-anchor column is dirty (Y nodes sentinel behind the one-way bridge)");
assert.equal(yColumnClean, true, "the Y-anchor column is clean (every node reaches y0)");
// The full 8x8 sweep: every reachable pair Dijkstra-equal (X->Y crosses
// the bridge under the subset active list), Y->X the honest null.
let pairs = 0;
let reachable = 0;
let crossNulls = 0;
for (let o = 0; o < g.nodeCount; o++) {
for (let t = 0; t < g.nodeCount; t++) {
pairs++;
const optimal = dijkstra(g, o, t);
const result = routeAstar(g, o, t);
if (optimal === Infinity) {
assert.equal(result, null, `pair ${o}->${t}: unreachable must resolve null`);
crossNulls++;
continue;
}
reachable++;
assert.ok(result, `pair ${o}->${t}: reachable must route`);
assert.ok(
Math.abs(result.seconds - optimal) < 1e-6,
`pair ${o}->${t}: subset-ALT cost ${result.seconds} vs Dijkstra ${optimal}`,
);
}
}
assert.equal(pairs, 64, "the full 8x8 sweep ran");
assert.equal(reachable, 48, "48 reachable pairs: 12 per ring + 16 over the bridge");
assert.equal(crossNulls, 16, "every Y->X pair is a null (the bridge is one-way)");
});
+524
View File
@@ -0,0 +1,524 @@
#!/usr/bin/env node
/*
* ch-query.test.mjs — the uphill bidirectional CH query gates (Q1–Q5)
* plus the in-memory ARG3 codec pins (G1–G3).
* ----------------------------------------------------------------------------
* RED first (plan 08.1-01 task 2): every case fails while
* routing/src/ch-query.ts does not exist (lazy-import pattern).
*
* This suite is where the deleted ALT cases (astar.test.mjs A9/A10/A12/A13)
* FOLDED per D-04/D-05: their surviving content — Dijkstra equivalence over
* directed fixtures, the unreachable miss, the oneway law — is re-pinned
* against the CH query; the ALT table assertions are gone with the tables.
* ALL fixtures here are IN-MEMORY (encodeGraph → decodeGraph through the
* ONE authority); the committed mini-graph.bin is still ARG2 in this task
* and is NOT read (task 3 regenerates it and re-pins the committed-fixture
* cases in graph/astar.test.mjs).
*
* Q1 exhaustive equivalence — the CH query equals brute-force Dijkstra
* for EVERY pair of the 25-node in-memory
* grid; path endpoints are origin/goal
* Q2 directed oneway law — ring + chord + one-way-in spur: every pair
* matches the oracle in BOTH directions and
* every returned hop is a traversable
* original edge (bit0 never relaxed)
* Q3 original-edge seconds — reported seconds equal a fresh sum over
* the returned ORIGINAL-edge path (shortcut
* weights steer only — Pitfall 3)
* Q4 unreachable -> null — the island fold (A9/A10): cross-component
* pairs resolve null; internal pairs stay
* Dijkstra-equal
* Q5 structural deletion — no ALT tokens in astar.ts; landmark.ts and
* landmark.test.mjs ABSENT; index.ts exports
* no landmark symbols (D-04/D-05)
* G1 ARG3 round-trip — contractGraph output round-trips rank +
* shortcut CSR exactly through zero-copy views
* G2 rejection vectors — ARG1 bytes -> null; a version-2 tamper -> null
* G3 fail-honest decode — truncated / hostile-count / chShortcutCount=0
* buffers -> null BEFORE any typed-array
* allocation (T-08.1-01/T-08.1-02)
*
* Run: npm run routing:test (exit 0 = all green)
*/
import { strict as assert } from "node:assert";
import { readFileSync, existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "./harness.mjs";
const here = path.dirname(fileURLToPath(import.meta.url)); // routing/test
let chQueryPromise = null;
function loadChQuery() {
if (!chQueryPromise) chQueryPromise = import("../src/ch-query.ts");
return chQueryPromise;
}
let graphPromise = null;
function loadGraph() {
if (!graphPromise) graphPromise = import("../src/graph.ts");
return graphPromise;
}
let contractionPromise = null;
function loadContraction() {
if (!contractionPromise) contractionPromise = import("../src/contraction.ts");
return contractionPromise;
}
function toArrayBuffer(buf) {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
}
let geoPromise = null;
async function haversineM(a, b) {
if (!geoPromise) geoPromise = import("../src/geo.ts");
const { haversineM: h } = await geoPromise;
return h(a, b);
}
/** Brute-force O(n^2) Dijkstra — plain arrays, SAME traversal law. */
function dijkstra(g, origin, goal) {
const dist = new Array(g.nodeCount).fill(Infinity);
const done = new Array(g.nodeCount).fill(false);
dist[origin] = 0;
for (;;) {
let u = -1;
let best = Infinity;
for (let i = 0; i < g.nodeCount; i++) {
if (!done[i] && dist[i] < best) {
best = dist[i];
u = i;
}
}
if (u === -1 || u === goal) return dist[goal];
done[u] = true;
for (let e = g.edgeStart[u]; e < g.edgeStart[u + 1]; e++) {
if ((g.edgeFlags[e] & 1) !== 0) continue; // oneway-reverse: never relaxed
const v = g.edgeTarget[e];
const nd = dist[u] + g.edgeSeconds[e];
if (nd < dist[v]) dist[v] = nd;
}
}
}
/** The traversable (bit0 clear) original edge u -> v, or -1. */
function findTraversableEdge(g, u, v) {
for (let e = g.edgeStart[u]; e < g.edgeStart[u + 1]; e++) {
if (g.edgeTarget[e] === v && (g.edgeFlags[e] & 1) === 0) return e;
}
return -1;
}
/** Fresh sum over the returned ORIGINAL-edge path (the Pitfall 3 oracle). */
function resumSeconds(g, nodes) {
let seconds = 0;
for (let i = 1; i < nodes.length; i++) {
const e = findTraversableEdge(g, nodes[i - 1], nodes[i]);
if (e < 0) return -1; // a non-traversable hop: the caller asserts loud
seconds += g.edgeSeconds[e];
}
return seconds;
}
/* ————— in-memory fixtures (encodeGraph → decodeGraph, the ONE authority) ————— */
/**
* The 25-node grid, the committed fixture's shape rebuilt in memory:
* 5x5, 80 directed edge entries, one oneway pair (2->3), perimeter 60
* km/h + interior 30 km/h, three street names. Contracted through the
* ONE CH core, encoded through the ONE codec.
*/
async function buildGridGraph() {
const { encodeGraph, decodeGraph } = await loadGraph();
const { contractGraph } = await loadContraction();
const lat = [];
const lng = [];
for (let r = 0; r < 5; r++) {
for (let c = 0; c < 5; c++) {
lat.push((3150000 + r * 500) / 1e6);
lng.push((101690000 + c * 500) / 1e6);
}
}
const id = (r, c) => r * 5 + c;
const edges = [];
const addEdge = async (u, v, perimeter) => {
const kmh = perimeter ? 60 : 30;
const meters = await haversineM({ lat: lat[u], lng: lng[u] }, { lat: lat[v], lng: lng[v] });
edges.push({ from: u, to: v, seconds: meters / (kmh / 3.6) });
};
for (let r = 0; r < 5; r++) {
for (let c = 0; c < 4; c++) {
await addEdge(id(r, c), id(r, c + 1), r === 0 || r === 4);
await addEdge(id(r, c + 1), id(r, c), r === 0 || r === 4);
}
}
for (let c = 0; c < 5; c++) {
for (let r = 0; r < 4; r++) {
await addEdge(id(r, c), id(r + 1, c), c === 0 || c === 4);
await addEdge(id(r + 1, c), id(r, c), c === 0 || c === 4);
}
}
const reverse = edges.find((e) => e.from === 3 && e.to === 2);
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 ch = contractGraph({ nodeCount: 25, edges });
const g = decodeGraph(
encodeGraph({
lat,
lng,
edges,
names: ["Jalan Mini 1", "Jalan Mini 2", "Jalan Persiaran Mini"],
maxSpeedKmh: 60,
ch,
}),
);
assert.ok(g, "the in-memory ARG3 grid decodes");
assert.ok(g.chShortcutCount > 0, "the contracted grid carries a hierarchy");
return g;
}
/**
* The directed oneway fixture (the A12 fold): a directed 8-node ring
* (10 s one-way hops; every reverse mirror stored with bit0 — never
* relaxed), a bidirectional chord (0<->4, 15 s), and a one-way-in spur
* head (node 8 -> 3: nothing reaches 8). Directed reach asymmetry is
* exactly what broke the ALT four-family potential; under CH the
* shortcuts inherit direction structurally.
*/
async function buildRingGraph() {
const { encodeGraph, decodeGraph } = await loadGraph();
const { contractGraph } = await loadContraction();
const lat = [];
const lng = [];
for (let i = 0; i < 8; i++) {
lat.push((3100000 + Math.round(400 * Math.sin((i / 8) * 2 * Math.PI))) / 1e6);
lng.push((101700000 + Math.round(400 * Math.cos((i / 8) * 2 * Math.PI))) / 1e6);
}
lat.push(3095000 / 1e6); // node 8: the spur head
lng.push(101705000 / 1e6);
const edges = [];
for (let i = 0; i < 8; i++) {
const next = (i + 1) % 8;
edges.push({ from: i, to: next, seconds: 10, flags: 0 }); // the one-way hop
edges.push({ from: next, to: i, seconds: 10, flags: 1 }); // bit0: stored, never relaxed
}
edges.push({ from: 0, to: 4, seconds: 15, flags: 0 }); // bidirectional chord
edges.push({ from: 4, to: 0, seconds: 15, flags: 0 });
edges.push({ from: 8, to: 3, seconds: 10, flags: 0 }); // one-way-in spur
const ch = contractGraph({ nodeCount: 9, edges });
const g = decodeGraph(
encodeGraph({
lat,
lng,
edges,
names: ["Ring Road"],
maxSpeedKmh: 60,
ch,
}),
);
assert.ok(g, "the oneway-ring synthetic decodes");
assert.equal(g.nodeCount, 9);
return g;
}
/**
* The island fixture (the A9/A10 fold): component A is a 3x3 grid
* (nodes 0-8), component B a separate 4-node path (nodes 9-12) far
* apart. Cross-component pairs are unreachable — the honest miss.
*/
async function buildIslandGraph() {
const { encodeGraph, decodeGraph } = await loadGraph();
const { contractGraph } = await loadContraction();
const lat = [];
const lng = [];
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
lat.push((3150000 + r * 500) / 1e6);
lng.push((101690000 + c * 500) / 1e6);
}
}
for (let i = 0; i < 4; i++) {
lat.push((3200000 + i * 500) / 1e6);
lng.push((101750000 + i * 500) / 1e6);
}
const edges = [];
// Two weight classes (the committed fixture's speed-class law): a
// UNIFORM-weight graph ties every priority, contracts id-first from the
// endpoints, and starves to ZERO shortcuts — an ARG3 the decode law
// rightly refuses. Varied weights break the ties so the contraction
// builds a real hierarchy.
const link = (u, v, cheap) => {
const seconds = cheap ? 6 : 12;
edges.push({ from: u, to: v, seconds, flags: 0 });
edges.push({ from: v, to: u, seconds, flags: 0 });
};
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
if (c < 2) link(r * 3 + c, r * 3 + c + 1, r === 1);
if (r < 2) link(r * 3 + c, (r + 1) * 3 + c, c === 1);
}
}
for (let i = 0; i < 3; i++) link(9 + i, 10 + i, i === 1);
const ch = contractGraph({ nodeCount: 13, edges });
assert.ok(ch.stats.shortcutCount > 0, "the island contraction builds a hierarchy");
const g = decodeGraph(
encodeGraph({
lat,
lng,
edges,
names: ["Island Road"],
maxSpeedKmh: 60,
ch,
}),
);
assert.ok(g, "the island synthetic decodes");
assert.equal(g.nodeCount, 13);
return g;
}
/* ————— Q1–Q5 ————— */
test("Q1 exhaustive equivalence — the CH query matches brute-force Dijkstra on EVERY 25x25 node pair", async () => {
const { routeCH } = await loadChQuery();
const g = await buildGridGraph();
let pairs = 0;
let reachable = 0;
for (let o = 0; o < g.nodeCount; o++) {
for (let t = 0; t < g.nodeCount; t++) {
pairs++;
const optimal = dijkstra(g, o, t);
const result = routeCH(g, o, t);
if (optimal === Infinity) {
assert.equal(result, null, `pair ${o}->${t}: unreachable must resolve null, never a path`);
continue;
}
reachable++;
assert.ok(result, `pair ${o}->${t}: reachable must route`);
assert.ok(
Math.abs(result.seconds - optimal) < 1e-6,
`pair ${o}->${t}: CH cost ${result.seconds} vs Dijkstra ${optimal}`,
);
assert.equal(result.nodes[0], o, `pair ${o}->${t}: path starts at origin`);
assert.equal(result.nodes[result.nodes.length - 1], t, `pair ${o}->${t}: path ends at goal`);
}
}
assert.equal(pairs, 625, "the full 25x25 sweep ran");
assert.ok(reachable > 600, `the grid is densely connected (${reachable} reachable pairs)`);
});
test("Q2 directed oneway law — ring + chord + spur matches the oracle in BOTH directions; every hop is a traversable original edge", async () => {
const { routeCH } = await loadChQuery();
const g = await buildRingGraph();
let pairs = 0;
let reachable = 0;
let crossNulls = 0;
for (let o = 0; o < g.nodeCount; o++) {
for (let t = 0; t < g.nodeCount; t++) {
pairs++;
const optimal = dijkstra(g, o, t);
const result = routeCH(g, o, t);
if (optimal === Infinity) {
assert.equal(result, null, `pair ${o}->${t}: unreachable must resolve null`);
crossNulls++;
continue;
}
reachable++;
assert.ok(result, `pair ${o}->${t}: reachable must route`);
assert.ok(
Math.abs(result.seconds - optimal) < 1e-6,
`pair ${o}->${t}: CH cost ${result.seconds} vs Dijkstra ${optimal}`,
);
// The unpacking law: every returned hop is a TRAVERSABLE original
// edge — no bit0 mirror and no unpacked shortcut survives.
for (let i = 1; i < result.nodes.length; i++) {
assert.ok(
findTraversableEdge(g, result.nodes[i - 1], result.nodes[i]) >= 0,
`pair ${o}->${t}: hop ${result.nodes[i - 1]}->${result.nodes[i]} is a traversable original edge`,
);
}
}
}
assert.equal(pairs, 81, "the full 9x9 sweep ran");
assert.ok(reachable > 40, `the ring is reachable (${reachable} pairs)`);
assert.ok(crossNulls >= 8, `the spur head is one-way-in (${crossNulls} pairs can never reach it)`);
});
test("Q3 original-edge seconds — reported seconds equal a fresh sum over the unpacked ORIGINAL-edge path", async () => {
const { routeCH } = await loadChQuery();
const g = await buildGridGraph();
// A long cross-grid pair (the route rides shortcuts) and the oneway
// detour pair: both must re-sum exactly over original edges.
for (const [o, t] of [[0, 24], [3, 2], [6, 19], [12, 12]]) {
const result = routeCH(g, o, t);
if (o === t) {
assert.deepEqual(result.nodes, [o], "the trivial pair is the single node");
assert.equal(result.seconds, 0, "the trivial pair costs 0");
continue;
}
assert.ok(result, `${o}->${t} routes`);
const fresh = resumSeconds(g, result.nodes);
assert.ok(fresh >= 0, `${o}->${t}: every returned hop is a traversable original edge`);
assert.ok(
Math.abs(result.seconds - fresh) < 1e-6,
`${o}->${t}: reported ${result.seconds} === original-edge re-sum ${fresh} (shortcut weights never enter seconds)`,
);
const optimal = dijkstra(g, o, t);
assert.ok(Math.abs(result.seconds - optimal) < 1e-6, `${o}->${t}: and it is the Dijkstra optimum`);
// distanceM is the haversine sum over the SAME unpacked path.
let meters = 0;
for (let i = 1; i < result.nodes.length; i++) {
const a = { lat: g.lat[result.nodes[i - 1]], lng: g.lng[result.nodes[i - 1]] };
const b = { lat: g.lat[result.nodes[i]], lng: g.lng[result.nodes[i]] };
meters += await haversineM(a, b);
}
assert.ok(
Math.abs(result.distanceM - meters) < 1e-6,
`${o}->${t}: distanceM is the haversine sum over the unpacked path`,
);
}
});
test("Q4 unreachable -> null — the island fold: cross pairs resolve null, internal pairs stay Dijkstra-equal", async () => {
const { routeCH } = await loadChQuery();
const g = await buildIslandGraph();
let pairs = 0;
let crossNulls = 0;
for (let o = 0; o < g.nodeCount; o++) {
for (let t = 0; t < g.nodeCount; t++) {
pairs++;
const optimal = dijkstra(g, o, t);
const result = routeCH(g, o, t);
const inA = o < 9 && t < 9;
const inB = o >= 9 && t >= 9;
if (!inA && !inB) {
assert.equal(optimal, Infinity, `cross pair ${o}->${t}: Dijkstra agrees unreachable`);
assert.equal(result, null, `cross pair ${o}->${t}: resolves null, never a fabricated path`);
crossNulls++;
continue;
}
assert.ok(result, `pair ${o}->${t} (${inA ? "A" : "B"}-internal) routes`);
assert.ok(
Math.abs(result.seconds - optimal) < 1e-6,
`pair ${o}->${t}: CH cost ${result.seconds} vs Dijkstra ${optimal}`,
);
}
}
assert.equal(pairs, 169, "the full 13x13 sweep ran");
assert.equal(crossNulls, 72, "every cross-component pair is a null");
});
test("Q5 structural deletion — no ALT tokens in astar.ts; landmark.ts + landmark.test.mjs ABSENT; index exports none (D-04/D-05)", () => {
const src = readFileSync(path.join(here, "..", "src", "astar.ts"), "utf8");
const code = src
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
for (const token of ["landmarkTo", "landmarkFrom", "cleanColumnsFor", "LANDMARK_SENTINEL"]) {
assert.ok(!code.includes(token), `comment-stripped astar.ts must not contain ${token}`);
}
assert.equal(existsSync(path.join(here, "..", "src", "landmark.ts")), false, "landmark.ts is deleted (D-05)");
assert.equal(
existsSync(path.join(here, "landmark.test.mjs")),
false,
"landmark.test.mjs is deleted (D-05)",
);
const indexSrc = readFileSync(path.join(here, "..", "src", "index.ts"), "utf8");
const indexCode = indexSrc
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
assert.ok(!indexCode.includes("landmark"), "index.ts exports no landmark symbols");
});
/* ————— G1–G3 — the in-memory ARG3 codec pins ————— */
test("G1 ARG3 round-trip — contractGraph output round-trips rank + shortcut CSR exactly through zero-copy views", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const { contractGraph } = await loadContraction();
const g0 = await buildGridGraph(); // builds through encode/decode already
assert.ok(g0, "the grid decoded (G1's round-trip ran inside the builder)");
// Explicit round-trip on a fresh contraction output.
const { contractGraph: contract2 } = await loadContraction();
const edges = [
{ from: 0, to: 1, seconds: 1, flags: 0 },
{ from: 1, to: 0, seconds: 1, flags: 0 },
{ from: 0, to: 2, seconds: 1, flags: 0 },
{ from: 2, to: 0, seconds: 1, flags: 0 },
];
const ch = contract2({ nodeCount: 3, edges });
const { encodeGraph: enc, decodeGraph: dec } = await loadGraph();
const buf = enc({
lat: [3.15, 3.1505, 3.151],
lng: [101.69, 101.6905, 101.691],
edges,
names: [],
maxSpeedKmh: 60,
ch,
}).slice(0);
const g = dec(buf);
assert.ok(g, "decode succeeds");
assert.equal(g.chShortcutCount, ch.shortcutTarget.length, "the header count matches the contraction");
assert.deepEqual([...g.chRank], [...ch.rank], "rank restores exactly");
assert.deepEqual([...g.shortcutStart], [...ch.shortcutStart], "shortcutStart restores exactly");
assert.deepEqual([...g.shortcutTarget], [...ch.shortcutTarget], "shortcutTarget restores exactly");
assert.deepEqual([...g.shortcutSeconds], [...ch.shortcutSeconds], "shortcutSeconds restores exactly");
assert.deepEqual([...g.shortcutVia], [...ch.shortcutVia], "shortcutVia restores exactly");
assert.equal(g.chRank.buffer, buf, "chRank is a zero-copy view");
assert.equal(g.shortcutVia.buffer, buf, "shortcutVia is a zero-copy view");
});
test("G2 rejection vectors — ARG1 bytes -> null; a version-2 tamper -> null (D-07 one decode path)", async () => {
const { decodeGraph } = await loadGraph();
// The frozen Plan 08-01 vector (the ARG1 rejection fixture).
const arg1 = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph-arg1.bin")));
assert.equal(decodeGraph(arg1), null, "ARG1 bytes -> null, never a throw or a fallback decode");
// A version-2 tamper of an in-memory ARG3 buffer refuses too.
const { encodeGraph } = await loadGraph();
const { contractGraph } = await loadContraction();
const edges = [
{ from: 0, to: 1, seconds: 1, flags: 0 },
{ from: 1, to: 0, seconds: 1, flags: 0 },
{ from: 0, to: 2, seconds: 1, flags: 0 },
{ from: 2, to: 0, seconds: 1, flags: 0 },
];
const buf = encodeGraph({
lat: [3.15, 3.1505, 3.151],
lng: [101.69, 101.6905, 101.691],
edges,
names: [],
maxSpeedKmh: 60,
ch: contractGraph({ nodeCount: 3, edges }),
});
const v2 = buf.slice(0);
new DataView(v2).setUint32(4, 2, true);
assert.equal(decodeGraph(v2), null, "version 2 -> null (ARG2 bytes decode to nothing — D-07)");
});
test("G3 fail-honest decode — truncated / hostile-count / chShortcutCount=0 buffers -> null BEFORE any allocation", async () => {
const { decodeGraph, GRAPH_MAGIC, GRAPH_VERSION } = await loadGraph();
const { encodeGraph } = await loadGraph();
const { contractGraph } = await loadContraction();
const edges = [
{ from: 0, to: 1, seconds: 1, flags: 0 },
{ from: 1, to: 0, seconds: 1, flags: 0 },
{ from: 0, to: 2, seconds: 1, flags: 0 },
{ from: 2, to: 0, seconds: 1, flags: 0 },
];
const ch = contractGraph({ nodeCount: 3, edges });
const buf = encodeGraph({
lat: [3.15, 3.1505, 3.151],
lng: [101.69, 101.6905, 101.691],
edges,
names: [],
maxSpeedKmh: 60,
ch,
});
assert.equal(decodeGraph(buf.slice(0, buf.byteLength - 1)), null, "truncated -> null");
const hostile = new ArrayBuffer(72);
const dv = new DataView(hostile);
dv.setUint32(0, GRAPH_MAGIC, true);
dv.setUint32(4, GRAPH_VERSION, true);
dv.setUint32(8, 1_000_000, true); // a million nodes on a 72-byte buffer
assert.equal(decodeGraph(hostile), null, "hostile count -> null on arithmetic, no allocation");
const zeroed = buf.slice(0);
new DataView(zeroed).setUint32(60, 0, true);
assert.equal(decodeGraph(zeroed), null, "chShortcutCount = 0 -> null even when the size matches");
});
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env node
/*
* contraction.test.mjs — the CH construction core gates (C1–C7).
* ------------------------------------------------------------------
* RED first (plan 08.1-01 task 1): every case fails while
* routing/src/contraction.ts does not exist (lazy-import pattern —
* each case reports its own ERR_MODULE_NOT_FOUND, the landmark.test.mjs
* precedent this suite replaces).
*
* The module under test is the ONE contraction authority (the
* single-authority law): build-time node ordering + contraction +
* overlay-only witness search + shortcut emission, deterministic and
* pure. The case index:
*
* C1 total order — ranks are distinct and cover exactly [0, N)
* (a tie would drop legitimate upward edges —
* research Pitfall 9)
* C2 shortcut weights — every emitted shortcut's seconds equal the
* sum of the weights of the two edges it
* replaced (f32 round-trip tolerance)
* C3 upward shortcuts — rank(from) > rank(via) and rank(to) >
* rank(via) for every shortcut (the query's
* strict uphill law depends on it)
* C4 hand fixture — a 3-node bidirectional path yields the
* LITERAL expected contraction order and
* shortcut set (never a tautology — research
* Pitfall 10)
* C5 overlay witness — the witness search sees ONLY the overlay
* graph: a previously contracted node cannot
* witness a shortcut, so the shortcut that
* keeps the hierarchy distance-preserving IS
* emitted (the #1 correctness trap — research
* Pitfall 1)
* C6 explosion tripwire — a budget-miss cascade above
* CH_SHORTCUT_EXPLOSION_RATIO * nodeCount
* fails loud, naming the ratio and the
* measured count; a healthy graph passes
* C7 determinism — two calls on identical input produce
* byte-identical rank and shortcut arrays
*
* Run: npm run routing:test (exit 0 = all green)
*/
import { strict as assert } from "node:assert";
import { test } from "./harness.mjs";
// Lazy module load — missing module => per-case ERR_MODULE_NOT_FOUND (RED).
let contractionPromise = null;
function loadContraction() {
if (!contractionPromise) contractionPromise = import("../src/contraction.ts");
return contractionPromise;
}
/* ————— hand-built fixtures (hand-computed expectations) ————— */
/**
* Two independent overlay gadgets (nodes 0-3 and 4-7), the C5 shape at
* two weight classes (1+1 and 2+3). Hand-traced: inside each gadget ALL
* four priorities tie at −2 → the id order contracts the bypass first
* (its own pair is witnessed by the then-alive u → no shortcut), then u
* — whose pair has lost its only witness to the contraction → the
* shortcut a→b (via u) MUST be emitted. Result: exactly two shortcuts,
* 2→3 (w2, via 1) and 6→7 (w5, via 5) — a non-trivial invariant-sweep
* fixture for C1/C2/C3/C7.
*/
function pairsInput() {
return {
nodeCount: 8,
edges: [
{ from: 2, to: 1, seconds: 1, flags: 0 }, // a -> u (gadget A)
{ from: 1, to: 3, seconds: 1, flags: 0 }, // u -> b
{ from: 2, to: 0, seconds: 1, flags: 0 }, // a -> c
{ from: 0, to: 3, seconds: 1, flags: 0 }, // c -> b
{ from: 6, to: 5, seconds: 2, flags: 0 }, // a -> u (gadget B)
{ from: 5, to: 7, seconds: 3, flags: 0 }, // u -> b
{ from: 6, to: 4, seconds: 2, flags: 0 }, // a -> c
{ from: 4, to: 7, seconds: 3, flags: 0 }, // c -> b
],
};
}
/**
* C4's hand fixture: a 3-node bidirectional star — the MID node has id 0,
* the two endpoints ids 1 and 2, all four edges unit weight.
* HAND-COMPUTED under the plan's exact algorithm: every node's priority
* ties at −2 (the mid: 2 shortcuts would be added − 4 incident edges;
* an endpoint: 0 pairs − its 2 mirror edges), so the tie-break contracts
* the mid (id 0) FIRST. Its pairs (1,2) and (2,1) have no overlay
* witness (the endpoints' only edges ride the mid), so BOTH shortcuts
* are emitted with weight 1+1 = 2. The endpoints then contract free
* (their only edges are dead; deletedNeighbours = 1 each). Literal
* expectation below — never a tautology (research Pitfall 10).
*/
function handInput() {
return {
nodeCount: 3,
edges: [
{ from: 0, to: 1, seconds: 1, flags: 0 },
{ from: 1, to: 0, seconds: 1, flags: 0 },
{ from: 0, to: 2, seconds: 1, flags: 0 },
{ from: 2, to: 0, seconds: 1, flags: 0 },
],
};
}
/**
* C5's hand fixture: the overlay-witness law. Nodes: 0 = the bypass c,
* 1 = the mid node u, 2 = the witness source a, 3 = the target b. Edges:
* a→u→b (1+1) and a→c→b (1+1). ALL four priorities tie at −2 → the id
* order contracts the bypass (node 0) FIRST: its own pair (a,b) is
* witnessed by the then-alive u → no shortcut. When u (node 1) pops, its
* pair (a=2, b=3) had a witness ONLY through the bypass — now CONTRACTED
* — so the overlay search cannot traverse it and the shortcut a→b
* (via u, weight 2) MUST be emitted. On the ORIGINAL graph the a→c→b
* path still exists; a witness search that leaked out of the overlay
* would find it and silently drop the required shortcut — the exact #1
* trap, and the emitted shortcut is what keeps the hierarchy
* distance-preserving.
*/
function overlayInput() {
return {
nodeCount: 4,
edges: [
{ from: 2, to: 1, seconds: 1, flags: 0 }, // a -> u
{ from: 1, to: 3, seconds: 1, flags: 0 }, // u -> b
{ from: 2, to: 0, seconds: 1, flags: 0 }, // a -> c
{ from: 0, to: 3, seconds: 1, flags: 0 }, // c -> b
],
};
}
/**
* C6's explosion fixture (9 nodes, 40 edges): node 0 is the BYPASS hub,
* node 1 the BURST hub, nodes 2..8 the seven Y nodes; both hubs ride
* every Y with weight 1 in both directions, and the Y nodes carry a
* weight-100 ring (never on a cheapest path — pure degree ballast).
* HAND-TRACED under the plan's exact algorithm: the two hubs tie at
* priority −14 (42 pairs, all witnessed by the OTHER hub — 0 shortcuts —
* minus 14 incident edges) and the Y nodes sit at −6, so the tie-break
* contracts the bypass (id 0) FIRST. Its own 42 pairs are witnessed by
* the still-alive burst hub → no shortcuts. The burst hub's pop then
* recomputes against the overlay: its witness (the bypass) is DEAD, the
* weight-100 ring exceeds every candidate bound → all 7×6 = 42 pairs
* emit shortcuts (2 each, via the burst hub) — 42 > 4×9 = 36 → the
* tripwire MUST fail loud. (The Y nodes then witness each other through
* the fresh shortcuts — no further growth — but the count already blew
* the guard.)
*/
function explosionInput() {
const edges = [];
for (let y = 2; y <= 8; y++) {
edges.push({ from: 0, to: y, seconds: 1, flags: 0 });
edges.push({ from: y, to: 0, seconds: 1, flags: 0 });
edges.push({ from: 1, to: y, seconds: 1, flags: 0 });
edges.push({ from: y, to: 1, seconds: 1, flags: 0 });
}
for (let y = 2; y < 8; y++) {
edges.push({ from: y, to: y + 1, seconds: 100, flags: 0 });
edges.push({ from: y + 1, to: y, seconds: 100, flags: 0 });
}
return { nodeCount: 9, edges };
}
/** Min seconds over the ORIGINAL traversable (bit0-clear) edges u->v. */
function minTraversableSeconds(input, from, to) {
let best = Infinity;
for (const e of input.edges) {
if (e.from === from && e.to === to && (e.flags & 1) === 0 && e.seconds < best) {
best = e.seconds;
}
}
return best;
}
/* ————— C1–C7 ————— */
test("C1 total order — ranks are distinct and cover exactly [0, nodeCount)", async () => {
const { contractGraph, CH_LAMBDA_DELETED_NEIGHBOURS } = await loadContraction();
assert.equal(CH_LAMBDA_DELETED_NEIGHBOURS, 1, "the λ constant is the named export");
const input = pairsInput();
const result = contractGraph(input);
assert.ok(result.rank instanceof Uint32Array, "rank is a Uint32Array");
assert.equal(result.rank.length, input.nodeCount, "one rank per node");
const seen = new Set(result.rank);
assert.equal(seen.size, input.nodeCount, "ranks are DISTINCT (a tie drops upward edges — Pitfall 9)");
for (const r of result.rank) {
assert.ok(Number.isInteger(r) && r >= 0 && r < input.nodeCount, `rank ${r} in [0, ${input.nodeCount})`);
}
// stats travel with the result (the build prints them — D-08/D-09)
assert.equal(typeof result.stats.shortcutCount, "number");
assert.equal(typeof result.stats.ratioPerNode, "number");
assert.equal(typeof result.stats.witnessSearches, "number");
assert.equal(typeof result.stats.witnessBudgetHits, "number");
assert.equal(result.stats.shortcutCount, result.shortcutTarget.length, "stats agree with the CSR");
assert.ok(
Math.abs(result.stats.ratioPerNode - result.stats.shortcutCount / input.nodeCount) < 1e-12,
"ratioPerNode is shortcuts / nodeCount",
);
});
test("C2 shortcut weights — every shortcut's seconds equal the sum of the two edges it replaced", async () => {
const { contractGraph } = await loadContraction();
const input = pairsInput();
const result = contractGraph(input);
assert.ok(result.shortcutTarget.length > 0, "the chain fixture emits at least one shortcut");
for (let u = 0; u < input.nodeCount; u++) {
for (let s = result.shortcutStart[u]; s < result.shortcutStart[u + 1]; s++) {
const to = result.shortcutTarget[s];
const via = result.shortcutVia[s];
const w1 = minTraversableSeconds(input, u, via);
const w2 = minTraversableSeconds(input, via, to);
assert.ok(
Number.isFinite(w1) && Number.isFinite(w2),
`shortcut ${u}->${to} (via ${via}): both constituent edges exist in the input`,
);
assert.ok(
Math.abs(result.shortcutSeconds[s] - (w1 + w2)) < 1e-6,
`shortcut ${u}->${to}: ${result.shortcutSeconds[s]} === ${w1} + ${w2} (f32 tolerance)`,
);
}
}
});
test("C3 upward shortcuts — rank(from) > rank(via) and rank(to) > rank(via) for every shortcut", async () => {
const { contractGraph } = await loadContraction();
const input = pairsInput();
const result = contractGraph(input);
assert.ok(result.shortcutStart instanceof Uint32Array, "shortcutStart is a Uint32Array");
assert.equal(result.shortcutStart.length, input.nodeCount + 1, "CSR row pointers: N+1 entries");
for (let u = 0; u < input.nodeCount; u++) {
for (let s = result.shortcutStart[u]; s < result.shortcutStart[u + 1]; s++) {
const to = result.shortcutTarget[s];
const via = result.shortcutVia[s];
assert.ok(
result.rank[u] > result.rank[via],
`shortcut ${u}->${to}: rank(from)=${result.rank[u]} > rank(via)=${result.rank[via]}`,
);
assert.ok(
result.rank[to] > result.rank[via],
`shortcut ${u}->${to}: rank(to)=${result.rank[to]} > rank(via)=${result.rank[via]}`,
);
}
}
});
test("C4 hand fixture — the 3-node star yields its literal contraction order and shortcut set", async () => {
const { contractGraph } = await loadContraction();
const result = contractGraph(handInput());
// Literal rank (hand-computed in handInput's doc comment): the mid node
// (id 0) contracts first on the priority tie; endpoints follow by id.
assert.deepEqual([...result.rank], [0, 1, 2], "rank: the mid node first, then endpoints by id");
// Literal shortcut set: 1->2 (via 0, w2) and 2->1 (via 0, w2).
assert.deepEqual([...result.shortcutStart], [0, 0, 1, 2], "CSR: node 0 has 0, node 1 has 1, node 2 has 1");
assert.deepEqual([...result.shortcutTarget], [2, 1], "targets: 1->2 and 2->1");
assert.deepEqual([...result.shortcutSeconds], [2, 2], "unit weights sum to 2");
assert.deepEqual([...result.shortcutVia], [0, 0], "both shortcuts ride the contracted mid node");
assert.equal(result.stats.shortcutCount, 2);
});
test("C5 overlay witness law — a contracted node cannot witness a shortcut (the #1 trap)", async () => {
const { contractGraph } = await loadContraction();
const result = contractGraph(overlayInput());
// Hand-traced (overlayInput's doc comment): the bypass (node 0)
// contracts first; when the mid node u (node 1) contracts, its pair
// (a=2 -> b=3) has NO overlay witness left (the bypass is contracted),
// so the shortcut 2->3 (via 1) MUST be emitted — the emitted shortcut
// is exactly what keeps the overlay distance-preserving.
const found = [];
for (let u = 0; u < 4; u++) {
for (let s = result.shortcutStart[u]; s < result.shortcutStart[u + 1]; s++) {
found.push({ from: u, to: result.shortcutTarget[s], seconds: result.shortcutSeconds[s], via: result.shortcutVia[s] });
}
}
assert.equal(found.length, 1, `exactly the one required shortcut was emitted (got ${JSON.stringify(found)})`);
assert.equal(found[0].from, 2, "shortcut source is a");
assert.equal(found[0].to, 3, "shortcut target is b");
assert.equal(found[0].via, 1, "via is the contracted mid node u");
assert.ok(Math.abs(found[0].seconds - 2) < 1e-6, "weight is the 1+1 sum");
});
test("C6 explosion tripwire — fails loud above CH_SHORTCUT_EXPLOSION_RATIO * nodeCount; a healthy graph passes", async () => {
const { contractGraph, CH_SHORTCUT_EXPLOSION_RATIO, CH_WPS_MAX_SETTLED, CH_WPS_SETTLED_PER_DEGREE } =
await loadContraction();
assert.equal(CH_SHORTCUT_EXPLOSION_RATIO, 4, "the tripwire ratio is the named constant");
assert.equal(CH_WPS_MAX_SETTLED, 60, "the witness settle budget floor is the named constant");
assert.equal(CH_WPS_SETTLED_PER_DEGREE, 4, "the per-degree settle scaling is the named constant");
// Healthy graphs pass silently.
assert.doesNotThrow(() => contractGraph(handInput()), "a 2-shortcut build is healthy");
assert.doesNotThrow(() => contractGraph(pairsInput()), "the two-gadget build is healthy");
// The hand-traced cascade: 42 shortcuts on 9 nodes (4×9 = 36) — the
// message names the ratio and the measured count.
assert.throws(
() => contractGraph(explosionInput()),
(err) => {
assert.ok(err instanceof Error, "the tripwire throws an Error");
assert.match(err.message, /shortcut explosion/, "the message names the failure");
assert.match(err.message, /42/, "the message names the measured shortcut count");
assert.match(err.message, /4/, "the message names the ratio");
return true;
},
"the budget-miss cascade fails loud above the ratio",
);
});
test("C7 determinism — identical input yields byte-identical rank and shortcut arrays", async () => {
const { contractGraph } = await loadContraction();
const a = contractGraph(pairsInput());
const b = contractGraph(pairsInput());
assert.deepEqual([...a.rank], [...b.rank], "ranks identical");
assert.deepEqual([...a.shortcutStart], [...b.shortcutStart], "CSR pointers identical");
assert.deepEqual([...a.shortcutTarget], [...b.shortcutTarget], "targets identical");
assert.deepEqual([...a.shortcutSeconds], [...b.shortcutSeconds], "seconds identical");
assert.deepEqual([...a.shortcutVia], [...b.shortcutVia], "via identical");
assert.equal(a.stats.witnessSearches, b.stats.witnessSearches, "even the stats are deterministic");
// The oneway bit0 law: a blocked mirror edge is never relaxed — dropping
// the traversable direction changes the result; the FLAGGED one does not.
const withBlocked = {
nodeCount: 3,
edges: [
{ from: 0, to: 1, seconds: 1, flags: 0 },
{ from: 1, to: 0, seconds: 1, flags: 1 }, // bit0: stored, NEVER relaxed
{ from: 1, to: 2, seconds: 1, flags: 0 },
{ from: 2, to: 1, seconds: 1, flags: 0 },
],
};
const blocked = contractGraph(withBlocked);
// 1->0 is blocked, so node 0 has no in-edge: contracting 1 emits only 0->2.
assert.deepEqual([...blocked.shortcutStart], [0, 1, 1, 1], "only node 0's shortcut exists");
assert.deepEqual([...blocked.shortcutTarget], [2], "0->2 via 1");
assert.deepEqual([...blocked.shortcutVia], [1]);
assert.equal(blocked.stats.shortcutCount, 1, "the bit0 edge was never relaxed");
});
+101 -31
View File
@@ -3,24 +3,28 @@
* 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.
* mini-graph.bin — the routing correctness ground truth (plan 06-01) and
* the snap/search 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.
*
* PROVENANCE (Phase 8 plan 08-01): routing/test/fixtures/mini-graph-arg1.bin
* holds the frozen ARG1-era bytes preserved expressly as the v1
* decode-rejection vector consumed by the G-suite — it is NOT regenerated
* by this script, ever.
* PROVENANCE (Phase 08.1 plan 01): routing/test/fixtures/mini-graph-arg2.bin
* holds the frozen ARG2-era bytes preserved expressly as the ARG2
* decode-rejection vector consumed by the G-suite (D-12) — it is NOT
* regenerated by this script, ever.
*
* 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.
* ARG3 (Phase 08.1, D-12): the fixture carries a tiny real CH stage —
* contraction via contractGraph (the SAME pure module the real build
* script uses), passed through the ONE encodeGraph authority as the `ch`
* block. 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):
@@ -34,7 +38,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";
import { contractGraph, CH_SHORTCUT_EXPLOSION_RATIO } from "../../src/contraction.ts";
const GRID = 5;
const LAT0 = 3.15;
@@ -92,27 +96,86 @@ reverse.flags = 1;
if (edges.length !== 80) throw new Error(`expected 80 directed edges, built ${edges.length}`);
// 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");
// CH STAGE (Phase 08.1): the fixture regenerates as ARG3 THROUGH the one
// encode authority — the same contractGraph the real build script (plan 02)
// runs. Fail-loud invariants before any write (the `edges.length !== 80`
// style), per RESEARCH §Pattern 4.
const ch = contractGraph({ nodeCount: GRID * GRID, edges });
const rankValues = [...ch.rank];
if (new Set(rankValues).size !== GRID * GRID) {
throw new Error(`rank is not a total order — ${new Set(rankValues).size} distinct for ${GRID * GRID} nodes`);
}
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`);
if (rankValues.some((r) => !Number.isInteger(r) || r < 0 || r >= GRID * GRID)) {
throw new Error("a rank value is outside [0, nodeCount)");
}
// The shortcut band for this grid (measured 2026-10-26, this host: 41 —
// the literature's healthy ~1.4-2.0 per node; the tripwire sits at 4x).
const SHORTCUT_BAND_MIN = 20;
const SHORTCUT_BAND_MAX = 80;
if (ch.stats.shortcutCount < SHORTCUT_BAND_MIN || ch.stats.shortcutCount > SHORTCUT_BAND_MAX) {
throw new Error(
`shortcut count ${ch.stats.shortcutCount} outside the expected grid band ` +
`[${SHORTCUT_BAND_MIN}, ${SHORTCUT_BAND_MAX}] — check the contraction/witness budget`,
);
}
// Overlay-aware weight map: a cascading shortcut's constituents are the
// OVERLAY edges it replaced (originals OR earlier shortcuts — emission
// order is the contraction order, so replaying rank[via]-ascending
// rebuilds every weight exactly).
const weight = new Map(); // "a,b" -> min overlay weight
for (const e of edges) {
if ((e.flags & 1) !== 0) continue;
const key = `${e.from},${e.to}`;
const prev = weight.get(key);
if (prev === undefined || e.seconds < prev) weight.set(key, e.seconds);
}
const shortcutEntries = [];
for (let u = 0; u < GRID * GRID; u++) {
for (let s = ch.shortcutStart[u]; s < ch.shortcutStart[u + 1]; s++) {
shortcutEntries.push({ from: u, to: ch.shortcutTarget[s], seconds: ch.shortcutSeconds[s], via: ch.shortcutVia[s] });
}
}
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(", ")}`);
shortcutEntries.sort((a, b) => ch.rank[a.via] - ch.rank[b.via] || a.from - b.from || a.to - b.to);
for (const sc of shortcutEntries) {
const via = sc.via;
if (!Number.isInteger(via) || via < 0 || via >= GRID * GRID) {
throw new Error(`shortcut ${sc.from}->${sc.to} has an out-of-range via ${via}`);
}
if (ch.rank[sc.from] <= ch.rank[via] || ch.rank[sc.to] <= ch.rank[via]) {
throw new Error(`shortcut ${sc.from}->${sc.to} (via ${via}) is not upward`);
}
const w1 = weight.get(`${sc.from},${via}`);
const w2 = weight.get(`${via},${sc.to}`);
if (w1 === undefined || w2 === undefined) {
throw new Error(`shortcut ${sc.from}->${sc.to} (via ${via}): a constituent overlay edge is missing`);
}
if (sc.seconds !== Math.fround(w1 + w2)) {
throw new Error(`shortcut ${sc.from}->${sc.to} weight ${sc.seconds} != f32(${w1} + ${w2})`);
}
const key = `${sc.from},${sc.to}`;
const prev = weight.get(key);
// The contraction's overlay accumulates f64 sums (the f32 rounding
// happens once, at CSR assembly) — replay the same f64 domain so the
// next cascade level compares exactly.
if (prev === undefined || w1 + w2 < prev) weight.set(key, w1 + w2);
}
// Strong connectivity (the grid is bidirectional except the ONE oneway
// mirror — every node reaches every node).
const adj = Array.from({ length: GRID * GRID }, () => []);
for (const e of edges) {
if ((e.flags & 1) !== 0) continue;
adj[e.from].push(e.to);
}
const seen = new Uint8Array(GRID * GRID);
const stack = [0];
seen[0] = 1;
while (stack.length > 0) {
const u = stack.pop();
for (const v of adj[u]) if (!seen[v]) { seen[v] = 1; stack.push(v); }
}
if (seen.some((s) => s === 0)) {
throw new Error("the grid is not strongly connected — fixture invariant broken");
}
const bin = encodeGraph({
@@ -121,11 +184,18 @@ const bin = encodeGraph({
edges,
names,
maxSpeedKmh: SPEED_PERIMETER_KMH,
landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from },
ch: {
rank: ch.rank,
shortcutStart: ch.shortcutStart,
shortcutTarget: ch.shortcutTarget,
shortcutSeconds: ch.shortcutSeconds,
shortcutVia: ch.shortcutVia,
},
});
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, ` +
`L=${LANDMARK_COUNT} landmarks (nodes ${[...anchors].join(",")}), quantum ${LANDMARK_QUANTUM_S}s, ` +
`coverage ${coverage.map((c) => c.toFixed(2)).join("/")}, ${bin.byteLength} B`,
`CH shortcuts ${ch.stats.shortcutCount} (${ch.stats.ratioPerNode.toFixed(2)}/node, ` +
`witness searches ${ch.stats.witnessSearches}, budget hits ${ch.stats.witnessBudgetHits}), ` +
`${bin.byteLength} B`,
);
Binary file not shown.
Binary file not shown.
+171 -101
View File
@@ -5,27 +5,35 @@
* RED first (plan 06-01 task 1): every decode-bearing case fails while
* routing/src/graph.ts does not exist (lazy-import pattern — each case
* reports its own ERR_MODULE_NOT_FOUND, the fixture-source.test.mjs style).
* Re-pinned + extended by plan 08-02: the layout version bumped to ARG2 —
* a 72-byte header (landmarkCount u32 @60, landmarkQuantumS f32 @64,
* reserved u32 @68) plus a u16 landmark-table SUFFIX (to-table, from-table,
* u32 anchor ids). One decode path, one search path: anything that is not
* exactly an ARG2 graph decodes to null (D-01 fail-honest) — the committed
* mini-graph-arg1.bin (Plan 08-01) is the standing ARG1 rejection vector.
* Re-pinned by Phase 08.1 plan 01 task 2: the layout version bumped to
* ARG3 — the CH-carrying layout. One decode path, one search path:
* anything that is not exactly an ARG3 graph decodes to null (the D-01
* fail-honest family) — the committed mini-graph-arg1.bin / (task 3)
* mini-graph-arg2.bin files are the standing rejection vectors.
*
* The binary layout is PINNED by 06-01 + 08-02 (plans 04/07 depend on it):
* little-endian CSR — header (magic/version/counts/bbox/maxSpeed/landmark
* block) ++ f64 lat++lng ++ u32 edgeStart[n+1] ++ u32 targets ++ u8 flags
* ++ f32 seconds ++ u32 nameIds ++ u32 nameOffsets[m+1] ++ u8 name pool ++
* u16 toTable[L*N] ++ u16 fromTable[L*N] ++ u32 landmarkNodes[L].
* The header is padded to 72 bytes so the f64 node arrays are 8-byte
* aligned — zero-copy TypedArray views REQUIRE aligned offsets (an
* unaligned Float64Array constructor throws RangeError in every engine).
* The binary layout is PINNED by 06-01 + 08-02 + Phase 08.1 (plans 04/07
* depend on the base; Phase 11 will download the same bytes):
* little-endian CSR — header (magic/version/counts/bbox/maxSpeed +
* chShortcutCount u32 @60, reserved u32 @64/@68) ++ f64 lat ++ lng ++
* u32 edgeStart[n+1] ++ u32 targets ++ u8 flags ++ f32 seconds ++
* u32 nameIds ++ u32 nameOffsets[m+1] ++ u8 name pool ++ u32 chRank[n]
* ++ u32 shortcutStart[n+1] ++ u32 shortcutTarget[S] ++ f32
* shortcutSeconds[S] ++ u32 shortcutVia[S].
* The header stays 72 bytes so the f64 node arrays are 8-byte aligned —
* zero-copy TypedArray views REQUIRE aligned offsets (an unaligned
* Float64Array constructor throws RangeError in every engine). The CH
* suffix starts at the first 4-byte boundary after the name pool.
*
* G1 round-trip — encodeGraph → decodeGraph restores coords,
* targets, flags, weights, names, bbox exactly
* targets, flags, weights, names, bbox AND the
* CH fields (rank + shortcut CSR) exactly
* G2 bad magic -> null — T-06-03 fail-honest (0xdeadbeef)
* G3 bad version -> null — ARG1 fixture bytes -> null (D-01 one-path
* law); version 1 and 3 tamper -> null
* law); version 1, 2 and 4 tampers -> null
* (the accepted version is 3)
* G4 committed fixture — mini-graph.bin (ARG3) is 25 nodes / 80
* edges / rank 25 / >= 1 shortcut; the frozen
* mini-graph-arg2.bin decodes to null (D-12)
* G4 truncated/hostile -> null — byte-length ≠ header-declared size
* (incl. huge-count header on a tiny buffer,
* T-06-03b: size check BEFORE view allocation)
@@ -33,19 +41,22 @@
* flipping an input byte flips the decoded view
* G6 name pool — nameAt(0) === "Jalan Mini 1";
* nameAt(UNNAMED_NAME_ID) === undefined
* G7 committed fixture — mini-graph.bin (ARG2) decodes: 25 nodes, 80
* edges, 3 names, maxSpeed 60, exactly ONE
* oneway flag, landmarkCount >= 1, quantum 0.1
* G8 exported constants — GRAPH_MAGIC === 0x41524732, GRAPH_VERSION === 2
* G9 table round-trip — the four landmark properties restore exactly;
* the 0xFFFF sentinel is DATA; u16 tables are
* zero-copy views
* G10 encode validation — malformed landmark input throws NAMED
* G7 committed fixture — mini-graph.bin (ARG3, regenerated through
* the ONE encode authority) decodes: 25 nodes,
* 80 edges, 3 names, maxSpeed 60, exactly ONE
* oneway flag, a total-order rank, >= 1
* upward shortcut; the frozen ARG2 vector
* decodes to null
* G8 exported constants — GRAPH_MAGIC === 0x41524733, GRAPH_VERSION === 3
* G9 table round-trip — the CH fields restore exactly; the CH arrays
* are zero-copy views over the input buffer
* G10 encode validation — malformed ch input throws NAMED
* encodeGraph: errors (no silent clamp, T-08-04)
* G11 decode rejection — landmarkCount = 0 -> null even when the size
* matches (D-01/A5: no landmark-less ARG2);
* monster landmarkCount on a tiny buffer ->
* null by arithmetic BEFORE allocation (T-08-01)
* G11 decode rejection — chShortcutCount = 0 -> null even when the
* size matches (an ARG3 with no CH is not a
* road graph — A5); monster chShortcutCount on
* a tiny buffer -> null by arithmetic BEFORE
* allocation (T-08.1-01)
*
* Run: npm run routing:test (exit 0 = all green)
*/
@@ -71,6 +82,26 @@ function toArrayBuffer(buf) {
/* ————— synthetic 3-node graph (the round-trip vector) ————— */
/**
* Hand-built CH block for the 3-node synthetic (rank of length 3,
* shortcutStart of length 4, ONE shortcut so the round-trip is
* meaningful). The shortcut 0->2 (via 1) is upward against this rank:
* rank(from)=1 > rank(via)=0 and rank(to)=2 > 0 — the same law
* contractGraph guarantees. (contractGraph over the 3 synthetic edges
* itself emits no shortcut — the 1->2 mirror is bit0-blocked — so the
* hand block is the codec's round-trip vector, exactly the plan's
* option B.)
*/
function syntheticCh() {
return {
rank: Uint32Array.of(1, 0, 2),
shortcutStart: Uint32Array.of(0, 1, 1, 1),
shortcutTarget: Uint32Array.of(2),
shortcutSeconds: Float32Array.of(12),
shortcutVia: Uint32Array.of(1),
};
}
function syntheticInput() {
return {
lat: [3.15, 3.1505, 3.151],
@@ -82,24 +113,18 @@ function syntheticInput() {
],
names: ["Jalan Mini 1", "Jalan Mini 2"],
maxSpeedKmh: 60,
// ARG2 landmark block (plan 08-02): L=1 anchored at node 0. quantum 0.5
// is f32-exact so the round-trip asserts plain equality; the to-row
// carries a 0xFFFF sentinel ON PURPOSE — sentinels are DATA (row-major
// [l*N + v]: node 2 cannot reach the anchor in this made-up table).
landmarks: {
quantumS: 0.5,
nodes: [0],
to: Uint16Array.of(0, 25, 0xffff),
from: Uint16Array.of(0, 30, 60),
},
// ARG3 CH block (Phase 08.1): REQUIRED — an ARG3 with no CH is not a
// road graph. Hand-built, upward, one shortcut; see syntheticCh().
ch: syntheticCh(),
};
}
/* ————— G1–G8 ————— */
test("G1 round-trip — encodeGraph -> decodeGraph restores coords, targets, flags, weights, names, bbox exactly", async () => {
test("G1 round-trip — encodeGraph -> decodeGraph restores coords, targets, flags, weights, names, bbox, and the CH tables exactly", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const input = syntheticInput();
const ch = input.ch;
const buf = encodeGraph(input);
assert.ok(buf instanceof ArrayBuffer, "encodeGraph returns an ArrayBuffer");
const g = decodeGraph(buf);
@@ -121,6 +146,17 @@ test("G1 round-trip — encodeGraph -> decodeGraph restores coords, targets, fla
"bbox derived from the nodes at encode time",
);
assert.equal(g.maxSpeedKmh, 60);
// ARG3 CH fields round-trip (task 2's re-pin).
assert.equal(g.chShortcutCount, 1, "the header carries the shortcut count");
assert.equal(g.chRank.length, 3, "chRank length equals nodeCount");
assert.equal(g.shortcutStart.length, 4, "shortcutStart length equals nodeCount + 1");
assert.equal(g.shortcutTarget.length, g.chShortcutCount, "target length equals chShortcutCount");
assert.equal(g.shortcutSeconds.length, g.chShortcutCount, "seconds length equals chShortcutCount");
assert.equal(g.shortcutVia.length, g.chShortcutCount, "via length equals chShortcutCount");
assert.deepEqual([...g.chRank], [...ch.rank], "rank values match the input exactly");
assert.deepEqual([...g.shortcutTarget], [...ch.shortcutTarget], "shortcut targets match");
assert.deepEqual([...g.shortcutSeconds], [...ch.shortcutSeconds], "shortcut seconds match");
assert.deepEqual([...g.shortcutVia], [...ch.shortcutVia], "shortcut vias match");
});
test("G2 bad magic -> null — a tampered header never half-decodes (T-06-03)", async () => {
@@ -131,20 +167,46 @@ test("G2 bad magic -> null — a tampered header never half-decodes (T-06-03)",
assert.equal(decodeGraph(tampered), null, "wrong magic -> null, never a throw");
});
test("G3 bad version -> null — ARG1 fixture bytes refuse; version 1 and 3 tamper to null (D-01 one-path law)", async () => {
test("G3 bad version -> null — ARG1 fixture bytes refuse; version 1, 2 and 4 tampers to null (D-01 one-path law)", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
// The frozen Plan 08-01 vector: REAL ARG1 bytes decode to null — the
// Phase 6 router-pending state covers every pre-ARG2 binary (D-01).
// There is exactly one decode path; no v1 fallback branch exists.
// The frozen rejection vectors: REAL ARG1 bytes decode to null — the
// Phase 6 router-pending state covers every pre-ARG3 binary (D-01).
// There is exactly one decode path; no v1/v2 fallback branch exists.
// (mini-graph-arg2.bin — the frozen ARG2-era bytes, D-12 — is pinned in
// the committed-fixture cases G4/G7; the version-2 tamper here covers
// the same law in memory.)
const arg1 = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph-arg1.bin")));
assert.equal(decodeGraph(arg1), null, "ARG1 bytes -> null, never a throw or a fallback decode");
const buf = encodeGraph(syntheticInput());
const v1 = buf.slice(0);
new DataView(v1).setUint32(4, 1, true);
assert.equal(decodeGraph(v1), null, "version 1 -> null");
const v3 = buf.slice(0);
new DataView(v3).setUint32(4, 3, true);
assert.equal(decodeGraph(v3), null, "version 3 -> null");
const v2 = buf.slice(0);
new DataView(v2).setUint32(4, 2, true);
assert.equal(decodeGraph(v2), null, "version 2 -> null (ARG2 is a rejection vector — D-07)");
const v4 = buf.slice(0);
new DataView(v4).setUint32(4, 4, true);
assert.equal(decodeGraph(v4), null, "version 4 -> null (the accepted version is 3)");
});
test("G4 committed ARG3 fixture — 25 nodes / 80 edges / rank 25 / >=1 shortcut; the preserved ARG2 vector decodes to null", async () => {
const { decodeGraph, GRAPH_MAGIC, GRAPH_VERSION } = await loadGraph();
// The committed ground truth, read as bytes: ARG3 header fields only.
const bin = readFileSync(path.join(here, "fixtures", "mini-graph.bin"));
assert.equal(bin.readUInt32LE(0), GRAPH_MAGIC, "the committed fixture is ARG3-magic");
assert.equal(bin.readUInt32LE(4), GRAPH_VERSION, "the committed fixture is version 3");
assert.equal(bin.readUInt32LE(8), 25, "25 nodes");
assert.equal(bin.readUInt32LE(12), 80, "80 directed edges");
assert.ok(bin.readUInt32LE(60) >= 1, "the committed hierarchy carries at least one shortcut");
// Decoding sees the rank table's length too.
const g = decodeGraph(toArrayBuffer(bin));
assert.ok(g, "the committed fixture decodes as ARG3");
assert.equal(g.chRank.length, 25, "one rank per node");
assert.equal(g.chShortcutCount, bin.readUInt32LE(60), "the header count agrees with the decoded graph");
// The preserved ARG2-era bytes (frozen before this fixture's
// regeneration — D-12) decode to null: one decode path, no fallback.
const arg2 = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph-arg2.bin")));
assert.equal(decodeGraph(arg2), null, "ARG2 bytes -> null (the D-07 rejection vector)");
});
test("G4 truncated + hostile-size buffer -> null — exact byte-size check BEFORE any view allocation (T-06-03b)", async () => {
@@ -175,6 +237,7 @@ test("G5 zero-copy views — the views' .buffer IS the input buffer; flipping an
assert.ok(g, "decode succeeds");
assert.equal(g.lat.buffer, buf, "lat is a VIEW over the input buffer (zero-copy)");
assert.equal(g.edgeSeconds.buffer, buf, "edgeSeconds is a VIEW over the input buffer");
assert.equal(g.chRank.buffer, buf, "chRank is a VIEW over the input buffer (CH zero-copy)");
// The padded header ends at byte 72 — the first lat byte. Flip it.
const dv = new DataView(buf);
const before = g.lat[0];
@@ -192,12 +255,12 @@ test("G6 name pool — nameAt(0) resolves the first pool name; the unnamed senti
assert.equal(g.nameAt(999), undefined, "out-of-range id -> undefined");
});
test("G7 committed ARG2 fixture — decodes to 25 nodes / 80 edges / 3 names / L>=1 landmarks, maxSpeed 60, ONE oneway", async () => {
test("G7 committed ARG3 fixture — decodes to 25 nodes / 80 edges / 3 names / a real hierarchy, maxSpeed 60, ONE oneway", async () => {
const { decodeGraph } = await loadGraph();
const bin = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph.bin")));
assert.ok(bin.byteLength > 1000, `the committed fixture is substantial (${bin.byteLength} B)`);
const g = decodeGraph(bin);
assert.ok(g, "the committed fixture decodes cleanly");
assert.ok(g, "the committed fixture decodes cleanly (ARG3 — regenerated through the ONE encode authority, task 3)");
assert.equal(g.nodeCount, 25, "5x5 grid");
assert.equal(g.edgeCount, 80, "20 H + 20 V segments, both directions");
assert.equal(g.nameCount, 3, "Jalan Mini 1 / Jalan Mini 2 / Jalan Persiaran Mini");
@@ -217,79 +280,87 @@ test("G7 committed ARG2 fixture — decodes to 25 nodes / 80 edges / 3 names / L
if (g.edgeSeconds[e] > 5) sawSlow = true;
}
assert.ok(sawFast && sawSlow, "both speed classes (60 and 30 km/h) exist in the weights");
// ARG2 landmark laws (plan 08-02): the fixture regenerates THROUGH the
// encode authority with a tiny real landmark stage (L=2).
assert.equal(g.landmarkCount, 2, "the build's tiny L");
assert.ok(
Math.abs(g.landmarkQuantumS - 0.1) < 1e-6,
`quantum is the 0.1 s c-unit (f32 readback ${g.landmarkQuantumS})`,
);
assert.equal(g.landmarkTo.length, g.landmarkCount * g.nodeCount, "to-table is L*N row-major");
assert.equal(g.landmarkFrom.length, g.landmarkCount * g.nodeCount, "from-table is L*N row-major");
for (let l = 0; l < g.landmarkCount; l++) {
const a = g.landmarkNodes[l];
assert.ok(Number.isInteger(a) && a >= 0 && a < g.nodeCount, `anchor ${l} in range`);
assert.equal(g.landmarkTo[l * g.nodeCount + a], 0, `anchor ${l}'s own to-cell is 0`);
assert.equal(g.landmarkFrom[l * g.nodeCount + a], 0, `anchor ${l}'s own from-cell is 0`);
// ARG3 CH laws (Phase 08.1, D-12): the fixture regenerates THROUGH the
// encode authority with the tiny real contraction stage (S = 41 on the
// 5x5 grid — the healthy ~1.6/node band).
assert.equal(g.chRank.length, 25, "one rank per node");
assert.equal(g.shortcutStart.length, 26, "CSR row pointers: nodeCount + 1");
assert.equal(g.shortcutTarget.length, g.chShortcutCount, "shortcut CSR lengths agree with the header");
assert.equal(g.shortcutSeconds.length, g.chShortcutCount, "shortcut seconds length agrees");
assert.equal(g.shortcutVia.length, g.chShortcutCount, "shortcut via length agrees");
assert.ok(g.chShortcutCount >= 1, "the committed hierarchy carries at least one shortcut");
// Rank is a total order (Pitfall 9) and the shortcuts are upward.
assert.equal(new Set(g.chRank).size, 25, "ranks are DISTINCT");
for (let u = 0; u < g.nodeCount; u++) {
for (let s = g.shortcutStart[u]; s < g.shortcutStart[u + 1]; s++) {
const via = g.shortcutVia[s];
assert.ok(g.chRank[u] > g.chRank[via] && g.chRank[g.shortcutTarget[s]] > g.chRank[via], `shortcut ${u}->${g.shortcutTarget[s]} is upward against via ${via}`);
}
}
// The preserved ARG2-era bytes decode to null (D-12's rejection vector).
const arg2 = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph-arg2.bin")));
assert.equal(decodeGraph(arg2), null, "ARG2 bytes -> null (the D-07 rejection vector)");
});
test("G8 exported constants — GRAPH_MAGIC === 0x41524732 ('ARG2'), GRAPH_VERSION === 2", async () => {
test("G8 exported constants — GRAPH_MAGIC === 0x41524733 ('ARG3'), GRAPH_VERSION === 3", async () => {
const mod = await loadGraph();
assert.equal(mod.GRAPH_MAGIC, 0x41524732);
assert.equal(mod.GRAPH_VERSION, 2);
assert.equal(mod.GRAPH_MAGIC, 0x41524733);
assert.equal(mod.GRAPH_VERSION, 3);
assert.equal(mod.UNNAMED_NAME_ID, 0xffffffff);
});
test("G9 table round-trip — the four landmark properties restore exactly; sentinels are DATA; tables are zero-copy", async () => {
test("G9 table round-trip — the CH fields restore exactly; the CH arrays are zero-copy views", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const buf = encodeGraph(syntheticInput()).slice(0);
const ch = syntheticCh();
const buf = encodeGraph({ ...syntheticInput(), ch }).slice(0);
const g = decodeGraph(buf);
assert.ok(g, "decode succeeds");
assert.equal(g.landmarkCount, 1, "landmarkCount === L");
assert.equal(g.landmarkTo.length, 1 * 3, "to-table row-major L*N");
assert.equal(g.landmarkFrom.length, 1 * 3, "from-table row-major L*N");
assert.deepEqual([...g.landmarkNodes], [0], "landmarkNodes[i] matches the input anchors");
assert.equal(g.landmarkQuantumS, 0.5, "quantum restores exactly (0.5 is f32-exact)");
assert.deepEqual([...g.landmarkTo], [0, 25, 0xffff], "to-table bytes restore exactly — 0xFFFF is DATA");
assert.deepEqual([...g.landmarkFrom], [0, 30, 60], "from-table bytes restore exactly");
assert.equal(g.landmarkTo.buffer, buf, "the u16 to-table is a zero-copy view too");
assert.equal(g.landmarkFrom.buffer, buf, "the u16 from-table is a zero-copy view too");
assert.equal(g.chShortcutCount, 1, "chShortcutCount === S");
assert.deepEqual([...g.chRank], [...ch.rank], "chRank restores exactly");
assert.deepEqual([...g.shortcutStart], [...ch.shortcutStart], "shortcutStart restores exactly");
assert.deepEqual([...g.shortcutTarget], [...ch.shortcutTarget], "shortcutTarget restores exactly");
assert.deepEqual([...g.shortcutSeconds], [...ch.shortcutSeconds], "shortcutSeconds restores exactly");
assert.deepEqual([...g.shortcutVia], [...ch.shortcutVia], "shortcutVia restores exactly");
assert.equal(g.chRank.buffer, buf, "chRank is a zero-copy view");
assert.equal(g.shortcutStart.buffer, buf, "shortcutStart is a zero-copy view");
assert.equal(g.shortcutTarget.buffer, buf, "shortcutTarget is a zero-copy view");
assert.equal(g.shortcutSeconds.buffer, buf, "shortcutSeconds is a zero-copy view");
assert.equal(g.shortcutVia.buffer, buf, "shortcutVia is a zero-copy view");
});
test("G10 encode validation — malformed landmark input throws NAMED encodeGraph: errors (T-08-04, no silent clamp)", async () => {
test("G10 encode validation — malformed ch input throws NAMED encodeGraph: errors (T-08-04, no silent clamp)", async () => {
const { encodeGraph } = await loadGraph();
const base = syntheticInput();
const mangle = (over) => ({ ...base, landmarks: { ...base.landmarks, ...over } });
const mangle = (over) => ({ ...base, ch: { ...base.ch, ...over } });
const cases = [
["zero anchors", mangle({ nodes: [] })],
["to-row length != L*N", mangle({ to: Uint16Array.of(0, 25) })],
["from-row length != L*N", mangle({ from: Uint16Array.of(0) })],
["anchor id >= nodeCount", mangle({ nodes: [7] })],
["anchor id negative", mangle({ nodes: [-1] })],
["quantum 0", mangle({ quantumS: 0 })],
["quantum negative", mangle({ quantumS: -1 })],
["quantum NaN", mangle({ quantumS: Number.NaN })],
["landmarks block missing", { ...base, landmarks: undefined }],
["ch block missing", { ...base, ch: undefined }],
["rank length != nodeCount", mangle({ rank: Uint32Array.of(1, 0) })],
["shortcutStart length != nodeCount+1", mangle({ shortcutStart: Uint32Array.of(0, 1, 1) })],
["target length != S (via disagrees)", mangle({ shortcutVia: Uint32Array.of() })],
["seconds length != S (target disagrees)", mangle({ shortcutSeconds: Float32Array.of() })],
["via id >= nodeCount", mangle({ shortcutVia: Uint32Array.of(7) })],
["via id negative-ish (huge)", mangle({ shortcutTarget: Uint32Array.of(0xffff_ffff), shortcutVia: Uint32Array.of(0xffff_ffff) })],
["non-finite shortcut seconds", mangle({ shortcutSeconds: Float32Array.of(Number.NaN) })],
["negative shortcut seconds", mangle({ shortcutSeconds: Float32Array.of(-1) })],
];
for (const [label, input] of cases) {
assert.throws(() => encodeGraph(input), /^Error: encodeGraph: /, `${label} -> named encodeGraph: error`);
}
});
test("G11 decode rejection — landmarkCount 0 -> null even when the size matches; monster count -> null by arithmetic (T-08-01)", async () => {
test("G11 decode rejection — chShortcutCount 0 -> null even when the size matches; monster count -> null by arithmetic (T-08.1-01)", async () => {
const { encodeGraph, decodeGraph, GRAPH_MAGIC, GRAPH_VERSION } = await loadGraph();
const buf = encodeGraph(syntheticInput());
const zeroed = buf.slice(0);
new DataView(zeroed).setUint32(60, 0, true);
assert.equal(decodeGraph(zeroed), null, "landmarkCount = 0 -> null (D-01/A5: no landmark-less ARG2)");
// A CONSISTENT landmark-less buffer (byte size exactly matches the L=0
// layout: header + sections + align4(namePoolEnd), no suffix) still
// refuses — the law is the count itself, not a size mismatch. Quantum
// is valid here so the L=0 law is isolated.
assert.equal(decodeGraph(zeroed), null, "chShortcutCount = 0 -> null (no CH-less ARG3 — A5)");
// A CONSISTENT CH-less buffer (byte size exactly matches the S=0 ARG3
// layout: header + base sections + align4(namePoolEnd) + chRank[n] +
// shortcutStart[n+1]) still refuses — the law is the count itself, not
// a size mismatch.
const n = 1;
const lay = 72 + 8 * n + 8 * n + 4 * (n + 1) + 4 * 1; // = 100: header + lat + lng + edgeStart[2] + nameOffset[1]; no suffix
const bare = new ArrayBuffer(lay); // byteLength === the exact L=0 layout total
const lay = 72 + 8 * n + 8 * n + 4 * (n + 1) + 4 * 1 + 4 * n + 4 * (n + 1); // = 112: header + lat + lng + edgeStart[2] + nameOffset[1] + chRank[1] + shortcutStart[2]; no shortcut payload
const bare = new ArrayBuffer(lay);
const dv = new DataView(bare);
dv.setUint32(0, GRAPH_MAGIC, true);
dv.setUint32(4, GRAPH_VERSION, true);
@@ -297,13 +368,12 @@ test("G11 decode rejection — landmarkCount 0 -> null even when the size matche
dv.setUint32(12, 0, true); // edgeCount
dv.setUint32(16, 0, true); // nameCount
dv.setUint32(20, 0, true); // namePoolLen
dv.setFloat32(64, 0.5, true); // a valid quantum…
dv.setUint32(60, 0, true); // …but landmarkCount = 0
assert.equal(decodeGraph(bare), null, "an exactly-sized landmark-less ARG2 -> null");
// Monster header: L = 2,000,000 on a tiny buffer — the exact-size
// arithmetic rejects BEFORE any Uint16Array allocation (a null return,
dv.setUint32(60, 0, true); // chShortcutCount = 0
assert.equal(decodeGraph(bare), null, "an exactly-sized CH-less ARG3 -> null");
// Monster header: S = 2,000,000 on a tiny buffer — the exact-size
// arithmetic rejects BEFORE any typed-array allocation (a null return,
// never a RangeError).
const monster = buf.slice(0);
new DataView(monster).setUint32(60, 2_000_000, true);
assert.equal(decodeGraph(monster), null, "monster landmarkCount on a tiny buffer -> null, no allocation");
assert.equal(decodeGraph(monster), null, "monster chShortcutCount on a tiny buffer -> null, no allocation");
});
-269
View File
@@ -1,269 +0,0 @@
#!/usr/bin/env node
/*
* landmark.test.mjs — the pure landmark compute core gates (L1–L6).
* ------------------------------------------------------------------
* RED first (plan 08-02 task 1): every case fails while
* routing/src/landmark.ts does not exist (lazy-import pattern — each
* case reports its own ERR_MODULE_NOT_FOUND).
*
* The ALT preprocessing core (research Pattern 2): distances live in the
* ĉ metric — edge weights floor-quantized ONCE to fixed 0.1 s units —
* and every table cell is an EXACT integer Dijkstra result. Hand-computed
* expectations below; JS arithmetic is deterministic so the pinned quanta
* (verified: floor(2.55/0.1)=25, floor(3/0.1)=30, floor(1.5/0.1)=15,
* grid perimeter hop=33, interior hop=66) are stable.
*
* L1 anchors — farthestPointAnchors: deterministic, degree>=2
* filtered, well-spread on the 5-node path
* L2 quantum exactness — 2.55 s edge -> 25 quanta; two edges -> 50
* exactly (integer arithmetic, no float drift)
* L3 sentinel + self — unreachable pairs -> 0xFFFF; the landmark's
* own cell = 0 (oneway chain + isolated node)
* L4 asymmetry — to/from rows differ on a oneway graph
* L5 overflow throws — any label > 65000 quanta throws, naming the
* distance and the fix (raise landmarkQuantumS)
* L6 5x5 grid shape — anchors [0, 18] (interior node 18 is the ĉ
* farthest at 297 quanta); coverage 1.0
*
* Run: npm run routing:test (exit 0 = all green)
*/
import { strict as assert } from "node:assert";
import { test } from "./harness.mjs";
let landmarkPromise = null;
function loadLandmark() {
if (!landmarkPromise) landmarkPromise = import("../src/landmark.ts");
return landmarkPromise;
}
/* ————— hand-built vectors ————— */
/** 5-node path 0-1-2-3-4, two-way, 3.0 s per edge (30 quanta @ 0.1). */
function pathInput() {
const edges = [];
for (let u = 0; u < 4; u++) {
edges.push({ from: u, to: u + 1, seconds: 3, flags: 0 });
edges.push({ from: u + 1, to: u, seconds: 3, flags: 0 });
}
return { nodeCount: 5, edges, quantumS: 0.1 };
}
/** Oneway chain 0->1->2 (bit0 mirrors) + isolated node 3, 1.5 s/edge. */
function onewayInput() {
return {
nodeCount: 4,
edges: [
{ from: 0, to: 1, seconds: 1.5, flags: 0 },
{ from: 1, to: 0, seconds: 1.5, flags: 1 }, // blocked mirror
{ from: 1, to: 2, seconds: 1.5, flags: 0 },
{ from: 2, to: 1, seconds: 1.5, flags: 1 }, // blocked mirror
],
quantumS: 0.1,
};
}
/** The fixture builder's exact 5x5 grid shape (25 nodes, 80 edge entries,
* perimeter 60 km/h / interior 30 km/h, one oneway mirror at 3->2). */
async function gridInput() {
const { haversineM } = await import("../src/geo.ts");
const GRID = 5;
const lat = [];
const lng = [];
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
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, perimeter) {
const kmh = perimeter ? 60 : 30;
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), flags: 0 });
}
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID - 1; c++) {
const perimeter = r === 0 || r === GRID - 1;
addEdge(id(r, c), id(r, c + 1), perimeter);
addEdge(id(r, c + 1), id(r, c), perimeter);
}
}
for (let c = 0; c < GRID; c++) {
for (let r = 0; r < GRID - 1; r++) {
const perimeter = c === 0 || c === GRID - 1;
addEdge(id(r, c), id(r + 1, c), perimeter);
addEdge(id(r + 1, c), id(r, c), perimeter);
}
}
const reverse = edges.find((e) => e.from === 3 && e.to === 2);
reverse.flags = 1; // the fixture's single oneway-reverse mirror
return { nodeCount: GRID * GRID, edges, quantumS: 0.1 };
}
/* ————— L1–L6 ————— */
test("L1 anchors — deterministic, degree>=2-filtered, well-spread on the 5-node path", async () => {
const { farthestPointAnchors } = await loadLandmark();
const input = pathInput();
const a = farthestPointAnchors(input, 2);
assert.ok(a instanceof Uint32Array, "anchors are a Uint32Array");
assert.equal(a.length, 2);
// Candidates (degree>=2) are nodes 1,2,3 — endpoints excluded. First
// anchor = first candidate by id; pass-from-1 minima are [30,0,30,60,90]
// quanta, so the farthest candidate is node 3.
assert.deepEqual([...a], [1, 3], "first candidate by id, then farthest spread");
// L=3: after folding anchor 3, minima are [30,0,30,0,30] -> node 2.
assert.deepEqual([...farthestPointAnchors(input, 3)], [1, 3, 2]);
// Determinism: identical input -> identical anchors (no randomness).
assert.deepEqual([...farthestPointAnchors(pathInput(), 2)], [...a]);
// More anchors than candidates: stop early, never pad.
assert.equal(farthestPointAnchors(input, 10).length, 3, "only 3 candidates exist");
});
test("L2 quantum exactness — floor(2.55/0.1)=25 per edge; two edges sum to exactly 50 quanta", async () => {
const { landmarkTables, LANDMARK_QUANTUM_S } = await loadLandmark();
assert.equal(LANDMARK_QUANTUM_S, 0.1, "the default quantum is the doc'd 0.1 s");
const input = {
nodeCount: 3,
edges: [
{ from: 0, to: 1, seconds: 2.55, flags: 0 },
{ from: 1, to: 0, seconds: 2.55, flags: 0 },
{ from: 1, to: 2, seconds: 2.55, flags: 0 },
{ from: 2, to: 1, seconds: 2.55, flags: 0 },
],
quantumS: 0.1,
};
const { to, from } = landmarkTables(input, Uint32Array.of(0));
assert.equal(from.length, 1 * 3, "row-major L*N cells");
assert.equal(from[0 * 3 + 1], 25, "one 2.55 s edge = exactly 25 quanta (floor(25.4999...))");
assert.equal(from[0 * 3 + 2], 50, "two edges = exactly 50 quanta — integer arithmetic, no drift");
assert.equal(to[0 * 3 + 2], 50, "the to-row mirrors on a two-way path");
});
test("L3 sentinel + self-cell — unreachable pairs -> 0xFFFF; the landmark's own cell = 0", async () => {
const { landmarkTables, LANDMARK_SENTINEL } = await loadLandmark();
assert.equal(LANDMARK_SENTINEL, 0xffff);
const input = onewayInput();
const { to, from } = landmarkTables(input, Uint32Array.of(0));
assert.equal(to[0 * 4 + 0], 0, "landmark's own to-cell is 0");
assert.equal(from[0 * 4 + 0], 0, "landmark's own from-cell is 0");
assert.equal(from[0 * 4 + 1], 15, "0->1 traverses (floor(1.5/0.1)=15 quanta)");
assert.equal(from[0 * 4 + 2], 30, "0->1->2 traverses");
assert.equal(to[0 * 4 + 1], LANDMARK_SENTINEL, "1 cannot reach 0 against the oneway -> sentinel");
assert.equal(to[0 * 4 + 2], LANDMARK_SENTINEL, "2 cannot reach 0 -> sentinel");
// Isolated node: every cell is the sentinel.
assert.equal(to[0 * 4 + 3], LANDMARK_SENTINEL, "isolated node -> sentinel in the to-row");
assert.equal(from[0 * 4 + 3], LANDMARK_SENTINEL, "isolated node -> sentinel in the from-row");
});
test("L4 asymmetry — reversed vs forward rows differ on an oneway graph", async () => {
const { landmarkTables } = await loadLandmark();
const { to, from } = landmarkTables(onewayInput(), Uint32Array.of(1));
// Rows are indexed by anchor POSITION (row-major [ℓ*N+v], ℓ = 0 here),
// not by the anchor's node id. Anchor at the middle node 1: forward
// reaches 2 (15 quanta) but the to-row for 2 is the sentinel (2 cannot
// get back to 1).
assert.equal(from[0 * 4 + 2], 15, "1->2 traverses");
assert.equal(to[0 * 4 + 2], 0xffff, "2->1 does not (oneway) — asymmetry is representable");
assert.notEqual(to[0 * 4 + 2], from[0 * 4 + 2]);
});
test("L5 overflow throws — any label > 65000 quanta fails loud, naming the fix", async () => {
const { landmarkTables, farthestPointAnchors, LANDMARK_MAX_QUANTA } = await loadLandmark();
assert.equal(LANDMARK_MAX_QUANTA, 65000);
// 3-node path so node 1 is a degree-2 candidate — the anchor-selection
// pass actually runs (a 2-node graph has no candidates and returns
// empty before any Dijkstra).
const input = {
nodeCount: 3,
edges: [
{ from: 0, to: 1, seconds: 10, flags: 0 }, // floor(10/0.0001) = 100000 quanta
{ from: 1, to: 0, seconds: 10, flags: 0 },
{ from: 1, to: 2, seconds: 10, flags: 0 },
{ from: 2, to: 1, seconds: 10, flags: 0 },
],
quantumS: 0.0001,
};
assert.throws(
() => landmarkTables(input, Uint32Array.of(0)),
/100000 exceeds 65000 \(raise landmarkQuantumS\)/,
"the table pass names the offending distance and the fix",
);
assert.throws(
() => farthestPointAnchors(input, 1),
/exceeds 65000 \(raise landmarkQuantumS\)/,
"the anchor-selection pass fails loud on the same law",
);
});
test("L5b i32 wrap throws — an edge quantizing to >= 2^31 fails at prepare, before the Int32Array store", async () => {
const { landmarkTables, farthestPointAnchors } = await loadLandmark();
// seconds 3e10 at the 0.1 s quantum = 3e11 quanta — an absurd but FINITE
// positive weight. Stored unchecked it would wrap NEGATIVE in the
// Int32Array (-64771072 for 3e10), silently bypassing the LANDMARK_MAX_QUANTA
// throw (a negative contribution never exceeds it) and poisoning the
// tables. The wrap must be caught at the table-build entry instead.
const input = {
nodeCount: 3,
edges: [
{ from: 0, to: 1, seconds: 3e10, flags: 0 }, // floor(3e10/0.1) = 3e11 quanta >= 2^31
{ from: 1, to: 0, seconds: 10, flags: 0 },
{ from: 1, to: 2, seconds: 10, flags: 0 },
{ from: 2, to: 1, seconds: 10, flags: 0 },
],
quantumS: 0.1,
};
assert.throws(
() => landmarkTables(input, Uint32Array.of(0)),
/i32 weight ceiling 2147483647 \(raise landmarkQuantumS\)/,
"landmarkTables throws at the storage boundary, naming the edge, the ceiling and the fix",
);
assert.throws(
() => farthestPointAnchors(input, 1),
/i32 weight ceiling 2147483647/,
"the anchor-selection entry shares the same prepare-time law",
);
// The ceiling itself stays inside the legal domain: 2147483646 quanta on
// one edge does NOT throw here (the dijkstraPass 65000 ceiling is the law
// that fires later — prepare only rejects the i32 WRAP class).
const justUnder = {
nodeCount: 2,
edges: [
{ from: 0, to: 1, seconds: 214748364.6, flags: 0 }, // floor(/0.1) = 2147483646 < 2^31
{ from: 1, to: 0, seconds: 10, flags: 0 },
],
quantumS: 0.1,
};
assert.throws(
() => landmarkTables(justUnder, Uint32Array.of(0)),
/exceeds 65000/,
"just-under-ceiling still fails — via the documented 65000 law, never silently",
);
assert.doesNotThrow(
() => landmarkTables(
{ nodeCount: 2, edges: [{ from: 0, to: 1, seconds: 25.5, flags: 0 }, { from: 1, to: 0, seconds: 10, flags: 0 }], quantumS: 0.1 },
Uint32Array.of(0),
),
"ordinary weights are untouched by the new check",
);
});
test("L6 5x5 grid shape — anchors [0,18]; farthest 297 quanta; coverage 1.0", async () => {
const { farthestPointAnchors, landmarkTables, landmarkCoverage } = await loadLandmark();
const input = await gridInput();
const anchors = farthestPointAnchors(input, 2);
// Farthest-point over the ĉ metric: interior hops cost 66 quanta (2x the
// perimeter's 33), so the farthest node from corner 0 is the INTERIOR
// node 18 at 297 quanta — NOT the opposite corner 24 (264). Verified
// against an independent brute-force Dijkstra (297 unique max).
assert.deepEqual([...anchors], [0, 18], "first candidate is node 0; farthest is node 18");
const { to, from } = landmarkTables(input, anchors);
assert.equal(to.length, 2 * 25, "L*N cells (L=2, N=25)");
assert.equal(to[0 * 25 + 0], 0, "self-cell 0");
assert.equal(from[1 * 25 + 18], 0, "anchor 18's own from-cell 0 (row = position 1)");
assert.equal(from[0 * 25 + 18], 297, "0->18 = 3 perimeter + 3 interior hops = 99+198, exact");
assert.equal(to[1 * 25 + 0], 297, "the to-row mirrors on the grid");
const coverage = landmarkCoverage(to, anchors, 25);
assert.deepEqual(coverage, [1, 1], "the grid is strongly connected — full coverage");
});
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env node
/*
* maintainer-scripts.test.mjs — structural + offline behavioral pins for the
* routing maintainer toolchain (Phase 08.1 plan 02).
* -----------------------------------------------------------------------------
* The build and measurement scripts are MAINTAINER-TIME tools — the full
* build needs the Overpass network and minutes of compute (D-09), so this
* suite pins them the way rollout.test.mjs pins the upload/Worker pair:
* mechanically, from source shape plus the ONE offline surface (--check on
* the committed ARG3 fixture). A pin here is enforced, not customary.
*
* BS* build-road-graph.mjs — the CH preprocessing stage (plan 02 task 1):
* zero landmark/ALT tokens in code, ONE contractGraph call feeding
* the `ch:` encode input, the full measured-stats print block
* (D-08/D-09: bytes, witness counts, wall-clock — never a payload
* ceiling), the unchanged 1-80 MB D-01 envelope, and `--check`
* decoding the committed fixture green with CH facts.
* MG* the measurement gates (plan 02 task 2): the frozen constants stay
* pinned exactly once each (the constant-mirror law) and the
* decode-failure wording honestly names ARG3.
*
* Comment-strip shape = the plan's own verify gate: drop block-comment
* CONTINUATION lines (leading `*`) and whole-line `//` comments; anything
* else — code, string literals, block-comment OPENING lines — counts.
*/
import { strict as assert } from "node:assert";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "./harness.mjs";
const here = path.dirname(fileURLToPath(import.meta.url)); // routing/test
const BUILD_SCRIPT = path.join(here, "..", "scripts", "build-road-graph.mjs");
const FIXTURE_BIN = path.join(here, "fixtures", "mini-graph.bin");
function readSrc(file) {
return readFileSync(file, "utf8");
}
/** The plan-verify comment-strip shape (see the header): a token on a code
* line, a string literal, or a block-comment OPENING line counts. */
function stripCommentLines(src) {
return src
.split("\n")
.filter((line) => !/^\s*\*/.test(line) && !/^\s*\/\//.test(line))
.join("\n");
}
function countOccurrences(haystack, needle) {
return haystack.split(needle).length - 1;
}
/* ————— BS — build-road-graph.mjs: the CH preprocessing stage ————— */
test("BS1 the build is landmark-free — no landmark/ALT-stage tokens survive comment stripping", () => {
const code = stripCommentLines(readSrc(BUILD_SCRIPT));
const hits = code.split("\n").filter((line) => line.includes("landmark"));
assert.equal(hits.length, 0, `landmark tokens must be gone entirely, found ${hits.length}:\n${hits.join("\n")}`);
});
test("BS2 the build runs ONE contractGraph stage and encodes the ch block — never a landmarks field", () => {
const code = stripCommentLines(readSrc(BUILD_SCRIPT));
assert.ok(
countOccurrences(code, "contractGraph(") >= 1,
"the build must call contractGraph (the ONE CH construction authority)",
);
assert.ok(!code.includes("landmarks:"), "the encode input must carry ch:, never landmarks:");
assert.ok(!code.includes("--landmarks"), "the --landmarks knob is deleted (D-05: no ALT preprocessing)");
});
test("BS3 the build prints the measured CH stats block — counts, witness numbers, CH bytes, wall-clock, total bytes", () => {
const src = readSrc(BUILD_SCRIPT);
for (const label of [
"contracted nodes:",
"shortcuts:",
"witness searches:",
"chRank bytes:",
"shortcut bytes:",
"build wall-clock:",
"total binary:",
]) {
assert.ok(src.includes(label), `the CH stats block must print "${label}" (D-08/D-09: measured, never hoped)`);
}
});
test("BS4 the D-01 envelope constants are unchanged and single — the 1-80 MB gate stays, no new payload ceiling", () => {
const src = readSrc(BUILD_SCRIPT);
assert.equal(
countOccurrences(src, "MIN_BINARY_BYTES = 1 * 1024 * 1024"),
1,
"the enforced 1 MB floor must appear exactly once, unchanged",
);
assert.equal(
countOccurrences(src, "MAX_BINARY_BYTES = 80 * 1024 * 1024"),
1,
"the enforced 80 MB ceiling must appear exactly once, unchanged",
);
});
test("BS5 --check decodes the committed ARG3 fixture green and prints CH facts — no landmark facts, no network", () => {
const res = spawnSync(process.execPath, [BUILD_SCRIPT, "--check", FIXTURE_BIN], {
encoding: "utf8",
timeout: 30000,
});
assert.equal(res.status, 0, `--check on the committed fixture must exit 0 (stderr: ${res.stderr})`);
const out = res.stdout ?? "";
assert.match(out, /chShortcutCount: \d+/, "the ARG3 header field must print");
assert.match(out, /shortcut ratio: [\d.]+ per node/, "the shortcut count/ratio must print");
assert.match(out, /chRank length: \d+/, "the rank length must print");
assert.ok(!/landmark/i.test(out), "--check prints CH facts, never landmark facts");
});
/* ————— MG — the measurement gates: frozen constants + honest ARG3 wording ————— */
const OPTIMALITY_SCRIPT = path.join(here, "..", "scripts", "measure-optimality-gate.mjs");
const SWEEP_SCRIPT = path.join(here, "..", "scripts", "measure-astar-oracle-sweep.mjs");
const CPU_SCRIPT = path.join(here, "..", "scripts", "measure-route-cpu.mjs");
test("MG1 the optimality gate keeps the frozen D-03 constants and honestly names ARG3 when the binary does not decode", () => {
const src = readSrc(OPTIMALITY_SCRIPT);
assert.equal(
countOccurrences(src, "OPTIMALITY_EPSILON_PCT = 0.1"),
1,
"the ±0.1% epsilon mirror must appear exactly once, unchanged",
);
assert.equal(
countOccurrences(src, "REQUIRED_BASELINE_ARG_VERSION = 1"),
1,
"the frozen pre-ALT record's argVersion pin must appear exactly once, unchanged",
);
const code = stripCommentLines(src);
assert.ok(code.includes("ARG3"), "the decode-failure message must name ARG3 (the current binary's format)");
assert.ok(code.includes("build-road-graph.mjs"), "the failure must name the rebuild command");
});
test("MG2 the oracle sweep keeps the seeded 200-pair symmetric-compare constants and honestly names ARG3", () => {
const src = readSrc(SWEEP_SCRIPT);
for (const [constant, occurrences] of [
["SWEEP_PAIR_COUNT = 200", 1],
["SWEEP_SEED = 0x08f1", 1],
["SWEEP_OVERSAMPLE_FACTOR = 3", 1],
["SWEEP_MIN_PAIR_KM = 12", 1],
["ORACLE_EPSILON_S = 1e-6", 1],
]) {
assert.equal(countOccurrences(src, constant), occurrences, `"${constant}" must appear exactly ${occurrences} time(s), unchanged`);
}
const code = stripCommentLines(src);
assert.ok(code.includes("ARG3"), "the decode-failure message must name ARG3 (the current binary's format)");
});
test("MG3 the CPU gate keeps the budget and sample constants exactly once — the 8 ms product budget is the binding pass bar", () => {
const src = readSrc(CPU_SCRIPT);
for (const [constant, occurrences] of [
["P95_BUDGET_MS = 8", 1],
["ROUTE_COUNT = 25", 1],
["SAMPLE_SEED = 0x06d4", 1],
["EXTENDED_PAIR_COUNT = 100", 1],
["EXTENDED_SEED = 0x08a1", 1],
]) {
assert.equal(countOccurrences(src, constant), occurrences, `"${constant}" must appear exactly ${occurrences} time(s), unchanged`);
}
});
+4 -2
View File
@@ -65,6 +65,7 @@ test("RO1 upload-format-agnostic — the upload path is byte-shipping, never for
"decodeGraph",
"0x41524731", // ARG1 magic
"0x41524732", // ARG2 magic
"0x41524733", // ARG3 magic (Phase 08.1 — the ban covers the CURRENT format too)
"GRAPH_VERSION",
]) {
assert.ok(!code.includes(token), `upload code must never reference ${token} (the migration is byte-shipping — D-01)`);
@@ -90,8 +91,9 @@ test("RO2 one decode path at the Worker — the router never branches on format"
const calls = code.match(/decodeGraph\(/g) ?? [];
assert.equal(calls.length, 1, "exactly one decodeGraph call site — the ONE decode path");
// NO version-conditional branch against the graph bytes: no version
// constant import/compare, no magic, no `=== 2` anywhere in the file.
for (const token of ["GRAPH_VERSION", "0x41524731", "0x41524732", "=== 2"]) {
// constant import/compare, no magic (ARG1, ARG2, or the current ARG3),
// no `=== 2` anywhere in the file.
for (const token of ["GRAPH_VERSION", "0x41524731", "0x41524732", "0x41524733", "=== 2"]) {
assert.ok(!code.includes(token), `the router must never branch on format (${token})`);
}
});
+23 -13
View File
@@ -25,7 +25,11 @@
* (WeakMap; build-counter, kept loose) +
* structural: WeakMap and cos() in the source
*
* Run: npm run routing:test (exit 0 = all green)
* Phase 08.1 re-pin (plan 01 tasks 2-3): the committed mini-graph.bin is
* now ARG3 (regenerated through the ONE encode authority) and every case
* runs unchanged. S3's synthetic unnamed-edge graph moved to the ARG3 ch
* block (a 3-node star — a 2-node graph can carry no valid shortcut, and
* a shortcut-less ARG3 is not a road graph).
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
@@ -109,21 +113,27 @@ test("S3 nameId — a perimeter fix resolves 'Jalan Persiaran Mini'; an unnamed
assert.notEqual(atNode1.nameId, null, "perimeter edges carry names");
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).
// Synthetic 3-node star with UNNAMED edges -> nameId null.
// ARG3 (Phase 08.1): the ch block is required by the one encode
// authority; it comes from the ONE contraction core. A 2-node graph can
// carry no valid shortcut (no via node exists), so the fixture is the
// C4 star — mid node 0, unnamed legs to 1 and 2 — whose contraction
// emits the two endpoint shortcuts (decode refuses a shortcut-less
// ARG3). The snapped segment 0->1 covers the unnamed fix.
const { contractGraph } = await import("../src/contraction.ts");
const starEdges = [
{ from: 0, to: 1, seconds: 4 },
{ from: 1, to: 0, seconds: 4 },
{ from: 0, to: 2, seconds: 4 },
{ from: 2, to: 0, seconds: 4 },
];
const bare = encodeGraph({
lat: [3.15, 3.1505],
lng: [101.69, 101.6905],
edges: [{ from: 0, to: 1, seconds: 4 }],
lat: [3.15, 3.1505, 3.16],
lng: [101.69, 101.6905, 101.7],
edges: starEdges,
names: [],
maxSpeedKmh: 60,
landmarks: {
quantumS: 0.5,
nodes: [0],
to: Uint16Array.of(0, 30),
from: Uint16Array.of(0, 30),
},
ch: contractGraph({ nodeCount: 3, edges: starEdges }),
});
const { decodeGraph, UNNAMED_NAME_ID } = await loadGraph();
const bg = decodeGraph(bare);