From 2a5a0b5b6a6a803b9eea811ac72e5b3af1e4ea83 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sun, 25 Oct 2026 22:07:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(08):=20F1=20=E2=80=94=20ALT=20landmark=20co?= =?UTF-8?q?ntainment=20is=20clean-columns-only;=20per-node=20sentinel=20sk?= =?UTF-8?q?ip=20removed=20(RC-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- routing/src/astar.ts | 141 +++++++++++++++++++++++++++++++++---------- 1 file changed, 110 insertions(+), 31 deletions(-) diff --git a/routing/src/astar.ts b/routing/src/astar.ts index 4b6b7b3..7a2b900 100644 --- a/routing/src/astar.ts +++ b/routing/src/astar.ts @@ -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(); + +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,ℓ) }