Skip to main content

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