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/// 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}