fix(08-05): D-03 optimality gate green — fromTable families removed from the ALT potential
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).
This commit is contained in:
@@ -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();
|
||||
}
|
||||
+56
-45
@@ -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;
|
||||
|
||||
@@ -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)");
|
||||
|
||||
Reference in New Issue
Block a user