Skip to main content

apex_solver/linalg/sparse/schur/
explicit.rs

1//! # Explicit Sparse Schur Complement Solver
2//!
3//! This module implements the **explicit** Schur complement method for
4//! bundle adjustment and structured optimization problems, over a sparse
5//! Hessian. Equivalent to Ceres's `SPARSE_SCHUR`.
6//!
7//! ## Explicit vs Implicit Schur Complement
8//!
9//! **Explicit Schur:** This formulation physically constructs the reduced camera matrix
10//! (S = B - E C⁻¹ Eᵀ) in memory and solves it using direct sparse Cholesky factorization.
11//! It provides the most accurate results with moderate memory usage.
12//!
13//! **Implicit Schur:** The alternative formulation (see
14//! [`implicit`](super::implicit)) never constructs S explicitly, instead
15//! solving the system using matrix-free PCG. It's more memory-efficient for
16//! very large problems.
17//!
18//! ## When to Use Explicit Schur
19//!
20//! - Medium-to-large bundle adjustment problems (< 10,000 cameras)
21//! - When accuracy is paramount
22//! - When you have sufficient memory to store the reduced camera system
23//! - When direct factorization is faster than iterative methods
24//!
25//! ## Usage Example
26//!
27//! ```no_run
28//! # use apex_solver::linalg::{ExplicitSparseSchur, ExplicitSchurVariant, SchurPreconditioner};
29//! # use apex_solver::linalg::StructureAware;
30//! # use apex_solver::core::VarKey;
31//! # use apex_solver::core::variable::ManifoldVariable;
32//! # use slotmap::{SlotMap, SecondaryMap};
33//! # use std::collections::HashSet;
34//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
35//! # let variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
36//! # let variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
37//! # let landmark_keys: HashSet<VarKey> = HashSet::new();
38//! use apex_solver::linalg::{ExplicitSparseSchur, ExplicitSchurVariant, SchurPreconditioner};
39//! use apex_solver::linalg::StructureAware;
40//!
41//! let mut solver = ExplicitSparseSchur::new()
42//!     .with_variant(ExplicitSchurVariant::Sparse) // Cholesky
43//!     .with_preconditioner(SchurPreconditioner::None);
44//! solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
45//! # Ok(())
46//! # }
47//! ```
48
49use crate::core::VarKey;
50use crate::core::variable::ManifoldVariable;
51use crate::error::ErrorLogging;
52use crate::linalg::regularization::invert_with_retry_dyn;
53use crate::linalg::schur::{BlockSpan, EliminatedBlocks, SchurOrdering, SchurPartition};
54use crate::linalg::schur::{PcgParams, SchurPreconditioner, solve_pcg};
55use crate::linalg::sparse::normal_eq::{LazyNormalEquations, NormalEquations};
56use crate::linalg::sparse::pattern;
57use crate::linalg::{Damping, LinAlgError, LinAlgResult, LinearSolver, SparseMode, StructureAware};
58use faer::sparse::{SparseColMat, SymbolicSparseColMat, Triplet};
59use faer::{
60    Accum, Mat, Side,
61    linalg::solvers::Solve,
62    sparse::linalg::solvers::{Llt, SymbolicLlt},
63};
64use nalgebra::DMatrix;
65use rayon::prelude::*;
66use slotmap::{SecondaryMap, SlotMap};
67use tracing::debug;
68
69/// Explicit Schur complement solver variant: how the reduced camera system
70/// `S` is built and solved once elimination is set up.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum ExplicitSchurVariant {
73    /// Form `S` explicitly, then factorize it with sparse Cholesky.
74    ///
75    /// Most accurate and fastest per iteration, but `S` is accumulated into a
76    /// dense `kept_dof²` buffer, so memory grows quadratically in the retained
77    /// set. Equivalent to Ceres's `SPARSE_SCHUR`.
78    #[default]
79    Sparse,
80    /// Form `S` explicitly, then solve it with PCG instead of Cholesky.
81    ///
82    /// Carries the same `kept_dof²` memory cost as [`Self::Sparse`] while
83    /// solving less exactly, so it buys nothing on memory — but it is
84    /// competitive on time, measuring 0.84x-1.47x [`Self::Sparse`] across the
85    /// four BAL datasets.
86    ///
87    /// Equivalent to Ceres's `ITERATIVE_SCHUR` with
88    /// `use_explicit_schur_complement = true`, which Ceres permits only
89    /// alongside `SCHUR_JACOBI`. [`SchurPreconditioner::SchurJacobi`] is
90    /// likewise the default here, reading `S`'s diagonal blocks straight off
91    /// the `S` this variant has already formed.
92    Iterative,
93    /// Form `S` chunk by chunk **directly from `J`**, then factorize with
94    /// sparse Cholesky.
95    ///
96    /// Algebraically identical to [`Self::Sparse`], but never materializes
97    /// `JᵀJ`, `Jᵀ`, or the value permutation forming it requires — on the
98    /// largest BAL problem those account for ~24 GB of the ~32 GB needed before
99    /// elimination can even start. This is Ceres's `SchurEliminator` strategy.
100    ///
101    /// Requires each eliminated variable's rows to be contiguous;
102    /// `Problem::group_rows_for_elimination` arranges that when a Schur solver
103    /// is selected.
104    ///
105    /// **This trades time for memory, and is slower in wall-clock than
106    /// [`Self::Sparse`]** — 1.13x-2.01x across the BAL datasets. The chunk
107    /// sweep is inherently serial: reading rows out of a column-major `J`
108    /// without building a CSR copy relies on one forward-only cursor per kept
109    /// column, which forces chunks to be visited in increasing row order. The
110    /// `JᵀJ` path it replaces is fully parallel (rayon gather plus faer's
111    /// threaded sparse kernels), so on a many-core machine the lost
112    /// parallelism outweighs the saved work. Ceres parallelizes its
113    /// `SchurEliminator` over chunks because it consumes a row-block matrix;
114    /// that option is not open to a CSC-only sweep.
115    Chunked,
116}
117
118/// Explicit Schur Complement Solver for Bundle Adjustment, over a sparse
119/// Hessian. Equivalent to Ceres's `SPARSE_SCHUR`.
120#[derive(Debug, Clone)]
121pub struct ExplicitSparseSchur {
122    partition: Option<SchurPartition>,
123    /// Diagonal blocks of `H_ee`; allocated once per structure and reused.
124    eliminated: EliminatedBlocks,
125    /// Chunk-wise eliminator, built lazily for [`ExplicitSchurVariant::Chunked`].
126    chunked: Option<super::chunk_eliminator::ChunkedSchurEliminator>,
127    /// `J` from the last chunked solve.
128    ///
129    /// The chunked path never forms `JᵀJ`, so the quadratic model is served as
130    /// `Jᵀ(J·v)` — which needs `J` after the solve returns. Holding it costs one
131    /// extra copy of the Jacobian; still far less than the `Jᵀ`, permutation and
132    /// `JᵀJ` that forming the normal equations would require.
133    chunked_jacobian: Option<SparseColMat<usize, f64>>,
134    /// Structural fingerprint of the Hessian whose block-diagonality was checked.
135    ///
136    /// The check is structural, so it only has to run when the sparsity
137    /// changes — not on every solve. Keyed on the full
138    /// [`PatternFingerprint`](pattern::PatternFingerprint): the old
139    /// `(nrows, ncols, nnz)` triple aliased equal-`nnz` permutations and could
140    /// skip the check for a coupled pattern.
141    verified_pattern: Option<pattern::PatternFingerprint>,
142    ordering: SchurOrdering,
143    variant: ExplicitSchurVariant,
144    preconditioner: SchurPreconditioner,
145
146    // CG parameters
147    /// Forcing-sequence parameter η; `0.0` disables the quadratic-model rule.
148    cg_q_tolerance: f64,
149    cg_max_iterations: usize,
150    cg_tolerance: f64,
151
152    // Cached symbolic machinery for forming `JᵀJ` and `Jᵀr` in parallel.
153    ne_cache: LazyNormalEquations,
154
155    /// Cached value-position maps from `JᵀJ`'s sparsity into the extracted
156    /// `H_kk`/`H_ke` value arrays; rebuilt only when the pattern changes.
157    extraction: Option<ExtractionCache>,
158
159    /// Column-major occupancy bitmap of the dense Schur accumulator: bit
160    /// `(col, row)` is set once `S[row, col]` has been written. Captured as
161    /// a side effect of the updates (one OR each), it seeds the sparse
162    /// output pattern without ever sorting triplets. Keyed on the `JᵀJ`
163    /// fingerprint so a structure change resets it.
164    s_bitmap: Option<(pattern::PatternFingerprint, Vec<u64>)>,
165
166    // Cached matrices
167    hessian: Option<SparseColMat<usize, f64>>,
168    gradient: Option<Mat<f64>>,
169    /// Symbolic Cholesky of the last reduced system, keyed on the filtered
170    /// `S` pattern hash — reused while the pattern holds instead of re-running
171    /// the fill-reducing ordering analysis every iteration.
172    s_llt_cache: Option<(u64, SymbolicLlt<usize>)>,
173}
174
175/// Cached extraction of `H_kk` and `H_ke` from a `JᵀJ` with a fixed
176/// sparsity pattern.
177///
178/// [`Self::extract_kept_block`] and [`Self::extract_coupling_block`] re-walk
179/// every `JᵀJ` nonzero and rebuild CSC matrices through triplets (allocate +
180/// sort) on every solve, though the pattern is static per structure — within
181/// one optimizer run it never changes. The cache instead records, once per
182/// pattern, which `JᵀJ` value feeds which extracted value slot; a solve is
183/// then two parallel gathers. Entries the extraction drops (eliminated rows
184/// of kept columns, or anything touching a fixed column) never appear in the
185/// maps.
186#[derive(Debug, Clone)]
187struct ExtractionCache {
188    fingerprint: pattern::PatternFingerprint,
189    h_kk_symbolic: SymbolicSparseColMat<usize>,
190    h_ke_symbolic: SymbolicSparseColMat<usize>,
191    /// `H_kk` value slot → `JᵀJ` flat value index.
192    h_kk_src: Vec<u32>,
193    /// `H_ke` value slot → `JᵀJ` flat value index.
194    h_ke_src: Vec<u32>,
195}
196
197impl ExtractionCache {
198    /// Build the maps for one `JᵀJ` pattern against `partition`.
199    ///
200    /// One-time cost: two sorted triplet builds plus one HashMap pass, paid
201    /// on the first solve (and never again while the pattern holds).
202    fn build(partition: &SchurPartition, hessian: &SparseColMat<usize, f64>) -> LinAlgResult<Self> {
203        let symbolic = hessian.symbolic();
204
205        // Kept- / eliminated-local column of each global column; `u32::MAX`
206        // for columns outside the partition (fully fixed variables).
207        let mut kept_cols = vec![u32::MAX; hessian.ncols()];
208        for block in partition.kept_blocks() {
209            for offset in 0..block.dof {
210                let global = block.col_start + offset;
211                if let Some(local) = partition.kept_local(global) {
212                    kept_cols[global] = local as u32;
213                }
214            }
215        }
216        let mut elim_cols = vec![u32::MAX; hessian.ncols()];
217        for (block_idx, block) in partition.eliminated_blocks().iter().enumerate() {
218            let base = partition.eliminated_offset(block_idx);
219            for offset in 0..block.dof {
220                elim_cols[block.col_start + offset] = (base + offset) as u32;
221            }
222        }
223
224        // Classify every `JᵀJ` value once: kept×kept feeds `H_kk`, kept×elim
225        // feeds `H_ke`; everything else is dropped, matching the uncached
226        // extraction's predicates exactly.
227        let mut kk_triplets: Vec<(usize, usize, u32)> = Vec::new();
228        let mut ke_triplets: Vec<(usize, usize, u32)> = Vec::new();
229        for col in 0..hessian.ncols() {
230            let col_start = symbolic.col_range(col).start;
231            let rows = symbolic.row_idx_of_col_raw(col);
232            let c_kept = kept_cols[col];
233            let c_elim = elim_cols[col];
234            for (k, &row) in rows.iter().enumerate() {
235                let src = (col_start + k) as u32;
236                if c_kept != u32::MAX {
237                    if let Some(r) = partition.kept_local(row) {
238                        kk_triplets.push((r, c_kept as usize, src));
239                    }
240                } else if c_elim != u32::MAX
241                    && let Some(r) = partition.kept_local(row)
242                {
243                    ke_triplets.push((r, c_elim as usize, src));
244                }
245            }
246        }
247
248        let (h_kk_symbolic, h_kk_src) =
249            Self::symbolic_and_src(kk_triplets, partition.kept_dof(), partition.kept_dof())?;
250        let (h_ke_symbolic, h_ke_src) = Self::symbolic_and_src(
251            ke_triplets,
252            partition.kept_dof(),
253            partition.eliminated_dof(),
254        )?;
255
256        Ok(Self {
257            fingerprint: pattern::PatternFingerprint::of(hessian),
258            h_kk_symbolic,
259            h_ke_symbolic,
260            h_kk_src,
261            h_ke_src,
262        })
263    }
264
265    /// Sorted-CSC construction from `(row, col, src)` triplets.
266    ///
267    /// Entries of one `JᵀJ` bucket are unique in `(row, col)` — they come
268    /// from distinct entries of a product with a unique pattern — so the sort
269    /// needs no dedup pass.
270    fn symbolic_and_src(
271        mut triplets: Vec<(usize, usize, u32)>,
272        nrows: usize,
273        ncols: usize,
274    ) -> LinAlgResult<(SymbolicSparseColMat<usize>, Vec<u32>)> {
275        triplets.sort_unstable_by_key(|&(r, c, _)| (c, r));
276
277        let nnz = triplets.len();
278        let mut col_ptr = vec![0usize; ncols + 1];
279        let mut row_idx = Vec::with_capacity(nnz);
280        let mut src = Vec::with_capacity(nnz);
281        for &(r, c, s) in &triplets {
282            col_ptr[c + 1] += 1;
283            row_idx.push(r);
284            src.push(s);
285        }
286        for c in 0..ncols {
287            col_ptr[c + 1] += col_ptr[c];
288        }
289
290        let symbolic = SymbolicSparseColMat::new_checked(nrows, ncols, col_ptr, None, row_idx);
291        Ok((symbolic, src))
292    }
293
294    /// Gather the current values into the cached patterns.
295    ///
296    /// Every slot has exactly one source, so the gathers parallelize without
297    /// aliasing; the symbolic handles are `Arc`-backed, so cloning them per
298    /// solve is free.
299    fn extract(
300        &self,
301        hessian: &SparseColMat<usize, f64>,
302    ) -> (SparseColMat<usize, f64>, SparseColMat<usize, f64>) {
303        let values = hessian.as_ref().val();
304        let kk: Vec<f64> = self
305            .h_kk_src
306            .par_iter()
307            .map(|&s| values[s as usize])
308            .collect();
309        let ke: Vec<f64> = self
310            .h_ke_src
311            .par_iter()
312            .map(|&s| values[s as usize])
313            .collect();
314        (
315            SparseColMat::new(self.h_kk_symbolic.clone(), kk),
316            SparseColMat::new(self.h_ke_symbolic.clone(), ke),
317        )
318    }
319}
320
321/// Set the occupancy bit for dense slot `S[row, col]` in the column-major
322/// bitmap (`bits` holds one aligned word range per column).
323#[inline]
324fn set_dense_bit(bits: &mut [u64], wpc: usize, row: usize, col: usize) {
325    bits[col * wpc + (row >> 6)] |= 1u64 << (row & 63);
326}
327
328/// Derive the sparse `S` output from the occupancy bitmap: one CSC pattern
329/// over every slot the dense accumulator wrote whose symmetrized value
330/// passes the noise filter, plus the values themselves. The threshold
331/// applies to the averaged value — `(dense[r,c] + dense[c,r])·0.5` —
332/// exactly matching the dense path's symmetrize-then-filter semantics, so
333/// the output is bit-identical to the triplet path. Column-major bitmap
334/// layout makes this a sequential word scan — no triplet sort anywhere.
335fn build_schur_output(
336    kept_dof: usize,
337    wpc: usize,
338    bits: &[u64],
339    s_dense: &[f64],
340) -> (SymbolicSparseColMat<usize>, Vec<f64>, u64) {
341    // Pattern hash (FNV-1a over the CSC structure) — lets the caller cache
342    // factorization symbolics keyed on "the filtered S pattern is unchanged".
343    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
344    const FNV_PRIME: u64 = 0x100000001b3;
345    let mut pattern_hash = FNV_OFFSET;
346
347    // Pass 1: count slots passing the filter, per column.
348    let mut col_counts = vec![0usize; kept_dof];
349    let mut nnz = 0usize;
350    for c in 0..kept_dof {
351        let base = c * wpc;
352        for w in 0..wpc {
353            let mut word = bits[base + w];
354            while word != 0 {
355                let bit = word.trailing_zeros() as usize;
356                word &= word - 1;
357                let r = (w << 6) | bit;
358                if r < kept_dof {
359                    let avg = (s_dense[r * kept_dof + c] + s_dense[c * kept_dof + r]) * 0.5;
360                    if avg.abs() > 1e-12 {
361                        col_counts[c] += 1;
362                        nnz += 1;
363                    }
364                }
365            }
366        }
367    }
368
369    let mut col_ptr = Vec::with_capacity(kept_dof + 1);
370    col_ptr.push(0usize);
371    for c in 0..kept_dof {
372        col_ptr.push(col_ptr[c] + col_counts[c]);
373    }
374
375    // Pass 2: fill rows and values at the counted positions.
376    let mut row_idx = vec![0usize; nnz];
377    let mut s_values = vec![0.0f64; nnz];
378    let mut offsets = col_ptr[..kept_dof].to_vec();
379    for c in 0..kept_dof {
380        let base = c * wpc;
381        pattern_hash = (pattern_hash ^ (c as u64)).wrapping_mul(FNV_PRIME);
382        pattern_hash = (pattern_hash ^ (col_counts[c] as u64)).wrapping_mul(FNV_PRIME);
383        for w in 0..wpc {
384            let mut word = bits[base + w];
385            while word != 0 {
386                let bit = word.trailing_zeros() as usize;
387                word &= word - 1;
388                let r = (w << 6) | bit;
389                if r < kept_dof {
390                    let avg = (s_dense[r * kept_dof + c] + s_dense[c * kept_dof + r]) * 0.5;
391                    if avg.abs() > 1e-12 {
392                        let slot = offsets[c];
393                        offsets[c] += 1;
394                        row_idx[slot] = r;
395                        s_values[slot] = avg;
396                        pattern_hash = (pattern_hash ^ (r as u64)).wrapping_mul(FNV_PRIME);
397                    }
398                }
399            }
400        }
401    }
402
403    let symbolic = SymbolicSparseColMat::new_checked(kept_dof, kept_dof, col_ptr, None, row_idx);
404    (symbolic, s_values, pattern_hash)
405}
406
407impl ExplicitSparseSchur {
408    pub fn new() -> Self {
409        Self {
410            partition: None,
411            eliminated: EliminatedBlocks::default(),
412            chunked: None,
413            chunked_jacobian: None,
414            verified_pattern: None,
415            ordering: SchurOrdering::default(),
416            variant: ExplicitSchurVariant::default(),
417            preconditioner: SchurPreconditioner::default(),
418            cg_q_tolerance: crate::linalg::schur::DEFAULT_ETA,
419            cg_max_iterations: 200, // Match Ceres (was 500)
420            cg_tolerance: 1e-6,     // Relaxed for speed (was 1e-9)
421            ne_cache: LazyNormalEquations::default(),
422            extraction: None,
423            s_bitmap: None,
424            hessian: None,
425            gradient: None,
426            s_llt_cache: None,
427        }
428    }
429
430    pub fn with_ordering(mut self, ordering: SchurOrdering) -> Self {
431        self.ordering = ordering;
432        self
433    }
434
435    pub fn with_variant(mut self, variant: ExplicitSchurVariant) -> Self {
436        self.variant = variant;
437        self
438    }
439
440    pub fn with_preconditioner(mut self, preconditioner: SchurPreconditioner) -> Self {
441        self.preconditioner = preconditioner;
442        self
443    }
444
445    /// Set the PCG forcing-sequence parameter η (`0.0` disables the
446    /// quadratic-model stopping rule). Only the `Iterative` variant uses it.
447    pub fn with_cg_q_tolerance(mut self, q_tolerance: f64) -> Self {
448        self.cg_q_tolerance = q_tolerance;
449        self
450    }
451
452    pub fn with_cg_params(mut self, max_iter: usize, tol: f64) -> Self {
453        self.cg_max_iterations = max_iter;
454        self.cg_tolerance = tol;
455        self
456    }
457    /// The variable partition, once `initialize_structure` has run.
458    pub fn partition(&self) -> Option<&SchurPartition> {
459        self.partition.as_ref()
460    }
461
462    /// Verify `H_ee` is block-diagonal, once per sparsity pattern.
463    ///
464    /// Eliminating mutually connected variables silently yields a wrong step,
465    /// so this is checked rather than assumed — but the check is structural,
466    /// so repeating it every iteration would be pure overhead.
467    fn ensure_block_diagonal(&mut self, hessian: &SparseColMat<usize, f64>) -> LinAlgResult<()> {
468        let fingerprint = pattern::PatternFingerprint::of(hessian);
469        if self.verified_pattern == Some(fingerprint) {
470            return Ok(());
471        }
472        self.require_partition()?.verify_block_diagonal(hessian)?;
473        self.verified_pattern = Some(fingerprint);
474        Ok(())
475    }
476
477    /// Borrow the partition, or report that `initialize_structure` was skipped.
478    fn require_partition(&self) -> LinAlgResult<&SchurPartition> {
479        self.partition.as_ref().ok_or_else(|| {
480            LinAlgError::InvalidInput(
481                "Block structure not built. Call initialize_structure() first.".to_string(),
482            )
483            .log()
484        })
485    }
486
487    /// Partition the variables into eliminated and retained sets.
488    ///
489    /// Both sides accept arbitrary DOF and arbitrary column interleaving: the
490    /// eliminated set need not be 3-DOF points, and need not occupy a
491    /// contiguous column range. See [`SchurPartition`].
492    fn build_partition(
493        &mut self,
494        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
495        variable_index_map: &SecondaryMap<VarKey, usize>,
496        eliminate_keys: &std::collections::HashSet<VarKey>,
497    ) -> LinAlgResult<()> {
498        let mut kept = Vec::new();
499        let mut eliminated = Vec::new();
500
501        for (key, variable) in variables {
502            let col_start = *variable_index_map.get(key).ok_or_else(|| {
503                LinAlgError::InvalidInput(format!("VarKey {:?} not found in index map", key)).log()
504            })?;
505            let span = BlockSpan {
506                key,
507                col_start,
508                // Free columns only — fixed tangent coordinates own no
509                // column in the linear solve (ISSUE-0003).
510                dof: variable.free_dof(),
511            };
512            if eliminate_keys.contains(&key) {
513                eliminated.push(span);
514            } else {
515                kept.push(span);
516            }
517        }
518
519        let partition = SchurPartition::new(kept, eliminated)?;
520        self.eliminated = EliminatedBlocks::new(&partition);
521        self.partition = Some(partition);
522        self.verified_pattern = None;
523        Ok(())
524    }
525
526    /// Extract `H_kk` and `H_ke` through the pattern cache.
527    ///
528    /// The first call for a sparsity pattern pays [`ExtractionCache::build`];
529    /// every later call with the same pattern is two parallel gathers — the
530    /// uncached path's per-solve triplet rebuild and CSC conversion
531    /// disappears. [`Self::extract_kept_block`] and
532    /// [`Self::extract_coupling_block`] remain the reference implementations
533    /// the cache must agree with.
534    fn extract_kept_and_coupling(
535        &mut self,
536        hessian: &SparseColMat<usize, f64>,
537    ) -> LinAlgResult<(SparseColMat<usize, f64>, SparseColMat<usize, f64>)> {
538        let fingerprint = pattern::PatternFingerprint::of(hessian);
539        let cache = match self.extraction.take() {
540            Some(cache) if cache.fingerprint == fingerprint => cache,
541            _ => ExtractionCache::build(self.require_partition()?, hessian)?,
542        };
543        let out = cache.extract(hessian);
544        self.extraction = Some(cache);
545        Ok(out)
546    }
547
548    /// Extract `H_kk`, the retained-retained block.
549    ///
550    /// Indices go through the partition rather than a contiguous range, so the
551    /// retained variables need not be adjacent in column space.
552    ///
553    /// Test-only reference for [`Self::extract_kept_and_coupling`]'s cache:
554    /// production paths go through the cache, and these remain so tests can
555    /// compare cached extraction against the direct one.
556    #[cfg_attr(not(test), allow(dead_code))]
557    fn extract_kept_block(
558        &self,
559        hessian: &SparseColMat<usize, f64>,
560    ) -> LinAlgResult<SparseColMat<usize, f64>> {
561        let partition = self.require_partition()?;
562        let kept_dof = partition.kept_dof();
563        let symbolic = hessian.symbolic();
564
565        let mut triplets = Vec::new();
566        for block in partition.kept_blocks() {
567            for offset in 0..block.dof {
568                let global_col = block.col_start + offset;
569                let Some(local_col) = partition.kept_local(global_col) else {
570                    continue;
571                };
572                let rows = symbolic.row_idx_of_col_raw(global_col);
573                let vals = hessian.val_of_col(global_col);
574                for (idx, &global_row) in rows.iter().enumerate() {
575                    if let Some(local_row) = partition.kept_local(global_row) {
576                        triplets.push(Triplet::new(local_row, local_col, vals[idx]));
577                    }
578                }
579            }
580        }
581
582        SparseColMat::try_new_from_triplets(kept_dof, kept_dof, &triplets)
583            .map_err(|e| LinAlgError::SparseMatrixCreation(format!("H_kk: {:?}", e)))
584    }
585    /// Extract `H_ke`, the coupling between retained and eliminated variables.
586    ///
587    /// Columns follow the eliminated-local ordering, so block `i` occupies
588    /// `partition.eliminated_offset(i) .. + dof`.
589    ///
590    /// Test-only reference for [`Self::extract_kept_and_coupling`]'s cache;
591    /// see [`Self::extract_kept_block`].
592    #[cfg_attr(not(test), allow(dead_code))]
593    fn extract_coupling_block(
594        &self,
595        hessian: &SparseColMat<usize, f64>,
596    ) -> LinAlgResult<SparseColMat<usize, f64>> {
597        let partition = self.require_partition()?;
598        let symbolic = hessian.symbolic();
599
600        let mut triplets = Vec::new();
601        for (block_idx, block) in partition.eliminated_blocks().iter().enumerate() {
602            let col_base = partition.eliminated_offset(block_idx);
603            for offset in 0..block.dof {
604                let global_col = block.col_start + offset;
605                let rows = symbolic.row_idx_of_col_raw(global_col);
606                let vals = hessian.val_of_col(global_col);
607                for (idx, &global_row) in rows.iter().enumerate() {
608                    if let Some(local_row) = partition.kept_local(global_row) {
609                        triplets.push(Triplet::new(local_row, col_base + offset, vals[idx]));
610                    }
611                }
612            }
613        }
614
615        SparseColMat::try_new_from_triplets(
616            partition.kept_dof(),
617            partition.eliminated_dof(),
618            &triplets,
619        )
620        .map_err(|e| LinAlgError::SparseMatrixCreation(format!("H_ke: {:?}", e)))
621    }
622    /// Split the gradient into its retained and eliminated parts.
623    fn extract_gradient_blocks(&self, gradient: &Mat<f64>) -> LinAlgResult<(Mat<f64>, Mat<f64>)> {
624        let partition = self.require_partition()?;
625
626        let mut g_k = Mat::zeros(partition.kept_dof(), 1);
627        let mut g_e = Mat::zeros(partition.eliminated_dof(), 1);
628
629        for block in partition.kept_blocks() {
630            for offset in 0..block.dof {
631                let global = block.col_start + offset;
632                if let Some(local) = partition.kept_local(global) {
633                    g_k[(local, 0)] = gradient[(global, 0)];
634                }
635            }
636        }
637        for (block_idx, block) in partition.eliminated_blocks().iter().enumerate() {
638            let base = partition.eliminated_offset(block_idx);
639            for offset in 0..block.dof {
640                g_e[(base + offset, 0)] = gradient[(block.col_start + offset, 0)];
641            }
642        }
643
644        Ok((g_k, g_e))
645    }
646
647    /// Solve S * x = b using Cholesky factorization with automatic regularization
648    ///
649    /// If the initial factorization fails (matrix not positive definite),
650    /// we add small regularization to the diagonal and retry.
651    fn solve_with_cholesky(
652        &mut self,
653        a: &SparseColMat<usize, f64>,
654        b: &Mat<f64>,
655        pattern_hash: Option<u64>,
656    ) -> LinAlgResult<Mat<f64>> {
657        // Fast path: the caller proved the filtered `S` pattern is unchanged
658        // since the last solve (same bitmap hash), so the fill-reducing
659        // ordering analysis — a fixed per-iteration cost the numerics never
660        // see — can be reused as-is. Identical symbolic + identical values
661        // means the factorization is bit-identical to the uncached path.
662        if let (Some(hash), Some((cached_hash, sym))) = (pattern_hash, self.s_llt_cache.as_ref())
663            && *cached_hash == hash
664        {
665            if let Ok(cholesky) = Llt::try_new_with_symbolic(sym.clone(), a.as_ref(), Side::Lower) {
666                return Ok(cholesky.solve(b));
667            }
668            // The cached symbolic disagrees with this matrix (pattern drift
669            // the hash missed, or a singular value structure change) — fall
670            // through and rebuild from scratch.
671            self.s_llt_cache = None;
672        }
673
674        let sym = SymbolicLlt::try_new(a.symbolic(), Side::Lower).map_err(|e| {
675            LinAlgError::FactorizationFailed(format!("Symbolic Cholesky failed: {:?}", e))
676        })?;
677        // Cache only a first-attempt symbolic built from `a` itself: the
678        // regularization retries below widen the pattern with synthetic
679        // diagonal entries, which would poison future solves.
680        self.s_llt_cache = pattern_hash.map(|hash| (hash, sym.clone()));
681
682        // First attempt: direct factorization
683        match Llt::try_new_with_symbolic(sym.clone(), a.as_ref(), Side::Lower) {
684            Ok(cholesky) => return Ok(cholesky.solve(b)),
685            Err(e) => {
686                debug!(
687                    "Cholesky factorization failed: {:?}. Applying regularization.",
688                    e
689                );
690            }
691        }
692
693        // Retry with exponentially increasing regularization
694        let n = a.nrows();
695        let symbolic = a.symbolic();
696
697        // Compute trace and max diagonal for scaling
698        let mut trace = 0.0;
699        let mut max_diag = 0.0f64;
700        for col in 0..n {
701            let row_indices = symbolic.row_idx_of_col_raw(col);
702            let col_values = a.val_of_col(col);
703            for (idx, &row) in row_indices.iter().enumerate() {
704                if row == col {
705                    trace += col_values[idx];
706                    max_diag = max_diag.max(col_values[idx].abs());
707                }
708            }
709        }
710
711        // Try multiple regularization levels
712        let avg_diag = trace / n as f64;
713        let base_reg = avg_diag.max(max_diag).max(1.0);
714
715        for attempt in 0..5 {
716            let reg = base_reg * 10.0f64.powi(attempt - 4); // 1e-4, 1e-3, 1e-2, 1e-1, 1.0 times base
717            debug!(
718                "Cholesky attempt {}: regularization = {:.2e}",
719                attempt + 2,
720                reg
721            );
722
723            let mut triplets = Vec::with_capacity(n * 10);
724            for col in 0..n {
725                let row_indices = symbolic.row_idx_of_col_raw(col);
726                let col_values = a.val_of_col(col);
727                for (idx, &row) in row_indices.iter().enumerate() {
728                    triplets.push(Triplet::new(row, col, col_values[idx]));
729                }
730            }
731
732            for i in 0..n {
733                triplets.push(Triplet::new(i, i, reg));
734            }
735
736            let a_reg = match SparseColMat::try_new_from_triplets(n, n, &triplets) {
737                Ok(m) => m,
738                Err(e) => {
739                    debug!("Failed to create regularized matrix: {:?}", e);
740                    continue;
741                }
742            };
743
744            // Need to create a new symbolic structure for the regularized matrix
745            let sym_reg = match SymbolicLlt::try_new(a_reg.symbolic(), Side::Lower) {
746                Ok(s) => s,
747                Err(e) => {
748                    debug!("Symbolic factorization failed: {:?}", e);
749                    continue;
750                }
751            };
752
753            match Llt::try_new_with_symbolic(sym_reg, a_reg.as_ref(), Side::Lower) {
754                Ok(cholesky) => {
755                    debug!("Cholesky succeeded with regularization {:.2e}", reg);
756                    return Ok(cholesky.solve(b));
757                }
758                Err(e) => {
759                    debug!("Cholesky failed with reg {:.2e}: {:?}", reg, e);
760                }
761            }
762        }
763
764        Err(LinAlgError::SingularMatrix(format!(
765            "Schur complement singular after 5 regularization attempts (max reg = {:.2e})",
766            base_reg
767        )))
768    }
769
770    /// Solve using Preconditioned Conjugate Gradients (PCG).
771    ///
772    /// The preconditioner is the one [`Self::with_preconditioner`] selected —
773    /// see [`Self::build_preconditioner`]. Reductions and vector updates run
774    /// through faer's SIMD kernels; the SpMV uses faer's parallel sparse×dense
775    /// kernel into a hoisted buffer.
776    fn solve_with_pcg(
777        &self,
778        a: &SparseColMat<usize, f64>,
779        b: &Mat<f64>,
780        h_kk: &SparseColMat<usize, f64>,
781    ) -> LinAlgResult<Mat<f64>> {
782        let precond = self.build_preconditioner(a, h_kk)?;
783
784        let result = solve_pcg(
785            b,
786            &PcgParams::new(self.cg_max_iterations, self.cg_tolerance)
787                .with_q_tolerance(self.cg_q_tolerance),
788            |p, ap| {
789                // Ap = A * p (parallel sparse×dense faer kernel)
790                faer::sparse::linalg::matmul::sparse_dense_matmul(
791                    ap.as_mut(),
792                    Accum::Replace,
793                    a.as_ref(),
794                    p.as_ref(),
795                    1.0,
796                    faer::get_global_parallelism(),
797                );
798            },
799            |r, z| apply_block_preconditioner(precond.as_deref(), r, z),
800        );
801        Ok(result.x)
802    }
803
804    /// Build the PCG preconditioner blocks for the retained system.
805    ///
806    /// All three options act on the reduced system in kept-local coordinates,
807    /// one dense `dof × dof` inverse per retained variable:
808    ///
809    /// - [`SchurPreconditioner::None`] — `None`, so PCG runs unpreconditioned.
810    /// - [`SchurPreconditioner::BlockDiagonal`] — diagonal blocks of `H_kk`.
811    ///   Cheaper, but ignores the `H_ke·H_ee⁻¹·H_keᵀ` correction, so it is a
812    ///   preconditioner for the *unreduced* camera block, not for `S`.
813    /// - [`SchurPreconditioner::SchurJacobi`] — diagonal blocks of `S` itself.
814    ///   Since this variant has already formed `S`, they are simply read off
815    ///   it. This is Ceres's `SCHUR_JACOBI`, and the only preconditioner Ceres
816    ///   permits alongside `use_explicit_schur_complement`.
817    ///
818    /// A block that will not invert falls back to identity: a preconditioner is
819    /// a convergence aid, so a singular block must not fail the solve.
820    fn build_preconditioner(
821        &self,
822        s: &SparseColMat<usize, f64>,
823        h_kk: &SparseColMat<usize, f64>,
824    ) -> LinAlgResult<Option<Vec<DMatrix<f64>>>> {
825        let source = match self.preconditioner {
826            SchurPreconditioner::None => return Ok(None),
827            SchurPreconditioner::SchurJacobi => s,
828            SchurPreconditioner::BlockDiagonal => h_kk,
829        };
830        let partition = self.require_partition()?;
831
832        let blocks = partition
833            .kept_blocks()
834            .par_iter()
835            .enumerate()
836            .map(|(idx, block)| {
837                let offset = partition.kept_offset(idx);
838                let dense = diagonal_block(source, offset, block.dof);
839                invert_with_retry_dyn(&dense)
840                    .unwrap_or_else(|| DMatrix::identity(block.dof, block.dof))
841            })
842            .collect();
843        Ok(Some(blocks))
844    }
845    /// Form the Schur complement `S = H_kk − H_ke·H_ee⁻¹·H_keᵀ`.
846    ///
847    /// Exploits the block-diagonal structure of `H_ee`: each eliminated block
848    /// contributes independently, touching only the retained rows it couples
849    /// to. Blocks may have any DOF and may differ from one another, so a 1-DOF
850    /// inverse depth and a 3-DOF point can be eliminated in the same solve.
851    ///
852    /// `S` is accumulated densely (`kept_dof²`) and then filtered back to
853    /// sparse; that buffer is the current scaling limit for very large
854    /// retained sets.
855    fn compute_schur_complement(
856        &mut self,
857        h_kk: &SparseColMat<usize, f64>,
858        h_ke: &SparseColMat<usize, f64>,
859        h_ee_inv: &EliminatedBlocks,
860    ) -> LinAlgResult<(SparseColMat<usize, f64>, Option<u64>)> {
861        let partition = self.require_partition()?.clone();
862        let kept_dof = h_kk.nrows();
863        let h_ke_symbolic = h_ke.symbolic();
864        let wpc = kept_dof.div_ceil(64);
865
866        let mut s_dense = vec![0.0f64; kept_dof * kept_dof];
867
868        // Occupancy bitmap of the dense accumulator, one aligned word range
869        // per column: bit `(col, row)` set ⟺ `S[row, col]` was written.
870        // Captured as a side effect of the writes (one OR each — the update
871        // loop is bandwidth-bound, so this is free), it seeds the sparse
872        // output pattern without ever enumerating cliques or sorting
873        // triplets.
874        let extraction_fp = self.extraction.as_ref().map(|c| c.fingerprint);
875        let words = kept_dof * wpc;
876        let mut bits = match self.s_bitmap.take() {
877            Some((fp, b)) if Some(fp) == extraction_fp && b.len() == words => b,
878            _ => vec![0u64; words],
879        };
880
881        // S starts as H_kk.
882        let h_kk_symbolic = h_kk.symbolic();
883        for col in 0..h_kk.ncols() {
884            let rows = h_kk_symbolic.row_idx_of_col_raw(col);
885            let vals = h_kk.val_of_col(col);
886            for (idx, &row) in rows.iter().enumerate() {
887                s_dense[row * kept_dof + col] += vals[idx];
888                set_dense_bit(&mut bits, wpc, row, col);
889            }
890        }
891
892        let max_dof = partition
893            .eliminated_blocks()
894            .iter()
895            .map(|b| b.dof)
896            .max()
897            .unwrap_or(0)
898            .max(1);
899        // Scratch reused across blocks; `h_ke_rows` and `contrib` are row-major
900        // `n_rows × dof` strips.
901        let mut kept_rows: Vec<usize> = Vec::with_capacity(32);
902        let mut h_ke_rows: Vec<f64> = Vec::with_capacity(32 * max_dof);
903        let mut contrib: Vec<f64> = Vec::with_capacity(32 * max_dof);
904        let mut cursors: Vec<usize> = Vec::with_capacity(max_dof);
905        let mut col_rows: Vec<&[usize]> = Vec::with_capacity(max_dof);
906        let mut col_vals: Vec<&[f64]> = Vec::with_capacity(max_dof);
907
908        for block_idx in 0..h_ee_inv.len() {
909            let dof = h_ee_inv.dof(block_idx);
910            if dof == 0 {
911                continue;
912            }
913            let col_base = partition.eliminated_offset(block_idx);
914
915            kept_rows.clear();
916            h_ke_rows.clear();
917            cursors.clear();
918            cursors.resize(dof, 0);
919
920            // Hoist the column slices: looking them up per row per column costs
921            // more than the merge itself.
922            col_rows.clear();
923            col_vals.clear();
924            for local in 0..dof {
925                let col = col_base + local;
926                col_rows.push(h_ke_symbolic.row_idx_of_col_raw(col));
927                col_vals.push(h_ke.val_of_col(col));
928            }
929
930            // Merge the block's `dof` columns into dense rows: each retained
931            // variable appears once, carrying one value per eliminated column
932            // (zero where structurally absent).
933            loop {
934                let mut min_row = usize::MAX;
935                for local in 0..dof {
936                    if let Some(&row) = col_rows[local].get(cursors[local]) {
937                        min_row = min_row.min(row);
938                    }
939                }
940                if min_row == usize::MAX {
941                    break;
942                }
943
944                let strip = h_ke_rows.len();
945                h_ke_rows.resize(strip + dof, 0.0);
946                for local in 0..dof {
947                    if col_rows[local].get(cursors[local]) == Some(&min_row) {
948                        h_ke_rows[strip + local] = col_vals[local][cursors[local]];
949                        cursors[local] += 1;
950                    }
951                }
952                kept_rows.push(min_row);
953            }
954
955            if kept_rows.is_empty() {
956                continue;
957            }
958
959            // contrib = H_ke_rows · H_ee_inv, over the block's flat
960            // column-major values borrowed once.
961            let inv = h_ee_inv.block(block_idx);
962            contrib.clear();
963            contrib.resize(kept_rows.len() * dof, 0.0);
964            for r in 0..kept_rows.len() {
965                let row = &h_ke_rows[r * dof..(r + 1) * dof];
966                for c in 0..dof {
967                    let inv_col = &inv[c * dof..(c + 1) * dof];
968                    let mut acc = 0.0;
969                    for k in 0..dof {
970                        acc += row[k] * inv_col[k];
971                    }
972                    contrib[r * dof + c] = acc;
973                }
974            }
975
976            // S[i,j] -= contrib[i,:] · H_ke_rows[j,:].
977            //
978            // The rank-`dof` update is the innermost work in the whole solve,
979            // so the 3-DOF case — points in classic BA — gets an unrolled path.
980            // The general loop below handles every other size, including mixed
981            // ones within the same problem.
982            if dof == 3 {
983                for (i, &row_i) in kept_rows.iter().enumerate() {
984                    let (c0, c1, c2) = (contrib[i * 3], contrib[i * 3 + 1], contrib[i * 3 + 2]);
985                    let base = row_i * kept_dof;
986                    for (j, &row_j) in kept_rows.iter().enumerate() {
987                        let dot = c0 * h_ke_rows[j * 3]
988                            + c1 * h_ke_rows[j * 3 + 1]
989                            + c2 * h_ke_rows[j * 3 + 2];
990                        s_dense[base + row_j] -= dot;
991                        set_dense_bit(&mut bits, wpc, row_i, row_j);
992                    }
993                }
994            } else {
995                for (i, &row_i) in kept_rows.iter().enumerate() {
996                    let contrib_i = &contrib[i * dof..(i + 1) * dof];
997                    let base = row_i * kept_dof;
998                    for (j, &row_j) in kept_rows.iter().enumerate() {
999                        let h_ke_j = &h_ke_rows[j * dof..(j + 1) * dof];
1000                        let mut dot = 0.0;
1001                        for k in 0..dof {
1002                            dot += contrib_i[k] * h_ke_j[k];
1003                        }
1004                        s_dense[base + row_j] -= dot;
1005                        set_dense_bit(&mut bits, wpc, row_i, row_j);
1006                    }
1007                }
1008            }
1009        }
1010
1011        // The bitmap-derived output reproduces the legacy symmetrize →
1012        // filter → sort pipeline bit-for-bit — same per-iteration pattern
1013        // (the threshold applies to the current averaged values), same CSC
1014        // ordering — without the triplet sort or the symmetrize pass: the
1015        // pattern comes from the occupancy bits the updates set, and the
1016        // values fall out of the same walk. Falls back to the literal
1017        // legacy path when no extraction cache exists (direct test use).
1018        let output = if self.extraction.is_some() {
1019            let (symbolic, s_values, pattern_hash) =
1020                build_schur_output(kept_dof, wpc, &bits, &s_dense);
1021            let s = SparseColMat::new(symbolic, s_values);
1022            return Ok((s, Some(pattern_hash)));
1023        } else {
1024            // Force exact symmetry: accumulation over many blocks drifts.
1025            for i in 0..kept_dof {
1026                for j in (i + 1)..kept_dof {
1027                    let avg = (s_dense[i * kept_dof + j] + s_dense[j * kept_dof + i]) * 0.5;
1028                    s_dense[i * kept_dof + j] = avg;
1029                    s_dense[j * kept_dof + i] = avg;
1030                }
1031            }
1032            let mut s_triplets: Vec<Triplet<usize, usize, f64>> =
1033                Vec::with_capacity(kept_dof.saturating_mul(8));
1034            for row in 0..kept_dof {
1035                let row_base = row * kept_dof;
1036                for col in 0..kept_dof {
1037                    let val = s_dense[row_base + col];
1038                    if val.abs() > 1e-12 {
1039                        s_triplets.push(Triplet::new(row, col, val));
1040                    }
1041                }
1042            }
1043            SparseColMat::try_new_from_triplets(kept_dof, kept_dof, &s_triplets)
1044                .map_err(|e| LinAlgError::SparseMatrixCreation(format!("Schur S: {:?}", e)))?
1045        };
1046
1047        self.s_bitmap = Some((extraction_fp.unwrap_or_default(), bits));
1048        Ok((output, None))
1049    }
1050    /// Reduced right-hand side `g_k − H_ke·H_ee⁻¹·g_e`.
1051    fn compute_reduced_gradient(
1052        &self,
1053        g_k: &Mat<f64>,
1054        g_e: &Mat<f64>,
1055        h_ke: &SparseColMat<usize, f64>,
1056        h_ee_inv: &EliminatedBlocks,
1057    ) -> LinAlgResult<Mat<f64>> {
1058        let partition = self.require_partition()?;
1059        let kept_dof = g_k.nrows();
1060
1061        // H_ee⁻¹·g_e, blockwise.
1062        let mut hee_inv_ge = Mat::zeros(g_e.nrows(), 1);
1063        for block_idx in 0..h_ee_inv.len() {
1064            let dof = h_ee_inv.dof(block_idx);
1065            let base = partition.eliminated_offset(block_idx);
1066            let inv = h_ee_inv.block(block_idx);
1067            for r in 0..dof {
1068                let mut acc = 0.0;
1069                for c in 0..dof {
1070                    acc += inv[c * dof + r] * g_e[(base + c, 0)];
1071                }
1072                hee_inv_ge[(base + r, 0)] = acc;
1073            }
1074        }
1075
1076        // H_ke·(H_ee⁻¹·g_e)
1077        let mut correction = Mat::<f64>::zeros(kept_dof, 1);
1078        let symbolic = h_ke.symbolic();
1079        for col in 0..h_ke.ncols() {
1080            let rows = symbolic.row_idx_of_col_raw(col);
1081            let vals = h_ke.val_of_col(col);
1082            for (idx, &row) in rows.iter().enumerate() {
1083                correction[(row, 0)] += vals[idx] * hee_inv_ge[(col, 0)];
1084            }
1085        }
1086
1087        let mut g_reduced = Mat::zeros(kept_dof, 1);
1088        for i in 0..kept_dof {
1089            g_reduced[(i, 0)] = g_k[(i, 0)] - correction[(i, 0)];
1090        }
1091        Ok(g_reduced)
1092    }
1093    /// Back-substitute: `δ_e = H_ee⁻¹·(g_e − H_keᵀ·δ_k)`.
1094    fn back_substitute(
1095        &self,
1096        delta_k: &Mat<f64>,
1097        g_e: &Mat<f64>,
1098        h_ke: &SparseColMat<usize, f64>,
1099        h_ee_inv: &EliminatedBlocks,
1100    ) -> LinAlgResult<Mat<f64>> {
1101        let partition = self.require_partition()?;
1102        let eliminated_dof = g_e.nrows();
1103
1104        // H_keᵀ·δ_k
1105        let mut hke_t_delta = Mat::<f64>::zeros(eliminated_dof, 1);
1106        let symbolic = h_ke.symbolic();
1107        for col in 0..h_ke.ncols() {
1108            let rows = symbolic.row_idx_of_col_raw(col);
1109            let vals = h_ke.val_of_col(col);
1110            let mut acc = 0.0;
1111            for (idx, &row) in rows.iter().enumerate() {
1112                acc += vals[idx] * delta_k[(row, 0)];
1113            }
1114            hke_t_delta[(col, 0)] = acc;
1115        }
1116
1117        let mut delta_e = Mat::zeros(eliminated_dof, 1);
1118        for block_idx in 0..h_ee_inv.len() {
1119            let dof = h_ee_inv.dof(block_idx);
1120            let base = partition.eliminated_offset(block_idx);
1121            let inv = h_ee_inv.block(block_idx);
1122            for r in 0..dof {
1123                let mut acc = 0.0;
1124                for c in 0..dof {
1125                    let rhs = g_e[(base + c, 0)] - hke_t_delta[(base + c, 0)];
1126                    acc += inv[c * dof + r] * rhs;
1127                }
1128                delta_e[(base + r, 0)] = acc;
1129            }
1130        }
1131
1132        Ok(delta_e)
1133    }
1134}
1135
1136impl Default for ExplicitSparseSchur {
1137    fn default() -> Self {
1138        Self::new()
1139    }
1140}
1141
1142impl StructureAware for ExplicitSparseSchur {
1143    fn initialize_structure(
1144        &mut self,
1145        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1146        variable_index_map: &SecondaryMap<VarKey, usize>,
1147        schur_landmark_keys: &std::collections::HashSet<VarKey>,
1148    ) -> LinAlgResult<()> {
1149        // Effective landmark set: manual marks plus the SchurOrdering
1150        // auto-classification, so both the outer structure and the delegate
1151        // solver partition identically.
1152        let effective_keys = crate::linalg::schur::effective_landmark_keys(
1153            variables,
1154            schur_landmark_keys,
1155            &self.ordering,
1156        );
1157
1158        self.build_partition(variables, variable_index_map, &effective_keys)?;
1159
1160        // The matrix-free path is `ImplicitSparseSchur`, a different solver
1161        // the optimizer constructs directly — this solver only ever forms S.
1162        Ok(())
1163    }
1164}
1165
1166impl LinearSolver<SparseMode> for ExplicitSparseSchur {
1167    fn solve_normal_equation(
1168        &mut self,
1169        residuals: &Mat<f64>,
1170        jacobian: &SparseColMat<usize, f64>,
1171    ) -> LinAlgResult<Mat<f64>> {
1172        self.require_partition()?;
1173        if self.variant == ExplicitSchurVariant::Chunked {
1174            return self.solve_chunked(residuals, jacobian, None);
1175        }
1176
1177        // 1. Build H = JᵀJ and g = Jᵀr (parallel faer kernels, cached symbolic)
1178        let NormalEquations { hessian, gradient } = self.ne_cache.compute(residuals, jacobian)?;
1179        let mut neg_gradient = Mat::zeros(gradient.nrows(), 1);
1180        for i in 0..gradient.nrows() {
1181            neg_gradient[(i, 0)] = -gradient[(i, 0)];
1182        }
1183
1184        // 2. Split the system. `H_ee` must be block-diagonal for the
1185        //    elimination to be exact; that is checked, not assumed.
1186        self.ensure_block_diagonal(&hessian)?;
1187        let (h_kk, h_ke) = self.extract_kept_and_coupling(&hessian)?;
1188        let (g_k, g_e) = self.extract_gradient_blocks(&neg_gradient)?;
1189
1190        // 3. Gather and invert the H_ee diagonal blocks (any DOF, mixed sizes).
1191        let mut eliminated = std::mem::take(&mut self.eliminated);
1192        let inversion = (|| -> LinAlgResult<()> {
1193            let partition = self.require_partition()?;
1194            eliminated.gather(&hessian, partition);
1195            eliminated.invert_in_place(partition)
1196        })();
1197        if let Err(e) = inversion {
1198            self.eliminated = eliminated;
1199            return Err(e);
1200        }
1201
1202        // Publish H and g only on success, so neither is cloned — and so a
1203        // failed solve keeps the previous solve's published system instead of
1204        // a half-published new one (see the freshness contract on
1205        // `LinearSolver::get_hessian`).
1206        let result = self.solve_reduced_system(&h_kk, &h_ke, &g_k, &g_e, &eliminated);
1207        self.eliminated = eliminated;
1208        if result.is_ok() {
1209            self.hessian = Some(hessian);
1210            self.gradient = Some(gradient);
1211        }
1212        result
1213    }
1214    fn solve_augmented_equation(
1215        &mut self,
1216        residuals: &Mat<f64>,
1217        jacobian: &SparseColMat<usize, f64>,
1218        damping: &Damping,
1219    ) -> LinAlgResult<Mat<f64>> {
1220        self.require_partition()?;
1221        if self.variant == ExplicitSchurVariant::Chunked {
1222            return self.solve_chunked(residuals, jacobian, Some(damping));
1223        }
1224
1225        // 1. Build H = JᵀJ and g = Jᵀr (parallel faer kernels, cached symbolic)
1226        let NormalEquations { hessian, gradient } = self.ne_cache.compute(residuals, jacobian)?;
1227        let mut neg_gradient = Mat::zeros(gradient.nrows(), 1);
1228        for i in 0..gradient.nrows() {
1229            neg_gradient[(i, 0)] = -gradient[(i, 0)];
1230        }
1231
1232        // 2. Split the system, verifying the elimination precondition.
1233        self.ensure_block_diagonal(&hessian)?;
1234        let partition = self.require_partition()?;
1235        let kept_dof = partition.kept_dof();
1236        let num_eliminated_blocks = partition.eliminated_blocks().len();
1237        let (h_kk, h_ke) = self.extract_kept_and_coupling(&hessian)?;
1238        let (g_k, g_e) = self.extract_gradient_blocks(&neg_gradient)?;
1239
1240        debug!(
1241            "Iteration matrices: H {}×{}, H_kk {}×{}, H_ke {}×{}, {} eliminated blocks",
1242            hessian.nrows(),
1243            hessian.ncols(),
1244            h_kk.nrows(),
1245            h_kk.ncols(),
1246            h_ke.nrows(),
1247            h_ke.ncols(),
1248            num_eliminated_blocks
1249        );
1250
1251        // 3. λ·D is applied to *both* sides before elimination — damping the
1252        //    reduced system instead would not be the same problem.
1253        let h_kk_damped = damp_camera_block(&h_kk, kept_dof, damping)?;
1254
1255        let mut eliminated = std::mem::take(&mut self.eliminated);
1256        let prepared = (|| -> LinAlgResult<()> {
1257            let partition = self.require_partition()?;
1258            eliminated.gather(&hessian, partition);
1259            eliminated.damp(damping);
1260            eliminated.invert_in_place(partition)
1261        })();
1262        if let Err(e) = prepared {
1263            self.eliminated = eliminated;
1264            return Err(e);
1265        }
1266
1267        // Publish the *un-damped* H and g, but only on success: the optimizers
1268        // build the true quadratic model from these, and a failed solve must
1269        // keep the previous solve's published system (see the freshness
1270        // contract on `LinearSolver::get_hessian`).
1271        let result = self.solve_reduced_system(&h_kk_damped, &h_ke, &g_k, &g_e, &eliminated);
1272        self.eliminated = eliminated;
1273        if result.is_ok() {
1274            self.hessian = Some(hessian);
1275            self.gradient = Some(gradient);
1276        }
1277        result
1278    }
1279
1280    fn hessian_vec_product(&self, v: &Mat<f64>) -> Option<Mat<f64>> {
1281        if let Some(h) = self.hessian.as_ref() {
1282            return Some(
1283                <SparseMode as crate::linearizer::AssemblyBackend>::hessian_vec_product(h, v),
1284            );
1285        }
1286        // Chunked path: `JᵀJ` was never formed, so evaluate `Jᵀ(J·v)` from `J`.
1287        let j = self.chunked_jacobian.as_ref()?;
1288        let symbolic = j.symbolic();
1289
1290        let mut jv = Mat::<f64>::zeros(j.nrows(), 1);
1291        for col in 0..j.ncols() {
1292            let x = v[(col, 0)];
1293            if x == 0.0 {
1294                continue;
1295            }
1296            let rows = symbolic.row_idx_of_col_raw(col);
1297            let vals = j.val_of_col(col);
1298            for (i, &row) in rows.iter().enumerate() {
1299                jv[(row, 0)] += vals[i] * x;
1300            }
1301        }
1302
1303        let mut out = Mat::<f64>::zeros(j.ncols(), 1);
1304        for col in 0..j.ncols() {
1305            let rows = symbolic.row_idx_of_col_raw(col);
1306            let vals = j.val_of_col(col);
1307            let mut acc = 0.0;
1308            for (i, &row) in rows.iter().enumerate() {
1309                acc += vals[i] * jv[(row, 0)];
1310            }
1311            out[(col, 0)] = acc;
1312        }
1313        Some(out)
1314    }
1315
1316    fn get_hessian(&self) -> Option<&SparseColMat<usize, f64>> {
1317        self.hessian.as_ref()
1318    }
1319
1320    fn get_gradient(&self) -> Option<&Mat<f64>> {
1321        self.gradient.as_ref()
1322    }
1323}
1324
1325// Helper methods for ExplicitSparseSchur
1326impl ExplicitSparseSchur {
1327    /// Chunk-wise solve: `J` straight to the reduced system, no `JᵀJ`.
1328    ///
1329    /// The gradient published for the optimizers is `Jᵀr` over the *whole*
1330    /// system, which the eliminator produces as a by-product; the Hessian is
1331    /// not published at all, because it is never formed —
1332    /// [`LinearSolver::hessian_vec_product`] serves the quadratic model as
1333    /// `Jᵀ(J·v)` instead.
1334    fn solve_chunked(
1335        &mut self,
1336        residuals: &Mat<f64>,
1337        jacobian: &SparseColMat<usize, f64>,
1338        damping: Option<&Damping>,
1339    ) -> LinAlgResult<Mat<f64>> {
1340        // Rebuild the chunk layout only when the sparsity changes.
1341        if self.chunked.as_ref().is_none_or(|c| !c.matches(jacobian)) {
1342            let partition = self.require_partition()?;
1343            self.chunked = Some(super::chunk_eliminator::ChunkedSchurEliminator::new(
1344                jacobian, partition,
1345            )?);
1346        }
1347
1348        let mut eliminator = match self.chunked.take() {
1349            Some(e) => e,
1350            None => {
1351                return Err(LinAlgError::InvalidState(
1352                    "chunk eliminator not initialized".to_string(),
1353                )
1354                .log());
1355            }
1356        };
1357
1358        // The optimizers need +Jᵀr and the action of the un-damped Hessian.
1359        // Both are published only on success, so a failed solve keeps the
1360        // previous solve's published system (freshness contract on
1361        // `LinearSolver::get_hessian`).
1362        let outcome = (|| -> LinAlgResult<Mat<f64>> {
1363            let partition = self.require_partition()?.clone();
1364            let reduced = eliminator.eliminate(jacobian, residuals, &partition, damping)?;
1365
1366            // S is accumulated dense; hand Cholesky a sparse view of it.
1367            let kept_dof = reduced.kept_dof;
1368            // faer's `Mat` is column-major, so walk columns outermost to keep
1369            // the read sequential.
1370            let mut triplets: Vec<Triplet<usize, usize, f64>> =
1371                Vec::with_capacity(kept_dof.saturating_mul(8));
1372            for col in 0..kept_dof {
1373                for row in 0..kept_dof {
1374                    let v = reduced.s[(row, col)];
1375                    if v.abs() > 1e-12 {
1376                        triplets.push(Triplet::new(row, col, v));
1377                    }
1378                }
1379            }
1380            let s = SparseColMat::try_new_from_triplets(kept_dof, kept_dof, &triplets)
1381                .map_err(|e| LinAlgError::SparseMatrixCreation(format!("Schur S: {e:?}")))?;
1382
1383            // The eliminator returns +g; the reduced system solves S·δ = −g_red.
1384            let mut rhs = Mat::<f64>::zeros(kept_dof, 1);
1385            for i in 0..kept_dof {
1386                rhs[(i, 0)] = -reduced.g_reduced[(i, 0)];
1387            }
1388            // The chunked path rebuilds S from a different representation and
1389            // carries no pattern hash — the symbolic cache stays cold here.
1390            let delta_k = self.solve_with_cholesky(&s, &rhs, None)?;
1391
1392            // δ_e = H_ee⁻¹·(−g_e − H_keᵀ·δ_k); the eliminator already holds
1393            // H_ee⁻¹·g_e, so only the coupling term is left to apply.
1394            let delta_e = self.back_substitute_chunked(&delta_k, &reduced, jacobian, &partition)?;
1395            self.combine_updates(&delta_k, &delta_e)
1396        })();
1397
1398        self.chunked = Some(eliminator);
1399        if outcome.is_ok() {
1400            self.gradient = Some(Self::full_gradient(jacobian, residuals));
1401            self.hessian = None;
1402            self.chunked_jacobian = Some(jacobian.clone());
1403        }
1404        outcome
1405    }
1406
1407    /// `Jᵀr` over the whole system, for the optimizers' gradient.
1408    ///
1409    /// Parallel over columns; each column's dot product stays serial, so the
1410    /// result is bit-identical to the sequential version.
1411    fn full_gradient(jacobian: &SparseColMat<usize, f64>, residuals: &Mat<f64>) -> Mat<f64> {
1412        let symbolic = jacobian.symbolic();
1413        let per_col: Vec<f64> = (0..jacobian.ncols())
1414            .into_par_iter()
1415            .map(|col| {
1416                let rows = symbolic.row_idx_of_col_raw(col);
1417                let vals = jacobian.val_of_col(col);
1418                let mut acc = 0.0;
1419                for (i, &row) in rows.iter().enumerate() {
1420                    acc += vals[i] * residuals[(row, 0)];
1421                }
1422                acc
1423            })
1424            .collect();
1425        Mat::from_fn(jacobian.ncols(), 1, |r, _| per_col[r])
1426    }
1427
1428    /// `δ_e = H_ee⁻¹·(−g_e − H_keᵀ·δ_k)`, evaluated from `J` rather than `H_ke`.
1429    fn back_substitute_chunked(
1430        &self,
1431        delta_k: &Mat<f64>,
1432        reduced: &super::chunk_eliminator::ReducedSystem,
1433        jacobian: &SparseColMat<usize, f64>,
1434        partition: &SchurPartition,
1435    ) -> LinAlgResult<Mat<f64>> {
1436        let symbolic = jacobian.symbolic();
1437
1438        // J·δ_k over the retained columns only, giving the coupling term's
1439        // row-space vector without forming H_ke.
1440        let mut j_delta = Mat::<f64>::zeros(jacobian.nrows(), 1);
1441        let mut local = 0usize;
1442        for block in partition.kept_blocks() {
1443            for offset in 0..block.dof {
1444                let col = block.col_start + offset;
1445                let rows = symbolic.row_idx_of_col_raw(col);
1446                let vals = jacobian.val_of_col(col);
1447                let x = delta_k[(local, 0)];
1448                if x != 0.0 {
1449                    for (i, &row) in rows.iter().enumerate() {
1450                        j_delta[(row, 0)] += vals[i] * x;
1451                    }
1452                }
1453                local += 1;
1454            }
1455        }
1456
1457        let mut delta_e = Mat::<f64>::zeros(partition.eliminated_dof(), 1);
1458        // Scratch for Eᵀ·(J·δ_k), sized for the largest eliminated block. The
1459        // partition layer allows arbitrary DOF, so there is no cap: truncating
1460        // here would silently drop coupling terms and corrupt the step.
1461        let max_dof = partition
1462            .eliminated_blocks()
1463            .iter()
1464            .map(|block| block.dof)
1465            .max()
1466            .unwrap_or(0);
1467        let mut etjd = vec![0.0f64; max_dof];
1468        for (block_idx, block) in partition.eliminated_blocks().iter().enumerate() {
1469            let dof = block.dof;
1470            let base = partition.eliminated_offset(block_idx);
1471            let inv = reduced.eliminated_inverse.block(block_idx);
1472
1473            // Eᵀ·(J·δ_k) for this chunk.
1474            for (a, slot) in etjd[..dof].iter_mut().enumerate() {
1475                let col = block.col_start + a;
1476                let rows = symbolic.row_idx_of_col_raw(col);
1477                let vals = jacobian.val_of_col(col);
1478                let mut acc = 0.0;
1479                for (i, &row) in rows.iter().enumerate() {
1480                    acc += vals[i] * j_delta[(row, 0)];
1481                }
1482                *slot = acc;
1483            }
1484
1485            // δ_e = −H_ee⁻¹·g_e − H_ee⁻¹·Eᵀ(J·δ_k)
1486            for r in 0..dof {
1487                let mut acc = -reduced.eliminated_rhs[(base + r, 0)];
1488                for (c, &term) in etjd[..dof].iter().enumerate() {
1489                    acc -= inv[c * dof + r] * term;
1490                }
1491                delta_e[(base + r, 0)] = acc;
1492            }
1493        }
1494
1495        Ok(delta_e)
1496    }
1497
1498    /// Eliminate, solve the reduced system, and back-substitute.
1499    ///
1500    /// Shared by the damped and undamped paths, which differ only in whether
1501    /// `h_kk` and the `H_ee` blocks already carry `λ·D`.
1502    fn solve_reduced_system(
1503        &mut self,
1504        h_kk: &SparseColMat<usize, f64>,
1505        h_ke: &SparseColMat<usize, f64>,
1506        g_k: &Mat<f64>,
1507        g_e: &Mat<f64>,
1508        h_ee_inv: &EliminatedBlocks,
1509    ) -> LinAlgResult<Mat<f64>> {
1510        let (s, s_pattern_hash) = self.compute_schur_complement(h_kk, h_ke, h_ee_inv)?;
1511        let g_reduced = self.compute_reduced_gradient(g_k, g_e, h_ke, h_ee_inv)?;
1512
1513        let delta_k = match self.variant {
1514            ExplicitSchurVariant::Iterative => self.solve_with_pcg(&s, &g_reduced, h_kk)?,
1515            ExplicitSchurVariant::Sparse | ExplicitSchurVariant::Chunked => {
1516                self.solve_with_cholesky(&s, &g_reduced, s_pattern_hash)?
1517            }
1518        };
1519
1520        let delta_e = self.back_substitute(&delta_k, g_e, h_ke, h_ee_inv)?;
1521        self.combine_updates(&delta_k, &delta_e)
1522    }
1523
1524    /// Scatter the two solution halves back into one full-length update.
1525    ///
1526    /// Goes through the partition, so it stays correct when the retained and
1527    /// eliminated variables interleave in column space.
1528    fn combine_updates(&self, delta_k: &Mat<f64>, delta_e: &Mat<f64>) -> LinAlgResult<Mat<f64>> {
1529        let partition = self.require_partition()?;
1530        let mut delta = Mat::zeros(partition.total_dof(), 1);
1531
1532        for block in partition.kept_blocks() {
1533            for offset in 0..block.dof {
1534                let global = block.col_start + offset;
1535                if let Some(local) = partition.kept_local(global) {
1536                    delta[(global, 0)] = delta_k[(local, 0)];
1537                }
1538            }
1539        }
1540        for (block_idx, block) in partition.eliminated_blocks().iter().enumerate() {
1541            let base = partition.eliminated_offset(block_idx);
1542            for offset in 0..block.dof {
1543                delta[(block.col_start + offset, 0)] = delta_e[(base + offset, 0)];
1544            }
1545        }
1546
1547        debug!(
1548            "Update norms: delta_kept={:.6e}, delta_eliminated={:.6e}, combined={:.6e}",
1549            delta_k.norm_l2(),
1550            delta_e.norm_l2(),
1551            delta.norm_l2()
1552        );
1553
1554        Ok(delta)
1555    }
1556}
1557
1558/// Extract the dense `dof × dof` diagonal block at `offset` from a matrix in
1559/// kept-local coordinates.
1560fn diagonal_block(m: &SparseColMat<usize, f64>, offset: usize, dof: usize) -> DMatrix<f64> {
1561    let symbolic = m.symbolic();
1562    let mut block = DMatrix::zeros(dof, dof);
1563    for local_col in 0..dof {
1564        let col = offset + local_col;
1565        let rows = symbolic.row_idx_of_col_raw(col);
1566        let vals = m.val_of_col(col);
1567        for (idx, &row) in rows.iter().enumerate() {
1568            if row >= offset && row < offset + dof {
1569                block[(row - offset, local_col)] = vals[idx];
1570            }
1571        }
1572    }
1573    block
1574}
1575
1576/// Apply `z = M⁻¹·r` for a block-diagonal `M⁻¹`, or `z = r` when there is none.
1577///
1578/// Blocks are in kept-local order and contiguous, so the flat offset advances
1579/// block by block without consulting the partition.
1580fn apply_block_preconditioner(blocks: Option<&[DMatrix<f64>]>, r: &Mat<f64>, z: &mut Mat<f64>) {
1581    let Some(blocks) = blocks else {
1582        faer::zip!(z, r).for_each(|faer::unzip!(z, r)| *z = *r);
1583        return;
1584    };
1585
1586    let mut offset = 0usize;
1587    for inv in blocks {
1588        let dof = inv.nrows();
1589        for row in 0..dof {
1590            let mut acc = 0.0;
1591            for col in 0..dof {
1592                acc += inv[(row, col)] * r[(offset + col, 0)];
1593            }
1594            z[(offset + row, 0)] = acc;
1595        }
1596        offset += dof;
1597    }
1598}
1599
1600/// `H_cc + λ·D` with `D_jj = clamp(H_jj, min_diagonal, max_diagonal)`.
1601///
1602/// Damping only ever touches the diagonal, so when every diagonal entry is
1603/// already present in the pattern this is a value-only edit: clone the CSC
1604/// value array and add λ·D at the cached diagonal offsets. That is O(nnz),
1605/// against the O(cam_size × nnz) linear `find` over a freshly built triplet
1606/// list that this replaces, and it skips the triplet sort entirely.
1607///
1608/// A camera observed by no landmark has a structurally empty diagonal, which
1609/// damping must materialize. That grows the pattern, so it falls back to
1610/// rebuilding from triplets — still a single O(nnz) pass, with the missing
1611/// diagonals appended.
1612///
1613/// Mirrors [`NormalEquationsCache::damped_hessian`](crate::linalg::sparse::normal_eq)
1614/// so both damping sites share one rule.
1615fn damp_camera_block(
1616    h_cc: &SparseColMat<usize, f64>,
1617    cam_size: usize,
1618    damping: &Damping,
1619) -> LinAlgResult<SparseColMat<usize, f64>> {
1620    let symbolic = h_cc.symbolic();
1621
1622    // Absolute offsets of each column's diagonal entry in the flat value array.
1623    let diag_pos: Vec<Option<usize>> = (0..h_cc.ncols())
1624        .map(|col| {
1625            let col_start = symbolic.col_range(col).start;
1626            symbolic
1627                .row_idx_of_col_raw(col)
1628                .iter()
1629                .position(|&row| row == col)
1630                .map(|local| col_start + local)
1631        })
1632        .collect();
1633
1634    if diag_pos.iter().all(Option::is_some) {
1635        let owned = symbolic.to_owned().map_err(|e| {
1636            LinAlgError::SparseMatrixCreation(format!("Damped H_cc symbolic: {e:?}"))
1637        })?;
1638        let mut values = h_cc.as_ref().val().to_vec();
1639        for pos in diag_pos.iter().flatten() {
1640            values[*pos] += damping.diagonal_term(values[*pos]);
1641        }
1642        return Ok(SparseColMat::new(owned, values));
1643    }
1644
1645    // Slow path: at least one diagonal must be created.
1646    let mut triplets = Vec::with_capacity(h_cc.compute_nnz() + cam_size);
1647    for col in 0..h_cc.ncols() {
1648        let row_indices = symbolic.row_idx_of_col_raw(col);
1649        let col_values = h_cc.val_of_col(col);
1650        for (idx, &row) in row_indices.iter().enumerate() {
1651            let val = col_values[idx];
1652            let val = if row == col {
1653                val + damping.diagonal_term(val)
1654            } else {
1655                val
1656            };
1657            triplets.push(Triplet::new(row, col, val));
1658        }
1659    }
1660    for (i, pos) in diag_pos.iter().enumerate().take(cam_size) {
1661        if pos.is_none() {
1662            // H_ii is structurally zero, so the clamp floors the damping at
1663            // λ·min_diagonal.
1664            triplets.push(Triplet::new(i, i, damping.diagonal_term(0.0)));
1665        }
1666    }
1667    SparseColMat::try_new_from_triplets(cam_size, cam_size, &triplets)
1668        .map_err(|e| LinAlgError::SparseMatrixCreation(format!("Damped H_cc: {e:?}")))
1669}
1670
1671#[cfg(test)]
1672mod tests {
1673    use super::*;
1674    use crate::core::VarKey;
1675    use crate::core::variable::Variable;
1676    use apex_manifolds::{LieGroup, ManifoldType, rn, se3};
1677    use nalgebra::DVector;
1678    use slotmap::{SecondaryMap, SlotMap};
1679
1680    type TestResult = Result<(), Box<dyn std::error::Error>>;
1681
1682    // Type alias for the test setup tuple
1683    type TestSetup = (
1684        SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1685        SecondaryMap<VarKey, usize>,
1686        SparseColMat<usize, f64>,
1687        Mat<f64>,
1688        std::collections::HashSet<VarKey>,
1689    );
1690
1691    /// Build a minimal BA-style test setup:
1692    /// 2 SE3 cameras + 3 Rn landmarks
1693    /// Jacobian: 36 rows × 21 cols
1694    ///
1695    /// Structure guarantees H_cc = 3·I₁₂ and H_pp = 4·I₃ (positive definite).
1696    fn create_schur_test_setup() -> Result<TestSetup, Box<dyn std::error::Error>> {
1697        let se3_id = DVector::from_vec(vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
1698        let pt_zero = DVector::from_vec(vec![0.0, 0.0, 0.0]);
1699
1700        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1701        let cam0 = variables.insert(Box::new(Variable::new(se3::SE3::from_param_slice(
1702            se3_id.as_slice(),
1703        ))));
1704        let cam1 = variables.insert(Box::new(Variable::new(se3::SE3::from_param_slice(
1705            se3_id.as_slice(),
1706        ))));
1707        let pt0 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_zero.clone()))));
1708        let pt1 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_zero.clone()))));
1709        let pt2 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_zero.clone()))));
1710
1711        // cam0 → 0..5, cam1 → 6..11, pt0 → 12..14, pt1 → 15..17, pt2 → 18..20
1712        let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
1713        variable_index_map.insert(cam0, 0);
1714        variable_index_map.insert(cam1, 6);
1715        variable_index_map.insert(pt0, 12);
1716        variable_index_map.insert(pt1, 15);
1717        variable_index_map.insert(pt2, 18);
1718
1719        // Jacobian: 2 cameras × 3 landmarks × 6 rows_per_obs = 36 rows, 21 cols
1720        // For observation (cam_i, pt_j), row_base = (ci * 3 + li) * 6
1721        //   J[row_base+k, cam_col+k] = 1.0  (k=0..5, camera DOF)
1722        //   J[row_base+k, lm_col + (k%3)] = 1.0  (landmark DOF repeats to fill all 3)
1723        let n_rows = 36;
1724        let n_cols = 21;
1725        let cam_cols = [0usize, 6];
1726        let lm_cols = [12usize, 15, 18];
1727
1728        let mut triplets: Vec<Triplet<usize, usize, f64>> = Vec::new();
1729        for (ci, &cam_col) in cam_cols.iter().enumerate() {
1730            for (li, &lm_col) in lm_cols.iter().enumerate() {
1731                let row_base = (ci * 3 + li) * 6;
1732                for k in 0..6 {
1733                    triplets.push(Triplet::new(row_base + k, cam_col + k, 1.0));
1734                    triplets.push(Triplet::new(row_base + k, lm_col + (k % 3), 1.0));
1735                }
1736            }
1737        }
1738
1739        let jacobian = SparseColMat::try_new_from_triplets(n_rows, n_cols, &triplets)?;
1740        let residuals = Mat::from_fn(n_rows, 1, |i, _| (i % 5) as f64 * 0.1);
1741
1742        let mut landmark_keys = std::collections::HashSet::new();
1743        landmark_keys.insert(pt0);
1744        landmark_keys.insert(pt1);
1745        landmark_keys.insert(pt2);
1746
1747        Ok((
1748            variables,
1749            variable_index_map,
1750            jacobian,
1751            residuals,
1752            landmark_keys,
1753        ))
1754    }
1755
1756    /// The pattern-cached extraction must agree exactly with the direct
1757    /// reference extractors: same pattern, same values, and a second call on
1758    /// the same pattern must hit the cache and reproduce the first.
1759    #[test]
1760    fn cached_extraction_matches_direct_reference() -> TestResult {
1761        let (variables, variable_index_map, jacobian, _residuals, landmark_keys) =
1762            create_schur_test_setup()?;
1763        let mut solver = ExplicitSparseSchur::new();
1764        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1765
1766        let h_full = {
1767            let mut cache =
1768                crate::linalg::sparse::normal_eq::NormalEquationsCache::try_new(&jacobian)?;
1769            let residuals = Mat::zeros(jacobian.nrows(), 1);
1770            cache.compute(&residuals, &jacobian)?.hessian
1771        };
1772
1773        let kk_ref = solver.extract_kept_block(&h_full)?;
1774        let ke_ref = solver.extract_coupling_block(&h_full)?;
1775        let (kk_first, ke_first) = solver.extract_kept_and_coupling(&h_full)?;
1776        let (kk_second, ke_second) = solver.extract_kept_and_coupling(&h_full)?;
1777
1778        for (cached, direct, what) in [
1779            (&kk_first, &kk_ref, "H_kk first call"),
1780            (&kk_second, &kk_ref, "H_kk cached call"),
1781            (&ke_first, &ke_ref, "H_ke first call"),
1782            (&ke_second, &ke_ref, "H_ke cached call"),
1783        ] {
1784            assert_eq!(
1785                (cached.nrows(), cached.ncols()),
1786                (direct.nrows(), direct.ncols()),
1787                "{what}: shape mismatch"
1788            );
1789            assert_eq!(cached.compute_nnz(), direct.compute_nnz(), "{what}: nnz");
1790            for col in 0..direct.ncols() {
1791                assert_eq!(
1792                    cached.symbolic().row_idx_of_col_raw(col),
1793                    direct.symbolic().row_idx_of_col_raw(col),
1794                    "{what}: pattern mismatch in column {col}"
1795                );
1796                assert_eq!(
1797                    cached.val_of_col(col),
1798                    direct.val_of_col(col),
1799                    "{what}: value mismatch in column {col}"
1800                );
1801            }
1802        }
1803        Ok(())
1804    }
1805
1806    /// The reduced camera matrix against naive dense algebra.
1807    ///
1808    /// Pins the `dof == 3` unrolled path, the general loop, the flat
1809    /// column-major `EliminatedBlocks` indexing and the exact-symmetrization
1810    /// step against one independent `S = H_kk − H_ke·H_ee⁻¹·H_keᵀ` computed
1811    /// with dense nalgebra — so a future edit breaking any single
1812    /// representation fails here even if every other path still agrees.
1813    #[test]
1814    fn schur_complement_matches_naive_dense() -> TestResult {
1815        let (variables, variable_index_map, jacobian, _residuals, landmark_keys) =
1816            create_schur_test_setup()?;
1817        let mut solver = ExplicitSparseSchur::new();
1818        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1819
1820        // Dense J, then H = JᵀJ.
1821        let nrows = jacobian.nrows();
1822        let ncols = jacobian.ncols();
1823        let mut dense_j = nalgebra::DMatrix::zeros(nrows, ncols);
1824        for col in 0..ncols {
1825            let rows = jacobian.symbolic().row_idx_of_col_raw(col);
1826            let vals = jacobian.val_of_col(col);
1827            for (i, &row) in rows.iter().enumerate() {
1828                dense_j[(row, col)] = vals[i];
1829            }
1830        }
1831        let h = dense_j.transpose() * &dense_j;
1832
1833        // Kept vs eliminated columns from the same index map the solver used.
1834        let mut elim_cols = Vec::new();
1835        for key in &landmark_keys {
1836            let start = variable_index_map.get(*key).ok_or("elim key missing")?;
1837            let dof = variables.get(*key).ok_or("elim var missing")?.dof();
1838            elim_cols.extend(*start..*start + dof);
1839        }
1840        elim_cols.sort_unstable();
1841        let kept_cols: Vec<usize> = (0..ncols).filter(|c| !elim_cols.contains(c)).collect();
1842
1843        let submatrix = |rows: &[usize], cols: &[usize]| -> nalgebra::DMatrix<f64> {
1844            nalgebra::DMatrix::from_fn(rows.len(), cols.len(), |r, c| h[(rows[r], cols[c])])
1845        };
1846        let h_kk = submatrix(&kept_cols, &kept_cols);
1847        let h_ke = submatrix(&kept_cols, &elim_cols);
1848        let h_ee = submatrix(&elim_cols, &elim_cols);
1849        let h_ee_inv = h_ee.try_inverse().ok_or("naive H_ee must invert")?;
1850        let s_naive = h_kk - &h_ke * h_ee_inv * h_ke.transpose();
1851
1852        // Solver's S through its own extraction + arena + complement path,
1853        // sharing one H so the comparison is legible.
1854        let h_full = {
1855            let mut cache =
1856                crate::linalg::sparse::normal_eq::NormalEquationsCache::try_new(&jacobian)?;
1857            let residuals = Mat::zeros(nrows, 1);
1858            cache.compute(&residuals, &jacobian)?.hessian
1859        };
1860        let h_kk2 = solver.extract_kept_block(&h_full)?;
1861        let h_ke2 = solver.extract_coupling_block(&h_full)?;
1862        let mut blocks = EliminatedBlocks::new(solver.partition().ok_or("partition missing")?);
1863        blocks.gather(&h_full, solver.partition().ok_or("partition missing")?);
1864        blocks.invert_in_place(solver.partition().ok_or("partition missing")?)?;
1865        let (s, _) = solver.compute_schur_complement(&h_kk2, &h_ke2, &blocks)?;
1866
1867        // Exact symmetry, then value agreement with naive dense math.
1868        // Entries the solver filters (|v| <= 1e-12) read back as 0.0, which
1869        // stays inside the 1e-9 comparison tolerance by construction.
1870        let s_val = |i: usize, j: usize| -> f64 {
1871            let rows = s.symbolic().row_idx_of_col_raw(j);
1872            let vals = s.val_of_col(j);
1873            rows.iter()
1874                .position(|&r| r == i)
1875                .map(|p| vals[p])
1876                .unwrap_or(0.0)
1877        };
1878        let k = kept_cols.len();
1879        assert_eq!((s.nrows(), s.ncols()), (k, k));
1880        for i in 0..k {
1881            for j in 0..k {
1882                let (a, b) = (s_val(i, j), s_val(j, i));
1883                assert!(
1884                    (a - b).abs() < 1e-12,
1885                    "S not symmetric at ({i},{j}): {a} vs {b}"
1886                );
1887                assert!(
1888                    (a - s_naive[(i, j)]).abs() < 1e-9,
1889                    "S[{i},{j}] = {a}, naive dense = {}",
1890                    s_naive[(i, j)]
1891                );
1892            }
1893        }
1894        Ok(())
1895    }
1896
1897    #[test]
1898    fn test_schur_ordering_rn3_eliminated() {
1899        let ordering = SchurOrdering::default();
1900        // Default: RN(3) is eliminated (landmark)
1901        assert!(ordering.should_eliminate(&ManifoldType::RN, 3));
1902    }
1903
1904    #[test]
1905    fn test_schur_ordering_se3_not_eliminated() {
1906        let ordering = SchurOrdering::default();
1907        // SE3 variables are never eliminated (camera)
1908        assert!(!ordering.should_eliminate(&ManifoldType::SE3, 6));
1909    }
1910
1911    #[test]
1912    fn test_schur_ordering_wrong_type_not_eliminated() {
1913        let ordering = SchurOrdering::default();
1914        // SE3(6) is not in eliminate_types
1915        assert!(!ordering.should_eliminate(&ManifoldType::SE3, 6));
1916    }
1917
1918    #[test]
1919    fn test_schur_ordering_wrong_size_not_eliminated() {
1920        let ordering = SchurOrdering::default();
1921        // RN with size != 3 is not eliminated
1922        assert!(!ordering.should_eliminate(&ManifoldType::RN, 6));
1923        assert!(!ordering.should_eliminate(&ManifoldType::RN, 2));
1924    }
1925
1926    #[test]
1927    fn test_schur_ordering_by_name_matches_type() {
1928        let ordering = SchurOrdering::default();
1929        assert!(ordering.should_eliminate_by_name("Rn", 3));
1930        assert!(!ordering.should_eliminate_by_name("SE3", 6));
1931        assert!(!ordering.should_eliminate_by_name("NotAManifold", 3));
1932    }
1933
1934    #[test]
1935    fn test_auto_elimination_is_opt_in() -> Result<(), Box<dyn std::error::Error>> {
1936        use crate::core::variable::Variable;
1937        use apex_manifolds::{rn, se3};
1938        use nalgebra::DVector;
1939        use slotmap::SlotMap;
1940
1941        // Two SE3 cameras and two Rn(3) landmarks, none marked manually.
1942        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1943        let se3_data = DVector::from_vec(vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
1944        let cam0 = variables.insert(Box::new(Variable::new(se3::SE3::from_param_slice(
1945            se3_data.as_slice(),
1946        ))));
1947        let cam1 = variables.insert(Box::new(Variable::new(se3::SE3::from_param_slice(
1948            se3_data.as_slice(),
1949        ))));
1950        let pt_data = DVector::from_vec(vec![0.0, 0.0, 0.0]);
1951        let pt0 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_data.clone()))));
1952        let pt1 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_data.clone()))));
1953
1954        let mut index_map = SecondaryMap::new();
1955        index_map.insert(cam0, 0);
1956        index_map.insert(cam1, 6);
1957        index_map.insert(pt0, 12);
1958        index_map.insert(pt1, 15);
1959
1960        let empty_marks = std::collections::HashSet::new();
1961
1962        // Default ordering: auto-classification stays off — Rn(3) is ambiguous
1963        // (landmarks vs self-calibration intrinsics), so only pt0 is eliminated
1964        // and the unmarked pt1 stays a camera.
1965        let mut marks = std::collections::HashSet::new();
1966        marks.insert(pt0);
1967        let mut solver = ExplicitSparseSchur::new();
1968        solver.initialize_structure(&variables, &index_map, &marks)?;
1969        let structure = solver.partition().ok_or("partition missing")?;
1970        assert_eq!(
1971            structure.eliminated_blocks().len(),
1972            1,
1973            "auto-detection must be off by default"
1974        );
1975        assert_eq!(structure.kept_blocks().len(), 3);
1976        assert!(structure.kept_blocks().iter().any(|b| b.key == pt1));
1977
1978        // Opt-in: unmarked Rn(3) variables are eliminated as landmarks.
1979        let ordering = SchurOrdering::default().with_auto_detect(true);
1980        let mut solver = ExplicitSparseSchur::new().with_ordering(ordering);
1981        solver.initialize_structure(&variables, &index_map, &empty_marks)?;
1982        let structure = solver.partition().ok_or("partition missing")?;
1983        assert_eq!(
1984            structure.eliminated_blocks().len(),
1985            2,
1986            "Rn(3) variables must auto-eliminate"
1987        );
1988        assert_eq!(
1989            structure.kept_blocks().len(),
1990            2,
1991            "SE3 variables must stay cameras"
1992        );
1993
1994        // Manual marks still eliminate regardless of type/size.
1995        let mut marks = std::collections::HashSet::new();
1996        marks.insert(cam1);
1997        let mut solver = ExplicitSparseSchur::new();
1998        solver.initialize_structure(&variables, &index_map, &marks)?;
1999        let structure = solver.partition().ok_or("partition missing")?;
2000        assert!(structure.eliminated_blocks().iter().any(|b| b.key == cam1));
2001
2002        Ok(())
2003    }
2004    /// A partition needs both sides populated; the shape itself is covered in
2005    /// depth by `schur_partition`'s own tests.
2006    #[test]
2007    fn test_partition_requires_both_sides() {
2008        assert!(SchurPartition::new(Vec::new(), Vec::new()).is_err());
2009    }
2010
2011    #[test]
2012    fn test_solver_creation() {
2013        let solver = ExplicitSparseSchur::new();
2014        assert!(solver.partition().is_none());
2015    }
2016
2017    #[test]
2018    fn test_schur_variants() {
2019        let solver = ExplicitSparseSchur::new()
2020            .with_variant(ExplicitSchurVariant::Iterative)
2021            .with_preconditioner(SchurPreconditioner::BlockDiagonal)
2022            .with_cg_params(100, 1e-8);
2023
2024        assert_eq!(solver.cg_max_iterations, 100);
2025        assert!((solver.cg_tolerance - 1e-8).abs() < 1e-12);
2026        assert_eq!(solver.variant, ExplicitSchurVariant::Iterative);
2027        assert_eq!(solver.preconditioner, SchurPreconditioner::BlockDiagonal);
2028    }
2029
2030    /// Each [`SchurPreconditioner`] must actually reach the PCG loop.
2031    ///
2032    /// The regression this pins: `preconditioner` used to be stored and never
2033    /// read, so `solve_with_pcg` always ran scalar Jacobi and every setting
2034    /// was silently equivalent. `SchurJacobi` reads `S`'s diagonal blocks and
2035    /// `BlockDiagonal` reads `H_kk`'s, so on a system where the
2036    /// `H_ke·H_ee⁻¹·H_keᵀ` correction is non-zero the two must differ.
2037    #[test]
2038    fn preconditioner_selection_reaches_pcg() -> Result<(), LinAlgError> {
2039        use faer::sparse::Triplet;
2040
2041        // One 2-DOF retained block so the preconditioner is a real 2x2 inverse
2042        // rather than a scalar.
2043        let kept = vec![BlockSpan {
2044            key: VarKey::default(),
2045            col_start: 0,
2046            dof: 2,
2047        }];
2048        let eliminated = vec![BlockSpan {
2049            key: VarKey::default(),
2050            col_start: 2,
2051            dof: 1,
2052        }];
2053        let partition = SchurPartition::new(kept, eliminated)?;
2054        let mut solver = ExplicitSparseSchur::new();
2055        solver.partition = Some(partition);
2056
2057        let mk = |vals: [f64; 4]| -> Result<SparseColMat<usize, f64>, LinAlgError> {
2058            SparseColMat::try_new_from_triplets(
2059                2,
2060                2,
2061                &[
2062                    Triplet::new(0, 0, vals[0]),
2063                    Triplet::new(1, 0, vals[1]),
2064                    Triplet::new(0, 1, vals[2]),
2065                    Triplet::new(1, 1, vals[3]),
2066                ],
2067            )
2068            .map_err(|e| LinAlgError::SparseMatrixCreation(format!("{e:?}")))
2069        };
2070        let h_kk = mk([4.0, 1.0, 1.0, 5.0])?;
2071        // S differs from H_kk by the elimination correction.
2072        let s = mk([2.0, 0.5, 0.5, 3.0])?;
2073
2074        solver.preconditioner = SchurPreconditioner::None;
2075        assert!(
2076            solver.build_preconditioner(&s, &h_kk)?.is_none(),
2077            "None must leave PCG unpreconditioned"
2078        );
2079
2080        solver.preconditioner = SchurPreconditioner::SchurJacobi;
2081        let blocks_missing = || LinAlgError::InvalidInput("expected blocks".into());
2082        let schur = solver
2083            .build_preconditioner(&s, &h_kk)?
2084            .ok_or_else(blocks_missing)?;
2085        solver.preconditioner = SchurPreconditioner::BlockDiagonal;
2086        let plain = solver
2087            .build_preconditioner(&s, &h_kk)?
2088            .ok_or_else(blocks_missing)?;
2089
2090        // SchurJacobi inverts S's block; BlockDiagonal inverts H_kk's.
2091        let expect_schur = DMatrix::from_row_slice(2, 2, &[2.0, 0.5, 0.5, 3.0])
2092            .try_inverse()
2093            .ok_or_else(|| LinAlgError::InvalidInput("S block must invert".into()))?;
2094        for r in 0..2 {
2095            for c in 0..2 {
2096                assert!(
2097                    (schur[0][(r, c)] - expect_schur[(r, c)]).abs() < 1e-12,
2098                    "SchurJacobi must invert S's diagonal block"
2099                );
2100                assert!(
2101                    (schur[0][(r, c)] - plain[0][(r, c)]).abs() > 1e-9,
2102                    "SchurJacobi and BlockDiagonal must not coincide at ({r},{c})"
2103                );
2104            }
2105        }
2106        Ok(())
2107    }
2108    /// Solver with a partition of `kept_dof` retained columns followed by one
2109    /// eliminated block of `dof`, plus that block's inverse preloaded.
2110    fn solver_with_block(
2111        kept_dof: usize,
2112        dof: usize,
2113        inverse: &[f64],
2114    ) -> Result<(ExplicitSparseSchur, EliminatedBlocks), LinAlgError> {
2115        let kept = (0..kept_dof)
2116            .map(|i| BlockSpan {
2117                key: VarKey::default(),
2118                col_start: i,
2119                dof: 1,
2120            })
2121            .collect();
2122        let eliminated = vec![BlockSpan {
2123            key: VarKey::default(),
2124            col_start: kept_dof,
2125            dof,
2126        }];
2127        let partition = SchurPartition::new(kept, eliminated)?;
2128        let mut blocks = EliminatedBlocks::new(&partition);
2129        blocks.block_mut(0).copy_from_slice(inverse);
2130        let mut solver = ExplicitSparseSchur::new();
2131        solver.partition = Some(partition);
2132        Ok((solver, blocks))
2133    }
2134
2135    /// S = H_kk − H_ke·H_ee⁻¹·H_keᵀ against hand-computed values, unchanged
2136    /// from before the generalization.
2137    #[test]
2138    fn test_compute_schur_complement_known_matrix() -> Result<(), LinAlgError> {
2139        use faer::sparse::Triplet;
2140
2141        // H_ee⁻¹ = 0.5·I₃
2142        let inv = [0.5, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.5];
2143        let (mut solver, blocks) = solver_with_block(2, 3, &inv)?;
2144
2145        let h_kk = SparseColMat::try_new_from_triplets(
2146            2,
2147            2,
2148            &[Triplet::new(0, 0, 4.0), Triplet::new(1, 1, 5.0)],
2149        )
2150        .map_err(|e| LinAlgError::SparseMatrixCreation(format!("{e:?}")))?;
2151        let h_ke = SparseColMat::try_new_from_triplets(
2152            2,
2153            3,
2154            &[Triplet::new(0, 0, 1.0), Triplet::new(1, 1, 2.0)],
2155        )
2156        .map_err(|e| LinAlgError::SparseMatrixCreation(format!("{e:?}")))?;
2157
2158        let (s, _) = solver.compute_schur_complement(&h_kk, &h_ke, &blocks)?;
2159
2160        assert_eq!(s.nrows(), 2);
2161        assert_eq!(s.ncols(), 2);
2162
2163        // S(0,0) = 4 − 1·0.5·1 = 3.5 ; S(1,1) = 5 − 2·0.5·2 = 3.0
2164        let dense = dense_of(&s);
2165        assert!((dense[0][0] - 3.5).abs() < 1e-12, "got {}", dense[0][0]);
2166        assert!((dense[1][1] - 3.0).abs() < 1e-12, "got {}", dense[1][1]);
2167        Ok(())
2168    }
2169
2170    #[test]
2171    fn test_back_substitute() -> Result<(), LinAlgError> {
2172        use faer::sparse::Triplet;
2173
2174        // H_ee⁻¹ = I₃
2175        let inv = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
2176        let (solver, blocks) = solver_with_block(2, 3, &inv)?;
2177
2178        let delta_c = Mat::from_fn(2, 1, |i, _| (i + 1) as f64); // [1; 2]
2179        let g_p = Mat::from_fn(3, 1, |i, _| (i + 1) as f64); // [1; 2; 3]
2180
2181        let h_cp = SparseColMat::try_new_from_triplets(
2182            2,
2183            3,
2184            &[Triplet::new(0, 0, 1.0), Triplet::new(1, 1, 1.0)],
2185        )
2186        .map_err(|e| LinAlgError::SparseMatrixCreation(format!("{e:?}")))?;
2187
2188        // Compute δp = H_pp^{-1} * (g_p - H_cp^T * δc)
2189        // H_cp^T * δc = [1*1; 1*2; 0] = [1; 2; 0]
2190        // g_p - result = [1; 2; 3] - [1; 2; 0] = [0; 0; 3]
2191        // H_pp^{-1} * [0; 0; 3] = [0; 0; 3]
2192        let delta_p = solver.back_substitute(&delta_c, &g_p, &h_cp, &blocks)?;
2193
2194        assert_eq!(delta_p.nrows(), 3);
2195        assert!((delta_p[(0, 0)]).abs() < 1e-10);
2196        assert!((delta_p[(1, 0)]).abs() < 1e-10);
2197        assert!((delta_p[(2, 0)] - 3.0).abs() < 1e-10);
2198        Ok(())
2199    }
2200
2201    #[test]
2202    fn test_compute_reduced_gradient() -> Result<(), LinAlgError> {
2203        use faer::sparse::Triplet;
2204
2205        // H_ee⁻¹ = 2·I₃
2206        let inv = [2.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 2.0];
2207        let (solver, blocks) = solver_with_block(2, 3, &inv)?;
2208
2209        let g_c = Mat::from_fn(2, 1, |i, _| (i + 1) as f64); // [1; 2]
2210        let g_p = Mat::from_fn(3, 1, |i, _| (i + 1) as f64); // [1; 2; 3]
2211
2212        let h_cp = SparseColMat::try_new_from_triplets(
2213            2,
2214            3,
2215            &[Triplet::new(0, 0, 1.0), Triplet::new(1, 1, 1.0)],
2216        )
2217        .map_err(|e| LinAlgError::SparseMatrixCreation(format!("{e:?}")))?;
2218
2219        // Compute g_reduced = g_c - H_cp * H_pp^{-1} * g_p
2220        // H_pp^{-1} * g_p = 2*[1; 2; 3] = [2; 4; 6]
2221        // H_cp * [2; 4; 6] = [1*2; 1*4] = [2; 4]
2222        // g_reduced = [1; 2] - [2; 4] = [-1; -2]
2223        let g_reduced = solver.compute_reduced_gradient(&g_c, &g_p, &h_cp, &blocks)?;
2224
2225        assert_eq!(g_reduced.nrows(), 2);
2226        assert!((g_reduced[(0, 0)] + 1.0).abs() < 1e-10);
2227        assert!((g_reduced[(1, 0)] + 2.0).abs() < 1e-10);
2228        Ok(())
2229    }
2230
2231    // -------------------------------------------------------------------------
2232    // New tests for uncovered code paths
2233    // -------------------------------------------------------------------------
2234
2235    /// Test ExplicitSparseSchur::default() equals new()
2236    #[test]
2237    fn test_solver_default() {
2238        let solver = ExplicitSparseSchur::default();
2239        assert!(solver.partition().is_none());
2240        assert!(solver.hessian.is_none());
2241        assert!(solver.gradient.is_none());
2242    }
2243
2244    /// Test partition() getter after initialize_structure
2245    #[test]
2246    fn test_partition_getter() -> TestResult {
2247        let (variables, variable_index_map, _, _, landmark_keys) = create_schur_test_setup()?;
2248        let mut solver = ExplicitSparseSchur::new();
2249
2250        assert!(solver.partition().is_none());
2251        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2252        assert!(solver.partition().is_some());
2253        Ok(())
2254    }
2255
2256    /// Test with_ordering() builder stores the custom ordering
2257    #[test]
2258    fn test_with_ordering_builder() {
2259        let ordering = SchurOrdering {
2260            eliminate_types: vec![ManifoldType::RN],
2261            eliminate_rn_size: Some(3),
2262            auto_detect: false,
2263        };
2264        let solver = ExplicitSparseSchur::new().with_ordering(ordering);
2265        assert_eq!(solver.ordering.eliminate_rn_size, Some(3));
2266    }
2267    /// Diagonal 3-DOF block inversion, the classic-BA case, through the
2268    /// generalized arena. Sizes 1/2/4/6/9 are covered in `schur_partition`.
2269    #[test]
2270    fn test_eliminated_block_inversion_3dof() -> TestResult {
2271        // diag(2, 3, 4) → diag(0.5, 1/3, 0.25)
2272        let inv_seed = [2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0];
2273        let kept = vec![BlockSpan {
2274            key: VarKey::default(),
2275            col_start: 0,
2276            dof: 1,
2277        }];
2278        let eliminated = vec![BlockSpan {
2279            key: VarKey::default(),
2280            col_start: 1,
2281            dof: 3,
2282        }];
2283        let partition = SchurPartition::new(kept, eliminated)?;
2284        let mut blocks = EliminatedBlocks::new(&partition);
2285        blocks.block_mut(0).copy_from_slice(&inv_seed);
2286        blocks.invert_in_place(&partition)?;
2287
2288        assert!((blocks.at(0, 0, 0) - 0.5).abs() < 1e-10);
2289        assert!((blocks.at(0, 1, 1) - 1.0 / 3.0).abs() < 1e-10);
2290        assert!((blocks.at(0, 2, 2) - 0.25).abs() < 1e-10);
2291        Ok(())
2292    }
2293
2294    /// Test initialize_structure() correctly partitions 2 cameras + 3 landmarks
2295    #[test]
2296    fn test_explicit_schur_initialize_structure() -> TestResult {
2297        let (variables, variable_index_map, _, _, landmark_keys) = create_schur_test_setup()?;
2298        let mut solver = ExplicitSparseSchur::new();
2299        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2300
2301        let bs = solver.partition().ok_or("partition is None")?;
2302        assert_eq!(bs.kept_blocks().len(), 2);
2303        assert_eq!(bs.eliminated_blocks().len(), 3);
2304        assert_eq!(bs.kept_dof(), 12); // 2 × 6
2305        assert_eq!(bs.eliminated_dof(), 9); // 3 × 3
2306        Ok(())
2307    }
2308
2309    /// Test extract_gradient_blocks() splits gradient correctly
2310    #[test]
2311    fn test_extract_gradient_blocks() -> TestResult {
2312        let (variables, variable_index_map, _, _, landmark_keys) = create_schur_test_setup()?;
2313        let mut solver = ExplicitSparseSchur::new();
2314        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2315
2316        // Gradient over full variable space (21 DOF)
2317        let gradient = Mat::from_fn(21, 1, |i, _| i as f64);
2318        let (g_c, g_p) = solver.extract_gradient_blocks(&gradient)?;
2319
2320        assert_eq!(g_c.nrows(), 12); // camera DOF
2321        assert_eq!(g_p.nrows(), 9); // landmark DOF
2322        Ok(())
2323    }
2324
2325    /// Test full Schur solve pipeline with Sparse (Cholesky) variant
2326    #[test]
2327    fn test_explicit_schur_solve_normal_equation() -> TestResult {
2328        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
2329            create_schur_test_setup()?;
2330        let mut solver = ExplicitSparseSchur::new().with_variant(ExplicitSchurVariant::Sparse);
2331        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2332
2333        let delta =
2334            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
2335        assert_eq!(delta.nrows(), 21);
2336        assert_eq!(delta.ncols(), 1);
2337        Ok(())
2338    }
2339
2340    /// Test full Schur augmented solve (LM damping) with Sparse variant
2341    #[test]
2342    fn test_explicit_schur_solve_augmented_equation() -> TestResult {
2343        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
2344            create_schur_test_setup()?;
2345        let mut solver = ExplicitSparseSchur::new().with_variant(ExplicitSchurVariant::Sparse);
2346        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2347
2348        let delta = LinearSolver::<SparseMode>::solve_augmented_equation(
2349            &mut solver,
2350            &residuals,
2351            &jacobian,
2352            &Damping::identity(0.1),
2353        )?;
2354        assert_eq!(delta.nrows(), 21);
2355        Ok(())
2356    }
2357
2358    /// `ExplicitSchurVariant::Iterative` forms `S` explicitly, then solves it
2359    /// with PCG (`solve_with_pcg`) instead of Cholesky — exercise that path
2360    /// and check it agrees with the Cholesky variant on the same system.
2361    #[test]
2362    fn test_explicit_schur_solve_iterative_variant() -> TestResult {
2363        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
2364            create_schur_test_setup()?;
2365        let mut solver = ExplicitSparseSchur::new()
2366            .with_variant(ExplicitSchurVariant::Iterative)
2367            .with_cg_params(200, 1e-10);
2368        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2369
2370        let delta =
2371            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
2372        assert_eq!(delta.nrows(), 21);
2373
2374        let mut cholesky_solver =
2375            ExplicitSparseSchur::new().with_variant(ExplicitSchurVariant::Sparse);
2376        cholesky_solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2377        let cholesky_delta = LinearSolver::<SparseMode>::solve_normal_equation(
2378            &mut cholesky_solver,
2379            &residuals,
2380            &jacobian,
2381        )?;
2382
2383        for i in 0..21 {
2384            assert!(
2385                (delta[(i, 0)] - cholesky_delta[(i, 0)]).abs() < 1e-4,
2386                "PCG-on-S must agree with Cholesky-on-S at {i}: {} vs {}",
2387                delta[(i, 0)],
2388                cholesky_delta[(i, 0)]
2389            );
2390        }
2391        Ok(())
2392    }
2393
2394    /// Test get_hessian() and get_gradient() trait methods after solve
2395    #[test]
2396    fn test_explicit_schur_get_hessian_gradient() -> TestResult {
2397        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
2398            create_schur_test_setup()?;
2399        let mut solver = ExplicitSparseSchur::new();
2400        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2401
2402        assert!(LinearSolver::<SparseMode>::get_hessian(&solver).is_none());
2403        assert!(LinearSolver::<SparseMode>::get_gradient(&solver).is_none());
2404
2405        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
2406
2407        let h = LinearSolver::<SparseMode>::get_hessian(&solver);
2408        let g = LinearSolver::<SparseMode>::get_gradient(&solver);
2409        assert!(h.is_some());
2410        assert!(g.is_some());
2411        let h = h.ok_or("hessian is None")?;
2412        let g = g.ok_or("gradient is None")?;
2413        assert_eq!(h.nrows(), 21);
2414        assert_eq!(g.nrows(), 21);
2415        Ok(())
2416    }
2417
2418    /// Test two solves with different λ produce different updates
2419    #[test]
2420    fn test_explicit_schur_augmented_lambda_effect() -> TestResult {
2421        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
2422            create_schur_test_setup()?;
2423
2424        let mut solver1 = ExplicitSparseSchur::new();
2425        solver1.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2426        let delta1 = LinearSolver::<SparseMode>::solve_augmented_equation(
2427            &mut solver1,
2428            &residuals,
2429            &jacobian,
2430            &Damping::identity(0.001),
2431        )?;
2432
2433        let mut solver2 = ExplicitSparseSchur::new();
2434        solver2.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2435        let delta2 = LinearSolver::<SparseMode>::solve_augmented_equation(
2436            &mut solver2,
2437            &residuals,
2438            &jacobian,
2439            &Damping::identity(100.0),
2440        )?;
2441
2442        // Different λ values should produce different updates
2443        let norm_diff: f64 = (0..21)
2444            .map(|i| (delta1[(i, 0)] - delta2[(i, 0)]).powi(2))
2445            .sum();
2446        assert!(
2447            norm_diff > 1e-10,
2448            "Different λ should yield different updates"
2449        );
2450        Ok(())
2451    }
2452
2453    /// Test combine_updates() merges camera and landmark deltas at correct offsets
2454    #[test]
2455    fn test_combine_updates() -> TestResult {
2456        let (variables, variable_index_map, _, _, landmark_keys) = create_schur_test_setup()?;
2457        let mut solver = ExplicitSparseSchur::new();
2458        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2459
2460        // Camera delta: 12×1, landmark delta: 9×1
2461        let delta_c = Mat::from_fn(12, 1, |_, _| 1.0);
2462        let delta_p = Mat::from_fn(9, 1, |_, _| 2.0);
2463
2464        let combined = solver.combine_updates(&delta_c, &delta_p)?;
2465        assert_eq!(combined.nrows(), 21);
2466
2467        // Camera values (cam_start..cam_end = 0..12)
2468        for i in 0..12 {
2469            assert!((combined[(i, 0)] - 1.0).abs() < 1e-10);
2470        }
2471        // Landmark values (land_start..land_end = 12..21)
2472        for i in 12..21 {
2473            assert!((combined[(i, 0)] - 2.0).abs() < 1e-10);
2474        }
2475        Ok(())
2476    }
2477
2478    /// Test solve without initialize_structure returns error
2479    #[test]
2480    fn test_explicit_schur_solve_without_init_returns_error() -> TestResult {
2481        let triplets: Vec<Triplet<usize, usize, f64>> = vec![Triplet::new(0, 0, 1.0)];
2482        let jacobian =
2483            SparseColMat::try_new_from_triplets(1, 1, &triplets).map_err(|e| format!("{e:?}"))?;
2484        let residuals = Mat::from_fn(1, 1, |_, _| 1.0);
2485        let mut solver = ExplicitSparseSchur::new();
2486
2487        let result =
2488            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian);
2489        assert!(result.is_err());
2490        Ok(())
2491    }
2492
2493    // -------------------------------------------------------------------------
2494    // New tests for previously uncovered code paths
2495    // -------------------------------------------------------------------------
2496
2497    /// Test SchurOrdering::new() produces the same result as default().
2498    #[test]
2499    fn test_schur_ordering_new_equals_default() {
2500        let a = SchurOrdering::new();
2501        let b = SchurOrdering::default();
2502        assert_eq!(a.eliminate_rn_size, b.eliminate_rn_size);
2503        assert_eq!(a.eliminate_types.len(), b.eliminate_types.len());
2504    }
2505
2506    /// Test extract_camera_block produces a square matrix of camera DOF.
2507    #[test]
2508    fn test_extract_camera_block() -> TestResult {
2509        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
2510            create_schur_test_setup()?;
2511        let mut solver = ExplicitSparseSchur::new();
2512        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2513
2514        // Build Hessian H = J^T J
2515        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
2516        let hessian = solver.hessian.clone().ok_or("hessian is None")?;
2517
2518        let mut fresh = ExplicitSparseSchur::new();
2519        fresh.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2520        let h_cc = fresh.extract_kept_block(&hessian)?;
2521
2522        // camera DOF = 12 (2 cameras × 6)
2523        assert_eq!(h_cc.nrows(), 12);
2524        assert_eq!(h_cc.ncols(), 12);
2525        Ok(())
2526    }
2527
2528    /// Test extract_coupling_block produces a matrix with camera rows × landmark cols.
2529    #[test]
2530    fn test_extract_coupling_block() -> TestResult {
2531        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
2532            create_schur_test_setup()?;
2533        let mut solver = ExplicitSparseSchur::new();
2534        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2535
2536        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
2537        let hessian = solver.hessian.clone().ok_or("hessian is None")?;
2538
2539        let mut fresh = ExplicitSparseSchur::new();
2540        fresh.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2541        let h_cp = fresh.extract_coupling_block(&hessian)?;
2542
2543        // H_cp: camera DOF rows × landmark DOF cols = 12 × 9
2544        assert_eq!(h_cp.nrows(), 12);
2545        assert_eq!(h_cp.ncols(), 9);
2546        Ok(())
2547    }
2548
2549    /// Gathering H_ee yields one block per eliminated variable, sized by its DOF.
2550    #[test]
2551    fn test_gather_eliminated_blocks() -> TestResult {
2552        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
2553            create_schur_test_setup()?;
2554        let mut solver = ExplicitSparseSchur::new();
2555        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2556
2557        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
2558        let hessian = solver.hessian.clone().ok_or("hessian is None")?;
2559
2560        let mut fresh = ExplicitSparseSchur::new();
2561        fresh.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
2562        let partition = fresh.partition().ok_or("partition is None")?;
2563        let mut blocks = EliminatedBlocks::new(partition);
2564        blocks.gather(&hessian, partition);
2565
2566        // 3 landmarks → 3 blocks, each 3 DOF
2567        assert_eq!(blocks.len(), 3);
2568        for i in 0..blocks.len() {
2569            assert_eq!(blocks.dof(i), 3);
2570        }
2571        Ok(())
2572    }
2573
2574    /// Test solve_with_cholesky satisfies Ax ≈ b for a known SPD system.
2575    ///
2576    /// Regression probe (diagnostic, kept): assemble a real SE2 pose chain
2577    /// through `Problem` — grouping, workspace, sparse assembly — and require
2578    /// the Schur step to equal the Cholesky step on that assembled system,
2579    /// twice in a row with one solver instance. Hand-built Jacobians cannot
2580    /// catch assembly-ordering interactions; this one can.
2581    #[test]
2582    fn schur_matches_cholesky_on_assembled_pose_chain() -> TestResult {
2583        use crate::core::problem::Problem;
2584        use crate::factors::pose::{BetweenFactor, PriorFactor};
2585        use crate::linalg::JacobianMode;
2586        use crate::linalg::sparse::cholesky::SparseCholeskySolver;
2587        use apex_manifolds::{ManifoldType, se2::SE2};
2588        use nalgebra::dvector;
2589
2590        let mut problem = Problem::new(JacobianMode::Sparse);
2591        let mut keys = Vec::new();
2592        for i in 0..5 {
2593            keys.push(
2594                problem.add_variable(ManifoldType::SE2, dvector![i as f64 + 0.1, 0.05, 0.02]),
2595            );
2596        }
2597        for w in keys.windows(2) {
2598            problem.add_residual_block(
2599                &[w[0], w[1]],
2600                Box::new(BetweenFactor::new(SE2::from_xy_angle(1.0, 0.0, 0.0))),
2601                None,
2602            );
2603        }
2604        problem.add_residual_block(
2605            &[keys[0]],
2606            Box::new(PriorFactor::new(SE2::from_xy_angle(0.0, 0.0, 0.0))),
2607            None,
2608        );
2609        problem.mark_for_elimination(keys[1]);
2610        problem.mark_for_elimination(keys[3]);
2611        problem.group_rows_for_elimination();
2612
2613        let state = crate::optimizer::initialize_optimization_state(&mut problem)?;
2614        let symbolic = state.symbolic_structure.as_ref().ok_or("sparse symbolic")?;
2615        let mut workspace = crate::linearizer::AssemblyWorkspace::build(&problem);
2616        let (residuals, jacobian) = crate::linearizer::cpu::sparse::assemble_sparse(
2617            &problem,
2618            &state.variables,
2619            &state.variable_index_map,
2620            symbolic,
2621            &mut workspace,
2622        )?;
2623
2624        let mut eliminate = std::collections::HashSet::new();
2625        eliminate.insert(keys[1]);
2626        eliminate.insert(keys[3]);
2627
2628        let mut cholesky = SparseCholeskySolver::new();
2629        let reference =
2630            crate::linalg::LinearSolver::<crate::linalg::SparseMode>::solve_normal_equation(
2631                &mut cholesky,
2632                &residuals,
2633                &jacobian,
2634            )?;
2635
2636        let mut schur = ExplicitSparseSchur::new();
2637        schur.initialize_structure(&state.variables, &state.variable_index_map, &eliminate)?;
2638        let first =
2639            crate::linalg::LinearSolver::<crate::linalg::SparseMode>::solve_normal_equation(
2640                &mut schur, &residuals, &jacobian,
2641            )?;
2642        let second =
2643            crate::linalg::LinearSolver::<crate::linalg::SparseMode>::solve_normal_equation(
2644                &mut schur, &residuals, &jacobian,
2645            )?;
2646
2647        let scale = reference.norm_l2().max(1.0);
2648        for (label, step) in [("first", &first), ("second", &second)] {
2649            assert_eq!(reference.nrows(), step.nrows(), "{label}: length");
2650            for i in 0..reference.nrows() {
2651                let diff = (reference[(i, 0)] - step[(i, 0)]).abs();
2652                assert!(
2653                    diff / scale < 1e-9,
2654                    "{label}: component {i} differs — cholesky {}, schur {} (rel {:.3e})",
2655                    reference[(i, 0)],
2656                    step[(i, 0)],
2657                    diff / scale
2658                );
2659            }
2660        }
2661        Ok(())
2662    }
2663
2664    /// Diagnostic multi-iteration probe (kept): replicate three Gauss-Newton
2665    /// iterations — assemble, solve with both solvers, compare, apply the
2666    /// Cholesky step — to locate the first iteration where the Schur step
2667    /// stops matching on evolving (not fixed) linearizations.
2668    #[test]
2669    fn schur_tracks_cholesky_across_iterations() -> TestResult {
2670        use crate::core::problem::Problem;
2671        use crate::factors::pose::{BetweenFactor, PriorFactor};
2672        use crate::linalg::JacobianMode;
2673        use crate::linalg::sparse::cholesky::SparseCholeskySolver;
2674        use apex_manifolds::{ManifoldType, se2::SE2};
2675        use nalgebra::dvector;
2676
2677        let mut problem = Problem::new(JacobianMode::Sparse);
2678        let mut keys = Vec::new();
2679        for i in 0..5 {
2680            keys.push(
2681                problem.add_variable(ManifoldType::SE2, dvector![i as f64 + 0.1, 0.05, 0.02]),
2682            );
2683        }
2684        for w in keys.windows(2) {
2685            problem.add_residual_block(
2686                &[w[0], w[1]],
2687                Box::new(BetweenFactor::new(SE2::from_xy_angle(1.0, 0.0, 0.0))),
2688                None,
2689            );
2690        }
2691        problem.add_residual_block(
2692            &[keys[0]],
2693            Box::new(PriorFactor::new(SE2::from_xy_angle(0.0, 0.0, 0.0))),
2694            None,
2695        );
2696        problem.mark_for_elimination(keys[1]);
2697        problem.mark_for_elimination(keys[3]);
2698        problem.group_rows_for_elimination();
2699
2700        let mut state = crate::optimizer::initialize_optimization_state(&mut problem)?;
2701        let mut eliminate = std::collections::HashSet::new();
2702        eliminate.insert(keys[1]);
2703        eliminate.insert(keys[3]);
2704
2705        let mut cholesky = SparseCholeskySolver::new();
2706        let mut schur = ExplicitSparseSchur::new();
2707        schur.initialize_structure(&state.variables, &state.variable_index_map, &eliminate)?;
2708
2709        for iter in 0..3 {
2710            let symbolic = state.symbolic_structure.as_ref().ok_or("sparse symbolic")?;
2711            let mut workspace = crate::linearizer::AssemblyWorkspace::build(&problem);
2712            let (residuals, jacobian) = crate::linearizer::cpu::sparse::assemble_sparse(
2713                &problem,
2714                &state.variables,
2715                &state.variable_index_map,
2716                symbolic,
2717                &mut workspace,
2718            )?;
2719            let reference =
2720                crate::linalg::LinearSolver::<crate::linalg::SparseMode>::solve_normal_equation(
2721                    &mut cholesky,
2722                    &residuals,
2723                    &jacobian,
2724                )?;
2725            let step =
2726                crate::linalg::LinearSolver::<crate::linalg::SparseMode>::solve_normal_equation(
2727                    &mut schur, &residuals, &jacobian,
2728                )?;
2729            let scale = reference.norm_l2().max(1.0);
2730            let mut max_rel: f64 = 0.0;
2731            for i in 0..reference.nrows() {
2732                max_rel = max_rel.max((reference[(i, 0)] - step[(i, 0)]).abs() / scale);
2733            }
2734            assert!(
2735                max_rel < 1e-9,
2736                "iteration {iter}: Schur step diverges from Cholesky (max rel {max_rel:.3e})"
2737            );
2738            crate::optimizer::apply_parameter_step(
2739                &mut state.variables,
2740                step.as_ref(),
2741                &state.sorted_vars,
2742            );
2743        }
2744        Ok(())
2745    }
2746
2747    /// Diagnostic multi-iteration probe, exact-loop replica (kept): same as
2748    /// above but reusing ONE workspace across iterations, exactly like
2749    /// `iteration_preamble` does — instead of rebuilding it per iteration.
2750    /// Diverges here but not above ⟹ workspace-reuse assembly bug.
2751    #[test]
2752    fn schur_tracks_cholesky_with_shared_workspace() -> TestResult {
2753        use crate::core::problem::Problem;
2754        use crate::factors::pose::{BetweenFactor, PriorFactor};
2755        use crate::linalg::JacobianMode;
2756        use crate::linalg::sparse::cholesky::SparseCholeskySolver;
2757        use apex_manifolds::{ManifoldType, se2::SE2};
2758        use nalgebra::dvector;
2759
2760        let mut problem = Problem::new(JacobianMode::Sparse);
2761        let mut keys = Vec::new();
2762        for i in 0..5 {
2763            keys.push(
2764                problem.add_variable(ManifoldType::SE2, dvector![i as f64 + 0.1, 0.05, 0.02]),
2765            );
2766        }
2767        for w in keys.windows(2) {
2768            problem.add_residual_block(
2769                &[w[0], w[1]],
2770                Box::new(BetweenFactor::new(SE2::from_xy_angle(1.0, 0.0, 0.0))),
2771                None,
2772            );
2773        }
2774        problem.add_residual_block(
2775            &[keys[0]],
2776            Box::new(PriorFactor::new(SE2::from_xy_angle(0.0, 0.0, 0.0))),
2777            None,
2778        );
2779        problem.mark_for_elimination(keys[1]);
2780        problem.mark_for_elimination(keys[3]);
2781        problem.group_rows_for_elimination();
2782
2783        let mut state = crate::optimizer::initialize_optimization_state(&mut problem)?;
2784        let mut eliminate = std::collections::HashSet::new();
2785        eliminate.insert(keys[1]);
2786        eliminate.insert(keys[3]);
2787
2788        // Drive BOTH solvers from one shared assembly each iteration, so any
2789        // divergence is the solvers', not the assembly's.
2790        let costs = |variables: &slotmap::SlotMap<
2791            crate::core::VarKey,
2792            Box<dyn crate::core::variable::ManifoldVariable>,
2793        >|
2794         -> Result<f64, Box<dyn std::error::Error>> {
2795            Ok(problem.compute_residual_and_cost_sparse(variables)?.1)
2796        };
2797
2798        let mut cholesky = SparseCholeskySolver::new();
2799        let mut schur = ExplicitSparseSchur::new();
2800        schur.initialize_structure(&state.variables, &state.variable_index_map, &eliminate)?;
2801
2802        for iter in 0..3 {
2803            let symbolic = state.symbolic_structure.as_ref().ok_or("sparse symbolic")?;
2804            let (residuals, jacobian) = crate::linearizer::cpu::sparse::assemble_sparse(
2805                &problem,
2806                &state.variables,
2807                &state.variable_index_map,
2808                symbolic,
2809                &mut state.workspace,
2810            )?;
2811            let reference =
2812                crate::linalg::LinearSolver::<crate::linalg::SparseMode>::solve_normal_equation(
2813                    &mut cholesky,
2814                    &residuals,
2815                    &jacobian,
2816                )?;
2817            let step =
2818                crate::linalg::LinearSolver::<crate::linalg::SparseMode>::solve_normal_equation(
2819                    &mut schur, &residuals, &jacobian,
2820                )?;
2821            let scale = reference.norm_l2().max(1.0);
2822            let mut max_rel: f64 = 0.0;
2823            for i in 0..reference.nrows() {
2824                max_rel = max_rel.max((reference[(i, 0)] - step[(i, 0)]).abs() / scale);
2825            }
2826            assert!(
2827                max_rel < 1e-9,
2828                "iteration {iter}: Schur step diverges from Cholesky (max rel {max_rel:.3e})"
2829            );
2830            crate::optimizer::apply_parameter_step(
2831                &mut state.variables,
2832                reference.as_ref(),
2833                &state.sorted_vars,
2834            );
2835            let _ = costs(&state.variables)?;
2836        }
2837        Ok(())
2838    }
2839
2840    #[test]
2841    fn test_solve_with_cholesky_small_spd() -> TestResult {
2842        let mut solver = ExplicitSparseSchur::new();
2843
2844        // 2×2 SPD matrix A = [[4,1],[1,3]]
2845        let triplets = vec![
2846            Triplet::new(0usize, 0usize, 4.0f64),
2847            Triplet::new(1usize, 0usize, 1.0f64),
2848            Triplet::new(0usize, 1usize, 1.0f64),
2849            Triplet::new(1usize, 1usize, 3.0f64),
2850        ];
2851        let a =
2852            SparseColMat::try_new_from_triplets(2, 2, &triplets).map_err(|e| format!("{e:?}"))?;
2853        let b = Mat::from_fn(2, 1, |i, _| (i + 1) as f64); // [1; 2]
2854
2855        let x = solver.solve_with_cholesky(&a, &b, None)?;
2856        assert_eq!(x.nrows(), 2);
2857
2858        // Verify: A·x ≈ b
2859        // A·x = [4*x0+1*x1; 1*x0+3*x1]
2860        let ax0 = 4.0 * x[(0, 0)] + 1.0 * x[(1, 0)];
2861        let ax1 = 1.0 * x[(0, 0)] + 3.0 * x[(1, 0)];
2862        assert!((ax0 - 1.0).abs() < 1e-8, "A·x[0] = {ax0}");
2863        assert!((ax1 - 2.0).abs() < 1e-8, "A·x[1] = {ax1}");
2864        Ok(())
2865    }
2866
2867    /// Test solve_with_pcg converges on a small diagonal (trivial) system.
2868    #[test]
2869    fn test_solve_with_pcg_diagonal_system() -> TestResult {
2870        // A partition is required: the preconditioner is built per retained
2871        // block. Two 1-DOF kept blocks match this 2x2 system.
2872        let inv = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
2873        let (solver, _) = solver_with_block(2, 3, &inv)?;
2874
2875        // Diagonal SPD: [[2,0],[0,3]]
2876        let triplets = vec![
2877            Triplet::new(0usize, 0usize, 2.0f64),
2878            Triplet::new(1usize, 1usize, 3.0f64),
2879        ];
2880        let a =
2881            SparseColMat::try_new_from_triplets(2, 2, &triplets).map_err(|e| format!("{e:?}"))?;
2882        let b = Mat::from_fn(2, 1, |i, _| (i + 1) as f64); // [1; 2]
2883
2884        let x = solver.solve_with_pcg(&a, &b, &a)?;
2885        // Expected: x = [1/2; 2/3]
2886        assert!((x[(0, 0)] - 0.5).abs() < 1e-6, "x[0] = {}", x[(0, 0)]);
2887        assert!((x[(1, 0)] - 2.0 / 3.0).abs() < 1e-6, "x[1] = {}", x[(1, 0)]);
2888        Ok(())
2889    }
2890
2891    /// Test initialize_structure returns Err when only landmark variables are present (no cameras).
2892    #[test]
2893    fn test_initialize_structure_no_cameras_returns_error() {
2894        use crate::core::variable::Variable;
2895        use apex_manifolds::rn;
2896        use nalgebra::DVector;
2897
2898        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
2899        let k = variables.insert(Box::new(Variable::new(rn::Rn::new(DVector::zeros(3)))));
2900        let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
2901        variable_index_map.insert(k, 0);
2902        let mut landmark_keys = std::collections::HashSet::new();
2903        landmark_keys.insert(k);
2904
2905        let mut solver = ExplicitSparseSchur::new();
2906        let result = solver.initialize_structure(&variables, &variable_index_map, &landmark_keys);
2907        assert!(
2908            result.is_err(),
2909            "Expected Err when no camera variables present"
2910        );
2911    }
2912
2913    /// Test initialize_structure returns Err when only camera variables are present (no landmarks).
2914    #[test]
2915    fn test_initialize_structure_no_landmarks_returns_error() {
2916        use crate::core::variable::Variable;
2917        use apex_manifolds::se3;
2918
2919        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
2920        let _k = variables.insert(Box::new(Variable::new(se3::SE3::from_param_slice(&[
2921            1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
2922        ]))));
2923        let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
2924        variable_index_map.insert(_k, 0);
2925        let landmark_keys = std::collections::HashSet::<VarKey>::new(); // no landmarks
2926
2927        let mut solver = ExplicitSparseSchur::new();
2928        let result = solver.initialize_structure(&variables, &variable_index_map, &landmark_keys);
2929        assert!(
2930            result.is_err(),
2931            "Expected Err when no landmark variables present"
2932        );
2933    }
2934
2935    /// Dense read-back helper for the damping tests.
2936    fn dense_of(m: &SparseColMat<usize, f64>) -> Vec<Vec<f64>> {
2937        let mut out = vec![vec![0.0; m.ncols()]; m.nrows()];
2938        for (col, out_col) in (0..m.ncols()).zip(0..m.ncols()) {
2939            let rows = m.symbolic().row_idx_of_col_raw(col);
2940            let vals = m.val_of_col(col);
2941            for (i, &r) in rows.iter().enumerate() {
2942                out[r][out_col] = vals[i];
2943            }
2944        }
2945        out
2946    }
2947
2948    /// Fully populated diagonal: damping takes the value-only fast path and
2949    /// must leave every off-diagonal untouched.
2950    #[test]
2951    fn test_damp_camera_block_full_diagonal() -> TestResult {
2952        let triplets = vec![
2953            Triplet::new(0usize, 0usize, 4.0_f64),
2954            Triplet::new(1, 0, 1.0),
2955            Triplet::new(0, 1, 1.0),
2956            Triplet::new(1, 1, 9.0),
2957        ];
2958        let h_cc = SparseColMat::try_new_from_triplets(2, 2, &triplets)?;
2959        let damping = Damping::new(0.5, 1e-6, 1e32)?;
2960
2961        let damped = dense_of(&damp_camera_block(&h_cc, 2, &damping)?);
2962
2963        assert!((damped[0][0] - (4.0 + 0.5 * 4.0)).abs() < 1e-12);
2964        assert!((damped[1][1] - (9.0 + 0.5 * 9.0)).abs() < 1e-12);
2965        assert!((damped[0][1] - 1.0).abs() < 1e-12, "off-diagonal changed");
2966        assert!((damped[1][0] - 1.0).abs() < 1e-12, "off-diagonal changed");
2967        Ok(())
2968    }
2969
2970    /// A camera with no landmark observations has a structurally empty
2971    /// diagonal. Damping must materialize it at λ·min_diagonal rather than
2972    /// silently skipping the column.
2973    #[test]
2974    fn test_damp_camera_block_missing_diagonal() -> TestResult {
2975        // Column 1 carries only an off-diagonal entry.
2976        let triplets = vec![
2977            Triplet::new(0usize, 0usize, 4.0_f64),
2978            Triplet::new(0, 1, 2.0),
2979        ];
2980        let h_cc = SparseColMat::try_new_from_triplets(2, 2, &triplets)?;
2981        let damping = Damping::new(0.5, 1e-3, 1e32)?;
2982
2983        let damped = dense_of(&damp_camera_block(&h_cc, 2, &damping)?);
2984
2985        assert!((damped[0][0] - (4.0 + 0.5 * 4.0)).abs() < 1e-12);
2986        // clamp(0, 1e-3, 1e32) * 0.5
2987        assert!((damped[1][1] - 0.5 * 1e-3).abs() < 1e-12);
2988        assert!((damped[0][1] - 2.0).abs() < 1e-12, "off-diagonal changed");
2989        Ok(())
2990    }
2991}