fix(08): F1 — ALT landmark containment is clean-columns-only; per-node sentinel skip removed (RC-1)

Debug session alt-potential-4x-slowdown, root cause RC-1 (LIVE correctness
bug): bound()'s per-node sentinel skip (tv === 0xffff -> drop landmark l)
varied the active landmark family set PER NODE, so the toTable max lost
cross-edge consistency across edges leaving the 6,285-node disconnected
island (2.02% of cells) — shipped routeAstar returned suboptimal seconds
on 1/194 fresh random real-graph pairs vs the Dijkstra oracle (worst
single-edge family variance 1,026 s). D-03's fixed corpus was green on
sampling luck.

The fix (reverseFor precedent, in-constraint):
- per-graph WeakMap memo cleanColumnsFor: clean[l] = 'to-column l has ZERO
  sentinel cells' — O(L*n) sequential row scans once per graph/isolate
- per-search active list activeL = clean columns only; bound() loops the
  active list with NO per-node sentinel branch; the constant-table
  goal/origin NaN checks are retained (belt)
- on the current binary (0/24 clean columns) potentials degrade to EXACTLY
  haversine — the designed sound fallback; the pre-ALT CPU band returns
- ALT stays live and tested on strongly connected fixtures (mini-graph
  0/2 dirty columns, A12 ring all-clean, new A13 mixed subset case)
- LANDMARK_SENTINEL_U16 named constant; sentinel compares at setup scope
  only; pure-module law untouched

BH stop rule, reductions, balanced alternation, meet probes, real-seconds
reconstruction: UNTOUCHED. F2 (transposed table) explicitly out of scope.

Battery: routing 81/81 (A11 RED pins now green, A13 new), bot 126/126,
routing+bot typecheck clean.
This commit is contained in:
araharah-executor
2026-10-25 22:07:07 +08:00
parent c071e205e4
commit 2a5a0b5b6a
+110 -31
View File
@@ -52,6 +52,14 @@ 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. */
@@ -194,6 +202,53 @@ function reverseFor(graph: RoadGraph): ReverseAdj {
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
@@ -234,16 +289,26 @@ function reverseFor(graph: RoadGraph): ReverseAdj {
* 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
* 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's island,
* skip, and oneway seals) is the standing optimality proof.
* 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.
*/
export function routeAstar(graph: RoadGraph, originNode: number, goalNode: number): AstarResult | null {
const n = graph.nodeCount;
@@ -258,15 +323,21 @@ 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, 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
// 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.
// 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
@@ -284,14 +355,19 @@ export function routeAstar(graph: RoadGraph, originNode: number, goalNode: numbe
// 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 === 0xffff ? Number.NaN : gTo;
goalToU[l] = gTo === LANDMARK_SENTINEL_U16 ? Number.NaN : gTo;
const oTo = graph.landmarkTo[base + originNode]!;
origToU[l] = oTo === 0xffff ? Number.NaN : oTo;
origToU[l] = oTo === LANDMARK_SENTINEL_U16 ? Number.NaN : oTo;
}
// Directional heuristics + opposite potentials, lazy per node — only
@@ -306,21 +382,24 @@ export function routeAstar(graph: RoadGraph, originNode: number, goalNode: numbe
// 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.
// 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 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
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,ℓ)
}