From 149636ce0a8ca890c7b90bc7e64efd69beafedf2 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sun, 25 Oct 2026 19:54:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(08-05):=20D-03=20optimality=20gate=20green?= =?UTF-8?q?=20=E2=80=94=20fromTable=20families=20removed=20from=20the=20AL?= =?UTF-8?q?T=20potential?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1: the D-03 gate script (measure-optimality-gate.mjs) reads the frozen pre-ALT record, re-routes the same coordinate-pinned pairs on the current ARG2 binary, and gates on seconds-equality (±0.1%) + verdict-class preservation — never on node paths (equal-cost ties are documented behavior). Rule-1 deviation the gate forced on its first run: routeAstar returned SUBOPTIMAL real-graph routes (x038: 1234.6s vs the 1214.8s Dijkstra optimum; x045: 1475.5s vs 1460.6s) — the fixture sweep could not see it. Root cause: the fromTable bound d̂(ℓ,t) − d̂(ℓ,v) is one-directional Lipschitz (its u-side term needs the REVERSE edge; a oneway does not provide one — the anchor reached node 264611 126.8s faster than its own 1.49s upstream neighbour 264610), and the shared Ikeda average p = (h_f − h_b)/2 is consistent only when every family inside h_f/h_b is two-sided. The toTable bound d̂(v,ℓ) − d̂(t,ℓ) rides stored traversable edges in BOTH directions; it alone joins the haversine now. Stop rule, reductions, meet probes, reconstruction: untouched. landmarkTables/build verified EXACT (0 mismatches vs independent integer Dijkstras, both rows, all 24 anchors). - measure-optimality-gate.mjs: exported core + CLI guard (route-corpus pattern); epsilon mirror + argVersion asserts precede any measurement; per-pair rows, extract-drift WARNING (never a re-record), honest exit law - astar.ts: potential() consults landmarkTo only; doc-comments carry the one-directional-Lipschitz law and the x038/x045 evidence - astar.test.mjs: A11 re-pinned (landmarkFrom must NOT appear in the comment-stripped search — re-adding it trips the pin); A12 oneway-ring sweep added (directed reach asymmetry stays Dijkstra-equal on every pair) Battery: routing 80/80, bot 126/126, both typechecks green. D-03 gate: GATE PASS — 53/53 routed pairs equal within ±0.1% (max |Δ| 0.005%), verdict classes preserved (6 unreachable + 41 unsnappable, 0 re-snapped). --- routing/scripts/measure-optimality-gate.mjs | 302 ++++++++++++++++++++ routing/src/astar.ts | 101 ++++--- routing/test/astar.test.mjs | 84 +++++- 3 files changed, 441 insertions(+), 46 deletions(-) create mode 100644 routing/scripts/measure-optimality-gate.mjs diff --git a/routing/scripts/measure-optimality-gate.mjs b/routing/scripts/measure-optimality-gate.mjs new file mode 100644 index 0000000..57a0098 --- /dev/null +++ b/routing/scripts/measure-optimality-gate.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node +/* + * measure-optimality-gate.mjs — the D-03 optimality gate (Phase 8 plan 08-05). + * ------------------------------------------------------------------------------ + * A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src, + * web/src, or routing/src imports it (the route-corpus/measure-route-cpu law). + * + * Purpose: prove the ALT preprocessing (ARG2 + landmark steering) did not move + * route OPTIMALITY, by re-routing the SAME coordinate-pinned pairs the frozen + * pre-ALT record (Plan 08-01's routing/test/fixtures/alt-baseline.json) + * measured, and gating on: + * D03A — seconds equality: |post − pre| within ± OPTIMALITY_EPSILON_PCT % of + * pre (equal seconds pass; equal-cost TIES are fine — D-03); + * D03B — verdict-class preservation: reachable/unreachable stays the same + * per pair (unsnappable pairs that now snap are an INFO count, not a + * violation — D-03's letter gates the reachable/unreachable classes). + * + * WHAT THE GATE NEVER DOES: compare NODE PATHS. A different equal-cost path + * (a tie) is documented behavior under bidirectional search + ALT potentials — + * structurally invisible to this gate (no path read exists here; only seconds + * and verdict classes are compared). + * + * 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). + * + * 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 + * cannot fake a pass. Extract drift (node/edge count deltas vs the record) is + * a printed WARNING, never a violation, and NEVER a reason to re-record + * (Pitfall 1: the record is the anchor; mass failures escalate to the user). + * + * Echo law: pair ids + measurements ONLY (the route-corpus posture) — pair + * coordinates in the record are public bbox-derived values, never user data. + * + * Usage: + * node routing/scripts/measure-optimality-gate.mjs + * + * Exit codes: 0 GATE PASS (zero violations across both checks); 1 any + * violation, a mirror-assert failure, or an absent/undecodable input. + */ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { decodeGraph } from "../src/graph.ts"; +import { snapToGraph as snap } from "../src/snap.ts"; +import { routeAstar as astar } from "../src/astar.ts"; + +const here = dirname(fileURLToPath(import.meta.url)); // routing/scripts +/** The frozen pre-ALT record — the D-03 anchor (committed, immutable). */ +const RECORD_PATH = resolve(here, "..", "test", "fixtures", "alt-baseline.json"); +/** The current binary under test (same path law as measure-route-cpu.mjs). */ +const GRAPH_PATH = resolve(here, "..", "data", "kl-roadgraph.v1.bin"); + +/** + * D-03's named epsilon (±0.1% of the pre-ALT seconds) — the MIRROR of the + * record's epsilon block. The main() assert below refuses to measure when + * the two disagree: the record cannot be edited to fake an epsilon, and this + * constant cannot drift from the record silently. + */ +const OPTIMALITY_EPSILON_PCT = 0.1; +/** The record must be the PRE-ALT baseline (Pitfall 1's guard, mirrored). */ +const REQUIRED_BASELINE_ARG_VERSION = 1; + +function fail(message) { + console.error(`measure-optimality-gate: ${message}`); + process.exit(1); +} + +function toArrayBuffer(buf) { + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +/** + * The measurement core — exported so a suite can mirror the SAME numbers this + * CLI prints (one measurement, two reporters — the route-corpus split). + * + * For each record pair: snap both endpoints on the CURRENT graph, route with + * the CURRENT search, and classify: + * "pass" — seconds within epsilon (both routed) or the same null + * verdict class preserved (unreachable/unsnappable); + * "violation" — a D03A seconds break or a D03B reachable/unreachable class + * break; + * "re-snapped" — record-unsnappable that now snaps (INFO; counted, printed + * as a WARNING in the summary when > 0, never a violation); + * "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 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 }} + */ +export function measureOptimality(graph, record, { snap, astar }) { + const checks = { + D03A: { id: "D03A", label: "seconds equality (±0.1%)", violations: [] }, + D03B: { id: "D03B", label: "verdict class preserved", violations: [] }, + }; + const rows = []; + let reSnapped = 0; + + for (const pair of record.pairs) { + const pre = pair.seconds; // number | null + const verdictPre = pair.verdict; // "reachable" | "unreachable" | "unsnappable" + + const origin = snap(graph, pair.origin); + const goal = snap(graph, pair.destination); + let post = null; + let verdictPost = "unsnappable"; + if (origin !== null && goal !== null) { + const result = astar(graph, origin.node, goal.node); + if (result === null) verdictPost = "unreachable"; + else { + verdictPost = "reachable"; + post = result.seconds; + } + } + + let cls = "pass"; + let deltaPct = null; + + if (verdictPre === "unsnappable") { + if (verdictPost === "unsnappable") { + cls = "pass"; // unsnappable preserved — the honest null, stable + } else { + // The pair now snaps (+ routes or honestly reports unreachable): + // an INFO re-snap, counted and summary-WARNED, NOT a violation. + cls = "re-snapped"; + reSnapped += 1; + } + } else if (verdictPost === "unsnappable") { + cls = "unsnapped"; + checks.D03B.violations.push({ + pairId: pair.pairId, + reason: `verdict class break: ${verdictPre} (pre-ALT record) -> unsnappable (post-ALT snap miss)`, + }); + } else if (verdictPre !== verdictPost) { + cls = "violation"; + checks.D03B.violations.push({ + pairId: pair.pairId, + reason: `verdict class break: ${verdictPre} -> ${verdictPost}`, + }); + } else if (verdictPost === "reachable") { + // Both sides routed — D03A seconds equality (paths are NEVER compared; + // equal-cost tie differences cannot produce a violation by construction). + deltaPct = ((post - pre) / pre) * 100; + if (Math.abs(deltaPct) > OPTIMALITY_EPSILON_PCT) { + cls = "violation"; + checks.D03A.violations.push({ + pairId: pair.pairId, + reason: `seconds drift ${pre.toFixed(1)}s -> ${post.toFixed(1)}s (Δ ${deltaPct.toFixed(3)}% beyond ±${OPTIMALITY_EPSILON_PCT}%)`, + }); + } + } + // else: unreachable -> unreachable — class preserved, pass. + + rows.push({ pairId: pair.pairId, pre, post, deltaPct, verdictPre, verdictPost, class: cls }); + } + + const pass = + checks.D03A.violations.length === 0 && checks.D03B.violations.length === 0; + return { checks, rows, reSnapped, pass }; +} + +/* ————— CLI reporter ————— */ + +function formatSeconds(v) { + return v === null ? "unsnappable" : `${v.toFixed(1)}s`; +} + +function report(record, results, drift) { + const routed = results.rows.filter((r) => r.class === "pass" && r.pre !== null && r.post !== null); + const unreachPreserved = results.rows.filter( + (r) => r.verdictPre === "unreachable" && r.verdictPost === "unreachable", + ).length; + const unsnapPreserved = results.rows.filter( + (r) => r.verdictPre === "unsnappable" && r.verdictPost === "unsnappable", + ).length; + const maxAbsDelta = routed.reduce((m, r) => Math.max(m, Math.abs(r.deltaPct ?? 0)), 0); + + console.log(`D-03 optimality gate — ${results.rows.length} record pairs vs the current binary`); + for (const row of results.rows) { + const delta = row.deltaPct === null ? "Δ —" : `Δ ${row.deltaPct >= 0 ? "+" : ""}${row.deltaPct.toFixed(3)}%`; + const tag = + row.class === "re-snapped" + ? "re-snapped INFO" + : row.class === "unsnapped" || row.class === "violation" + ? "FAIL" + : "pass"; + const note = row.class === "re-snapped" ? " (re-snapped — no pre-ALT seconds to compare)" : ""; + console.log(` ${row.pairId}: pre ${formatSeconds(row.pre)} post ${formatSeconds(row.post)} (${delta})${note} ${tag}`); + } + + console.log( + `summary: ${results.rows.length} record pairs — ${routed.length} routed equal, ` + + `${unreachPreserved} unreachable preserved, ${unsnapPreserved} unsnappable preserved, ` + + `${results.reSnapped} re-snapped`, + ); + for (const check of Object.values(results.checks)) { + console.log(` ${check.id} (${check.label}): ${check.violations.length} violation(s)`); + for (const v of check.violations) { + console.log(` ${v.pairId}: ${v.reason}`); + } + } + console.log(` max |Δ%| over routed pairs: ${maxAbsDelta.toFixed(3)}%`); + console.log( + ` epsilon: ±${OPTIMALITY_EPSILON_PCT}% (OPTIMALITY_EPSILON_PCT — mirrored to the record's epsilon block: ${record.epsilon.pct}%)`, + ); + if (drift !== null) { + console.log( + ` extract drift vs the frozen record: nodes ${drift.nodes >= 0 ? "+" : ""}${drift.nodes}, edges ${drift.edges >= 0 ? "+" : ""}${drift.edges} — the seconds gate still applies`, + ); + } + if (results.reSnapped > 0) { + console.log( + `WARNING: ${results.reSnapped} record-unsnappable pair(s) now snap on the current graph (re-snapped INFO rows above) — counted, not a D-03 violation`, + ); + } + + if (!results.pass) { + const a = results.checks.D03A.violations.length; + const b = results.checks.D03B.violations.length; + console.error( + `GATE FAIL: D-03 optimality — ${a} D03A + ${b} D03B violations across ${results.rows.length} record pairs. ` + + `The record is IMMUTABLE (Pitfall 1): suspect extract drift first (the WARNING counts above), then escalate to the user — never edit or re-record the baseline.`, + ); + return false; + } + console.log( + `GATE PASS: D-03 optimality — ${routed.length}/${routed.length} routed pairs equal within ±${OPTIMALITY_EPSILON_PCT}%, verdict classes preserved ` + + `(${results.rows.length}/${results.rows.length} record pairs; ${unreachPreserved} unreachable + ${unsnapPreserved} unsnappable preserved, ${results.reSnapped} re-snapped)`, + ); + return true; +} + +function main() { + // Honest-refusal structure: EVERY guard below precedes any measurement call. + if (!existsSync(RECORD_PATH)) { + fail(`no baseline record at ${RECORD_PATH} — run measure-optimality-baseline first — the record is the D-03 anchor`); + } + if (!existsSync(GRAPH_PATH)) { + fail(`no graph at ${GRAPH_PATH} — build it first: node routing/scripts/build-road-graph.mjs`); + } + + const record = JSON.parse(readFileSync(RECORD_PATH, "utf8")); + if (!record || typeof record !== "object" || !Array.isArray(record.pairs) || record.pairs.length === 0) { + fail(`${RECORD_PATH} has no pairs array — the record schema is the gate's input contract`); + } + if (!record.graph || typeof record.graph.argVersion !== "number") { + fail(`${RECORD_PATH} has no graph block — the record schema is the gate's input contract`); + } + + // CONSTANT-MIRROR ASSERT (must precede any measurement): the gate's epsilon + // equals the record's epsilon block, by name and by value — a record edit + // alone cannot fake a pass. + if (record.epsilon?.name !== "OPTIMALITY_EPSILON_PCT" || record.epsilon?.pct !== OPTIMALITY_EPSILON_PCT) { + fail( + `epsilon mirror mismatch: the gate's OPTIMALITY_EPSILON_PCT is ${OPTIMALITY_EPSILON_PCT} but the record's epsilon block says ` + + `${JSON.stringify(record.epsilon ?? null)} — align the constant with the record's provenance (or git-revert the record edit); the record itself is immutable`, + ); + } + if (record.graph.argVersion !== REQUIRED_BASELINE_ARG_VERSION) { + fail( + `the baseline must be the pre-ALT record (argVersion ${REQUIRED_BASELINE_ARG_VERSION}) — this record says argVersion ${record.graph.argVersion}`, + ); + } + + 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`, + ); + } + + console.log( + `record: ${record.pairs.length} pairs recorded ${record.recordedAt} ` + + `(ARG${record.graph.argVersion}: ${record.graph.nodeCount} nodes / ${record.graph.edgeCount} edges, epsilon ±${record.epsilon.pct}%)`, + ); + console.log(`current: ${graph.nodeCount} nodes / ${graph.edgeCount} edges (decoded from ${GRAPH_PATH})`); + let drift = null; + const nodeDrift = graph.nodeCount - record.graph.nodeCount; + const edgeDrift = graph.edgeCount - record.graph.edgeCount; + if (nodeDrift !== 0 || edgeDrift !== 0) { + drift = { nodes: nodeDrift, edges: edgeDrift }; + console.log( + `WARNING: extract drift vs the frozen record — the seconds gate still applies (NEVER a reason to re-record; Pitfall 1)`, + ); + } + + const results = measureOptimality(graph, record, { snap, astar }); + const pass = report(record, results, drift); + process.exit(pass ? 0 : 1); +} + +// Run only when invoked directly — importing this module (an in-process +// mirror of the same numbers) must never execute the CLI (route-corpus law). +const invoked = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ""; +if (invoked === import.meta.url) { + main(); +} diff --git a/routing/src/astar.ts b/routing/src/astar.ts index 95f2b96..4b6b7b3 100644 --- a/routing/src/astar.ts +++ b/routing/src/astar.ts @@ -219,22 +219,31 @@ function reverseFor(graph: RoadGraph): ReverseAdj { * exhaustive 625-pair Dijkstra-equivalence sweep is the standing proof, * re-run on every pass. * - * ALT (plan 08-03): the h_f/h_b bodies are max(ALT triangle bounds, - * haversine) over the ARG2 landmark tables (graph.landmarkTo / - * graph.landmarkFrom, quantized at graph.landmarkQuantumS). 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). Sentinel laws - * (research Pattern 3): a landmark whose goal/origin-side cell is the - * 0xFFFF sentinel (DATA) drops for the WHOLE search — the search-scoped - * constant skip; a per-node sentinel cell skips that ℓ; a node sentinel - * for every remaining ℓ falls back to the pure haversine bound (admissible - * + consistent everywhere). The survey's (πf−πr)/2 combination is what the + * 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): a + * landmark whose goal/origin-side to-cell is the 0xFFFF sentinel (DATA) + * drops for the WHOLE search — the search-scoped constant skip; a + * per-node sentinel cell skips that ℓ; a node sentinel for every + * remaining ℓ falls back to the pure haversine bound (admissible + + * consistent everywhere). 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's island + - * skip seals) is the standing optimality proof. + * BH skeleton, and A1's sweep re-run under ALT (with A9/A10/A12's island, + * skip, and oneway seals) is the standing optimality proof. */ export function routeAstar(graph: RoadGraph, originNode: number, goalNode: number): AstarResult | null { const n = graph.nodeCount; @@ -249,31 +258,40 @@ export function routeAstar(graph: RoadGraph, originNode: number, goalNode: numbe const origLat = graph.lat[originNode]!; const origLng = graph.lng[originNode]!; - // ALT per-search constants (plan 08-03): the goal/origin-side table cells - // hoisted ONCE per search, kept in QUANTA units with a 0xFFFF sentinel - // written as NaN. A sentinel constant drops that landmark for the WHOLE - // search — the search-scoped skip; these constant tables are the ONLY + // ALT per-search constants (plan 08-03, amended 08-05): the goal/origin-side + // to-table cells hoisted ONCE per search, kept in QUANTA units with a 0xFFFF + // sentinel written as NaN. A sentinel constant drops that landmark for the + // WHOLE search — the search-scoped skip; these constant tables are the ONLY // place skip decisions live (never a per-node ℓ-mask mid-max, which would // vary the landmark set inside the max and break the max-of-Lipschitz // consistency argument). 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. + // + // 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 goalToU = new Float64Array(L); - const goalFromU = new Float64Array(L); const origToU = new Float64Array(L); - const origFromU = new Float64Array(L); for (let l = 0; l < L; l++) { const base = l * n; const gTo = graph.landmarkTo[base + goalNode]!; goalToU[l] = gTo === 0xffff ? Number.NaN : gTo; - const gFrom = graph.landmarkFrom[base + goalNode]!; - goalFromU[l] = gFrom === 0xffff ? Number.NaN : gFrom; const oTo = graph.landmarkTo[base + originNode]!; origToU[l] = oTo === 0xffff ? Number.NaN : oTo; - const oFrom = graph.landmarkFrom[base + originNode]!; - origFromU[l] = oFrom === 0xffff ? Number.NaN : oFrom; } // Directional heuristics + opposite potentials, lazy per node — only @@ -283,49 +301,42 @@ export function routeAstar(graph: RoadGraph, originNode: number, goalNode: numbe 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 - // whose constants are constTo/constFrom. Four families per landmark ℓ - // (Bast et al. survey §2.2), evaluated in quanta and converted to seconds - // ONCE at the end: - // family A: d(v,t) >= d(v,ℓ) − d(t,ℓ) (toTable both terms) - // family B: d(v,t) >= d(ℓ,t) − d(ℓ,v) (fromTable both terms) - // (mirrored toward the origin by the hb call site). Per-node sentinel - // cells skip that ℓ — compared RAW (u16) BEFORE any float conversion - // (the sentinel read as a float would be a large-but-finite bogus bound - // dominating the max). A node sentinel for every remaining ℓ degrades to - // exactly the + // 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. Per-node + // sentinel cells skip that ℓ — compared RAW (u16) BEFORE any float + // conversion (the sentinel read as a float would be a large-but-finite + // bogus bound dominating the max). A node sentinel for every remaining ℓ + // 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, constFrom: Float64Array): number { + function bound(v: number, lat2: number, lng2: number, constTo: Float64Array): number { let bestTo = Number.NEGATIVE_INFINITY; - let bestFrom = Number.NEGATIVE_INFINITY; for (let l = 0; l < L; l++) { const base = l * n; const tv = graph.landmarkTo[base + v]!; if (tv === 0xffff) continue; // per-node sentinel skip — RAW u16 compare const cTo = constTo[l]!; if (!Number.isNaN(cTo)) bestTo = Math.max(bestTo, tv - cTo); // family A: d(v,ℓ) − d(t,ℓ) - const fv = graph.landmarkFrom[base + v]!; - if (fv === 0xffff) continue; - const cFrom = constFrom[l]!; - if (!Number.isNaN(cFrom)) bestFrom = Math.max(bestFrom, cFrom - fv); // family B: d(ℓ,t) − d(ℓ,v) } const hav = metersTo(graph, v, lat2, lng2) * heuristicScale; - const alt = Math.max(bestTo, bestFrom); - return alt > Number.NEGATIVE_INFINITY ? Math.max(hav, alt * Q) : hav; + 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, goalFromU); + hf = bound(v, goalLat, goalLng, goalToU); hfCache[v] = hf; } let hb = hbCache[v]!; if (Number.isNaN(hb)) { - hb = bound(v, origLat, origLng, origToU, origFromU); + hb = bound(v, origLat, origLng, origToU); hbCache[v] = hb; } const value = (hf - hb) / 2; diff --git a/routing/test/astar.test.mjs b/routing/test/astar.test.mjs index b5b6c14..1103286 100644 --- a/routing/test/astar.test.mjs +++ b/routing/test/astar.test.mjs @@ -368,6 +368,81 @@ test("A8 structural — no `new Map(` in astar.ts (typed arrays only in the hot 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. + */ +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"); + 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)); @@ -442,9 +517,16 @@ test("A11 structural (ALT) — astar.ts consults the landmark tables in CODE, no .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", "landmarkFrom", "landmarkQuantumS"]) { + 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)"); // Raw u16 sentinel handling on the table families — one spelling of the // sentinel (0xffff in code, never the decimal 65535). assert.ok(code.includes("0xffff"), "the sentinel is compared raw (u16, before float conversion)");