gam_solve/arrow_schur/solve_options.rs
1//! Solver configuration and the batched block-solve abstraction: BA solver
2//! modes, PCG/trust-region/mixed-precision/proximal options, diagnostics, and
3//! the [`BatchedBlockSolver`] trait with its CPU implementation.
4
5use super::*;
6
7/// One route-independent classification of an eigendirection of the raw exact
8/// observed information `A = B_raw + delta_C` (#2515).
9///
10/// Both dense and arrow evidence lanes must ask this function before deciding
11/// whether a direction is identified, a bounded prior-clamp wrinkle, or a true
12/// saddle. Keeping the decision as a typed result prevents the historical
13/// drift where dense used the majorizer pencil plus clamp basin while arrow used
14/// a local relative eigenvalue and rejected every negative direction.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum ExactADirectionClassification {
17 /// Curvature is positive and identifiable in the majorizer metric.
18 ResolvedPositive { curvature: f64 },
19 /// Curvature lies inside the common numerical-null band and contributes the
20 /// route-independent quotient constant `log(1) = 0`.
21 NumericalNull,
22 /// Raw `A` is negative, but all of the negativity is the exactly known,
23 /// bounded concave clamp omitted by the PSD majorizer. Evidence is priced
24 /// at the positive coarse-grained basin curvature.
25 ClampBasin { curvature: f64 },
26 /// Negative curvature remains after the clamp is restored: this state is a
27 /// saddle, not a mode, and evidence must refuse it.
28 Saddle { curvature: f64, basin: f64 },
29}
30
31/// Common numerical-null half-width for one exact-`A` direction.
32#[must_use]
33pub fn exact_a_direction_floor(
34 spectral_dimension: usize,
35 spectral_norm: f64,
36 majorizer_curvature: f64,
37) -> f64 {
38 let arithmetic_floor =
39 (spectral_dimension as f64) * f64::EPSILON * spectral_norm.abs();
40 let identifiability_floor = f64::EPSILON.sqrt() * majorizer_curvature.max(0.0);
41 arithmetic_floor.max(identifiability_floor)
42}
43
44/// Classify one exact-`A` eigendirection using the single #2673/#2515 contract.
45///
46/// The numerical-null half-width is
47///
48/// `max(dimension * eps * ||A||_2, sqrt(eps) * v^T B v)`.
49///
50/// The first term is the symmetric eigensolver's backward-error floor; the
51/// second is the invariant majorizer-pencil identifiability floor. A resolved
52/// negative direction is tested against the exactly known clamp curvature
53/// `v^T E v` before it may be called a saddle.
54#[must_use]
55pub fn classify_exact_a_direction(
56 curvature: f64,
57 spectral_dimension: usize,
58 spectral_norm: f64,
59 majorizer_curvature: f64,
60 clamp_curvature: f64,
61) -> ExactADirectionClassification {
62 let floor = exact_a_direction_floor(
63 spectral_dimension,
64 spectral_norm,
65 majorizer_curvature,
66 );
67 if curvature < -floor {
68 let basin = curvature + clamp_curvature.max(0.0);
69 if basin < -floor {
70 ExactADirectionClassification::Saddle { curvature, basin }
71 } else if basin > floor {
72 ExactADirectionClassification::ClampBasin { curvature: basin }
73 } else {
74 ExactADirectionClassification::NumericalNull
75 }
76 } else if curvature > floor {
77 ExactADirectionClassification::ResolvedPositive { curvature }
78 } else {
79 ExactADirectionClassification::NumericalNull
80 }
81}
82
83/// BA Schur solve variant for the reduced shared `β` system.
84///
85/// * [`ArrowSolverMode::Direct`] is BA's dense reduced-camera-system solve:
86/// eliminate the per-point/per-row blocks, form the reduced system, and
87/// Cholesky factor it. This is the Ceres/g2o default for modest camera
88/// counts and is appropriate here for `K <= 2000`.
89/// **GPU support: ✓** — requires dense H_ββ and dense per-row H_tβ slabs.
90///
91/// * [`ArrowSolverMode::SqrtBA`] ports Square-Root BA (Demmel/Gao/Gu et al.,
92/// CVPR 2021): Schur terms are formed as `(L_i^-1 H_tβ_i)^T
93/// (L_i^-1 H_tβ_i)` from the per-row square-root factor `L_i`, avoiding
94/// explicit `H_tt^-1 H_tβ` products. It is the preferred direct path when
95/// single-precision assembly is introduced or when row blocks are poorly
96/// conditioned.
97/// **GPU support: ✓** — requires dense H_ββ and dense per-row H_tβ slabs.
98///
99/// * [`ArrowSolverMode::InexactPCG`] ports "Bundle Adjustment in the Large"
100/// (Agarwal et al.): the Schur system is solved inexactly by PCG with a
101/// Jacobi Schur preconditioner, avoiding dense `K × K` factorization for
102/// SAE-manifold scale shared systems.
103/// **GPU support: CPU only** until the row-procedural H_tβ GPU PCG path
104/// (issue #288 Part B) is wired. The topology selector must not request
105/// `InexactPCG` via the GPU entry point; `solve_arrow_newton_step` returns
106/// `GpuRequiresDenseSystem` for matrix-free systems, and the wrapper in
107/// `solver/gpu/arrow_schur_gpu.rs` routes those to CPU InexactPCG
108/// automatically. At K ≥ 5000 the GPU PCG path will supersede the CPU path
109/// once the row-procedural H_tβ kernel and boxed GPU matvec backend in
110/// `run_pcg_with_preconditioner` are wired.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum ArrowSolverMode {
113 Direct,
114 SqrtBA,
115 InexactPCG,
116}
117
118impl ArrowSolverMode {
119 /// BA-size heuristic: dense RCS for modest `K`, inexact Schur PCG for
120 /// large shared systems. This follows Agarwal et al.'s direct-vs-iterative
121 /// split for large BA, mapped from cameras to decoder coefficients.
122 pub const fn automatic(k: usize) -> Self {
123 if k <= DIRECT_SOLVE_MAX_K {
124 Self::Direct
125 } else {
126 Self::InexactPCG
127 }
128 }
129
130}
131
132/// Reason the Steihaug-CG loop stopped.
133#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
134pub enum PcgStopReason {
135 /// Residual fell below the relative tolerance threshold.
136 #[default]
137 Converged,
138 /// Loop exhausted max_iterations without converging.
139 MaxIter,
140 /// Step hit the trust-region boundary (Steihaug boundary projection).
141 ///
142 /// This is also what a BOUNDED solve reports on negative curvature or a
143 /// non-positive preconditioned residual: Steihaug's answer to either is the
144 /// boundary step, so the boundary is the honest reason. In an UNBOUNDED
145 /// solve those two conditions do not produce a diagnostic at all -- they
146 /// return `ArrowSchurError::UnboundedNegativeCurvature` and
147 /// `ArrowSchurError::PcgFailed` respectively, so no `PcgStopReason` is
148 /// constructed on that path.
149 TrustRegion,
150}
151
152/// Per-solve instrumentation counters returned alongside the PCG solution.
153///
154/// All fields default to zero; callers that do not need diagnostics simply
155/// ignore the value. The struct is Copy so passing it through return tuples
156/// is zero-overhead.
157#[derive(Debug, Default, Clone, Copy)]
158pub struct ArrowPcgDiagnostics {
159 /// Number of CG iterations executed.
160 pub iterations: usize,
161 /// Total calls to the Schur matvec A·p.
162 pub matvec_calls: usize,
163 /// Total calls to the preconditioner M^{-1}·r.
164 pub precond_apply_calls: usize,
165 /// Number of times the LM ridge was escalated before a successful factor.
166 pub ridge_escalations: usize,
167 /// Relative residual at termination; 0.0 when the RHS was zero.
168 pub final_relative_residual: f64,
169 /// Why the loop stopped.
170 pub stopping_reason: PcgStopReason,
171 /// Mixed-precision certificate outcome for this solve.
172 pub mixed_precision_status: MixedPrecisionStatus,
173 /// True exactly when the matrix-free reduced-Schur PCG algorithm was
174 /// selected. This records the numerical owner independently of execution
175 /// placement: CPU and device InexactPCG both set it, while dense Direct
176 /// assembly/solve (including dense Steihaug trust-region work) leaves it
177 /// false. Use [`Self::used_device_arrow`] separately to answer where the
178 /// selected algorithm ran.
179 pub selected_matrix_free_pcg: bool,
180 /// True only when the reduced-Schur solve was **actually executed on the
181 /// device**: either the fully device-resident batched Arrow-Schur Direct
182 /// sequence (`try_device_arrow_direct` → `solve_arrow_newton_step`) or the
183 /// device-resident matrix-free SAE PCG (`solve_sae_matrix_free_pcg`, which
184 /// runs the matvec in CUDA kernels over device-resident frames). It is NOT
185 /// set merely because a GPU runtime exists and a dispatch gate fired (#1209).
186 pub used_device_arrow: bool,
187 /// True when a reduced-Schur matvec backend was injected through
188 /// `maybe_inject_gpu_schur_matvec` but the matvec itself runs as a
189 /// **host** (CPU Rust/Rayon) procedural closure — both the matrix-free
190 /// `build_row_procedural_matvec` branch and the `cuda::build_schur_matvec_backend`
191 /// branch return host closures that evaluate `Σ_i Y_iᵀ(Y_i x)` on the CPU,
192 /// even when a CUDA context was opened to build the per-row factors. This
193 /// path must NOT report `used_device_arrow`: the arithmetic is host-side
194 /// (#1209). Distinct field so perf accounting never mistakes a host
195 /// procedural matvec for true device execution.
196 pub injected_host_procedural_matvec: bool,
197}
198
199/// Outcome of an opt-in mixed-precision arrow solve.
200#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
201pub enum MixedPrecisionStatus {
202 /// The caller did not request mixed precision or this solve mode cannot use it.
203 #[default]
204 Off,
205 /// The f32 factor solve was refined until the f64 backward-error certificate held.
206 Certified { refinement_steps: usize },
207 /// The kappa gate or solve shape rejected mixed precision and the f64 path ran.
208 /// The declining reason is logged at `info` level when the fallback fires.
209 F64Fallback,
210}
211
212/// PCG controls for BA's inexact reduced-camera-system solve.
213///
214/// The defaults mirror the loose inner tolerances used by inexact-step LM in
215/// "Bundle Adjustment in the Large": solve the Schur system only accurately
216/// enough for a useful trust-region step, then let the outer LM iteration
217/// correct the remaining error.
218#[derive(Debug, Clone)]
219pub struct ArrowPcgOptions {
220 pub max_iterations: usize,
221 pub relative_tolerance: f64,
222}
223
224impl Default for ArrowPcgOptions {
225 fn default() -> Self {
226 Self {
227 max_iterations: DEFAULT_PCG_MAX_ITERATIONS,
228 relative_tolerance: DEFAULT_PCG_RELATIVE_TOLERANCE,
229 }
230 }
231}
232
233/// Trust-region controls for Steihaug-CG on the reduced BA system.
234///
235/// This is the Ceres-style guard around LM: `ridge_t`/`ridge_beta` provide
236/// Levenberg damping, while the trust radius bounds the reduced shared step
237/// in Euclidean β coordinates using Steihaug's truncated-CG stopping rules for
238/// boundary hits and negative curvature.
239#[derive(Debug, Clone)]
240pub struct ArrowTrustRegionOptions {
241 pub radius: f64,
242 pub steihaug_relative_tolerance: f64,
243 pub max_iterations: usize,
244}
245
246impl Default for ArrowTrustRegionOptions {
247 fn default() -> Self {
248 Self {
249 radius: DEFAULT_TRUST_REGION_RADIUS,
250 steihaug_relative_tolerance: DEFAULT_PCG_RELATIVE_TOLERANCE,
251 max_iterations: DEFAULT_PCG_MAX_ITERATIONS,
252 }
253 }
254}
255
256/// Opt-in Carson--Higham mixed-precision refinement for dense arrow solves.
257///
258/// Default is [`ArrowSolvePrecisionPolicy::F64Only`]: exact f64 solves remain the default.
259/// [`ArrowSolvePrecisionPolicy::CertifiedMixed`] stores f32 copies of the per-row Cholesky
260/// factors and dense Schur factor, solves corrections in f32, and recomputes the
261/// residual in f64 against the original arrow blocks. The standard refinement
262/// certificate is the normwise backward error
263///
264/// `||r||_inf / (||H||_inf ||x||_inf + ||b||_inf) <= residual_relative_tolerance`.
265///
266/// The kappa gate enforces `kappa_estimate * u_f32 < kappa_unit_roundoff_margin`;
267/// when it fails, the solve reports [`MixedPrecisionStatus::F64Fallback`] and
268/// logs the reason before using the f64 path.
269#[derive(Debug, Clone, Copy, PartialEq)]
270pub enum ArrowSolvePrecisionPolicy {
271 F64Only,
272 CertifiedMixed {
273 max_refinement_steps: usize,
274 residual_relative_tolerance: f64,
275 kappa_unit_roundoff_margin: f64,
276 },
277}
278
279impl Default for ArrowSolvePrecisionPolicy {
280 fn default() -> Self {
281 Self::F64Only
282 }
283}
284
285impl ArrowSolvePrecisionPolicy {
286 pub fn certified_mixed() -> Self {
287 Self::CertifiedMixed {
288 max_refinement_steps: DEFAULT_MIXED_PRECISION_MAX_REFINEMENTS,
289 residual_relative_tolerance: DEFAULT_MIXED_PRECISION_CERTIFICATE_TOLERANCE,
290 kappa_unit_roundoff_margin: DEFAULT_MIXED_PRECISION_KAPPA_MARGIN,
291 }
292 }
293
294 pub(crate) fn is_enabled(self) -> bool {
295 matches!(self, ArrowSolvePrecisionPolicy::CertifiedMixed { .. })
296 }
297}
298
299/// Conditioning contract for the undamped evidence cache.
300///
301/// This is intentionally independent of Newton-step damping. Evidence may
302/// accept a genuinely positive but ill-conditioned operator, or may unit-pin
303/// numerical nulls so they contribute `log 1 = 0`; neither choice is permitted
304/// to alter the Newton system or its Tikhonov floor.
305#[derive(Debug, Clone, Copy, PartialEq)]
306pub enum ArrowEvidencePolicy {
307 Strict,
308 PositiveDefinite,
309 UnitDeflation { relative_floor: f64 },
310 /// [`Self::UnitDeflation`]'s NULL band, and a typed REFUSAL for a RESOLVED
311 /// NEGATIVE direction instead of a unit pin (#2515).
312 ///
313 /// `UnitDeflation` deflates on `λ < floor` — one-sided, so it swallows every
314 /// negative eigenvalue however large, and prices it as `log 1 = 0` with a
315 /// `1/λ → 1` inverse. For the Gauss--Newton majorizer that is right: `B` is
316 /// PSD by construction, so a negative eigenvalue there is a rounding artefact
317 /// of a direction that is numerically null anyway. For an operator whose
318 /// negative curvature is a MODELLING VERDICT it is wrong, and silently so.
319 ///
320 /// This policy therefore requires the exact-A system's typed raw
321 /// `B`/`ΔC`/clamp carrier. Every dense, direct-arrow, SLQ, and rational Ritz
322 /// direction uses the common null half-width
323 /// `max(dim·ε·||A||, sqrt(ε)·v'Bv)` and restores `v'Ev` before deciding:
324 /// numerical nulls are unit-pinned, clamp-attributable negatives are priced
325 /// at their positive basin, and only residual negative curvature is refused
326 /// as a saddle. Missing classification geometry is a hard contract error;
327 /// raw sign plus a route-local relative floor is never a substitute.
328 ///
329 /// Measured on #2712's deflated anchor at `log λ_smooth = −1.05`
330 /// (`zz_attribute_the_broken_ladder_rung_2515`): the reduced Schur of the
331 /// exact observed information carries eigenvalues `−7.997610e-3` and
332 /// `−2.033493e-3`, relative magnitudes `1.4e-3` and `3.6e-4`, five decades
333 /// OUTSIDE the `1e-8` null band. `UnitDeflation` pinned both to `+1`; the
334 /// dense route classified the same two directions as clamp-attributable
335 /// negative curvature and priced them at their basin. The two complete outer
336 /// gradients then differed by `1.009` RELATIVE.
337 UnitDeflationRefusingIndefinite { relative_floor: f64 },
338}
339
340impl ArrowEvidencePolicy {
341 pub(crate) fn factors_undamped_evidence(self) -> bool {
342 !matches!(self, Self::Strict)
343 }
344
345 pub(crate) fn reduced_schur_policy(self) -> ReducedSchurPolicy {
346 match self {
347 Self::Strict | Self::PositiveDefinite => ReducedSchurPolicy::StrictNewton,
348 Self::UnitDeflation { relative_floor } => ReducedSchurPolicy::EvidenceUnitDeflation {
349 relative_floor,
350 refuse_resolved_indefinite: false,
351 },
352 Self::UnitDeflationRefusingIndefinite { relative_floor } => {
353 ReducedSchurPolicy::EvidenceUnitDeflation {
354 relative_floor,
355 refuse_resolved_indefinite: true,
356 }
357 }
358 }
359 }
360
361 /// Whether a resolved negative direction of the evidence operator is a
362 /// refusal rather than a unit pin. Read by every conditioning site so the
363 /// decision is made once, here, and not re-derived per site.
364 pub(crate) fn refuses_resolved_indefinite(self) -> bool {
365 matches!(self, Self::UnitDeflationRefusingIndefinite { .. })
366 }
367}
368
369/// Complete BA Schur solve options.
370///
371/// Use [`ArrowSolveOptions::automatic`] for normal latent-coordinate fits;
372/// use `ArrowSolveOptions::sqrt_ba` when the assembler has single-precision
373/// row blocks or an ill-conditioned gauge; use [`ArrowSolveOptions::inexact_pcg`]
374/// for SAE-manifold scale `K`.
375#[derive(Clone)]
376pub struct ArrowSolveOptions {
377 pub mode: ArrowSolverMode,
378 /// Backend policy owned by this solve. Keeping it in the immutable solve
379 /// request prevents one fit from changing another fit's device routing.
380 pub gpu_policy: gam_gpu::GpuPolicy,
381 pub pcg: ArrowPcgOptions,
382 pub trust_region: ArrowTrustRegionOptions,
383 /// Row chunk size for streaming direct/Square-Root Schur assembly.
384 pub streaming_chunk_size: Option<usize>,
385 /// Use the Riemannian latent projection before the Schur reduction. The
386 /// reduced Steihaug solve itself remains in Euclidean β coordinates.
387 pub riemannian_trust_region: bool,
388 /// Optional GPU-backed Schur matvec for CPU-driven `InexactPCG` at K ≥ 5000.
389 ///
390 /// When set, `run_pcg_with_preconditioner` delegates each `S·p` call to
391 /// this closure instead of the CPU `schur_matvec`. Constructed by
392 /// `crate::gpu_kernels::arrow_schur::gpu_schur_matvec_backend` when `cuda_selected()`
393 /// and the system has dense per-row H_tβ slabs. `None` means CPU-only PCG.
394 pub gpu_matvec: Option<GpuSchurMatvec>,
395 /// Conditioning contract for the separately-built undamped evidence cache.
396 pub evidence_policy: ArrowEvidencePolicy,
397 /// Arrow solve precision policy. Default is f64-only.
398 pub solve_precision: ArrowSolvePrecisionPolicy,
399 /// Optional spectral positive-definiteness floor on the *reduced Schur
400 /// complement* `S = H_ββ + ridge_β·I − Σ_i H_tβ^(i)ᵀ (H_tt^(i))⁻¹ H_tβ^(i)`,
401 /// as a relative fraction of `S`'s largest eigenvalue.
402 ///
403 /// `None` (default) keeps the strict contract: a non-PD `S` errors as
404 /// `ArrowSchurError::SchurFactorFailed` so the LM outer loop lifts
405 /// `ridge_beta` globally and re-forms `S`.
406 ///
407 /// `Some(floor)` engages the #1026 SAE co-collapse cure on the SOLVE path:
408 /// when the reduced Schur Cholesky refuses (collapsed atoms drive a per-row
409 /// `H_tt` near-singular, so the accumulated `(H_tt)⁻¹` over-subtracts `S`
410 /// into an INDEFINITE matrix), instead of rejecting and over-damping every
411 /// β direction with a global ridge, symmetric-eigendecompose `S` and clamp
412 /// every eigenvalue UP to `floor·max(λ)`. This is Levenberg–Marquardt
413 /// restricted to exactly the indefinite/collapsed subspace: the
414 /// well-conditioned β directions (`λ ≫ floor·max λ`) are untouched and the
415 /// step in those directions is the exact Newton step, while only the
416 /// collapsed directions receive the minimal damping needed for a PD solve.
417 /// The inner Newton then makes a real descent step rather than crawling
418 /// behind an inflated global ridge. Mirrors the per-row spectral floor the
419 /// evidence path uses for #1377/#1117/#1118
420 /// (`super::factorization::factor_spectral_deflated_criterion_row_with_geometry`); the
421 /// difference is the floored value — a small positive `floor·max λ`
422 /// (Tikhonov) for the solve, vs unit stiffness `+1` (`log 1 = 0`) for the
423 /// evidence log-det.
424 ///
425 /// Only consulted by the dense Direct / SqrtBA reduced solve (the only
426 /// caller of `super::reduced_solve::solve_dense_reduced_system`); the
427 /// InexactPCG path is unaffected.
428 pub newton_schur_tikhonov_rel_floor: Option<f64>,
429 /// #1017 device-resident framed SAE frame for the LM ridge ladder.
430 ///
431 /// When set (by [`super::newton_step::solve_with_lm_escalation_inner`] on a
432 /// device-admitted matrix-free SAE system), the large-border InexactPCG
433 /// branch recomputes only the ridge-dependent per-row `ainv` per ladder trial
434 /// and reuses the resident ridge-independent operand buffers, instead of
435 /// re-marshalling and re-uploading every operand through
436 /// `flatten_device_sae_frame_data` on each trial. Direct never installs this
437 /// frame: its canonical dense Schur factor owns both the step and evidence.
438 /// The InexactPCG solve is bit-identical; only the redundant per-trial upload
439 /// is removed. `None` (default) keeps the per-trial re-flatten path. A trait
440 /// object (like [`GpuSchurMatvec`]) keeps the CUDA-only device buffers out of
441 /// these cfg-independent options.
442 pub sae_resident_frame:
443 Option<std::sync::Arc<dyn crate::gpu_kernels::arrow_schur::SaeResidentFrame + Send + Sync>>,
444}
445
446impl std::fmt::Debug for ArrowSolveOptions {
447 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448 f.debug_struct("ArrowSolveOptions")
449 .field("mode", &self.mode)
450 .field("gpu_policy", &self.gpu_policy)
451 .field("pcg", &self.pcg)
452 .field("trust_region", &self.trust_region)
453 .field("streaming_chunk_size", &self.streaming_chunk_size)
454 .field("riemannian_trust_region", &self.riemannian_trust_region)
455 .field("gpu_matvec", &self.gpu_matvec.is_some())
456 .field("evidence_policy", &self.evidence_policy)
457 .field("solve_precision", &self.solve_precision)
458 .field(
459 "newton_schur_tikhonov_rel_floor",
460 &self.newton_schur_tikhonov_rel_floor,
461 )
462 .field("sae_resident_frame", &self.sae_resident_frame.is_some())
463 .finish()
464 }
465}
466
467/// Globalization guard for non-convex arrow-Schur inner steps.
468///
469/// The raw Schur solve is exactly Newton. For non-convex analytic penalties,
470/// full Newton can cycle. This controller adds a proximal LM shift `mu I` to
471/// both blocks and accepts only Armijo-decreasing trial points.
472#[derive(Debug, Clone)]
473pub struct ArrowProximalCorrectionOptions {
474 pub initial_ridge: f64,
475 pub ridge_growth: f64,
476 pub max_attempts: usize,
477 pub armijo_c1: f64,
478 pub gradient_tolerance: f64,
479 /// Relative objective resolution below which the proximal correction
480 /// declares convergence instead of failing.
481 ///
482 /// Near a stationary point the largest decrease the damped Newton model can
483 /// still achieve shrinks to the floating-point resolution of the objective
484 /// itself: at proximal ridge `μ → μ_max` the accepted step length is
485 /// `O(‖g‖ / μ)`, so the realised change in the objective falls below
486 /// `rel_tol · (|f| + 1)`. At that scale the Armijo sufficient-decrease test
487 /// compares two values that differ only by rounding noise, and no further
488 /// productive decrease is achievable. Rather than raise
489 /// `AdaptiveCorrectionFailed`, the loop then returns the incumbent state
490 /// (a zero step) as converged. This does NOT mask genuine non-convergence:
491 /// it triggers only when every attempted step either fails to decrease the
492 /// objective by more than this resolution OR increases it by no more than
493 /// this resolution (pure rounding). A step that genuinely reduces the
494 /// objective is always taken first.
495 pub convergence_objective_rel_tol: f64,
496}
497
498impl Default for ArrowProximalCorrectionOptions {
499 fn default() -> Self {
500 Self {
501 initial_ridge: DEFAULT_PROXIMAL_INITIAL_RIDGE,
502 ridge_growth: DEFAULT_PROXIMAL_RIDGE_GROWTH,
503 max_attempts: DEFAULT_PROXIMAL_MAX_ATTEMPTS,
504 armijo_c1: DEFAULT_ARMIJO_C1,
505 gradient_tolerance: DEFAULT_GRADIENT_TOLERANCE,
506 convergence_objective_rel_tol: DEFAULT_PROXIMAL_CONVERGENCE_REL_TOL,
507 }
508 }
509}
510
511/// Accepted proximal arrow-Schur step and the damping that made it descent.
512#[derive(Debug, Clone)]
513pub struct ArrowAcceptedProximalStep {
514 pub delta_t: Array1<f64>,
515 pub delta_beta: Array1<f64>,
516 pub ridge_t: f64,
517 pub ridge_beta: f64,
518 pub proximal_ridge: f64,
519 pub objective_value: f64,
520 pub trial_objective_value: f64,
521 pub gradient_dot_step: f64,
522 pub attempts: usize,
523}
524
525impl ArrowSolveOptions {
526 /// Select Direct for `K <= 2000` and InexactPCG above, following BA RCS
527 /// practice for dense-vs-iterative reduced systems.
528 pub fn automatic(k: usize) -> Self {
529 Self {
530 mode: ArrowSolverMode::automatic(k),
531 gpu_policy: gam_gpu::GpuPolicy::Auto,
532 pcg: ArrowPcgOptions::default(),
533 trust_region: ArrowTrustRegionOptions::default(),
534 streaming_chunk_size: None,
535 riemannian_trust_region: false,
536 gpu_matvec: None,
537 evidence_policy: ArrowEvidencePolicy::Strict,
538 solve_precision: ArrowSolvePrecisionPolicy::F64Only,
539 newton_schur_tikhonov_rel_floor: None,
540 sae_resident_frame: None,
541 }
542 }
543
544 /// Force dense reduced-camera-system Cholesky, the classic BA direct
545 /// solve for small `K`.
546 pub fn direct() -> Self {
547 Self {
548 mode: ArrowSolverMode::Direct,
549 gpu_policy: gam_gpu::GpuPolicy::Auto,
550 pcg: ArrowPcgOptions::default(),
551 trust_region: ArrowTrustRegionOptions::default(),
552 streaming_chunk_size: None,
553 riemannian_trust_region: false,
554 gpu_matvec: None,
555 evidence_policy: ArrowEvidencePolicy::Strict,
556 solve_precision: ArrowSolvePrecisionPolicy::F64Only,
557 newton_schur_tikhonov_rel_floor: None,
558 sae_resident_frame: None,
559 }
560 }
561
562 /// Force inexact BA Schur PCG with Jacobi preconditioning.
563 pub fn inexact_pcg() -> Self {
564 Self {
565 mode: ArrowSolverMode::InexactPCG,
566 gpu_policy: gam_gpu::GpuPolicy::Auto,
567 pcg: ArrowPcgOptions::default(),
568 trust_region: ArrowTrustRegionOptions::default(),
569 streaming_chunk_size: None,
570 riemannian_trust_region: false,
571 gpu_matvec: None,
572 evidence_policy: ArrowEvidencePolicy::Strict,
573 solve_precision: ArrowSolvePrecisionPolicy::F64Only,
574 newton_schur_tikhonov_rel_floor: None,
575 sae_resident_frame: None,
576 }
577 }
578
579 /// Route every device probe made by this solve under one per-request
580 /// policy. In particular, `Off` returns to the exact CPU implementation
581 /// before touching a possibly broken CUDA installation.
582 pub fn with_gpu_policy(mut self, gpu_policy: gam_gpu::GpuPolicy) -> Self {
583 self.gpu_policy = gpu_policy;
584 self
585 }
586
587 /// Build an undamped evidence cache that accepts positive-definite factors
588 /// regardless of their Newton-step condition-number gate.
589 pub fn with_positive_definite_evidence(mut self) -> Self {
590 self.evidence_policy = ArrowEvidencePolicy::PositiveDefinite;
591 self
592 }
593
594 /// Build the undamped evidence β-Schur with original-coordinate unit
595 /// deflation at `relative_floor`.
596 pub fn with_evidence_unit_deflation(mut self, relative_floor: f64) -> Self {
597 self.evidence_policy = ArrowEvidencePolicy::UnitDeflation { relative_floor };
598 self
599 }
600
601 /// [`Self::with_evidence_unit_deflation`], but the operator being factored is
602 /// one whose negative curvature is a modelling verdict rather than a rounding
603 /// artefact, so a RESOLVED negative direction is refused instead of pinned
604 /// (#2515). See [`ArrowEvidencePolicy::UnitDeflationRefusingIndefinite`].
605 pub fn with_indefinite_refusing_evidence_unit_deflation(
606 mut self,
607 relative_floor: f64,
608 ) -> Self {
609 self.evidence_policy =
610 ArrowEvidencePolicy::UnitDeflationRefusingIndefinite { relative_floor };
611 self
612 }
613
614 /// Enable Newton-only spectral Tikhonov damping on collapsed reduced-Schur
615 /// directions. This never changes the evidence value or inverse.
616 pub fn with_newton_schur_tikhonov(mut self, relative_floor: f64) -> Self {
617 self.newton_schur_tikhonov_rel_floor = Some(relative_floor);
618 self
619 }
620
621 /// Turn certified mixed precision ON for the streaming/residency reduced
622 /// solve unless the caller already pinned an explicit policy (#1014).
623 ///
624 /// Only `F64Only` (the inherited default) is upgraded to `CertifiedMixed`;
625 /// a caller that deliberately set a policy keeps it. The reduced-Schur f64
626 /// factor and every evidence log-determinant are unaffected — see
627 /// `mixed_precision_reduced_beta`.
628 #[must_use]
629 pub fn with_streaming_solve_precision_default(&self) -> Self {
630 let mut out = self.clone();
631 if matches!(out.solve_precision, ArrowSolvePrecisionPolicy::F64Only) {
632 out.solve_precision = ArrowSolvePrecisionPolicy::certified_mixed();
633 }
634 out
635 }
636}
637
638/// CPU/GPU seam for BA point-block work.
639///
640/// BA systems spend most time in independent point-block factorizations,
641/// triangular solves, and Schur block products. MegBA maps exactly these
642/// operations to GPU kernels. This trait keeps that boundary explicit so a
643/// CUDA/Ceres backend can replace [`CpuBatchedBlockSolver`] without changing
644/// `ArrowSchurSystem` algebra.
645pub trait BatchedBlockSolver {
646 /// Factor every per-row point block `H_tt^(i) + ridge_t I`, as in BA's
647 /// point elimination stage.
648 ///
649 /// `evidence_factorization` lifts the Newton-step κ rejection while still
650 /// requiring genuine PD unless the system's evidence deflation handles it.
651 fn factor_blocks(
652 &self,
653 rows: &[ArrowRowBlock],
654 ridge_t: f64,
655 d: usize,
656 evidence_factorization: bool,
657 ) -> Result<ArrowFactorSlab, ArrowSchurError>;
658
659 /// Factor under an explicit request policy. Backends without a device path
660 /// may ignore the policy; the production CPU/GPU hybrid honors it before
661 /// any runtime probe.
662 fn factor_blocks_with_policy(
663 &self,
664 rows: &[ArrowRowBlock],
665 ridge_t: f64,
666 d: usize,
667 evidence_factorization: bool,
668 gpu_policy: gam_gpu::GpuPolicy,
669 ) -> Result<ArrowFactorSlab, ArrowSchurError> {
670 match gpu_policy {
671 gam_gpu::GpuPolicy::Auto
672 | gam_gpu::GpuPolicy::Off
673 | gam_gpu::GpuPolicy::Required => {
674 self.factor_blocks(rows, ridge_t, d, evidence_factorization)
675 }
676 }
677 }
678
679 /// Solve one factored point block against a vector RHS.
680 fn solve_block_vector(
681 &self,
682 factor: ArrayView2<'_, f64>,
683 rhs: ArrayView1<'_, f64>,
684 ) -> Array1<f64>;
685
686 /// Solve one factored point block against a dense matrix RHS.
687 fn solve_block_matrix(
688 &self,
689 factor: ArrayView2<'_, f64>,
690 rhs: ArrayView2<'_, f64>,
691 ) -> Array2<f64>;
692
693 /// Apply the Square-Root BA lower-triangular solve `L_i^-1 rhs`.
694 fn sqrt_solve_block_matrix(
695 &self,
696 factor: ArrayView2<'_, f64>,
697 rhs: ArrayView2<'_, f64>,
698 ) -> Array2<f64>;
699
700 /// Subtract a row-local Schur product from the dense reduced system.
701 fn block_gemm_subtract(&self, schur: &mut Array2<f64>, left: &Array2<f64>, right: &Array2<f64>);
702}
703
704#[derive(Debug, Clone)]
705pub struct ArrowRowGaugeDeflation {
706 pub directions: Arc<[Vec<Array1<f64>>]>,
707}
708
709/// Orthonormal gauge basis on the reduced shared `beta` border.
710///
711/// Evidence factors use this as a Faddeev--Popov pin: for
712/// `Q = [q_1, ..., q_r]` and `P = I - Q Q^T`, the represented reduced
713/// operator is
714///
715/// `S_quot = P S P + Q Q^T`.
716///
717/// The quotient directions therefore contribute exactly `log(1) = 0` to the
718/// Laplace log-determinant, while inverse/trace consumers apply
719/// `P S_quot^-1 P`. Ordinary Newton solves do not consult this carrier; it is
720/// an evidence-coordinate contract only.
721#[derive(Debug, Clone)]
722pub struct ArrowBetaGaugeQuotient {
723 pub directions: Arc<[Array1<f64>]>,
724}
725
726impl ArrowBetaGaugeQuotient {
727 /// Orthonormalize a non-empty set of same-width border directions.
728 ///
729 /// A zero or linearly-dependent direction is a malformed gauge declaration,
730 /// not a numerical condition to hide, so construction fails loudly.
731 pub fn new(directions: Vec<Array1<f64>>) -> Result<Self, String> {
732 if directions.is_empty() {
733 return Err("ArrowBetaGaugeQuotient requires at least one direction".to_string());
734 }
735 let dim = directions[0].len();
736 if dim == 0 {
737 return Err("ArrowBetaGaugeQuotient directions must be non-empty".to_string());
738 }
739 let mut basis: Vec<Array1<f64>> = Vec::with_capacity(directions.len());
740 for (direction_idx, mut direction) in directions.into_iter().enumerate() {
741 if direction.len() != dim {
742 return Err(format!(
743 "ArrowBetaGaugeQuotient direction {direction_idx} length {} != {dim}",
744 direction.len()
745 ));
746 }
747 if direction.iter().any(|value| !value.is_finite()) {
748 return Err(format!(
749 "ArrowBetaGaugeQuotient direction {direction_idx} contains a non-finite value"
750 ));
751 }
752 for existing in &basis {
753 let coefficient = direction.dot(existing);
754 direction.scaled_add(-coefficient, existing);
755 }
756 let norm_sq = direction.dot(&direction);
757 if !(norm_sq.is_finite() && norm_sq > 0.0) {
758 return Err(format!(
759 "ArrowBetaGaugeQuotient direction {direction_idx} is zero or linearly dependent"
760 ));
761 }
762 direction *= norm_sq.sqrt().recip();
763 basis.push(direction);
764 }
765 Ok(Self {
766 directions: Arc::from(basis.into_boxed_slice()),
767 })
768 }
769
770 pub fn dimension(&self) -> usize {
771 self.directions.len()
772 }
773
774 pub(crate) fn border_dim(&self) -> usize {
775 self.directions[0].len()
776 }
777
778 /// `P x`, where `P = I - Q Q^T`.
779 pub fn project_complement(&self, x: ArrayView1<'_, f64>) -> Array1<f64> {
780 assert_eq!(x.len(), self.border_dim());
781 let mut out = x.to_owned();
782 for direction in self.directions.iter() {
783 let coefficient = out.dot(direction);
784 out.scaled_add(-coefficient, direction);
785 }
786 out
787 }
788
789 /// Dense Faddeev--Popov pin `P S P + Q Q^T`.
790 pub fn pin_reduced_schur(&self, schur: ArrayView2<'_, f64>) -> Array2<f64> {
791 let dim = self.border_dim();
792 assert_eq!(schur.dim(), (dim, dim));
793
794 // Apply P on the right, then on the left. Keeping the two projections
795 // explicit makes the implementation the exact matrix analogue of the
796 // matrix-free apply and avoids materializing a dense projector.
797 let mut right = schur.to_owned();
798 for direction in self.directions.iter() {
799 let schur_q = right.dot(direction);
800 for row in 0..dim {
801 for col in 0..dim {
802 right[[row, col]] -= schur_q[row] * direction[col];
803 }
804 }
805 }
806 let mut pinned = right;
807 for direction in self.directions.iter() {
808 let q_t_s = direction.dot(&pinned);
809 for row in 0..dim {
810 for col in 0..dim {
811 pinned[[row, col]] -= direction[row] * q_t_s[col];
812 }
813 }
814 }
815 for direction in self.directions.iter() {
816 for row in 0..dim {
817 for col in 0..dim {
818 pinned[[row, col]] += direction[row] * direction[col];
819 }
820 }
821 }
822 // Both the input Schur and the mathematical pin are symmetric. Clear
823 // the last-bit asymmetry from the two ordered dense projections before
824 // Cholesky so direct and matrix-free paths expose one self-adjoint op.
825 for row in 0..dim {
826 for col in (row + 1)..dim {
827 let value = 0.5 * (pinned[[row, col]] + pinned[[col, row]]);
828 pinned[[row, col]] = value;
829 pinned[[col, row]] = value;
830 }
831 }
832 pinned
833 }
834}
835
836impl ArrowRowGaugeDeflation {
837 pub fn new(directions: Vec<Vec<Array1<f64>>>) -> Self {
838 Self {
839 directions: Arc::from(directions.into_boxed_slice()),
840 }
841 }
842
843 pub(crate) fn row(&self, row: usize) -> &[Array1<f64>] {
844 self.directions.get(row).map(Vec::as_slice).unwrap_or(&[])
845 }
846}
847
848/// Current CPU implementation of the BA batched block interface.
849///
850/// It is intentionally plain Rust loops because `d` is tiny. The trait shape,
851/// not this implementation, is the load-bearing part for the future MegBA or
852/// Ceres backend.
853#[derive(Debug, Clone, Copy, Default)]
854pub struct CpuBatchedBlockSolver;
855
856impl BatchedBlockSolver for CpuBatchedBlockSolver {
857 fn factor_blocks(
858 &self,
859 rows: &[ArrowRowBlock],
860 ridge_t: f64,
861 d: usize,
862 evidence_factorization: bool,
863 ) -> Result<ArrowFactorSlab, ArrowSchurError> {
864 // Multi-GPU fast path: the per-row blocks `H_tt^(i) + ridge_t·I` are
865 // independent same-size SPD systems — exactly the batch
866 // `gam_gpu::try_cholesky_batched_lower_inplace` spreads across ALL
867 // usable devices (the batched POTRF tiles over the pool). It is only
868 // valid when every row is the uniform `d×d` shape; heterogeneous row
869 // dimensions keep the per-row CPU loop because the current cuSOLVER
870 // batched POTRF wrapper accepts one `(d, d)` shape per launch. It only
871 // succeeds when EVERY block is PD at
872 // the base ridge; a non-PD block returns `None`, so we fall back to the
873 // exact per-row CPU path that performs minimal per-block ridge
874 // escalation. After a successful batched factorization we re-apply the
875 // identical κ-conditioning rejection `factor_one_row` enforces, so the
876 // result is bit-for-bit equivalent (modulo IEEE reduction order) to the
877 // CPU loop: a barely-PD but ill-conditioned block forces the whole batch
878 // back onto the per-row path so its ridge can lift, never silently using
879 // a contaminated factor.
880 self.factor_blocks_with_policy(
881 rows,
882 ridge_t,
883 d,
884 evidence_factorization,
885 gam_gpu::global_policy(),
886 )
887 }
888
889 fn factor_blocks_with_policy(
890 &self,
891 rows: &[ArrowRowBlock],
892 ridge_t: f64,
893 d: usize,
894 evidence_factorization: bool,
895 gpu_policy: gam_gpu::GpuPolicy,
896 ) -> Result<ArrowFactorSlab, ArrowSchurError> {
897 if let Some(batched) =
898 try_factor_blocks_batched(rows, ridge_t, d, evidence_factorization, gpu_policy)?
899 {
900 return Ok(batched);
901 }
902 // Per-row Cholesky factorizations are INDEPENDENT (each reads only its own
903 // read-only `rows[i]` block), so factor rows in parallel then collect in
904 // row order — the ordered `collect` reproduces the serial push order
905 // bit-for-bit (no cross-row reduction; each block factored once). #1557 —
906 // pin any nested faer GEMM inside each row worker to `Par::Seq`.
907 let n = rows.len();
908 let parallel =
909 n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
910 let out = if parallel {
911 use rayon::prelude::*;
912 (0..n)
913 .into_par_iter()
914 .map(|row_idx| {
915 gam_problem::with_nested_parallel(|| {
916 factor_one_row(&rows[row_idx], ridge_t, d, row_idx, evidence_factorization)
917 })
918 })
919 .collect::<Result<Vec<_>, ArrowSchurError>>()?
920 } else {
921 let mut out = Vec::with_capacity(n);
922 for (row_idx, row) in rows.iter().enumerate() {
923 out.push(factor_one_row(
924 row,
925 ridge_t,
926 d,
927 row_idx,
928 evidence_factorization,
929 )?);
930 }
931 out
932 };
933 Ok(ArrowFactorSlab::from_blocks(out))
934 }
935
936 fn solve_block_vector(
937 &self,
938 factor: ArrayView2<'_, f64>,
939 rhs: ArrayView1<'_, f64>,
940 ) -> Array1<f64> {
941 match (factor.nrows(), factor.ncols(), rhs.len()) {
942 (1, 1, 1) => cholesky_solve_vector_fixed::<1>(factor, rhs),
943 (2, 2, 2) => cholesky_solve_vector_fixed::<2>(factor, rhs),
944 (3, 3, 3) => cholesky_solve_vector_fixed::<3>(factor, rhs),
945 (4, 4, 4) => cholesky_solve_vector_fixed::<4>(factor, rhs),
946 _ => cholesky_solve_vector(factor, rhs),
947 }
948 }
949
950 fn solve_block_matrix(
951 &self,
952 factor: ArrayView2<'_, f64>,
953 rhs: ArrayView2<'_, f64>,
954 ) -> Array2<f64> {
955 cholesky_solve_matrix(factor, rhs)
956 }
957
958 fn sqrt_solve_block_matrix(
959 &self,
960 factor: ArrayView2<'_, f64>,
961 rhs: ArrayView2<'_, f64>,
962 ) -> Array2<f64> {
963 forward_substitution_lower_matrix(factor, rhs)
964 }
965
966 fn block_gemm_subtract(
967 &self,
968 schur: &mut Array2<f64>,
969 left: &Array2<f64>,
970 right: &Array2<f64>,
971 ) {
972 // Performance: ndarray Array2 is row-major, so `right[[c, b]]` is
973 // unit-strided in `b`. The canonical (a, b, c) order produced
974 // strided reads of `left[[c, a]]` for every (a, b); reorder to
975 // (c, a, b) so the inner `b`-loop is contiguous in `right` and
976 // `left[[c, a]]` is hoisted out of the inner loop.
977 //
978 // Sparse SAE rows install a matrix-free `H_tβ` operator but the direct
979 // reduced-Schur path still asks `row_htbeta` for a dense `(d_i × K)`
980 // scratch matrix. Those rows have support on only the active atoms
981 // (`top_k · basis · p` columns), so blindly iterating the full `K × K`
982 // product made the Schur assembly compute-bound even though almost every
983 // entry multiplied by zero (#1995). Discover the non-zero column support
984 // of the two row factors once (`O(d_i·K)`) and multiply only the touched
985 // columns (`O(d_i·nnz_left·nnz_right)`). Dense callers still take the same
986 // arithmetic path with all columns active, while compact top-k rows scale
987 // with the active support rather than the global border width.
988 let k = schur.nrows();
989 let d = left.nrows();
990 assert_eq!(left.ncols(), k);
991 assert_eq!(right.nrows(), d);
992 assert_eq!(right.ncols(), k);
993 assert_eq!(schur.ncols(), k);
994
995 let mut left_active = Vec::with_capacity(k);
996 let mut right_active = Vec::with_capacity(k);
997 // HOT PATH (measured 2026-07-11, A10 real-workload profile: 50% of ALL
998 // fit cycles in non-inlined bounds-checked ndarray 2-D `IndexMut`, plus
999 // 13% self-time here): every scalar of the scan and of the rank-1
1000 // update went through `[[i, j]]` element access — a function call, a
1001 // bounds check, and 2-D stride arithmetic per double. Go through the
1002 // contiguous row slices and flat 1-D indexing instead. The operation
1003 // SEQUENCE is identical (same c → a → b order, same subtractions), so
1004 // the result is bit-exact — no reduction-order change.
1005 let schur_cols = schur.ncols();
1006 let schur_flat = schur
1007 .as_slice_mut()
1008 .expect("block_gemm_subtract: reduced Schur must be standard-layout");
1009 for c in 0..d {
1010 left_active.clear();
1011 right_active.clear();
1012 let left_row = left.row(c);
1013 let right_row = right.row(c);
1014 let left_row = left_row
1015 .as_slice()
1016 .expect("block_gemm_subtract: left row must be contiguous");
1017 let right_row = right_row
1018 .as_slice()
1019 .expect("block_gemm_subtract: right row must be contiguous");
1020 for col in 0..k {
1021 let l = left_row[col];
1022 let r = right_row[col];
1023 if l != 0.0 {
1024 left_active.push((col, l));
1025 }
1026 if r != 0.0 {
1027 right_active.push((col, r));
1028 }
1029 }
1030 if left_active.is_empty() || right_active.is_empty() {
1031 continue;
1032 }
1033 for &(a, lca) in &left_active {
1034 let row_off = a * schur_cols;
1035 if right_active.len() == k {
1036 // Dense right row: at nnz == k the active list is exactly
1037 // `col = 0..k` in order, so this contiguous fused loop is
1038 // BIT-IDENTICAL to the sparse one below (same b order,
1039 // same subtractions) while being vectorizable — the old
1040 // codegen was 771 instructions with 18 bounds/compare
1041 // patterns around exactly 2 scalar FP ops and zero vector
1042 // ops (A10 asm read, 2026-07-11).
1043 let schur_row = &mut schur_flat[row_off..row_off + k];
1044 for (s, &r) in schur_row.iter_mut().zip(right_row) {
1045 *s -= lca * r;
1046 }
1047 } else {
1048 for &(b, rcb) in &right_active {
1049 schur_flat[row_off + b] -= lca * rcb;
1050 }
1051 }
1052 }
1053 }
1054 }
1055}