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:
+110
-31
@@ -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,ℓ)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user