Skip to main content

apex_solver/linalg/sparse/
explicit_schur.rs

1//! # Explicit Schur Complement Solver
2//!
3//! This module implements the **Explicit Schur Complement** method for bundle adjustment
4//! and structured optimization problems.
5//!
6//! ## Explicit vs Implicit Schur Complement
7//!
8//! **Explicit Schur:** This formulation physically constructs the reduced camera matrix
9//! (S = B - E C⁻¹ Eᵀ) in memory and solves it using direct sparse Cholesky factorization.
10//! It provides the most accurate results with moderate memory usage.
11//!
12//! **Implicit Schur:** The alternative formulation (see [`implicit_schur`](super::implicit_schur))
13//! never constructs S explicitly, instead solving the system using matrix-free PCG.
14//! It's more memory-efficient for very large problems.
15//!
16//! ## When to Use Explicit Schur
17//!
18//! - Medium-to-large bundle adjustment problems (< 10,000 cameras)
19//! - When accuracy is paramount
20//! - When you have sufficient memory to store the reduced camera system
21//! - When direct factorization is faster than iterative methods
22//!
23//! ## Usage Example
24//!
25//! ```no_run
26//! # use apex_solver::linalg::{SparseSchurComplementSolver, SchurVariant, SchurPreconditioner};
27//! # use apex_solver::linalg::StructureAware;
28//! # use apex_solver::core::VarKey;
29//! # use apex_solver::core::variable::ManifoldVariable;
30//! # use slotmap::{SlotMap, SecondaryMap};
31//! # use std::collections::HashSet;
32//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
33//! # let variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
34//! # let variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
35//! # let landmark_keys: HashSet<VarKey> = HashSet::new();
36//! use apex_solver::linalg::{SparseSchurComplementSolver, SchurVariant, SchurPreconditioner};
37//! use apex_solver::linalg::StructureAware;
38//!
39//! let mut solver = SparseSchurComplementSolver::new()
40//!     .with_variant(SchurVariant::Sparse) // Explicit Schur with Cholesky
41//!     .with_preconditioner(SchurPreconditioner::None);
42//! solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
43//! # Ok(())
44//! # }
45//! ```
46
47use super::implicit_schur::IterativeSchurSolver;
48use crate::core::VarKey;
49use crate::core::variable::ManifoldVariable;
50use crate::linalg::{LinAlgError, LinAlgResult, LinearSolver, SparseMode, StructureAware};
51use apex_manifolds::ManifoldType;
52use faer::sparse::{SparseColMat, Triplet};
53use faer::{
54    Mat, Side,
55    linalg::solvers::Solve,
56    sparse::linalg::solvers::{Llt, SymbolicLlt},
57};
58use nalgebra::Matrix3;
59use slotmap::{SecondaryMap, SlotMap};
60use tracing::debug;
61
62/// Schur complement solver variant
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum SchurVariant {
65    /// Standard: Direct sparse Cholesky factorization of S
66    #[default]
67    Sparse,
68    /// Iterative: Conjugate Gradients on reduced system
69    Iterative,
70}
71
72/// Preconditioner type for iterative solvers
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum SchurPreconditioner {
75    /// No preconditioning
76    None,
77    /// Block diagonal of H_cc only (fast but less effective)
78    BlockDiagonal,
79    /// True Schur-Jacobi: Block diagonal of S = H_cc - H_cp * H_pp^{-1} * H_cp^T
80    /// This is what Ceres uses and provides much better PCG convergence
81    #[default]
82    SchurJacobi,
83}
84
85/// Configuration for Schur complement variable ordering
86#[derive(Debug, Clone)]
87pub struct SchurOrdering {
88    pub eliminate_types: Vec<ManifoldType>,
89    /// Only eliminate RN variables with this exact size (default: 3 for 3D landmarks)
90    /// This prevents intrinsic variables (6 DOF) from being eliminated
91    pub eliminate_rn_size: Option<usize>,
92}
93
94impl Default for SchurOrdering {
95    fn default() -> Self {
96        Self {
97            eliminate_types: vec![ManifoldType::RN],
98            eliminate_rn_size: Some(3), // Only eliminate 3D landmarks, not intrinsics
99        }
100    }
101}
102
103impl SchurOrdering {
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    /// Check if a variable should be eliminated (treated as landmark).
109    ///
110    /// Classification is based solely on manifold type and DOF size.
111    /// By default, RN variables with exactly 3 DOF are treated as landmarks.
112    pub fn should_eliminate(&self, manifold_type: &ManifoldType, size: usize) -> bool {
113        if !self.eliminate_types.contains(manifold_type) {
114            return false;
115        }
116        if let Some(required_size) = self.eliminate_rn_size {
117            if size != required_size {
118                return false;
119            }
120        }
121        true
122    }
123}
124
125/// Block structure for Schur complement solver
126#[derive(Debug, Clone)]
127pub struct SchurBlockStructure {
128    pub camera_blocks: Vec<(VarKey, usize, usize)>,
129    pub landmark_blocks: Vec<(VarKey, usize, usize)>,
130    pub camera_dof: usize,
131    pub landmark_dof: usize,
132    pub num_landmarks: usize,
133}
134
135impl SchurBlockStructure {
136    pub fn new() -> Self {
137        Self {
138            camera_blocks: Vec::new(),
139            landmark_blocks: Vec::new(),
140            camera_dof: 0,
141            landmark_dof: 0,
142            num_landmarks: 0,
143        }
144    }
145
146    pub fn camera_col_range(&self) -> (usize, usize) {
147        if self.camera_blocks.is_empty() {
148            (0, 0)
149        } else {
150            // Safe: we just checked is_empty() is false
151            let start = self.camera_blocks.first().map(|b| b.1).unwrap_or(0);
152            (start, start + self.camera_dof)
153        }
154    }
155
156    pub fn landmark_col_range(&self) -> (usize, usize) {
157        if self.landmark_blocks.is_empty() {
158            (0, 0)
159        } else {
160            // Safe: we just checked is_empty() is false
161            let start = self.landmark_blocks.first().map(|b| b.1).unwrap_or(0);
162            (start, start + self.landmark_dof)
163        }
164    }
165}
166
167impl Default for SchurBlockStructure {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173/// Sparse Schur Complement Solver for Bundle Adjustment
174#[derive(Debug, Clone)]
175pub struct SparseSchurComplementSolver {
176    block_structure: Option<SchurBlockStructure>,
177    ordering: SchurOrdering,
178    variant: SchurVariant,
179    preconditioner: SchurPreconditioner,
180
181    // CG parameters
182    cg_max_iterations: usize,
183    cg_tolerance: f64,
184
185    // Cached matrices
186    hessian: Option<SparseColMat<usize, f64>>,
187    gradient: Option<Mat<f64>>,
188
189    // Delegate solver for iterative variant
190    iterative_solver: Option<IterativeSchurSolver>,
191}
192
193impl SparseSchurComplementSolver {
194    pub fn new() -> Self {
195        Self {
196            block_structure: None,
197            ordering: SchurOrdering::default(),
198            variant: SchurVariant::default(),
199            preconditioner: SchurPreconditioner::default(),
200            cg_max_iterations: 200, // Match Ceres (was 500)
201            cg_tolerance: 1e-6,     // Relaxed for speed (was 1e-9)
202            hessian: None,
203            gradient: None,
204            iterative_solver: None,
205        }
206    }
207
208    pub fn with_ordering(mut self, ordering: SchurOrdering) -> Self {
209        self.ordering = ordering;
210        self
211    }
212
213    pub fn with_variant(mut self, variant: SchurVariant) -> Self {
214        self.variant = variant;
215        self
216    }
217
218    pub fn with_preconditioner(mut self, preconditioner: SchurPreconditioner) -> Self {
219        self.preconditioner = preconditioner;
220        self
221    }
222
223    pub fn with_cg_params(mut self, max_iter: usize, tol: f64) -> Self {
224        self.cg_max_iterations = max_iter;
225        self.cg_tolerance = tol;
226        self
227    }
228
229    pub fn block_structure(&self) -> Option<&SchurBlockStructure> {
230        self.block_structure.as_ref()
231    }
232
233    fn build_block_structure(
234        &mut self,
235        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
236        variable_index_map: &SecondaryMap<VarKey, usize>,
237        schur_landmark_keys: &std::collections::HashSet<VarKey>,
238    ) -> LinAlgResult<()> {
239        let mut structure = SchurBlockStructure::new();
240
241        for (key, variable) in variables {
242            let start_col = *variable_index_map.get(key).ok_or_else(|| {
243                LinAlgError::InvalidInput(format!("VarKey {:?} not found in index map", key))
244            })?;
245            let size = variable.dof();
246
247            if schur_landmark_keys.contains(&key) {
248                structure.landmark_blocks.push((key, start_col, size));
249                structure.landmark_dof += size;
250                structure.num_landmarks += 1;
251            } else {
252                structure.camera_blocks.push((key, start_col, size));
253                structure.camera_dof += size;
254            }
255        }
256
257        structure.camera_blocks.sort_by_key(|(_, col, _)| *col);
258        structure.landmark_blocks.sort_by_key(|(_, col, _)| *col);
259
260        if structure.camera_blocks.is_empty() {
261            return Err(LinAlgError::InvalidInput(
262                "No camera variables found".to_string(),
263            ));
264        }
265        if structure.landmark_blocks.is_empty() {
266            return Err(LinAlgError::InvalidInput(
267                "No landmark variables found".to_string(),
268            ));
269        }
270
271        // Log block structure for diagnostics
272        debug!("Schur complement block structure:");
273        debug!(
274            "  Camera blocks: {} variables, {} total DOF",
275            structure.camera_blocks.len(),
276            structure.camera_dof
277        );
278        debug!(
279            "  Landmark blocks: {} variables, {} total DOF",
280            structure.landmark_blocks.len(),
281            structure.landmark_dof
282        );
283        debug!("  Camera column range: {:?}", structure.camera_col_range());
284        debug!(
285            "  Landmark column range: {:?}",
286            structure.landmark_col_range()
287        );
288        debug!(
289            "  Schur complement S size: {} × {}",
290            structure.camera_dof, structure.camera_dof
291        );
292
293        // Validate column ranges are contiguous
294        let (_cam_start, cam_end) = structure.camera_col_range();
295        let (land_start, _land_end) = structure.landmark_col_range();
296        if cam_end != land_start {
297            debug!(
298                "WARNING: Gap between camera and landmark blocks! cam_end={}, land_start={}",
299                cam_end, land_start
300            );
301        }
302
303        self.block_structure = Some(structure);
304        Ok(())
305    }
306
307    /// Extract 3×3 diagonal blocks from H_pp
308    fn extract_landmark_blocks(
309        &self,
310        hessian: &SparseColMat<usize, f64>,
311    ) -> LinAlgResult<Vec<Matrix3<f64>>> {
312        let structure = self
313            .block_structure
314            .as_ref()
315            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not built".to_string()))?;
316
317        let mut blocks = Vec::with_capacity(structure.num_landmarks);
318        let symbolic = hessian.symbolic();
319
320        for (_, start_col, _) in &structure.landmark_blocks {
321            let mut block = Matrix3::<f64>::zeros();
322
323            for local_col in 0..3 {
324                let global_col = start_col + local_col;
325                let row_indices = symbolic.row_idx_of_col_raw(global_col);
326                let col_values = hessian.val_of_col(global_col);
327
328                for (idx, &row) in row_indices.iter().enumerate() {
329                    if row >= *start_col && row < start_col + 3 {
330                        let local_row = row - start_col;
331                        block[(local_row, local_col)] = col_values[idx];
332                    }
333                }
334            }
335
336            blocks.push(block);
337        }
338
339        Ok(blocks)
340    }
341
342    /// Invert all 3×3 blocks with numerical robustness
343    ///
344    /// This function checks the condition number of each block and applies
345    /// additional regularization for ill-conditioned blocks to prevent
346    /// numerical instability in the Schur complement computation.
347    fn invert_landmark_blocks(blocks: &[Matrix3<f64>]) -> LinAlgResult<Vec<Matrix3<f64>>> {
348        Self::invert_landmark_blocks_with_lambda(blocks, 0.0)
349    }
350
351    /// Invert all 3×3 blocks with numerical robustness and optional damping
352    ///
353    /// # Arguments
354    /// * `blocks` - The 3×3 H_pp diagonal blocks to invert
355    /// * `lambda` - LM damping parameter (already added to blocks if > 0)
356    ///
357    /// For severely ill-conditioned blocks, additional regularization is applied
358    /// to ensure numerical stability.
359    fn invert_landmark_blocks_with_lambda(
360        blocks: &[Matrix3<f64>],
361        lambda: f64,
362    ) -> LinAlgResult<Vec<Matrix3<f64>>> {
363        // Thresholds for numerical robustness
364        const CONDITION_THRESHOLD: f64 = 1e10; // Max acceptable condition number
365        const MIN_EIGENVALUE_THRESHOLD: f64 = 1e-12; // Below this is considered singular
366        const REGULARIZATION_SCALE: f64 = 1e-6; // Scale for additional regularization
367
368        let mut ill_conditioned_count = 0;
369        let mut regularized_count = 0;
370
371        let result: LinAlgResult<Vec<Matrix3<f64>>> = blocks
372            .iter()
373            .enumerate()
374            .map(|(i, block)| {
375                // Compute symmetric eigenvalues for condition number check
376                // For a 3x3 SPD matrix, eigenvalues give us the condition number
377                let eigenvalues = block.symmetric_eigenvalues();
378                let min_ev = eigenvalues.min();
379                let max_ev = eigenvalues.max();
380
381                if min_ev < MIN_EIGENVALUE_THRESHOLD {
382                    // Severely ill-conditioned: add strong regularization
383                    regularized_count += 1;
384                    let reg = lambda.max(REGULARIZATION_SCALE) + max_ev * REGULARIZATION_SCALE;
385                    let regularized = block + Matrix3::identity() * reg;
386                    regularized.try_inverse().ok_or_else(|| {
387                        LinAlgError::SingularMatrix(format!(
388                            "Landmark block {} singular even with regularization (min_ev={:.2e})",
389                            i, min_ev
390                        ))
391                    })
392                } else if max_ev / min_ev > CONDITION_THRESHOLD {
393                    // Ill-conditioned but not singular: add moderate regularization
394                    ill_conditioned_count += 1;
395                    let extra_reg = max_ev * REGULARIZATION_SCALE;
396                    let regularized = block + Matrix3::identity() * extra_reg;
397                    regularized.try_inverse().ok_or_else(|| {
398                        LinAlgError::SingularMatrix(format!(
399                            "Landmark block {} ill-conditioned (cond={:.2e})",
400                            i,
401                            max_ev / min_ev
402                        ))
403                    })
404                } else {
405                    // Well-conditioned: standard inversion
406                    block.try_inverse().ok_or_else(|| {
407                        LinAlgError::SingularMatrix(format!("Landmark block {} is singular", i))
408                    })
409                }
410            })
411            .collect();
412
413        // Log statistics about conditioning
414        if ill_conditioned_count > 0 || regularized_count > 0 {
415            debug!(
416                "Landmark block conditioning: {} ill-conditioned, {} regularized out of {}",
417                ill_conditioned_count,
418                regularized_count,
419                blocks.len()
420            );
421        }
422
423        result
424    }
425
426    /// Extract H_cc (camera-camera block)
427    fn extract_camera_block(
428        &self,
429        hessian: &SparseColMat<usize, f64>,
430    ) -> LinAlgResult<SparseColMat<usize, f64>> {
431        let structure = self
432            .block_structure
433            .as_ref()
434            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not built".to_string()))?;
435
436        let (cam_start, cam_end) = structure.camera_col_range();
437        let cam_size = structure.camera_dof;
438        let symbolic = hessian.symbolic();
439
440        let mut triplets = Vec::new();
441
442        for global_col in cam_start..cam_end {
443            let local_col = global_col - cam_start;
444            let row_indices = symbolic.row_idx_of_col_raw(global_col);
445            let col_values = hessian.val_of_col(global_col);
446
447            for (idx, &global_row) in row_indices.iter().enumerate() {
448                if global_row >= cam_start && global_row < cam_end {
449                    let local_row = global_row - cam_start;
450                    triplets.push(Triplet::new(local_row, local_col, col_values[idx]));
451                }
452            }
453        }
454
455        SparseColMat::try_new_from_triplets(cam_size, cam_size, &triplets)
456            .map_err(|e| LinAlgError::SparseMatrixCreation(format!("H_cc: {:?}", e)))
457    }
458
459    /// Extract H_cp (camera-point coupling)
460    fn extract_coupling_block(
461        &self,
462        hessian: &SparseColMat<usize, f64>,
463    ) -> LinAlgResult<SparseColMat<usize, f64>> {
464        let structure = self
465            .block_structure
466            .as_ref()
467            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not built".to_string()))?;
468
469        let (cam_start, cam_end) = structure.camera_col_range();
470        let (land_start, land_end) = structure.landmark_col_range();
471        let cam_size = structure.camera_dof;
472        let land_size = structure.landmark_dof;
473        let symbolic = hessian.symbolic();
474
475        let mut triplets = Vec::new();
476
477        for global_col in land_start..land_end {
478            let local_col = global_col - land_start;
479            let row_indices = symbolic.row_idx_of_col_raw(global_col);
480            let col_values = hessian.val_of_col(global_col);
481
482            for (idx, &global_row) in row_indices.iter().enumerate() {
483                if global_row >= cam_start && global_row < cam_end {
484                    let local_row = global_row - cam_start;
485                    triplets.push(Triplet::new(local_row, local_col, col_values[idx]));
486                }
487            }
488        }
489
490        SparseColMat::try_new_from_triplets(cam_size, land_size, &triplets)
491            .map_err(|e| LinAlgError::SparseMatrixCreation(format!("H_cp: {:?}", e)))
492    }
493
494    /// Extract gradient blocks
495    fn extract_gradient_blocks(&self, gradient: &Mat<f64>) -> LinAlgResult<(Mat<f64>, Mat<f64>)> {
496        let structure = self
497            .block_structure
498            .as_ref()
499            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not built".to_string()))?;
500
501        let (cam_start, cam_end) = structure.camera_col_range();
502        let (land_start, land_end) = structure.landmark_col_range();
503
504        let mut g_c = Mat::zeros(structure.camera_dof, 1);
505        for i in 0..(cam_end - cam_start) {
506            g_c[(i, 0)] = gradient[(cam_start + i, 0)];
507        }
508
509        let mut g_p = Mat::zeros(structure.landmark_dof, 1);
510        for i in 0..(land_end - land_start) {
511            g_p[(i, 0)] = gradient[(land_start + i, 0)];
512        }
513
514        Ok((g_c, g_p))
515    }
516
517    /// Solve S * x = b using Cholesky factorization with automatic regularization
518    ///
519    /// If the initial factorization fails (matrix not positive definite),
520    /// we add small regularization to the diagonal and retry.
521    fn solve_with_cholesky(
522        &self,
523        a: &SparseColMat<usize, f64>,
524        b: &Mat<f64>,
525    ) -> LinAlgResult<Mat<f64>> {
526        let sym = SymbolicLlt::try_new(a.symbolic(), Side::Lower).map_err(|e| {
527            LinAlgError::FactorizationFailed(format!("Symbolic Cholesky failed: {:?}", e))
528        })?;
529
530        // First attempt: direct factorization
531        match Llt::try_new_with_symbolic(sym.clone(), a.as_ref(), Side::Lower) {
532            Ok(cholesky) => return Ok(cholesky.solve(b)),
533            Err(e) => {
534                debug!(
535                    "Cholesky factorization failed: {:?}. Applying regularization.",
536                    e
537                );
538            }
539        }
540
541        // Retry with exponentially increasing regularization
542        let n = a.nrows();
543        let symbolic = a.symbolic();
544
545        // Compute trace and max diagonal for scaling
546        let mut trace = 0.0;
547        let mut max_diag = 0.0f64;
548        for col in 0..n {
549            let row_indices = symbolic.row_idx_of_col_raw(col);
550            let col_values = a.val_of_col(col);
551            for (idx, &row) in row_indices.iter().enumerate() {
552                if row == col {
553                    trace += col_values[idx];
554                    max_diag = max_diag.max(col_values[idx].abs());
555                }
556            }
557        }
558
559        // Try multiple regularization levels
560        let avg_diag = trace / n as f64;
561        let base_reg = avg_diag.max(max_diag).max(1.0);
562
563        for attempt in 0..5 {
564            let reg = base_reg * 10.0f64.powi(attempt - 4); // 1e-4, 1e-3, 1e-2, 1e-1, 1.0 times base
565            debug!(
566                "Cholesky attempt {}: regularization = {:.2e}",
567                attempt + 2,
568                reg
569            );
570
571            let mut triplets = Vec::with_capacity(n * 10);
572            for col in 0..n {
573                let row_indices = symbolic.row_idx_of_col_raw(col);
574                let col_values = a.val_of_col(col);
575                for (idx, &row) in row_indices.iter().enumerate() {
576                    triplets.push(Triplet::new(row, col, col_values[idx]));
577                }
578            }
579
580            for i in 0..n {
581                triplets.push(Triplet::new(i, i, reg));
582            }
583
584            let a_reg = match SparseColMat::try_new_from_triplets(n, n, &triplets) {
585                Ok(m) => m,
586                Err(e) => {
587                    debug!("Failed to create regularized matrix: {:?}", e);
588                    continue;
589                }
590            };
591
592            // Need to create a new symbolic structure for the regularized matrix
593            let sym_reg = match SymbolicLlt::try_new(a_reg.symbolic(), Side::Lower) {
594                Ok(s) => s,
595                Err(e) => {
596                    debug!("Symbolic factorization failed: {:?}", e);
597                    continue;
598                }
599            };
600
601            match Llt::try_new_with_symbolic(sym_reg, a_reg.as_ref(), Side::Lower) {
602                Ok(cholesky) => {
603                    debug!("Cholesky succeeded with regularization {:.2e}", reg);
604                    return Ok(cholesky.solve(b));
605                }
606                Err(e) => {
607                    debug!("Cholesky failed with reg {:.2e}: {:?}", reg, e);
608                }
609            }
610        }
611
612        Err(LinAlgError::SingularMatrix(format!(
613            "Schur complement singular after 5 regularization attempts (max reg = {:.2e})",
614            base_reg
615        )))
616    }
617
618    /// Solve using Preconditioned Conjugate Gradients (PCG)
619    ///
620    /// Uses Jacobi (diagonal) preconditioning for simplicity and robustness.
621    fn solve_with_pcg(&self, a: &SparseColMat<usize, f64>, b: &Mat<f64>) -> LinAlgResult<Mat<f64>> {
622        let n = b.nrows();
623        let max_iterations = self.cg_max_iterations;
624        let tolerance = self.cg_tolerance;
625
626        // Extract diagonal for Jacobi preconditioner
627        let symbolic = a.symbolic();
628        let mut precond = vec![1.0; n];
629        for (col, precond_val) in precond.iter_mut().enumerate().take(n) {
630            let row_indices = symbolic.row_idx_of_col_raw(col);
631            let col_values = a.val_of_col(col);
632            for (idx, &row) in row_indices.iter().enumerate() {
633                if row == col {
634                    let diag = col_values[idx];
635                    *precond_val = if diag.abs() > 1e-12 { 1.0 / diag } else { 1.0 };
636                    break;
637                }
638            }
639        }
640
641        // Initialize
642        let mut x = Mat::<f64>::zeros(n, 1);
643
644        // r = b - A*x (x starts at 0, so r = b)
645        let mut r = b.clone();
646
647        // z = M^{-1} * r (Jacobi preconditioning)
648        let mut z = Mat::<f64>::zeros(n, 1);
649        for i in 0..n {
650            z[(i, 0)] = precond[i] * r[(i, 0)];
651        }
652
653        let mut p = z.clone();
654
655        let mut rz_old = 0.0;
656        for i in 0..n {
657            rz_old += r[(i, 0)] * z[(i, 0)];
658        }
659
660        // Compute initial residual norm for relative tolerance
661        let mut r_norm_init = 0.0;
662        for i in 0..n {
663            r_norm_init += r[(i, 0)] * r[(i, 0)];
664        }
665        r_norm_init = r_norm_init.sqrt();
666        let abs_tol = tolerance * r_norm_init.max(1.0);
667
668        for _iter in 0..max_iterations {
669            // Ap = A * p (sparse matrix-vector product)
670            let mut ap = Mat::<f64>::zeros(n, 1);
671            for col in 0..n {
672                let row_indices = symbolic.row_idx_of_col_raw(col);
673                let col_values = a.val_of_col(col);
674                for (idx, &row) in row_indices.iter().enumerate() {
675                    ap[(row, 0)] += col_values[idx] * p[(col, 0)];
676                }
677            }
678
679            // alpha = (r^T z) / (p^T Ap)
680            let mut p_ap = 0.0;
681            for i in 0..n {
682                p_ap += p[(i, 0)] * ap[(i, 0)];
683            }
684
685            if p_ap.abs() < 1e-30 {
686                break;
687            }
688
689            let alpha = rz_old / p_ap;
690
691            // x = x + alpha * p
692            for i in 0..n {
693                x[(i, 0)] += alpha * p[(i, 0)];
694            }
695
696            // r = r - alpha * Ap
697            for i in 0..n {
698                r[(i, 0)] -= alpha * ap[(i, 0)];
699            }
700
701            // Check convergence
702            let mut r_norm = 0.0;
703            for i in 0..n {
704                r_norm += r[(i, 0)] * r[(i, 0)];
705            }
706            r_norm = r_norm.sqrt();
707
708            if r_norm < abs_tol {
709                break;
710            }
711
712            // z = M^{-1} * r
713            for i in 0..n {
714                z[(i, 0)] = precond[i] * r[(i, 0)];
715            }
716
717            // beta = (r_{k+1}^T z_{k+1}) / (r_k^T z_k)
718            let mut rz_new = 0.0;
719            for i in 0..n {
720                rz_new += r[(i, 0)] * z[(i, 0)];
721            }
722
723            if rz_old.abs() < 1e-30 {
724                break;
725            }
726
727            let beta = rz_new / rz_old;
728
729            // p = z + beta * p
730            for i in 0..n {
731                p[(i, 0)] = z[(i, 0)] + beta * p[(i, 0)];
732            }
733
734            rz_old = rz_new;
735        }
736
737        Ok(x)
738    }
739
740    /// Compute Schur complement: S = H_cc - H_cp * H_pp^{-1} * H_cp^T
741    ///
742    /// This is an efficient implementation that exploits:
743    /// 1. Block-diagonal structure of H_pp (each landmark is independent)
744    /// 2. Sparsity of H_cp (each landmark connects to only a few cameras)
745    /// 3. Dense accumulation for the small camera-camera matrix S
746    ///
747    /// Algorithm:
748    /// For each landmark block p:
749    ///   - Get the cameras that observe this landmark (non-zero rows in H_cp column block)
750    ///   - Compute contribution: H_cp[:, p] * H_pp[p,p]^{-1} * H_cp[:, p]^T
751    ///   - This is an outer product of sparse vectors, producing a small dense update
752    ///   - Accumulate into the dense result S
753    fn compute_schur_complement(
754        &self,
755        h_cc: &SparseColMat<usize, f64>,
756        h_cp: &SparseColMat<usize, f64>,
757        hpp_inv_blocks: &[Matrix3<f64>],
758    ) -> LinAlgResult<SparseColMat<usize, f64>> {
759        let cam_size = h_cc.nrows();
760        let h_cp_symbolic = h_cp.symbolic();
761
762        // Use a dense matrix for S since the Schur complement is typically dense
763        // For 89 cameras, this is only 89*89*8 = 63KB - very cache-friendly
764        let mut s_dense = vec![0.0f64; cam_size * cam_size];
765
766        // First, add H_cc to S
767        let h_cc_symbolic = h_cc.symbolic();
768        for col in 0..h_cc.ncols() {
769            let row_indices = h_cc_symbolic.row_idx_of_col_raw(col);
770            let col_values = h_cc.val_of_col(col);
771            for (idx, &row) in row_indices.iter().enumerate() {
772                s_dense[row * cam_size + col] += col_values[idx];
773            }
774        }
775
776        // Pre-allocate vectors for camera data per landmark
777        // Max cameras per landmark is bounded by number of cameras
778        let mut cam_rows: Vec<usize> = Vec::with_capacity(32);
779        let mut h_cp_block: Vec<[f64; 3]> = Vec::with_capacity(32);
780        let mut contrib_block: Vec<[f64; 3]> = Vec::with_capacity(32);
781
782        // Process each landmark block independently (sequential for efficiency)
783        for (block_idx, hpp_inv_block) in hpp_inv_blocks.iter().enumerate() {
784            let col_start = block_idx * 3;
785
786            cam_rows.clear();
787            h_cp_block.clear();
788
789            if col_start + 2 >= h_cp.ncols() {
790                continue;
791            }
792
793            let row_indices_0 = h_cp_symbolic.row_idx_of_col_raw(col_start);
794            let col_values_0 = h_cp.val_of_col(col_start);
795            let row_indices_1 = h_cp_symbolic.row_idx_of_col_raw(col_start + 1);
796            let col_values_1 = h_cp.val_of_col(col_start + 1);
797            let row_indices_2 = h_cp_symbolic.row_idx_of_col_raw(col_start + 2);
798            let col_values_2 = h_cp.val_of_col(col_start + 2);
799
800            let mut i0 = 0;
801            let mut i1 = 0;
802            let mut i2 = 0;
803
804            while i0 < row_indices_0.len() || i1 < row_indices_1.len() || i2 < row_indices_2.len() {
805                let r0 = if i0 < row_indices_0.len() {
806                    row_indices_0[i0]
807                } else {
808                    usize::MAX
809                };
810                let r1 = if i1 < row_indices_1.len() {
811                    row_indices_1[i1]
812                } else {
813                    usize::MAX
814                };
815                let r2 = if i2 < row_indices_2.len() {
816                    row_indices_2[i2]
817                } else {
818                    usize::MAX
819                };
820
821                let min_row = r0.min(r1).min(r2);
822                if min_row == usize::MAX {
823                    break;
824                }
825
826                let v0 = if r0 == min_row {
827                    i0 += 1;
828                    col_values_0[i0 - 1]
829                } else {
830                    0.0
831                };
832                let v1 = if r1 == min_row {
833                    i1 += 1;
834                    col_values_1[i1 - 1]
835                } else {
836                    0.0
837                };
838                let v2 = if r2 == min_row {
839                    i2 += 1;
840                    col_values_2[i2 - 1]
841                } else {
842                    0.0
843                };
844
845                cam_rows.push(min_row);
846                h_cp_block.push([v0, v1, v2]);
847            }
848
849            if cam_rows.is_empty() {
850                continue;
851            }
852
853            contrib_block.clear();
854            for h_cp_row in &h_cp_block {
855                let c0 = h_cp_row[0] * hpp_inv_block[(0, 0)]
856                    + h_cp_row[1] * hpp_inv_block[(1, 0)]
857                    + h_cp_row[2] * hpp_inv_block[(2, 0)];
858                let c1 = h_cp_row[0] * hpp_inv_block[(0, 1)]
859                    + h_cp_row[1] * hpp_inv_block[(1, 1)]
860                    + h_cp_row[2] * hpp_inv_block[(2, 1)];
861                let c2 = h_cp_row[0] * hpp_inv_block[(0, 2)]
862                    + h_cp_row[1] * hpp_inv_block[(1, 2)]
863                    + h_cp_row[2] * hpp_inv_block[(2, 2)];
864                contrib_block.push([c0, c1, c2]);
865            }
866
867            let n_cams = cam_rows.len();
868            for i in 0..n_cams {
869                let cam_i = cam_rows[i];
870                let contrib_i = &contrib_block[i];
871                for j in 0..n_cams {
872                    let cam_j = cam_rows[j];
873                    let h_cp_j = &h_cp_block[j];
874                    let dot = contrib_i[0] * h_cp_j[0]
875                        + contrib_i[1] * h_cp_j[1]
876                        + contrib_i[2] * h_cp_j[2];
877                    s_dense[cam_i * cam_size + cam_j] -= dot;
878                }
879            }
880        }
881
882        // Symmetrize the Schur complement to ensure numerical symmetry
883        // Due to floating-point accumulation errors across 156K+ landmarks,
884        // S can become slightly asymmetric. Force symmetry: S = 0.5 * (S + S^T)
885        for i in 0..cam_size {
886            for j in (i + 1)..cam_size {
887                let avg = (s_dense[i * cam_size + j] + s_dense[j * cam_size + i]) * 0.5;
888                s_dense[i * cam_size + j] = avg;
889                s_dense[j * cam_size + i] = avg;
890            }
891        }
892
893        // Convert dense matrix to sparse (filtering near-zeros)
894        // Use slightly larger threshold to avoid numerical noise issues
895        let mut s_triplets: Vec<Triplet<usize, usize, f64>> = Vec::new();
896        for col in 0..cam_size {
897            for row in 0..cam_size {
898                let val = s_dense[row * cam_size + col];
899                if val.abs() > 1e-12 {
900                    s_triplets.push(Triplet::new(row, col, val));
901                }
902            }
903        }
904
905        SparseColMat::try_new_from_triplets(cam_size, cam_size, &s_triplets)
906            .map_err(|e| LinAlgError::SparseMatrixCreation(format!("Schur S: {:?}", e)))
907    }
908
909    /// Compute reduced gradient: g_reduced = g_c - H_cp * H_pp^{-1} * g_p
910    fn compute_reduced_gradient(
911        &self,
912        g_c: &Mat<f64>,
913        g_p: &Mat<f64>,
914        h_cp: &SparseColMat<usize, f64>,
915        hpp_inv_blocks: &[Matrix3<f64>],
916    ) -> LinAlgResult<Mat<f64>> {
917        // Infer sizes from matrices
918        let land_size = g_p.nrows();
919        let cam_size = g_c.nrows();
920
921        // Compute H_pp^{-1} * g_p block-wise
922        let mut hpp_inv_gp = Mat::zeros(land_size, 1);
923
924        for (block_idx, hpp_inv_block) in hpp_inv_blocks.iter().enumerate() {
925            let row_start = block_idx * 3;
926
927            let gp_block = nalgebra::Vector3::new(
928                g_p[(row_start, 0)],
929                g_p[(row_start + 1, 0)],
930                g_p[(row_start + 2, 0)],
931            );
932
933            let result = hpp_inv_block * gp_block;
934            hpp_inv_gp[(row_start, 0)] = result[0];
935            hpp_inv_gp[(row_start + 1, 0)] = result[1];
936            hpp_inv_gp[(row_start + 2, 0)] = result[2];
937        }
938
939        // Compute H_cp * (H_pp^{-1} * g_p)
940        let mut h_cp_hpp_inv_gp = Mat::<f64>::zeros(cam_size, 1);
941        let symbolic = h_cp.symbolic();
942
943        for col in 0..h_cp.ncols() {
944            let row_indices = symbolic.row_idx_of_col_raw(col);
945            let col_values = h_cp.val_of_col(col);
946
947            for (idx, &row) in row_indices.iter().enumerate() {
948                h_cp_hpp_inv_gp[(row, 0)] += col_values[idx] * hpp_inv_gp[(col, 0)];
949            }
950        }
951
952        // g_reduced = g_c - H_cp * H_pp^{-1} * g_p
953        let mut g_reduced = Mat::zeros(cam_size, 1);
954        for i in 0..cam_size {
955            g_reduced[(i, 0)] = g_c[(i, 0)] - h_cp_hpp_inv_gp[(i, 0)];
956        }
957
958        Ok(g_reduced)
959    }
960
961    /// Back-substitute: δp = H_pp^{-1} * (g_p - H_cp^T * δc)
962    fn back_substitute(
963        &self,
964        delta_c: &Mat<f64>,
965        g_p: &Mat<f64>,
966        h_cp: &SparseColMat<usize, f64>,
967        hpp_inv_blocks: &[Matrix3<f64>],
968    ) -> LinAlgResult<Mat<f64>> {
969        // Infer size from matrix
970
971        let land_size = g_p.nrows();
972
973        // Compute H_cp^T * δc
974        let mut h_cp_t_delta_c = Mat::<f64>::zeros(land_size, 1);
975        let symbolic = h_cp.symbolic();
976
977        for col in 0..h_cp.ncols() {
978            let row_indices = symbolic.row_idx_of_col_raw(col);
979            let col_values = h_cp.val_of_col(col);
980
981            for (idx, &row) in row_indices.iter().enumerate() {
982                h_cp_t_delta_c[(col, 0)] += col_values[idx] * delta_c[(row, 0)];
983            }
984        }
985
986        // Compute rhs = g_p - H_cp^T * δc
987        let mut rhs = Mat::zeros(land_size, 1);
988        for i in 0..land_size {
989            rhs[(i, 0)] = g_p[(i, 0)] - h_cp_t_delta_c[(i, 0)];
990        }
991
992        // Compute δp = H_pp^{-1} * rhs block-wise
993        let mut delta_p = Mat::zeros(land_size, 1);
994
995        for (block_idx, hpp_inv_block) in hpp_inv_blocks.iter().enumerate() {
996            let row_start = block_idx * 3;
997
998            let rhs_block = nalgebra::Vector3::new(
999                rhs[(row_start, 0)],
1000                rhs[(row_start + 1, 0)],
1001                rhs[(row_start + 2, 0)],
1002            );
1003
1004            let result = hpp_inv_block * rhs_block;
1005            delta_p[(row_start, 0)] = result[0];
1006            delta_p[(row_start + 1, 0)] = result[1];
1007            delta_p[(row_start + 2, 0)] = result[2];
1008        }
1009
1010        Ok(delta_p)
1011    }
1012}
1013
1014impl Default for SparseSchurComplementSolver {
1015    fn default() -> Self {
1016        Self::new()
1017    }
1018}
1019
1020impl StructureAware for SparseSchurComplementSolver {
1021    fn initialize_structure(
1022        &mut self,
1023        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1024        variable_index_map: &SecondaryMap<VarKey, usize>,
1025        schur_landmark_keys: &std::collections::HashSet<VarKey>,
1026    ) -> LinAlgResult<()> {
1027        // Build block structure for all variants
1028        self.build_block_structure(variables, variable_index_map, schur_landmark_keys)?;
1029
1030        // Initialize delegate solver based on variant
1031        match self.variant {
1032            SchurVariant::Iterative => {
1033                let mut solver =
1034                    IterativeSchurSolver::with_cg_params(self.cg_max_iterations, self.cg_tolerance);
1035                solver.initialize_structure(variables, variable_index_map, schur_landmark_keys)?;
1036                self.iterative_solver = Some(solver);
1037            }
1038            SchurVariant::Sparse => {
1039                // No delegate solver needed for sparse variant
1040            }
1041        }
1042
1043        Ok(())
1044    }
1045}
1046
1047impl LinearSolver<SparseMode> for SparseSchurComplementSolver {
1048    fn solve_normal_equation(
1049        &mut self,
1050        residuals: &Mat<f64>,
1051        jacobian: &SparseColMat<usize, f64>,
1052    ) -> LinAlgResult<Mat<f64>> {
1053        use std::ops::Mul;
1054        let jacobians = jacobian;
1055
1056        if self.block_structure.is_none() {
1057            return Err(LinAlgError::InvalidInput(
1058                "Block structure not built. Call initialize_structure() first.".to_string(),
1059            ));
1060        }
1061
1062        // Sparse and Iterative variants use the same Schur complement formation
1063        // They differ only in how S*δc = g_reduced is solved:
1064        // - Sparse: Cholesky factorization
1065        // - Iterative: PCG
1066
1067        // 1. Build H = J^T * J and g = -J^T * r
1068        let jt = jacobians
1069            .transpose()
1070            .to_col_major()
1071            .map_err(|e| LinAlgError::MatrixConversion(format!("Transpose failed: {:?}", e)))?;
1072        let hessian = jt.mul(jacobians);
1073        let gradient = jacobians.transpose().mul(residuals);
1074        let mut neg_gradient = Mat::zeros(gradient.nrows(), 1);
1075        for i in 0..gradient.nrows() {
1076            neg_gradient[(i, 0)] = -gradient[(i, 0)];
1077        }
1078
1079        self.hessian = Some(hessian.clone());
1080        // Store the positive gradient (J^T * r) for predicted reduction calculation
1081        // The Schur solver internally uses neg_gradient (-J^T * r) for the solve
1082        self.gradient = Some(gradient.clone());
1083
1084        // 2. Extract blocks
1085        let h_cc = self.extract_camera_block(&hessian)?;
1086        let h_cp = self.extract_coupling_block(&hessian)?;
1087        let hpp_blocks = self.extract_landmark_blocks(&hessian)?;
1088        let (g_c, g_p) = self.extract_gradient_blocks(&neg_gradient)?;
1089
1090        // 3. Invert H_pp blocks
1091        let hpp_inv_blocks = Self::invert_landmark_blocks(&hpp_blocks)?;
1092
1093        // 4. Compute Schur complement S
1094        let s = self.compute_schur_complement(&h_cc, &h_cp, &hpp_inv_blocks)?;
1095
1096        // 5. Compute reduced gradient
1097        let g_reduced = self.compute_reduced_gradient(&g_c, &g_p, &h_cp, &hpp_inv_blocks)?;
1098
1099        // 6. Solve S * δc = g_reduced (Cholesky for Sparse, PCG for Iterative)
1100        let delta_c = match self.variant {
1101            SchurVariant::Iterative => self.solve_with_pcg(&s, &g_reduced)?,
1102            _ => self.solve_with_cholesky(&s, &g_reduced)?,
1103        };
1104
1105        // 7. Back-substitute for δp
1106        let delta_p = self.back_substitute(&delta_c, &g_p, &h_cp, &hpp_inv_blocks)?;
1107
1108        // 8. Combine results
1109        self.combine_updates(&delta_c, &delta_p)
1110    }
1111
1112    fn solve_augmented_equation(
1113        &mut self,
1114        residuals: &Mat<f64>,
1115        jacobian: &SparseColMat<usize, f64>,
1116        lambda: f64,
1117    ) -> LinAlgResult<Mat<f64>> {
1118        use std::ops::Mul;
1119        let jacobians = jacobian;
1120
1121        if self.block_structure.is_none() {
1122            return Err(LinAlgError::InvalidInput(
1123                "Block structure not built. Call initialize_structure() first.".to_string(),
1124            ));
1125        }
1126
1127        // Sparse and Iterative variants use the same Schur complement formation with damping
1128        // 1. Build H = J^T * J and g = -J^T * r
1129        let jt = jacobians
1130            .transpose()
1131            .to_col_major()
1132            .map_err(|e| LinAlgError::MatrixConversion(format!("Transpose failed: {:?}", e)))?;
1133        let hessian = jt.mul(jacobians);
1134        let gradient = jacobians.transpose().mul(residuals);
1135        let mut neg_gradient = Mat::zeros(gradient.nrows(), 1);
1136        for i in 0..gradient.nrows() {
1137            neg_gradient[(i, 0)] = -gradient[(i, 0)];
1138        }
1139
1140        self.hessian = Some(hessian.clone());
1141        // Store the positive gradient (J^T * r) for predicted reduction calculation
1142        // The Schur solver internally uses neg_gradient (-J^T * r) for the solve
1143        self.gradient = Some(gradient.clone());
1144
1145        // 2. Extract blocks
1146        let h_cc = self.extract_camera_block(&hessian)?;
1147        let h_cp = self.extract_coupling_block(&hessian)?;
1148        let mut hpp_blocks = self.extract_landmark_blocks(&hessian)?;
1149        let (g_c, g_p) = self.extract_gradient_blocks(&neg_gradient)?;
1150
1151        // Log matrix dimensions for diagnostics
1152        debug!("Iteration matrices:");
1153        debug!(
1154            "  Hessian (J^T*J): {} × {}",
1155            hessian.nrows(),
1156            hessian.ncols()
1157        );
1158        debug!("  H_cc (camera): {} × {}", h_cc.nrows(), h_cc.ncols());
1159        debug!("  H_cp (coupling): {} × {}", h_cp.nrows(), h_cp.ncols());
1160        debug!("  H_pp blocks: {} (3×3 each)", hpp_blocks.len());
1161
1162        // 3. Add damping to H_cc and H_pp
1163        let structure = self
1164            .block_structure
1165            .as_ref()
1166            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not initialized".into()))?;
1167        let cam_size = structure.camera_dof;
1168
1169        // Add λI to H_cc
1170        let mut h_cc_triplets = Vec::new();
1171        let h_cc_symbolic = h_cc.symbolic();
1172        for col in 0..h_cc.ncols() {
1173            let row_indices = h_cc_symbolic.row_idx_of_col_raw(col);
1174            let col_values = h_cc.val_of_col(col);
1175            for (idx, &row) in row_indices.iter().enumerate() {
1176                h_cc_triplets.push(Triplet::new(row, col, col_values[idx]));
1177            }
1178        }
1179        for i in 0..cam_size {
1180            if let Some(entry) = h_cc_triplets.iter_mut().find(|t| t.row == i && t.col == i) {
1181                *entry = Triplet::new(i, i, entry.val + lambda);
1182            } else {
1183                h_cc_triplets.push(Triplet::new(i, i, lambda));
1184            }
1185        }
1186        let h_cc_damped =
1187            SparseColMat::try_new_from_triplets(cam_size, cam_size, &h_cc_triplets)
1188                .map_err(|e| LinAlgError::SparseMatrixCreation(format!("Damped H_cc: {:?}", e)))?;
1189
1190        // Add λI to H_pp blocks
1191        for block in &mut hpp_blocks {
1192            block[(0, 0)] += lambda;
1193            block[(1, 1)] += lambda;
1194            block[(2, 2)] += lambda;
1195        }
1196
1197        // 4. Invert damped H_pp blocks
1198        let hpp_inv_blocks = Self::invert_landmark_blocks(&hpp_blocks)?;
1199
1200        // 5. Compute Schur complement with damped matrices
1201        let s = self.compute_schur_complement(&h_cc_damped, &h_cp, &hpp_inv_blocks)?;
1202
1203        // 6. Compute reduced gradient
1204        let g_reduced = self.compute_reduced_gradient(&g_c, &g_p, &h_cp, &hpp_inv_blocks)?;
1205
1206        // 7. Solve S * δc = g_reduced (Cholesky for Sparse, PCG for Iterative)
1207        let delta_c = match self.variant {
1208            SchurVariant::Iterative => self.solve_with_pcg(&s, &g_reduced)?,
1209            _ => self.solve_with_cholesky(&s, &g_reduced)?,
1210        };
1211
1212        // 8. Back-substitute for δp
1213        let delta_p = self.back_substitute(&delta_c, &g_p, &h_cp, &hpp_inv_blocks)?;
1214
1215        // 9. Combine results
1216        self.combine_updates(&delta_c, &delta_p)
1217    }
1218
1219    fn get_hessian(&self) -> Option<&SparseColMat<usize, f64>> {
1220        self.hessian.as_ref()
1221    }
1222
1223    fn get_gradient(&self) -> Option<&Mat<f64>> {
1224        self.gradient.as_ref()
1225    }
1226}
1227
1228// Helper methods for SparseSchurComplementSolver
1229impl SparseSchurComplementSolver {
1230    /// Combine camera and landmark updates into full update vector
1231    fn combine_updates(&self, delta_c: &Mat<f64>, delta_p: &Mat<f64>) -> LinAlgResult<Mat<f64>> {
1232        let structure = self
1233            .block_structure
1234            .as_ref()
1235            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not built".to_string()))?;
1236
1237        let total_dof = structure.camera_dof + structure.landmark_dof;
1238        let mut delta = Mat::zeros(total_dof, 1);
1239
1240        let (cam_start, cam_end) = structure.camera_col_range();
1241        let (land_start, land_end) = structure.landmark_col_range();
1242
1243        // Copy camera updates
1244        for i in 0..(cam_end - cam_start) {
1245            delta[(cam_start + i, 0)] = delta_c[(i, 0)];
1246        }
1247
1248        // Copy landmark updates
1249        for i in 0..(land_end - land_start) {
1250            delta[(land_start + i, 0)] = delta_p[(i, 0)];
1251        }
1252
1253        // Debug: Log update magnitude
1254        debug!(
1255            "Update norms: delta_c={:.6e}, delta_p={:.6e}, combined={:.6e}",
1256            delta_c.norm_l2(),
1257            delta_p.norm_l2(),
1258            delta.norm_l2()
1259        );
1260
1261        Ok(delta)
1262    }
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267    use super::*;
1268    use crate::core::VarKey;
1269    use crate::core::variable::Variable;
1270    use apex_manifolds::{LieGroup, rn, se3};
1271    use nalgebra::DVector;
1272    use slotmap::{SecondaryMap, SlotMap};
1273
1274    type TestResult = Result<(), Box<dyn std::error::Error>>;
1275
1276    // Type alias for the test setup tuple
1277    type TestSetup = (
1278        SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1279        SecondaryMap<VarKey, usize>,
1280        SparseColMat<usize, f64>,
1281        Mat<f64>,
1282        std::collections::HashSet<VarKey>,
1283    );
1284
1285    /// Build a minimal BA-style test setup:
1286    /// 2 SE3 cameras + 3 Rn landmarks
1287    /// Jacobian: 36 rows × 21 cols
1288    ///
1289    /// Structure guarantees H_cc = 3·I₁₂ and H_pp = 4·I₃ (positive definite).
1290    fn create_schur_test_setup() -> Result<TestSetup, Box<dyn std::error::Error>> {
1291        let se3_id = DVector::from_vec(vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
1292        let pt_zero = DVector::from_vec(vec![0.0, 0.0, 0.0]);
1293
1294        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1295        let cam0 = variables.insert(Box::new(Variable::new(se3::SE3::from_param_slice(
1296            se3_id.as_slice(),
1297        ))));
1298        let cam1 = variables.insert(Box::new(Variable::new(se3::SE3::from_param_slice(
1299            se3_id.as_slice(),
1300        ))));
1301        let pt0 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_zero.clone()))));
1302        let pt1 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_zero.clone()))));
1303        let pt2 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_zero.clone()))));
1304
1305        // cam0 → 0..5, cam1 → 6..11, pt0 → 12..14, pt1 → 15..17, pt2 → 18..20
1306        let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
1307        variable_index_map.insert(cam0, 0);
1308        variable_index_map.insert(cam1, 6);
1309        variable_index_map.insert(pt0, 12);
1310        variable_index_map.insert(pt1, 15);
1311        variable_index_map.insert(pt2, 18);
1312
1313        // Jacobian: 2 cameras × 3 landmarks × 6 rows_per_obs = 36 rows, 21 cols
1314        // For observation (cam_i, pt_j), row_base = (ci * 3 + li) * 6
1315        //   J[row_base+k, cam_col+k] = 1.0  (k=0..5, camera DOF)
1316        //   J[row_base+k, lm_col + (k%3)] = 1.0  (landmark DOF repeats to fill all 3)
1317        let n_rows = 36;
1318        let n_cols = 21;
1319        let cam_cols = [0usize, 6];
1320        let lm_cols = [12usize, 15, 18];
1321
1322        let mut triplets: Vec<Triplet<usize, usize, f64>> = Vec::new();
1323        for (ci, &cam_col) in cam_cols.iter().enumerate() {
1324            for (li, &lm_col) in lm_cols.iter().enumerate() {
1325                let row_base = (ci * 3 + li) * 6;
1326                for k in 0..6 {
1327                    triplets.push(Triplet::new(row_base + k, cam_col + k, 1.0));
1328                    triplets.push(Triplet::new(row_base + k, lm_col + (k % 3), 1.0));
1329                }
1330            }
1331        }
1332
1333        let jacobian = SparseColMat::try_new_from_triplets(n_rows, n_cols, &triplets)?;
1334        let residuals = Mat::from_fn(n_rows, 1, |i, _| (i % 5) as f64 * 0.1);
1335
1336        let mut landmark_keys = std::collections::HashSet::new();
1337        landmark_keys.insert(pt0);
1338        landmark_keys.insert(pt1);
1339        landmark_keys.insert(pt2);
1340
1341        Ok((
1342            variables,
1343            variable_index_map,
1344            jacobian,
1345            residuals,
1346            landmark_keys,
1347        ))
1348    }
1349
1350    #[test]
1351    fn test_schur_ordering_rn3_eliminated() {
1352        let ordering = SchurOrdering::default();
1353        // Default: RN(3) is eliminated (landmark)
1354        assert!(ordering.should_eliminate(&ManifoldType::RN, 3));
1355    }
1356
1357    #[test]
1358    fn test_schur_ordering_se3_not_eliminated() {
1359        let ordering = SchurOrdering::default();
1360        // SE3 variables are never eliminated (camera)
1361        assert!(!ordering.should_eliminate(&ManifoldType::SE3, 6));
1362    }
1363
1364    #[test]
1365    fn test_schur_ordering_wrong_type_not_eliminated() {
1366        let ordering = SchurOrdering::default();
1367        // SE3(6) is not in eliminate_types
1368        assert!(!ordering.should_eliminate(&ManifoldType::SE3, 6));
1369    }
1370
1371    #[test]
1372    fn test_schur_ordering_wrong_size_not_eliminated() {
1373        let ordering = SchurOrdering::default();
1374        // RN with size != 3 is not eliminated
1375        assert!(!ordering.should_eliminate(&ManifoldType::RN, 6));
1376        assert!(!ordering.should_eliminate(&ManifoldType::RN, 2));
1377    }
1378
1379    #[test]
1380    fn test_block_structure_creation() {
1381        let structure = SchurBlockStructure::new();
1382        assert_eq!(structure.camera_dof, 0);
1383        assert_eq!(structure.landmark_dof, 0);
1384    }
1385
1386    #[test]
1387    fn test_solver_creation() {
1388        let solver = SparseSchurComplementSolver::new();
1389        assert!(solver.block_structure.is_none());
1390    }
1391
1392    #[test]
1393    fn test_3x3_block_inversion() -> Result<(), LinAlgError> {
1394        let block = Matrix3::new(2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0);
1395        let inv = SparseSchurComplementSolver::invert_landmark_blocks(&[block])?;
1396        assert!((inv[0][(0, 0)] - 0.5).abs() < 1e-10);
1397        Ok(())
1398    }
1399
1400    #[test]
1401    fn test_schur_variants() {
1402        let solver = SparseSchurComplementSolver::new()
1403            .with_variant(SchurVariant::Iterative)
1404            .with_preconditioner(SchurPreconditioner::BlockDiagonal)
1405            .with_cg_params(100, 1e-8);
1406
1407        assert_eq!(solver.cg_max_iterations, 100);
1408        assert!((solver.cg_tolerance - 1e-8).abs() < 1e-12);
1409    }
1410
1411    #[test]
1412    fn test_compute_schur_complement_known_matrix() -> Result<(), LinAlgError> {
1413        use faer::sparse::Triplet;
1414
1415        let solver = SparseSchurComplementSolver::new();
1416
1417        // Create simple 2x2 H_cc (camera block)
1418        let h_cc_triplets = vec![Triplet::new(0, 0, 4.0), Triplet::new(1, 1, 5.0)];
1419        let h_cc = SparseColMat::try_new_from_triplets(2, 2, &h_cc_triplets)
1420            .map_err(|e| LinAlgError::SparseMatrixCreation(format!("{e:?}")))?;
1421
1422        // Create 2x3 H_cp (coupling block - 1 landmark with 3 DOF)
1423        let h_cp_triplets = vec![Triplet::new(0, 0, 1.0), Triplet::new(1, 1, 2.0)];
1424        let h_cp = SparseColMat::try_new_from_triplets(2, 3, &h_cp_triplets)
1425            .map_err(|e| LinAlgError::SparseMatrixCreation(format!("{e:?}")))?;
1426
1427        // Create H_pp^{-1} as identity scaled by 0.5
1428        let hpp_inv = vec![Matrix3::new(0.5, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.5)];
1429
1430        // Compute S = H_cc - H_cp * H_pp^{-1} * H_cp^T
1431        // H_cp has [1.0 at (0,0), 2.0 at (1,1), rest zeros]
1432        // H_cp * H_pp^{-1} (with H_pp^{-1} = 0.5*I) gives:
1433        //   Row 0: [0.5, 0, 0]
1434        //   Row 1: [0, 1.0, 0]
1435        // (H_cp * H_pp^{-1}) * H_cp^T:
1436        //   (0,0): 0.5*1 = 0.5, but we sum over all k, so actually just first column contribution
1437        //   The diagonal will be: row·row for each
1438        // Let me recalculate: S(0,0) = 4 - 0.5*1 = 3.5, but actual is 3.75
1439        // Actually the formula computes sum over all landmark DOF
1440        let s = solver.compute_schur_complement(&h_cc, &h_cp, &hpp_inv)?;
1441
1442        assert_eq!(s.nrows(), 2);
1443        assert_eq!(s.ncols(), 2);
1444        // Verify the actual computed values (diagonal elements of Schur complement)
1445        // S = H_cc - H_cp * H_pp^{-1} * H_cp^T
1446        // H_cp * H_pp^{-1} = [[0.5, 0, 0], [0, 1.0, 0]]
1447        // (H_cp * H_pp^{-1}) * H_cp^T:
1448        //   (0,0) = 0.5*1 = 0.5
1449        //   (1,1) = 1.0*2 = 2.0
1450        // S(0,0) = 4 - 0.5 = 3.5, S(1,1) = 5 - 2.0 = 3.0
1451        assert!((s[(0, 0)] - 3.5).abs() < 1e-10, "S(0,0) = {}", s[(0, 0)]);
1452        assert!((s[(1, 1)] - 3.0).abs() < 1e-10, "S(1,1) = {}", s[(1, 1)]);
1453        Ok(())
1454    }
1455
1456    #[test]
1457    fn test_back_substitute() -> Result<(), LinAlgError> {
1458        use faer::sparse::Triplet;
1459
1460        let solver = SparseSchurComplementSolver::new();
1461
1462        // Create test data
1463        let delta_c = Mat::from_fn(2, 1, |i, _| (i + 1) as f64); // [1; 2]
1464        let g_p = Mat::from_fn(3, 1, |i, _| (i + 1) as f64); // [1; 2; 3]
1465
1466        // H_cp (2x3)
1467        let h_cp_triplets = vec![Triplet::new(0, 0, 1.0), Triplet::new(1, 1, 1.0)];
1468        let h_cp = SparseColMat::try_new_from_triplets(2, 3, &h_cp_triplets)
1469            .map_err(|e| LinAlgError::SparseMatrixCreation(format!("{e:?}")))?;
1470
1471        // H_pp^{-1} (identity)
1472        let hpp_inv = vec![Matrix3::identity()];
1473
1474        // Compute δp = H_pp^{-1} * (g_p - H_cp^T * δc)
1475        // H_cp^T * δc = [1*1; 1*2; 0] = [1; 2; 0]
1476        // g_p - result = [1; 2; 3] - [1; 2; 0] = [0; 0; 3]
1477        // H_pp^{-1} * [0; 0; 3] = [0; 0; 3]
1478        let delta_p = solver.back_substitute(&delta_c, &g_p, &h_cp, &hpp_inv)?;
1479
1480        assert_eq!(delta_p.nrows(), 3);
1481        assert!((delta_p[(0, 0)]).abs() < 1e-10);
1482        assert!((delta_p[(1, 0)]).abs() < 1e-10);
1483        assert!((delta_p[(2, 0)] - 3.0).abs() < 1e-10);
1484        Ok(())
1485    }
1486
1487    #[test]
1488    fn test_compute_reduced_gradient() -> Result<(), LinAlgError> {
1489        use faer::sparse::Triplet;
1490
1491        let solver = SparseSchurComplementSolver::new();
1492
1493        // Create test data
1494        let g_c = Mat::from_fn(2, 1, |i, _| (i + 1) as f64); // [1; 2]
1495        let g_p = Mat::from_fn(3, 1, |i, _| (i + 1) as f64); // [1; 2; 3]
1496
1497        // H_cp (2x3)
1498        let h_cp_triplets = vec![Triplet::new(0, 0, 1.0), Triplet::new(1, 1, 1.0)];
1499        let h_cp = SparseColMat::try_new_from_triplets(2, 3, &h_cp_triplets)
1500            .map_err(|e| LinAlgError::SparseMatrixCreation(format!("{e:?}")))?;
1501
1502        // H_pp^{-1} (2*identity)
1503        let hpp_inv = vec![Matrix3::new(2.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 2.0)];
1504
1505        // Compute g_reduced = g_c - H_cp * H_pp^{-1} * g_p
1506        // H_pp^{-1} * g_p = 2*[1; 2; 3] = [2; 4; 6]
1507        // H_cp * [2; 4; 6] = [1*2; 1*4] = [2; 4]
1508        // g_reduced = [1; 2] - [2; 4] = [-1; -2]
1509        let g_reduced = solver.compute_reduced_gradient(&g_c, &g_p, &h_cp, &hpp_inv)?;
1510
1511        assert_eq!(g_reduced.nrows(), 2);
1512        assert!((g_reduced[(0, 0)] + 1.0).abs() < 1e-10);
1513        assert!((g_reduced[(1, 0)] + 2.0).abs() < 1e-10);
1514        Ok(())
1515    }
1516
1517    // -------------------------------------------------------------------------
1518    // New tests for uncovered code paths
1519    // -------------------------------------------------------------------------
1520
1521    /// Test SparseSchurComplementSolver::default() equals new()
1522    #[test]
1523    fn test_solver_default() {
1524        let solver = SparseSchurComplementSolver::default();
1525        assert!(solver.block_structure.is_none());
1526        assert!(solver.hessian.is_none());
1527        assert!(solver.gradient.is_none());
1528    }
1529
1530    /// Test SchurBlockStructure::default() has empty blocks
1531    #[test]
1532    fn test_block_structure_default() {
1533        let s = SchurBlockStructure::default();
1534        assert!(s.camera_blocks.is_empty());
1535        assert!(s.landmark_blocks.is_empty());
1536        assert_eq!(s.camera_dof, 0);
1537        assert_eq!(s.landmark_dof, 0);
1538    }
1539
1540    /// Test camera_col_range() and landmark_col_range() with known fields
1541    #[test]
1542    fn test_block_structure_col_ranges() {
1543        let mut s = SchurBlockStructure::new();
1544        // Empty → (0, 0)
1545        assert_eq!(s.camera_col_range(), (0, 0));
1546        assert_eq!(s.landmark_col_range(), (0, 0));
1547
1548        // Populate with known values (use dummy VarKeys from a temp SlotMap)
1549        let mut tmp: SlotMap<VarKey, ()> = SlotMap::with_key();
1550        let cam_key = tmp.insert(());
1551        let pt_key = tmp.insert(());
1552        s.camera_blocks.push((cam_key, 0, 6));
1553        s.camera_dof = 6;
1554        s.landmark_blocks.push((pt_key, 6, 3));
1555        s.landmark_dof = 3;
1556
1557        assert_eq!(s.camera_col_range(), (0, 6));
1558        assert_eq!(s.landmark_col_range(), (6, 9));
1559    }
1560
1561    /// Test block_structure() getter after initialize_structure
1562    #[test]
1563    fn test_block_structure_getter() -> TestResult {
1564        let (variables, variable_index_map, _, _, landmark_keys) = create_schur_test_setup()?;
1565        let mut solver = SparseSchurComplementSolver::new();
1566
1567        assert!(solver.block_structure().is_none());
1568        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1569        assert!(solver.block_structure().is_some());
1570        Ok(())
1571    }
1572
1573    /// Test with_ordering() builder stores the custom ordering
1574    #[test]
1575    fn test_with_ordering_builder() {
1576        let ordering = SchurOrdering {
1577            eliminate_types: vec![ManifoldType::RN],
1578            eliminate_rn_size: Some(3),
1579        };
1580        let solver = SparseSchurComplementSolver::new().with_ordering(ordering);
1581        assert_eq!(solver.ordering.eliminate_rn_size, Some(3));
1582    }
1583
1584    /// Test invert_landmark_blocks_with_lambda() inverts well-conditioned blocks
1585    ///
1586    /// Note: lambda is used only as a floor for regularization of ill-conditioned blocks;
1587    /// for well-conditioned blocks the standard inverse is returned unchanged.
1588    #[test]
1589    fn test_invert_landmark_blocks_with_lambda() -> TestResult {
1590        // Diagonal block: diag(2, 3, 4) → inverse is diag(0.5, 1/3, 0.25)
1591        let block = Matrix3::new(2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0);
1592
1593        // lambda=0 path (called by invert_landmark_blocks internally)
1594        let inv = SparseSchurComplementSolver::invert_landmark_blocks_with_lambda(&[block], 0.0)?;
1595        assert_eq!(inv.len(), 1);
1596        assert!((inv[0][(0, 0)] - 0.5).abs() < 1e-10);
1597        assert!((inv[0][(1, 1)] - 1.0 / 3.0).abs() < 1e-10);
1598        assert!((inv[0][(2, 2)] - 0.25).abs() < 1e-10);
1599
1600        // lambda > 0: for a well-conditioned block the result is still the standard inverse
1601        let inv_lam =
1602            SparseSchurComplementSolver::invert_landmark_blocks_with_lambda(&[block], 1.0)?;
1603        assert!((inv_lam[0][(0, 0)] - 0.5).abs() < 1e-10);
1604        Ok(())
1605    }
1606
1607    /// Test initialize_structure() correctly partitions 2 cameras + 3 landmarks
1608    #[test]
1609    fn test_explicit_schur_initialize_structure() -> TestResult {
1610        let (variables, variable_index_map, _, _, landmark_keys) = create_schur_test_setup()?;
1611        let mut solver = SparseSchurComplementSolver::new();
1612        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1613
1614        let bs = solver.block_structure().ok_or("block_structure is None")?;
1615        assert_eq!(bs.camera_blocks.len(), 2);
1616        assert_eq!(bs.landmark_blocks.len(), 3);
1617        assert_eq!(bs.camera_dof, 12); // 2 × 6
1618        assert_eq!(bs.landmark_dof, 9); // 3 × 3
1619        Ok(())
1620    }
1621
1622    /// Test extract_gradient_blocks() splits gradient correctly
1623    #[test]
1624    fn test_extract_gradient_blocks() -> TestResult {
1625        let (variables, variable_index_map, _, _, landmark_keys) = create_schur_test_setup()?;
1626        let mut solver = SparseSchurComplementSolver::new();
1627        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1628
1629        // Gradient over full variable space (21 DOF)
1630        let gradient = Mat::from_fn(21, 1, |i, _| i as f64);
1631        let (g_c, g_p) = solver.extract_gradient_blocks(&gradient)?;
1632
1633        assert_eq!(g_c.nrows(), 12); // camera DOF
1634        assert_eq!(g_p.nrows(), 9); // landmark DOF
1635        Ok(())
1636    }
1637
1638    /// Test full Schur solve pipeline with Sparse (Cholesky) variant
1639    #[test]
1640    fn test_explicit_schur_solve_normal_equation() -> TestResult {
1641        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1642            create_schur_test_setup()?;
1643        let mut solver = SparseSchurComplementSolver::new().with_variant(SchurVariant::Sparse);
1644        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1645
1646        let delta =
1647            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1648        assert_eq!(delta.nrows(), 21);
1649        assert_eq!(delta.ncols(), 1);
1650        Ok(())
1651    }
1652
1653    /// Test full Schur augmented solve (LM damping) with Sparse variant
1654    #[test]
1655    fn test_explicit_schur_solve_augmented_equation() -> TestResult {
1656        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1657            create_schur_test_setup()?;
1658        let mut solver = SparseSchurComplementSolver::new().with_variant(SchurVariant::Sparse);
1659        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1660
1661        let delta = LinearSolver::<SparseMode>::solve_augmented_equation(
1662            &mut solver,
1663            &residuals,
1664            &jacobian,
1665            0.1,
1666        )?;
1667        assert_eq!(delta.nrows(), 21);
1668        Ok(())
1669    }
1670
1671    /// Test Schur solve with Iterative (PCG) variant exercises solve_with_pcg path
1672    #[test]
1673    fn test_explicit_schur_solve_iterative_variant() -> TestResult {
1674        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1675            create_schur_test_setup()?;
1676        let mut solver = SparseSchurComplementSolver::new()
1677            .with_variant(SchurVariant::Iterative)
1678            .with_cg_params(200, 1e-6);
1679        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1680
1681        let delta =
1682            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1683        assert_eq!(delta.nrows(), 21);
1684        Ok(())
1685    }
1686
1687    /// Test get_hessian() and get_gradient() trait methods after solve
1688    #[test]
1689    fn test_explicit_schur_get_hessian_gradient() -> TestResult {
1690        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1691            create_schur_test_setup()?;
1692        let mut solver = SparseSchurComplementSolver::new();
1693        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1694
1695        assert!(LinearSolver::<SparseMode>::get_hessian(&solver).is_none());
1696        assert!(LinearSolver::<SparseMode>::get_gradient(&solver).is_none());
1697
1698        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1699
1700        let h = LinearSolver::<SparseMode>::get_hessian(&solver);
1701        let g = LinearSolver::<SparseMode>::get_gradient(&solver);
1702        assert!(h.is_some());
1703        assert!(g.is_some());
1704        let h = h.ok_or("hessian is None")?;
1705        let g = g.ok_or("gradient is None")?;
1706        assert_eq!(h.nrows(), 21);
1707        assert_eq!(g.nrows(), 21);
1708        Ok(())
1709    }
1710
1711    /// Test two solves with different λ produce different updates
1712    #[test]
1713    fn test_explicit_schur_augmented_lambda_effect() -> TestResult {
1714        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1715            create_schur_test_setup()?;
1716
1717        let mut solver1 = SparseSchurComplementSolver::new();
1718        solver1.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1719        let delta1 = LinearSolver::<SparseMode>::solve_augmented_equation(
1720            &mut solver1,
1721            &residuals,
1722            &jacobian,
1723            0.001,
1724        )?;
1725
1726        let mut solver2 = SparseSchurComplementSolver::new();
1727        solver2.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1728        let delta2 = LinearSolver::<SparseMode>::solve_augmented_equation(
1729            &mut solver2,
1730            &residuals,
1731            &jacobian,
1732            100.0,
1733        )?;
1734
1735        // Different λ values should produce different updates
1736        let norm_diff: f64 = (0..21)
1737            .map(|i| (delta1[(i, 0)] - delta2[(i, 0)]).powi(2))
1738            .sum();
1739        assert!(
1740            norm_diff > 1e-10,
1741            "Different λ should yield different updates"
1742        );
1743        Ok(())
1744    }
1745
1746    /// Test combine_updates() merges camera and landmark deltas at correct offsets
1747    #[test]
1748    fn test_combine_updates() -> TestResult {
1749        let (variables, variable_index_map, _, _, landmark_keys) = create_schur_test_setup()?;
1750        let mut solver = SparseSchurComplementSolver::new();
1751        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1752
1753        // Camera delta: 12×1, landmark delta: 9×1
1754        let delta_c = Mat::from_fn(12, 1, |_, _| 1.0);
1755        let delta_p = Mat::from_fn(9, 1, |_, _| 2.0);
1756
1757        let combined = solver.combine_updates(&delta_c, &delta_p)?;
1758        assert_eq!(combined.nrows(), 21);
1759
1760        // Camera values (cam_start..cam_end = 0..12)
1761        for i in 0..12 {
1762            assert!((combined[(i, 0)] - 1.0).abs() < 1e-10);
1763        }
1764        // Landmark values (land_start..land_end = 12..21)
1765        for i in 12..21 {
1766            assert!((combined[(i, 0)] - 2.0).abs() < 1e-10);
1767        }
1768        Ok(())
1769    }
1770
1771    /// Test solve without initialize_structure returns error
1772    #[test]
1773    fn test_explicit_schur_solve_without_init_returns_error() -> TestResult {
1774        let triplets: Vec<Triplet<usize, usize, f64>> = vec![Triplet::new(0, 0, 1.0)];
1775        let jacobian =
1776            SparseColMat::try_new_from_triplets(1, 1, &triplets).map_err(|e| format!("{e:?}"))?;
1777        let residuals = Mat::from_fn(1, 1, |_, _| 1.0);
1778        let mut solver = SparseSchurComplementSolver::new();
1779
1780        let result =
1781            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian);
1782        assert!(result.is_err());
1783        Ok(())
1784    }
1785
1786    // -------------------------------------------------------------------------
1787    // New tests for previously uncovered code paths
1788    // -------------------------------------------------------------------------
1789
1790    /// Test SchurOrdering::new() produces the same result as default().
1791    #[test]
1792    fn test_schur_ordering_new_equals_default() {
1793        let a = SchurOrdering::new();
1794        let b = SchurOrdering::default();
1795        assert_eq!(a.eliminate_rn_size, b.eliminate_rn_size);
1796        assert_eq!(a.eliminate_types.len(), b.eliminate_types.len());
1797    }
1798
1799    /// Test extract_camera_block produces a square matrix of camera DOF.
1800    #[test]
1801    fn test_extract_camera_block() -> TestResult {
1802        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1803            create_schur_test_setup()?;
1804        let mut solver = SparseSchurComplementSolver::new();
1805        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1806
1807        // Build Hessian H = J^T J
1808        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1809        let hessian = solver.hessian.clone().ok_or("hessian is None")?;
1810
1811        let mut fresh = SparseSchurComplementSolver::new();
1812        fresh.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1813        let h_cc = fresh.extract_camera_block(&hessian)?;
1814
1815        // camera DOF = 12 (2 cameras × 6)
1816        assert_eq!(h_cc.nrows(), 12);
1817        assert_eq!(h_cc.ncols(), 12);
1818        Ok(())
1819    }
1820
1821    /// Test extract_coupling_block produces a matrix with camera rows × landmark cols.
1822    #[test]
1823    fn test_extract_coupling_block() -> TestResult {
1824        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1825            create_schur_test_setup()?;
1826        let mut solver = SparseSchurComplementSolver::new();
1827        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1828
1829        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1830        let hessian = solver.hessian.clone().ok_or("hessian is None")?;
1831
1832        let mut fresh = SparseSchurComplementSolver::new();
1833        fresh.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1834        let h_cp = fresh.extract_coupling_block(&hessian)?;
1835
1836        // H_cp: camera DOF rows × landmark DOF cols = 12 × 9
1837        assert_eq!(h_cp.nrows(), 12);
1838        assert_eq!(h_cp.ncols(), 9);
1839        Ok(())
1840    }
1841
1842    /// Test extract_landmark_blocks produces one 3×3 block per landmark.
1843    #[test]
1844    fn test_extract_landmark_blocks() -> TestResult {
1845        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1846            create_schur_test_setup()?;
1847        let mut solver = SparseSchurComplementSolver::new();
1848        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1849
1850        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1851        let hessian = solver.hessian.clone().ok_or("hessian is None")?;
1852
1853        let mut fresh = SparseSchurComplementSolver::new();
1854        fresh.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1855        let blocks = fresh.extract_landmark_blocks(&hessian)?;
1856
1857        // 3 landmarks → 3 blocks
1858        assert_eq!(blocks.len(), 3);
1859        Ok(())
1860    }
1861
1862    /// Test solve_with_cholesky satisfies Ax ≈ b for a known SPD system.
1863    #[test]
1864    fn test_solve_with_cholesky_small_spd() -> TestResult {
1865        let solver = SparseSchurComplementSolver::new();
1866
1867        // 2×2 SPD matrix A = [[4,1],[1,3]]
1868        let triplets = vec![
1869            Triplet::new(0usize, 0usize, 4.0f64),
1870            Triplet::new(1usize, 0usize, 1.0f64),
1871            Triplet::new(0usize, 1usize, 1.0f64),
1872            Triplet::new(1usize, 1usize, 3.0f64),
1873        ];
1874        let a =
1875            SparseColMat::try_new_from_triplets(2, 2, &triplets).map_err(|e| format!("{e:?}"))?;
1876        let b = Mat::from_fn(2, 1, |i, _| (i + 1) as f64); // [1; 2]
1877
1878        let x = solver.solve_with_cholesky(&a, &b)?;
1879        assert_eq!(x.nrows(), 2);
1880
1881        // Verify: A·x ≈ b
1882        // A·x = [4*x0+1*x1; 1*x0+3*x1]
1883        let ax0 = 4.0 * x[(0, 0)] + 1.0 * x[(1, 0)];
1884        let ax1 = 1.0 * x[(0, 0)] + 3.0 * x[(1, 0)];
1885        assert!((ax0 - 1.0).abs() < 1e-8, "A·x[0] = {ax0}");
1886        assert!((ax1 - 2.0).abs() < 1e-8, "A·x[1] = {ax1}");
1887        Ok(())
1888    }
1889
1890    /// Test solve_with_pcg converges on a small diagonal (trivial) system.
1891    #[test]
1892    fn test_solve_with_pcg_diagonal_system() -> TestResult {
1893        let solver = SparseSchurComplementSolver::new();
1894
1895        // Diagonal SPD: [[2,0],[0,3]]
1896        let triplets = vec![
1897            Triplet::new(0usize, 0usize, 2.0f64),
1898            Triplet::new(1usize, 1usize, 3.0f64),
1899        ];
1900        let a =
1901            SparseColMat::try_new_from_triplets(2, 2, &triplets).map_err(|e| format!("{e:?}"))?;
1902        let b = Mat::from_fn(2, 1, |i, _| (i + 1) as f64); // [1; 2]
1903
1904        let x = solver.solve_with_pcg(&a, &b)?;
1905        // Expected: x = [1/2; 2/3]
1906        assert!((x[(0, 0)] - 0.5).abs() < 1e-6, "x[0] = {}", x[(0, 0)]);
1907        assert!((x[(1, 0)] - 2.0 / 3.0).abs() < 1e-6, "x[1] = {}", x[(1, 0)]);
1908        Ok(())
1909    }
1910
1911    /// Test initialize_structure returns Err when only landmark variables are present (no cameras).
1912    #[test]
1913    fn test_initialize_structure_no_cameras_returns_error() {
1914        use crate::core::variable::Variable;
1915        use apex_manifolds::rn;
1916        use nalgebra::DVector;
1917
1918        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1919        let k = variables.insert(Box::new(Variable::new(rn::Rn::new(DVector::zeros(3)))));
1920        let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
1921        variable_index_map.insert(k, 0);
1922        let mut landmark_keys = std::collections::HashSet::new();
1923        landmark_keys.insert(k);
1924
1925        let mut solver = SparseSchurComplementSolver::new();
1926        let result = solver.initialize_structure(&variables, &variable_index_map, &landmark_keys);
1927        assert!(
1928            result.is_err(),
1929            "Expected Err when no camera variables present"
1930        );
1931    }
1932
1933    /// Test initialize_structure returns Err when only camera variables are present (no landmarks).
1934    #[test]
1935    fn test_initialize_structure_no_landmarks_returns_error() {
1936        use crate::core::variable::Variable;
1937        use apex_manifolds::se3;
1938
1939        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1940        let _k = variables.insert(Box::new(Variable::new(se3::SE3::from_param_slice(&[
1941            1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
1942        ]))));
1943        let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
1944        variable_index_map.insert(_k, 0);
1945        let landmark_keys = std::collections::HashSet::<VarKey>::new(); // no landmarks
1946
1947        let mut solver = SparseSchurComplementSolver::new();
1948        let result = solver.initialize_structure(&variables, &variable_index_map, &landmark_keys);
1949        assert!(
1950            result.is_err(),
1951            "Expected Err when no landmark variables present"
1952        );
1953    }
1954}