apex_solver/linalg/
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::{SchurSolverAdapter, SchurVariant, SchurPreconditioner};
27//! # use std::collections::HashMap;
28//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
29//! # let variables = HashMap::new();
30//! # let variable_index_map = HashMap::new();
31//! use apex_solver::linalg::{SchurSolverAdapter, SchurVariant};
32//!
33//! let mut solver = SchurSolverAdapter::new_with_structure_and_config(
34//!     &variables,
35//!     &variable_index_map,
36//!     SchurVariant::Sparse, // Explicit Schur with Cholesky
37//!     SchurPreconditioner::None,
38//! )?;
39//! # Ok(())
40//! # }
41//! ```
42
43use crate::core::problem::VariableEnum;
44use crate::linalg::{
45    LinAlgError, LinAlgResult, StructuredSparseLinearSolver, implicit_schur::IterativeSchurSolver,
46};
47use crate::manifold::ManifoldType;
48use faer::sparse::{SparseColMat, Triplet};
49use faer::{
50    Mat, Side,
51    linalg::solvers::Solve,
52    sparse::linalg::solvers::{Llt, SymbolicLlt},
53};
54use nalgebra::Matrix3;
55use std::collections::HashMap;
56use tracing::{debug, info};
57
58/// Schur complement solver variant
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum SchurVariant {
61    /// Standard: Direct sparse Cholesky factorization of S
62    #[default]
63    Sparse,
64    /// Iterative: Conjugate Gradients on reduced system
65    Iterative,
66}
67
68/// Preconditioner type for iterative solvers
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum SchurPreconditioner {
71    /// No preconditioning
72    None,
73    /// Block diagonal of H_cc only (fast but less effective)
74    BlockDiagonal,
75    /// True Schur-Jacobi: Block diagonal of S = H_cc - H_cp * H_pp^{-1} * H_cp^T
76    /// This is what Ceres uses and provides much better PCG convergence
77    #[default]
78    SchurJacobi,
79}
80
81/// Configuration for Schur complement variable ordering
82#[derive(Debug, Clone)]
83pub struct SchurOrdering {
84    pub eliminate_types: Vec<ManifoldType>,
85    /// Only eliminate RN variables with this exact size (default: 3 for 3D landmarks)
86    /// This prevents intrinsic variables (6 DOF) from being eliminated
87    pub eliminate_rn_size: Option<usize>,
88}
89
90impl Default for SchurOrdering {
91    fn default() -> Self {
92        Self {
93            eliminate_types: vec![ManifoldType::RN],
94            eliminate_rn_size: Some(3), // Only eliminate 3D landmarks, not intrinsics
95        }
96    }
97}
98
99impl SchurOrdering {
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Check if a variable should be eliminated (treated as landmark).
105    ///
106    /// Uses variable name pattern matching for robust classification:
107    /// - Variables starting with "pt_" are landmarks (must be RN with 3 DOF)
108    /// - All other variables are camera parameters (poses, intrinsics)
109    ///
110    /// This correctly handles shared intrinsics (single RN variable for all cameras)
111    /// without misclassifying them as landmarks.
112    pub fn should_eliminate(&self, name: &str, manifold_type: &ManifoldType, size: usize) -> bool {
113        // Use explicit name pattern matching
114        if name.starts_with("pt_") {
115            // This is a landmark - verify constraints
116            if !self.eliminate_types.contains(manifold_type) {
117                panic!(
118                    "Landmark {} has manifold type {:?}, expected RN",
119                    name, manifold_type
120                );
121            }
122
123            // Check size constraint if specified
124            if self
125                .eliminate_rn_size
126                .is_some_and(|required_size| size != required_size)
127            {
128                panic!(
129                    "Landmark {} has {} DOF, expected {}",
130                    name,
131                    size,
132                    self.eliminate_rn_size.unwrap_or(0)
133                );
134            }
135            true
136        } else {
137            // Camera parameter (pose, intrinsic, etc.) - keep in camera block
138            false
139        }
140    }
141}
142
143/// Block structure for Schur complement solver
144#[derive(Debug, Clone)]
145pub struct SchurBlockStructure {
146    pub camera_blocks: Vec<(String, usize, usize)>,
147    pub landmark_blocks: Vec<(String, usize, usize)>,
148    pub camera_dof: usize,
149    pub landmark_dof: usize,
150    pub num_landmarks: usize,
151}
152
153impl SchurBlockStructure {
154    pub fn new() -> Self {
155        Self {
156            camera_blocks: Vec::new(),
157            landmark_blocks: Vec::new(),
158            camera_dof: 0,
159            landmark_dof: 0,
160            num_landmarks: 0,
161        }
162    }
163
164    pub fn camera_col_range(&self) -> (usize, usize) {
165        if self.camera_blocks.is_empty() {
166            (0, 0)
167        } else {
168            // Safe: we just checked is_empty() is false
169            let start = self.camera_blocks.first().map(|b| b.1).unwrap_or(0);
170            (start, start + self.camera_dof)
171        }
172    }
173
174    pub fn landmark_col_range(&self) -> (usize, usize) {
175        if self.landmark_blocks.is_empty() {
176            (0, 0)
177        } else {
178            // Safe: we just checked is_empty() is false
179            let start = self.landmark_blocks.first().map(|b| b.1).unwrap_or(0);
180            (start, start + self.landmark_dof)
181        }
182    }
183}
184
185impl Default for SchurBlockStructure {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191/// Sparse Schur Complement Solver for Bundle Adjustment
192#[derive(Debug, Clone)]
193pub struct SparseSchurComplementSolver {
194    block_structure: Option<SchurBlockStructure>,
195    ordering: SchurOrdering,
196    variant: SchurVariant,
197    preconditioner: SchurPreconditioner,
198
199    // CG parameters
200    cg_max_iterations: usize,
201    cg_tolerance: f64,
202
203    // Cached matrices
204    hessian: Option<SparseColMat<usize, f64>>,
205    gradient: Option<Mat<f64>>,
206
207    // Delegate solver for iterative variant
208    iterative_solver: Option<IterativeSchurSolver>,
209}
210
211impl SparseSchurComplementSolver {
212    pub fn new() -> Self {
213        Self {
214            block_structure: None,
215            ordering: SchurOrdering::default(),
216            variant: SchurVariant::default(),
217            preconditioner: SchurPreconditioner::default(),
218            cg_max_iterations: 200, // Match Ceres (was 500)
219            cg_tolerance: 1e-6,     // Relaxed for speed (was 1e-9)
220            hessian: None,
221            gradient: None,
222            iterative_solver: None,
223        }
224    }
225
226    pub fn with_ordering(mut self, ordering: SchurOrdering) -> Self {
227        self.ordering = ordering;
228        self
229    }
230
231    pub fn with_variant(mut self, variant: SchurVariant) -> Self {
232        self.variant = variant;
233        self
234    }
235
236    pub fn with_preconditioner(mut self, preconditioner: SchurPreconditioner) -> Self {
237        self.preconditioner = preconditioner;
238        self
239    }
240
241    pub fn with_cg_params(mut self, max_iter: usize, tol: f64) -> Self {
242        self.cg_max_iterations = max_iter;
243        self.cg_tolerance = tol;
244        self
245    }
246
247    pub fn block_structure(&self) -> Option<&SchurBlockStructure> {
248        self.block_structure.as_ref()
249    }
250
251    fn build_block_structure(
252        &mut self,
253        variables: &HashMap<String, VariableEnum>,
254        variable_index_map: &HashMap<String, usize>,
255    ) -> LinAlgResult<()> {
256        let mut structure = SchurBlockStructure::new();
257
258        for (name, variable) in variables {
259            let start_col = *variable_index_map.get(name).ok_or_else(|| {
260                LinAlgError::InvalidInput(format!("Variable {} not found in index map", name))
261            })?;
262            let size = variable.get_size();
263            let manifold_type = match variable {
264                VariableEnum::SE3(_) => ManifoldType::SE3,
265                VariableEnum::SE2(_) => ManifoldType::SE2,
266                VariableEnum::SO3(_) => ManifoldType::SO3,
267                VariableEnum::SO2(_) => ManifoldType::SO2,
268                VariableEnum::Rn(_) => ManifoldType::RN,
269            };
270
271            // Use name-based classification via SchurOrdering
272            if self.ordering.should_eliminate(name, &manifold_type, size) {
273                // Landmark - to be eliminated
274                structure
275                    .landmark_blocks
276                    .push((name.clone(), start_col, size));
277                structure.landmark_dof += size;
278                structure.num_landmarks += 1;
279            } else {
280                // Camera parameter - kept in reduced system
281                structure
282                    .camera_blocks
283                    .push((name.clone(), start_col, size));
284                structure.camera_dof += size;
285            }
286        }
287
288        structure.camera_blocks.sort_by_key(|(_, col, _)| *col);
289        structure.landmark_blocks.sort_by_key(|(_, col, _)| *col);
290
291        if structure.camera_blocks.is_empty() {
292            return Err(LinAlgError::InvalidInput(
293                "No camera variables found".to_string(),
294            ));
295        }
296        if structure.landmark_blocks.is_empty() {
297            return Err(LinAlgError::InvalidInput(
298                "No landmark variables found".to_string(),
299            ));
300        }
301
302        // Log block structure for diagnostics
303        info!("Schur complement block structure:");
304        info!(
305            "  Camera blocks: {} variables, {} total DOF",
306            structure.camera_blocks.len(),
307            structure.camera_dof
308        );
309        info!(
310            "  Landmark blocks: {} variables, {} total DOF",
311            structure.landmark_blocks.len(),
312            structure.landmark_dof
313        );
314        debug!("  Camera column range: {:?}", structure.camera_col_range());
315        debug!(
316            "  Landmark column range: {:?}",
317            structure.landmark_col_range()
318        );
319        info!(
320            "  Schur complement S size: {} × {}",
321            structure.camera_dof, structure.camera_dof
322        );
323
324        // Validate column ranges are contiguous
325        let (_cam_start, cam_end) = structure.camera_col_range();
326        let (land_start, _land_end) = structure.landmark_col_range();
327        if cam_end != land_start {
328            debug!(
329                "WARNING: Gap between camera and landmark blocks! cam_end={}, land_start={}",
330                cam_end, land_start
331            );
332        }
333
334        self.block_structure = Some(structure);
335        Ok(())
336    }
337
338    /// Extract 3×3 diagonal blocks from H_pp
339    fn extract_landmark_blocks(
340        &self,
341        hessian: &SparseColMat<usize, f64>,
342    ) -> LinAlgResult<Vec<Matrix3<f64>>> {
343        let structure = self
344            .block_structure
345            .as_ref()
346            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not built".to_string()))?;
347
348        let mut blocks = Vec::with_capacity(structure.num_landmarks);
349        let symbolic = hessian.symbolic();
350
351        for (_, start_col, _) in &structure.landmark_blocks {
352            let mut block = Matrix3::<f64>::zeros();
353
354            for local_col in 0..3 {
355                let global_col = start_col + local_col;
356                let row_indices = symbolic.row_idx_of_col_raw(global_col);
357                let col_values = hessian.val_of_col(global_col);
358
359                for (idx, &row) in row_indices.iter().enumerate() {
360                    if row >= *start_col && row < start_col + 3 {
361                        let local_row = row - start_col;
362                        block[(local_row, local_col)] = col_values[idx];
363                    }
364                }
365            }
366
367            blocks.push(block);
368        }
369
370        Ok(blocks)
371    }
372
373    /// Invert all 3×3 blocks with numerical robustness
374    ///
375    /// This function checks the condition number of each block and applies
376    /// additional regularization for ill-conditioned blocks to prevent
377    /// numerical instability in the Schur complement computation.
378    fn invert_landmark_blocks(blocks: &[Matrix3<f64>]) -> LinAlgResult<Vec<Matrix3<f64>>> {
379        Self::invert_landmark_blocks_with_lambda(blocks, 0.0)
380    }
381
382    /// Invert all 3×3 blocks with numerical robustness and optional damping
383    ///
384    /// # Arguments
385    /// * `blocks` - The 3×3 H_pp diagonal blocks to invert
386    /// * `lambda` - LM damping parameter (already added to blocks if > 0)
387    ///
388    /// For severely ill-conditioned blocks, additional regularization is applied
389    /// to ensure numerical stability.
390    fn invert_landmark_blocks_with_lambda(
391        blocks: &[Matrix3<f64>],
392        lambda: f64,
393    ) -> LinAlgResult<Vec<Matrix3<f64>>> {
394        // Thresholds for numerical robustness
395        const CONDITION_THRESHOLD: f64 = 1e10; // Max acceptable condition number
396        const MIN_EIGENVALUE_THRESHOLD: f64 = 1e-12; // Below this is considered singular
397        const REGULARIZATION_SCALE: f64 = 1e-6; // Scale for additional regularization
398
399        let mut ill_conditioned_count = 0;
400        let mut regularized_count = 0;
401
402        let result: LinAlgResult<Vec<Matrix3<f64>>> = blocks
403            .iter()
404            .enumerate()
405            .map(|(i, block)| {
406                // Compute symmetric eigenvalues for condition number check
407                // For a 3x3 SPD matrix, eigenvalues give us the condition number
408                let eigenvalues = block.symmetric_eigenvalues();
409                let min_ev = eigenvalues.min();
410                let max_ev = eigenvalues.max();
411
412                if min_ev < MIN_EIGENVALUE_THRESHOLD {
413                    // Severely ill-conditioned: add strong regularization
414                    regularized_count += 1;
415                    let reg = lambda.max(REGULARIZATION_SCALE) + max_ev * REGULARIZATION_SCALE;
416                    let regularized = block + Matrix3::identity() * reg;
417                    regularized.try_inverse().ok_or_else(|| {
418                        LinAlgError::SingularMatrix(format!(
419                            "Landmark block {} singular even with regularization (min_ev={:.2e})",
420                            i, min_ev
421                        ))
422                    })
423                } else if max_ev / min_ev > CONDITION_THRESHOLD {
424                    // Ill-conditioned but not singular: add moderate regularization
425                    ill_conditioned_count += 1;
426                    let extra_reg = max_ev * REGULARIZATION_SCALE;
427                    let regularized = block + Matrix3::identity() * extra_reg;
428                    regularized.try_inverse().ok_or_else(|| {
429                        LinAlgError::SingularMatrix(format!(
430                            "Landmark block {} ill-conditioned (cond={:.2e})",
431                            i,
432                            max_ev / min_ev
433                        ))
434                    })
435                } else {
436                    // Well-conditioned: standard inversion
437                    block.try_inverse().ok_or_else(|| {
438                        LinAlgError::SingularMatrix(format!("Landmark block {} is singular", i))
439                    })
440                }
441            })
442            .collect();
443
444        // Log statistics about conditioning
445        if ill_conditioned_count > 0 || regularized_count > 0 {
446            debug!(
447                "Landmark block conditioning: {} ill-conditioned, {} regularized out of {}",
448                ill_conditioned_count,
449                regularized_count,
450                blocks.len()
451            );
452        }
453
454        result
455    }
456
457    /// Extract H_cc (camera-camera block)
458    fn extract_camera_block(
459        &self,
460        hessian: &SparseColMat<usize, f64>,
461    ) -> LinAlgResult<SparseColMat<usize, f64>> {
462        let structure = self
463            .block_structure
464            .as_ref()
465            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not built".to_string()))?;
466
467        let (cam_start, cam_end) = structure.camera_col_range();
468        let cam_size = structure.camera_dof;
469        let symbolic = hessian.symbolic();
470
471        let mut triplets = Vec::new();
472
473        for global_col in cam_start..cam_end {
474            let local_col = global_col - cam_start;
475            let row_indices = symbolic.row_idx_of_col_raw(global_col);
476            let col_values = hessian.val_of_col(global_col);
477
478            for (idx, &global_row) in row_indices.iter().enumerate() {
479                if global_row >= cam_start && global_row < cam_end {
480                    let local_row = global_row - cam_start;
481                    triplets.push(Triplet::new(local_row, local_col, col_values[idx]));
482                }
483            }
484        }
485
486        SparseColMat::try_new_from_triplets(cam_size, cam_size, &triplets)
487            .map_err(|e| LinAlgError::SparseMatrixCreation(format!("H_cc: {:?}", e)))
488    }
489
490    /// Extract H_cp (camera-point coupling)
491    fn extract_coupling_block(
492        &self,
493        hessian: &SparseColMat<usize, f64>,
494    ) -> LinAlgResult<SparseColMat<usize, f64>> {
495        let structure = self
496            .block_structure
497            .as_ref()
498            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not built".to_string()))?;
499
500        let (cam_start, cam_end) = structure.camera_col_range();
501        let (land_start, land_end) = structure.landmark_col_range();
502        let cam_size = structure.camera_dof;
503        let land_size = structure.landmark_dof;
504        let symbolic = hessian.symbolic();
505
506        let mut triplets = Vec::new();
507
508        for global_col in land_start..land_end {
509            let local_col = global_col - land_start;
510            let row_indices = symbolic.row_idx_of_col_raw(global_col);
511            let col_values = hessian.val_of_col(global_col);
512
513            for (idx, &global_row) in row_indices.iter().enumerate() {
514                if global_row >= cam_start && global_row < cam_end {
515                    let local_row = global_row - cam_start;
516                    triplets.push(Triplet::new(local_row, local_col, col_values[idx]));
517                }
518            }
519        }
520
521        SparseColMat::try_new_from_triplets(cam_size, land_size, &triplets)
522            .map_err(|e| LinAlgError::SparseMatrixCreation(format!("H_cp: {:?}", e)))
523    }
524
525    /// Extract gradient blocks
526    fn extract_gradient_blocks(&self, gradient: &Mat<f64>) -> LinAlgResult<(Mat<f64>, Mat<f64>)> {
527        let structure = self
528            .block_structure
529            .as_ref()
530            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not built".to_string()))?;
531
532        let (cam_start, cam_end) = structure.camera_col_range();
533        let (land_start, land_end) = structure.landmark_col_range();
534
535        let mut g_c = Mat::zeros(structure.camera_dof, 1);
536        for i in 0..(cam_end - cam_start) {
537            g_c[(i, 0)] = gradient[(cam_start + i, 0)];
538        }
539
540        let mut g_p = Mat::zeros(structure.landmark_dof, 1);
541        for i in 0..(land_end - land_start) {
542            g_p[(i, 0)] = gradient[(land_start + i, 0)];
543        }
544
545        Ok((g_c, g_p))
546    }
547
548    /// Solve S * x = b using Cholesky factorization with automatic regularization
549    ///
550    /// If the initial factorization fails (matrix not positive definite),
551    /// we add small regularization to the diagonal and retry.
552    fn solve_with_cholesky(
553        &self,
554        a: &SparseColMat<usize, f64>,
555        b: &Mat<f64>,
556    ) -> LinAlgResult<Mat<f64>> {
557        let sym = SymbolicLlt::try_new(a.symbolic(), Side::Lower).map_err(|e| {
558            LinAlgError::FactorizationFailed(format!("Symbolic Cholesky failed: {:?}", e))
559        })?;
560
561        // First attempt: direct factorization
562        match Llt::try_new_with_symbolic(sym.clone(), a.as_ref(), Side::Lower) {
563            Ok(cholesky) => return Ok(cholesky.solve(b)),
564            Err(e) => {
565                debug!(
566                    "Cholesky factorization failed: {:?}. Applying regularization.",
567                    e
568                );
569            }
570        }
571
572        // Retry with exponentially increasing regularization
573        let n = a.nrows();
574        let symbolic = a.symbolic();
575
576        // Compute trace and max diagonal for scaling
577        let mut trace = 0.0;
578        let mut max_diag = 0.0f64;
579        for col in 0..n {
580            let row_indices = symbolic.row_idx_of_col_raw(col);
581            let col_values = a.val_of_col(col);
582            for (idx, &row) in row_indices.iter().enumerate() {
583                if row == col {
584                    trace += col_values[idx];
585                    max_diag = max_diag.max(col_values[idx].abs());
586                }
587            }
588        }
589
590        // Try multiple regularization levels
591        let avg_diag = trace / n as f64;
592        let base_reg = avg_diag.max(max_diag).max(1.0);
593
594        for attempt in 0..5 {
595            let reg = base_reg * 10.0f64.powi(attempt - 4); // 1e-4, 1e-3, 1e-2, 1e-1, 1.0 times base
596            debug!(
597                "Cholesky attempt {}: regularization = {:.2e}",
598                attempt + 2,
599                reg
600            );
601
602            let mut triplets = Vec::with_capacity(n * 10);
603            for col in 0..n {
604                let row_indices = symbolic.row_idx_of_col_raw(col);
605                let col_values = a.val_of_col(col);
606                for (idx, &row) in row_indices.iter().enumerate() {
607                    triplets.push(Triplet::new(row, col, col_values[idx]));
608                }
609            }
610
611            for i in 0..n {
612                triplets.push(Triplet::new(i, i, reg));
613            }
614
615            let a_reg = match SparseColMat::try_new_from_triplets(n, n, &triplets) {
616                Ok(m) => m,
617                Err(e) => {
618                    debug!("Failed to create regularized matrix: {:?}", e);
619                    continue;
620                }
621            };
622
623            // Need to create a new symbolic structure for the regularized matrix
624            let sym_reg = match SymbolicLlt::try_new(a_reg.symbolic(), Side::Lower) {
625                Ok(s) => s,
626                Err(e) => {
627                    debug!("Symbolic factorization failed: {:?}", e);
628                    continue;
629                }
630            };
631
632            match Llt::try_new_with_symbolic(sym_reg, a_reg.as_ref(), Side::Lower) {
633                Ok(cholesky) => {
634                    debug!("Cholesky succeeded with regularization {:.2e}", reg);
635                    return Ok(cholesky.solve(b));
636                }
637                Err(e) => {
638                    debug!("Cholesky failed with reg {:.2e}: {:?}", reg, e);
639                }
640            }
641        }
642
643        Err(LinAlgError::SingularMatrix(format!(
644            "Schur complement singular after 5 regularization attempts (max reg = {:.2e})",
645            base_reg
646        )))
647    }
648
649    /// Solve using Preconditioned Conjugate Gradients (PCG)
650    ///
651    /// Uses Jacobi (diagonal) preconditioning for simplicity and robustness.
652    fn solve_with_pcg(&self, a: &SparseColMat<usize, f64>, b: &Mat<f64>) -> LinAlgResult<Mat<f64>> {
653        let n = b.nrows();
654        let max_iterations = self.cg_max_iterations;
655        let tolerance = self.cg_tolerance;
656
657        // Extract diagonal for Jacobi preconditioner
658        let symbolic = a.symbolic();
659        let mut precond = vec![1.0; n];
660        for (col, precond_val) in precond.iter_mut().enumerate().take(n) {
661            let row_indices = symbolic.row_idx_of_col_raw(col);
662            let col_values = a.val_of_col(col);
663            for (idx, &row) in row_indices.iter().enumerate() {
664                if row == col {
665                    let diag = col_values[idx];
666                    *precond_val = if diag.abs() > 1e-12 { 1.0 / diag } else { 1.0 };
667                    break;
668                }
669            }
670        }
671
672        // Initialize
673        let mut x = Mat::<f64>::zeros(n, 1);
674
675        // r = b - A*x (x starts at 0, so r = b)
676        let mut r = b.clone();
677
678        // z = M^{-1} * r (Jacobi preconditioning)
679        let mut z = Mat::<f64>::zeros(n, 1);
680        for i in 0..n {
681            z[(i, 0)] = precond[i] * r[(i, 0)];
682        }
683
684        let mut p = z.clone();
685
686        let mut rz_old = 0.0;
687        for i in 0..n {
688            rz_old += r[(i, 0)] * z[(i, 0)];
689        }
690
691        // Compute initial residual norm for relative tolerance
692        let mut r_norm_init = 0.0;
693        for i in 0..n {
694            r_norm_init += r[(i, 0)] * r[(i, 0)];
695        }
696        r_norm_init = r_norm_init.sqrt();
697        let abs_tol = tolerance * r_norm_init.max(1.0);
698
699        for _iter in 0..max_iterations {
700            // Ap = A * p (sparse matrix-vector product)
701            let mut ap = Mat::<f64>::zeros(n, 1);
702            for col in 0..n {
703                let row_indices = symbolic.row_idx_of_col_raw(col);
704                let col_values = a.val_of_col(col);
705                for (idx, &row) in row_indices.iter().enumerate() {
706                    ap[(row, 0)] += col_values[idx] * p[(col, 0)];
707                }
708            }
709
710            // alpha = (r^T z) / (p^T Ap)
711            let mut p_ap = 0.0;
712            for i in 0..n {
713                p_ap += p[(i, 0)] * ap[(i, 0)];
714            }
715
716            if p_ap.abs() < 1e-30 {
717                break;
718            }
719
720            let alpha = rz_old / p_ap;
721
722            // x = x + alpha * p
723            for i in 0..n {
724                x[(i, 0)] += alpha * p[(i, 0)];
725            }
726
727            // r = r - alpha * Ap
728            for i in 0..n {
729                r[(i, 0)] -= alpha * ap[(i, 0)];
730            }
731
732            // Check convergence
733            let mut r_norm = 0.0;
734            for i in 0..n {
735                r_norm += r[(i, 0)] * r[(i, 0)];
736            }
737            r_norm = r_norm.sqrt();
738
739            if r_norm < abs_tol {
740                break;
741            }
742
743            // z = M^{-1} * r
744            for i in 0..n {
745                z[(i, 0)] = precond[i] * r[(i, 0)];
746            }
747
748            // beta = (r_{k+1}^T z_{k+1}) / (r_k^T z_k)
749            let mut rz_new = 0.0;
750            for i in 0..n {
751                rz_new += r[(i, 0)] * z[(i, 0)];
752            }
753
754            if rz_old.abs() < 1e-30 {
755                break;
756            }
757
758            let beta = rz_new / rz_old;
759
760            // p = z + beta * p
761            for i in 0..n {
762                p[(i, 0)] = z[(i, 0)] + beta * p[(i, 0)];
763            }
764
765            rz_old = rz_new;
766        }
767
768        Ok(x)
769    }
770
771    /// Compute Schur complement: S = H_cc - H_cp * H_pp^{-1} * H_cp^T
772    ///
773    /// This is an efficient implementation that exploits:
774    /// 1. Block-diagonal structure of H_pp (each landmark is independent)
775    /// 2. Sparsity of H_cp (each landmark connects to only a few cameras)
776    /// 3. Dense accumulation for the small camera-camera matrix S
777    ///
778    /// Algorithm:
779    /// For each landmark block p:
780    ///   - Get the cameras that observe this landmark (non-zero rows in H_cp column block)
781    ///   - Compute contribution: H_cp[:, p] * H_pp[p,p]^{-1} * H_cp[:, p]^T
782    ///   - This is an outer product of sparse vectors, producing a small dense update
783    ///   - Accumulate into the dense result S
784    fn compute_schur_complement(
785        &self,
786        h_cc: &SparseColMat<usize, f64>,
787        h_cp: &SparseColMat<usize, f64>,
788        hpp_inv_blocks: &[Matrix3<f64>],
789    ) -> LinAlgResult<SparseColMat<usize, f64>> {
790        let cam_size = h_cc.nrows();
791        let h_cp_symbolic = h_cp.symbolic();
792
793        // Use a dense matrix for S since the Schur complement is typically dense
794        // For 89 cameras, this is only 89*89*8 = 63KB - very cache-friendly
795        let mut s_dense = vec![0.0f64; cam_size * cam_size];
796
797        // First, add H_cc to S
798        let h_cc_symbolic = h_cc.symbolic();
799        for col in 0..h_cc.ncols() {
800            let row_indices = h_cc_symbolic.row_idx_of_col_raw(col);
801            let col_values = h_cc.val_of_col(col);
802            for (idx, &row) in row_indices.iter().enumerate() {
803                s_dense[row * cam_size + col] += col_values[idx];
804            }
805        }
806
807        // Pre-allocate vectors for camera data per landmark
808        // Max cameras per landmark is bounded by number of cameras
809        let mut cam_rows: Vec<usize> = Vec::with_capacity(32);
810        let mut h_cp_block: Vec<[f64; 3]> = Vec::with_capacity(32);
811        let mut contrib_block: Vec<[f64; 3]> = Vec::with_capacity(32);
812
813        // Process each landmark block independently (sequential for efficiency)
814        for (block_idx, hpp_inv_block) in hpp_inv_blocks.iter().enumerate() {
815            let col_start = block_idx * 3;
816
817            cam_rows.clear();
818            h_cp_block.clear();
819
820            if col_start + 2 >= h_cp.ncols() {
821                continue;
822            }
823
824            let row_indices_0 = h_cp_symbolic.row_idx_of_col_raw(col_start);
825            let col_values_0 = h_cp.val_of_col(col_start);
826            let row_indices_1 = h_cp_symbolic.row_idx_of_col_raw(col_start + 1);
827            let col_values_1 = h_cp.val_of_col(col_start + 1);
828            let row_indices_2 = h_cp_symbolic.row_idx_of_col_raw(col_start + 2);
829            let col_values_2 = h_cp.val_of_col(col_start + 2);
830
831            let mut i0 = 0;
832            let mut i1 = 0;
833            let mut i2 = 0;
834
835            while i0 < row_indices_0.len() || i1 < row_indices_1.len() || i2 < row_indices_2.len() {
836                let r0 = if i0 < row_indices_0.len() {
837                    row_indices_0[i0]
838                } else {
839                    usize::MAX
840                };
841                let r1 = if i1 < row_indices_1.len() {
842                    row_indices_1[i1]
843                } else {
844                    usize::MAX
845                };
846                let r2 = if i2 < row_indices_2.len() {
847                    row_indices_2[i2]
848                } else {
849                    usize::MAX
850                };
851
852                let min_row = r0.min(r1).min(r2);
853                if min_row == usize::MAX {
854                    break;
855                }
856
857                let v0 = if r0 == min_row {
858                    i0 += 1;
859                    col_values_0[i0 - 1]
860                } else {
861                    0.0
862                };
863                let v1 = if r1 == min_row {
864                    i1 += 1;
865                    col_values_1[i1 - 1]
866                } else {
867                    0.0
868                };
869                let v2 = if r2 == min_row {
870                    i2 += 1;
871                    col_values_2[i2 - 1]
872                } else {
873                    0.0
874                };
875
876                cam_rows.push(min_row);
877                h_cp_block.push([v0, v1, v2]);
878            }
879
880            if cam_rows.is_empty() {
881                continue;
882            }
883
884            contrib_block.clear();
885            for h_cp_row in &h_cp_block {
886                let c0 = h_cp_row[0] * hpp_inv_block[(0, 0)]
887                    + h_cp_row[1] * hpp_inv_block[(1, 0)]
888                    + h_cp_row[2] * hpp_inv_block[(2, 0)];
889                let c1 = h_cp_row[0] * hpp_inv_block[(0, 1)]
890                    + h_cp_row[1] * hpp_inv_block[(1, 1)]
891                    + h_cp_row[2] * hpp_inv_block[(2, 1)];
892                let c2 = h_cp_row[0] * hpp_inv_block[(0, 2)]
893                    + h_cp_row[1] * hpp_inv_block[(1, 2)]
894                    + h_cp_row[2] * hpp_inv_block[(2, 2)];
895                contrib_block.push([c0, c1, c2]);
896            }
897
898            let n_cams = cam_rows.len();
899            for i in 0..n_cams {
900                let cam_i = cam_rows[i];
901                let contrib_i = &contrib_block[i];
902                for j in 0..n_cams {
903                    let cam_j = cam_rows[j];
904                    let h_cp_j = &h_cp_block[j];
905                    let dot = contrib_i[0] * h_cp_j[0]
906                        + contrib_i[1] * h_cp_j[1]
907                        + contrib_i[2] * h_cp_j[2];
908                    s_dense[cam_i * cam_size + cam_j] -= dot;
909                }
910            }
911        }
912
913        // Symmetrize the Schur complement to ensure numerical symmetry
914        // Due to floating-point accumulation errors across 156K+ landmarks,
915        // S can become slightly asymmetric. Force symmetry: S = 0.5 * (S + S^T)
916        for i in 0..cam_size {
917            for j in (i + 1)..cam_size {
918                let avg = (s_dense[i * cam_size + j] + s_dense[j * cam_size + i]) * 0.5;
919                s_dense[i * cam_size + j] = avg;
920                s_dense[j * cam_size + i] = avg;
921            }
922        }
923
924        // Convert dense matrix to sparse (filtering near-zeros)
925        // Use slightly larger threshold to avoid numerical noise issues
926        let mut s_triplets: Vec<Triplet<usize, usize, f64>> = Vec::new();
927        for col in 0..cam_size {
928            for row in 0..cam_size {
929                let val = s_dense[row * cam_size + col];
930                if val.abs() > 1e-12 {
931                    s_triplets.push(Triplet::new(row, col, val));
932                }
933            }
934        }
935
936        SparseColMat::try_new_from_triplets(cam_size, cam_size, &s_triplets)
937            .map_err(|e| LinAlgError::SparseMatrixCreation(format!("Schur S: {:?}", e)))
938    }
939
940    /// Compute reduced gradient: g_reduced = g_c - H_cp * H_pp^{-1} * g_p
941    fn compute_reduced_gradient(
942        &self,
943        g_c: &Mat<f64>,
944        g_p: &Mat<f64>,
945        h_cp: &SparseColMat<usize, f64>,
946        hpp_inv_blocks: &[Matrix3<f64>],
947    ) -> LinAlgResult<Mat<f64>> {
948        // Infer sizes from matrices
949        let land_size = g_p.nrows();
950        let cam_size = g_c.nrows();
951
952        // Compute H_pp^{-1} * g_p block-wise
953        let mut hpp_inv_gp = Mat::zeros(land_size, 1);
954
955        for (block_idx, hpp_inv_block) in hpp_inv_blocks.iter().enumerate() {
956            let row_start = block_idx * 3;
957
958            let gp_block = nalgebra::Vector3::new(
959                g_p[(row_start, 0)],
960                g_p[(row_start + 1, 0)],
961                g_p[(row_start + 2, 0)],
962            );
963
964            let result = hpp_inv_block * gp_block;
965            hpp_inv_gp[(row_start, 0)] = result[0];
966            hpp_inv_gp[(row_start + 1, 0)] = result[1];
967            hpp_inv_gp[(row_start + 2, 0)] = result[2];
968        }
969
970        // Compute H_cp * (H_pp^{-1} * g_p)
971        let mut h_cp_hpp_inv_gp = Mat::<f64>::zeros(cam_size, 1);
972        let symbolic = h_cp.symbolic();
973
974        for col in 0..h_cp.ncols() {
975            let row_indices = symbolic.row_idx_of_col_raw(col);
976            let col_values = h_cp.val_of_col(col);
977
978            for (idx, &row) in row_indices.iter().enumerate() {
979                h_cp_hpp_inv_gp[(row, 0)] += col_values[idx] * hpp_inv_gp[(col, 0)];
980            }
981        }
982
983        // g_reduced = g_c - H_cp * H_pp^{-1} * g_p
984        let mut g_reduced = Mat::zeros(cam_size, 1);
985        for i in 0..cam_size {
986            g_reduced[(i, 0)] = g_c[(i, 0)] - h_cp_hpp_inv_gp[(i, 0)];
987        }
988
989        Ok(g_reduced)
990    }
991
992    /// Back-substitute: δp = H_pp^{-1} * (g_p - H_cp^T * δc)
993    fn back_substitute(
994        &self,
995        delta_c: &Mat<f64>,
996        g_p: &Mat<f64>,
997        h_cp: &SparseColMat<usize, f64>,
998        hpp_inv_blocks: &[Matrix3<f64>],
999    ) -> LinAlgResult<Mat<f64>> {
1000        // Infer size from matrix
1001
1002        let land_size = g_p.nrows();
1003
1004        // Compute H_cp^T * δc
1005        let mut h_cp_t_delta_c = Mat::<f64>::zeros(land_size, 1);
1006        let symbolic = h_cp.symbolic();
1007
1008        for col in 0..h_cp.ncols() {
1009            let row_indices = symbolic.row_idx_of_col_raw(col);
1010            let col_values = h_cp.val_of_col(col);
1011
1012            for (idx, &row) in row_indices.iter().enumerate() {
1013                h_cp_t_delta_c[(col, 0)] += col_values[idx] * delta_c[(row, 0)];
1014            }
1015        }
1016
1017        // Compute rhs = g_p - H_cp^T * δc
1018        let mut rhs = Mat::zeros(land_size, 1);
1019        for i in 0..land_size {
1020            rhs[(i, 0)] = g_p[(i, 0)] - h_cp_t_delta_c[(i, 0)];
1021        }
1022
1023        // Compute δp = H_pp^{-1} * rhs block-wise
1024        let mut delta_p = Mat::zeros(land_size, 1);
1025
1026        for (block_idx, hpp_inv_block) in hpp_inv_blocks.iter().enumerate() {
1027            let row_start = block_idx * 3;
1028
1029            let rhs_block = nalgebra::Vector3::new(
1030                rhs[(row_start, 0)],
1031                rhs[(row_start + 1, 0)],
1032                rhs[(row_start + 2, 0)],
1033            );
1034
1035            let result = hpp_inv_block * rhs_block;
1036            delta_p[(row_start, 0)] = result[0];
1037            delta_p[(row_start + 1, 0)] = result[1];
1038            delta_p[(row_start + 2, 0)] = result[2];
1039        }
1040
1041        Ok(delta_p)
1042    }
1043}
1044
1045impl Default for SparseSchurComplementSolver {
1046    fn default() -> Self {
1047        Self::new()
1048    }
1049}
1050
1051impl StructuredSparseLinearSolver for SparseSchurComplementSolver {
1052    fn initialize_structure(
1053        &mut self,
1054        variables: &HashMap<String, VariableEnum>,
1055        variable_index_map: &HashMap<String, usize>,
1056    ) -> LinAlgResult<()> {
1057        // Build block structure for all variants
1058        self.build_block_structure(variables, variable_index_map)?;
1059
1060        // Initialize delegate solver based on variant
1061        match self.variant {
1062            SchurVariant::Iterative => {
1063                let mut solver =
1064                    IterativeSchurSolver::with_cg_params(self.cg_max_iterations, self.cg_tolerance);
1065                solver.initialize_structure(variables, variable_index_map)?;
1066                self.iterative_solver = Some(solver);
1067            }
1068            SchurVariant::Sparse => {
1069                // No delegate solver needed for sparse variant
1070            }
1071        }
1072
1073        Ok(())
1074    }
1075
1076    fn solve_normal_equation(
1077        &mut self,
1078        residuals: &Mat<f64>,
1079        jacobians: &SparseColMat<usize, f64>,
1080    ) -> LinAlgResult<Mat<f64>> {
1081        use std::ops::Mul;
1082
1083        if self.block_structure.is_none() {
1084            return Err(LinAlgError::InvalidInput(
1085                "Block structure not built. Call initialize_structure() first.".to_string(),
1086            ));
1087        }
1088
1089        // Sparse and Iterative variants use the same Schur complement formation
1090        // They differ only in how S*δc = g_reduced is solved:
1091        // - Sparse: Cholesky factorization
1092        // - Iterative: PCG
1093
1094        // 1. Build H = J^T * J and g = -J^T * r
1095        let jt = jacobians
1096            .transpose()
1097            .to_col_major()
1098            .map_err(|e| LinAlgError::MatrixConversion(format!("Transpose failed: {:?}", e)))?;
1099        let hessian = jt.mul(jacobians);
1100        let gradient = jacobians.transpose().mul(residuals);
1101        let mut neg_gradient = Mat::zeros(gradient.nrows(), 1);
1102        for i in 0..gradient.nrows() {
1103            neg_gradient[(i, 0)] = -gradient[(i, 0)];
1104        }
1105
1106        self.hessian = Some(hessian.clone());
1107        // Store the positive gradient (J^T * r) for predicted reduction calculation
1108        // The Schur solver internally uses neg_gradient (-J^T * r) for the solve
1109        self.gradient = Some(gradient.clone());
1110
1111        // 2. Extract blocks
1112        let h_cc = self.extract_camera_block(&hessian)?;
1113        let h_cp = self.extract_coupling_block(&hessian)?;
1114        let hpp_blocks = self.extract_landmark_blocks(&hessian)?;
1115        let (g_c, g_p) = self.extract_gradient_blocks(&neg_gradient)?;
1116
1117        // 3. Invert H_pp blocks
1118        let hpp_inv_blocks = Self::invert_landmark_blocks(&hpp_blocks)?;
1119
1120        // 4. Compute Schur complement S
1121        let s = self.compute_schur_complement(&h_cc, &h_cp, &hpp_inv_blocks)?;
1122
1123        // 5. Compute reduced gradient
1124        let g_reduced = self.compute_reduced_gradient(&g_c, &g_p, &h_cp, &hpp_inv_blocks)?;
1125
1126        // 6. Solve S * δc = g_reduced (Cholesky for Sparse, PCG for Iterative)
1127        let delta_c = match self.variant {
1128            SchurVariant::Iterative => self.solve_with_pcg(&s, &g_reduced)?,
1129            _ => self.solve_with_cholesky(&s, &g_reduced)?,
1130        };
1131
1132        // 7. Back-substitute for δp
1133        let delta_p = self.back_substitute(&delta_c, &g_p, &h_cp, &hpp_inv_blocks)?;
1134
1135        // 8. Combine results
1136        self.combine_updates(&delta_c, &delta_p)
1137    }
1138
1139    fn solve_augmented_equation(
1140        &mut self,
1141        residuals: &Mat<f64>,
1142        jacobians: &SparseColMat<usize, f64>,
1143        lambda: f64,
1144    ) -> LinAlgResult<Mat<f64>> {
1145        use std::ops::Mul;
1146
1147        if self.block_structure.is_none() {
1148            return Err(LinAlgError::InvalidInput(
1149                "Block structure not built. Call initialize_structure() first.".to_string(),
1150            ));
1151        }
1152
1153        // Sparse and Iterative variants use the same Schur complement formation with damping
1154        // 1. Build H = J^T * J and g = -J^T * r
1155        let jt = jacobians
1156            .transpose()
1157            .to_col_major()
1158            .map_err(|e| LinAlgError::MatrixConversion(format!("Transpose failed: {:?}", e)))?;
1159        let hessian = jt.mul(jacobians);
1160        let gradient = jacobians.transpose().mul(residuals);
1161        let mut neg_gradient = Mat::zeros(gradient.nrows(), 1);
1162        for i in 0..gradient.nrows() {
1163            neg_gradient[(i, 0)] = -gradient[(i, 0)];
1164        }
1165
1166        self.hessian = Some(hessian.clone());
1167        // Store the positive gradient (J^T * r) for predicted reduction calculation
1168        // The Schur solver internally uses neg_gradient (-J^T * r) for the solve
1169        self.gradient = Some(gradient.clone());
1170
1171        // 2. Extract blocks
1172        let h_cc = self.extract_camera_block(&hessian)?;
1173        let h_cp = self.extract_coupling_block(&hessian)?;
1174        let mut hpp_blocks = self.extract_landmark_blocks(&hessian)?;
1175        let (g_c, g_p) = self.extract_gradient_blocks(&neg_gradient)?;
1176
1177        // Log matrix dimensions for diagnostics
1178        debug!("Iteration matrices:");
1179        debug!(
1180            "  Hessian (J^T*J): {} × {}",
1181            hessian.nrows(),
1182            hessian.ncols()
1183        );
1184        debug!("  H_cc (camera): {} × {}", h_cc.nrows(), h_cc.ncols());
1185        debug!("  H_cp (coupling): {} × {}", h_cp.nrows(), h_cp.ncols());
1186        debug!("  H_pp blocks: {} (3×3 each)", hpp_blocks.len());
1187
1188        // 3. Add damping to H_cc and H_pp
1189        let structure = self
1190            .block_structure
1191            .as_ref()
1192            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not initialized".into()))?;
1193        let cam_size = structure.camera_dof;
1194
1195        // Add λI to H_cc
1196        let mut h_cc_triplets = Vec::new();
1197        let h_cc_symbolic = h_cc.symbolic();
1198        for col in 0..h_cc.ncols() {
1199            let row_indices = h_cc_symbolic.row_idx_of_col_raw(col);
1200            let col_values = h_cc.val_of_col(col);
1201            for (idx, &row) in row_indices.iter().enumerate() {
1202                h_cc_triplets.push(Triplet::new(row, col, col_values[idx]));
1203            }
1204        }
1205        for i in 0..cam_size {
1206            if let Some(entry) = h_cc_triplets.iter_mut().find(|t| t.row == i && t.col == i) {
1207                *entry = Triplet::new(i, i, entry.val + lambda);
1208            } else {
1209                h_cc_triplets.push(Triplet::new(i, i, lambda));
1210            }
1211        }
1212        let h_cc_damped =
1213            SparseColMat::try_new_from_triplets(cam_size, cam_size, &h_cc_triplets)
1214                .map_err(|e| LinAlgError::SparseMatrixCreation(format!("Damped H_cc: {:?}", e)))?;
1215
1216        // Add λI to H_pp blocks
1217        for block in &mut hpp_blocks {
1218            block[(0, 0)] += lambda;
1219            block[(1, 1)] += lambda;
1220            block[(2, 2)] += lambda;
1221        }
1222
1223        // 4. Invert damped H_pp blocks
1224        let hpp_inv_blocks = Self::invert_landmark_blocks(&hpp_blocks)?;
1225
1226        // 5. Compute Schur complement with damped matrices
1227        let s = self.compute_schur_complement(&h_cc_damped, &h_cp, &hpp_inv_blocks)?;
1228
1229        // 6. Compute reduced gradient
1230        let g_reduced = self.compute_reduced_gradient(&g_c, &g_p, &h_cp, &hpp_inv_blocks)?;
1231
1232        // 7. Solve S * δc = g_reduced (Cholesky for Sparse, PCG for Iterative)
1233        let delta_c = match self.variant {
1234            SchurVariant::Iterative => self.solve_with_pcg(&s, &g_reduced)?,
1235            _ => self.solve_with_cholesky(&s, &g_reduced)?,
1236        };
1237
1238        // 8. Back-substitute for δp
1239        let delta_p = self.back_substitute(&delta_c, &g_p, &h_cp, &hpp_inv_blocks)?;
1240
1241        // 9. Combine results
1242        self.combine_updates(&delta_c, &delta_p)
1243    }
1244
1245    fn get_hessian(&self) -> Option<&SparseColMat<usize, f64>> {
1246        self.hessian.as_ref()
1247    }
1248
1249    fn get_gradient(&self) -> Option<&Mat<f64>> {
1250        self.gradient.as_ref()
1251    }
1252}
1253
1254// Helper methods for SparseSchurComplementSolver
1255impl SparseSchurComplementSolver {
1256    /// Combine camera and landmark updates into full update vector
1257    fn combine_updates(&self, delta_c: &Mat<f64>, delta_p: &Mat<f64>) -> LinAlgResult<Mat<f64>> {
1258        let structure = self
1259            .block_structure
1260            .as_ref()
1261            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not built".to_string()))?;
1262
1263        let total_dof = structure.camera_dof + structure.landmark_dof;
1264        let mut delta = Mat::zeros(total_dof, 1);
1265
1266        let (cam_start, cam_end) = structure.camera_col_range();
1267        let (land_start, land_end) = structure.landmark_col_range();
1268
1269        // Copy camera updates
1270        for i in 0..(cam_end - cam_start) {
1271            delta[(cam_start + i, 0)] = delta_c[(i, 0)];
1272        }
1273
1274        // Copy landmark updates
1275        for i in 0..(land_end - land_start) {
1276            delta[(land_start + i, 0)] = delta_p[(i, 0)];
1277        }
1278
1279        // Debug: Log update magnitude
1280        debug!(
1281            "Update norms: delta_c={:.6e}, delta_p={:.6e}, combined={:.6e}",
1282            delta_c.norm_l2(),
1283            delta_p.norm_l2(),
1284            delta.norm_l2()
1285        );
1286
1287        Ok(delta)
1288    }
1289}
1290
1291/// Adapter to use SparseSchurComplementSolver as a standard SparseLinearSolver
1292///
1293/// This adapter bridges the gap between StructuredSparseLinearSolver (which requires
1294/// variable information) and the standard SparseLinearSolver trait used by optimizers.
1295pub struct SchurSolverAdapter {
1296    schur_solver: SparseSchurComplementSolver,
1297    initialized: bool,
1298}
1299
1300impl SchurSolverAdapter {
1301    /// Create a new Schur solver adapter with problem structure
1302    ///
1303    /// This initializes the internal Schur solver with variable partitioning information.
1304    ///
1305    /// # Arguments
1306    /// * `variables` - Map of variable names to their typed instances
1307    /// * `variable_index_map` - Map from variable names to starting column indices in Jacobian
1308    ///
1309    /// # Returns
1310    /// Initialized adapter ready to solve linear systems
1311    pub fn new_with_structure(
1312        variables: &std::collections::HashMap<String, crate::core::problem::VariableEnum>,
1313        variable_index_map: &std::collections::HashMap<String, usize>,
1314    ) -> LinAlgResult<Self> {
1315        let mut solver = SparseSchurComplementSolver::new();
1316        solver.initialize_structure(variables, variable_index_map)?;
1317        Ok(Self {
1318            schur_solver: solver,
1319            initialized: true,
1320        })
1321    }
1322
1323    /// Create with custom configuration
1324    pub fn new_with_structure_and_config(
1325        variables: &std::collections::HashMap<String, crate::core::problem::VariableEnum>,
1326        variable_index_map: &std::collections::HashMap<String, usize>,
1327        variant: SchurVariant,
1328        preconditioner: SchurPreconditioner,
1329    ) -> LinAlgResult<Self> {
1330        let mut solver = SparseSchurComplementSolver::new()
1331            .with_variant(variant)
1332            .with_preconditioner(preconditioner);
1333        solver.initialize_structure(variables, variable_index_map)?;
1334        Ok(Self {
1335            schur_solver: solver,
1336            initialized: true,
1337        })
1338    }
1339}
1340
1341impl crate::linalg::SparseLinearSolver for SchurSolverAdapter {
1342    fn solve_normal_equation(
1343        &mut self,
1344        residuals: &Mat<f64>,
1345        jacobian: &SparseColMat<usize, f64>,
1346    ) -> LinAlgResult<Mat<f64>> {
1347        if !self.initialized {
1348            return Err(LinAlgError::InvalidInput(
1349                "Schur solver adapter not initialized with structure".to_string(),
1350            ));
1351        }
1352        self.schur_solver.solve_normal_equation(residuals, jacobian)
1353    }
1354
1355    fn solve_augmented_equation(
1356        &mut self,
1357        residuals: &Mat<f64>,
1358        jacobian: &SparseColMat<usize, f64>,
1359        lambda: f64,
1360    ) -> LinAlgResult<Mat<f64>> {
1361        if !self.initialized {
1362            return Err(LinAlgError::InvalidInput(
1363                "Schur solver adapter not initialized with structure".to_string(),
1364            ));
1365        }
1366        self.schur_solver
1367            .solve_augmented_equation(residuals, jacobian, lambda)
1368    }
1369
1370    fn get_hessian(&self) -> Option<&SparseColMat<usize, f64>> {
1371        self.schur_solver.get_hessian()
1372    }
1373
1374    fn get_gradient(&self) -> Option<&Mat<f64>> {
1375        self.schur_solver.get_gradient()
1376    }
1377
1378    fn compute_covariance_matrix(&mut self) -> Option<&Mat<f64>> {
1379        // TODO: Implement covariance computation for Schur complement solver
1380        // This requires inverting the Schur complement S, which is non-trivial
1381        // For now, return None
1382        None
1383    }
1384
1385    fn get_covariance_matrix(&self) -> Option<&Mat<f64>> {
1386        // Not implemented yet
1387        None
1388    }
1389}
1390
1391#[cfg(test)]
1392#[allow(clippy::unwrap_used, clippy::expect_used)]
1393mod tests {
1394    use super::*;
1395
1396    #[test]
1397    fn test_schur_ordering_shared_intrinsics() {
1398        let ordering = SchurOrdering::default();
1399
1400        // Landmarks should be eliminated
1401        assert!(ordering.should_eliminate("pt_00000", &ManifoldType::RN, 3));
1402        assert!(ordering.should_eliminate("pt_12345", &ManifoldType::RN, 3));
1403
1404        // Camera poses should NOT be eliminated
1405        assert!(!ordering.should_eliminate("cam_0000", &ManifoldType::SE3, 6));
1406        assert!(!ordering.should_eliminate("cam_0042", &ManifoldType::SE3, 6));
1407
1408        // Shared intrinsics (RN, 3 DOF) should NOT be eliminated - KEY TEST!
1409        assert!(!ordering.should_eliminate("shared_intrinsics", &ManifoldType::RN, 3));
1410
1411        // Per-camera intrinsics should NOT be eliminated
1412        assert!(!ordering.should_eliminate("intr_0000", &ManifoldType::RN, 3));
1413        assert!(!ordering.should_eliminate("intr_0042", &ManifoldType::RN, 3));
1414    }
1415
1416    #[test]
1417    fn test_schur_ordering_multiple_intrinsic_groups() {
1418        let ordering = SchurOrdering::default();
1419
1420        // Test that multiple intrinsic groups are NOT eliminated (camera parameters)
1421        assert!(
1422            !ordering.should_eliminate("intr_group_0000", &ManifoldType::RN, 3),
1423            "Intrinsic group 0 should be camera parameter (not eliminated)"
1424        );
1425        assert!(
1426            !ordering.should_eliminate("intr_group_0001", &ManifoldType::RN, 3),
1427            "Intrinsic group 1 should be camera parameter (not eliminated)"
1428        );
1429        assert!(
1430            !ordering.should_eliminate("intr_group_0005", &ManifoldType::RN, 3),
1431            "Intrinsic group 5 should be camera parameter (not eliminated)"
1432        );
1433        assert!(
1434            !ordering.should_eliminate("intr_group_0042", &ManifoldType::RN, 3),
1435            "Intrinsic group 42 should be camera parameter (not eliminated)"
1436        );
1437
1438        // Verify landmarks are still eliminated
1439        assert!(
1440            ordering.should_eliminate("pt_00000", &ManifoldType::RN, 3),
1441            "Landmarks should still be eliminated"
1442        );
1443
1444        // Verify camera poses are not eliminated
1445        assert!(
1446            !ordering.should_eliminate("cam_0000", &ManifoldType::SE3, 6),
1447            "Camera poses should not be eliminated"
1448        );
1449    }
1450
1451    #[test]
1452    #[should_panic(expected = "has manifold type")]
1453    fn test_schur_ordering_invalid_landmark_type() {
1454        let ordering = SchurOrdering::default();
1455        // Landmark with wrong manifold type should panic
1456        ordering.should_eliminate("pt_00000", &ManifoldType::SE3, 6);
1457    }
1458
1459    #[test]
1460    #[should_panic(expected = "has 6 DOF")]
1461    fn test_schur_ordering_invalid_landmark_size() {
1462        let ordering = SchurOrdering::default();
1463        // Landmark with wrong size should panic
1464        ordering.should_eliminate("pt_00000", &ManifoldType::RN, 6);
1465    }
1466
1467    #[test]
1468    fn test_block_structure_creation() {
1469        let structure = SchurBlockStructure::new();
1470        assert_eq!(structure.camera_dof, 0);
1471        assert_eq!(structure.landmark_dof, 0);
1472    }
1473
1474    #[test]
1475    fn test_solver_creation() {
1476        let solver = SparseSchurComplementSolver::new();
1477        assert!(solver.block_structure.is_none());
1478    }
1479
1480    #[test]
1481    fn test_3x3_block_inversion() {
1482        let block = Matrix3::new(2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0);
1483        let inv = SparseSchurComplementSolver::invert_landmark_blocks(&[block])
1484            .expect("Test: Block inversion should succeed");
1485        assert!((inv[0][(0, 0)] - 0.5).abs() < 1e-10);
1486    }
1487
1488    #[test]
1489    fn test_schur_variants() {
1490        let solver = SparseSchurComplementSolver::new()
1491            .with_variant(SchurVariant::Iterative)
1492            .with_preconditioner(SchurPreconditioner::BlockDiagonal)
1493            .with_cg_params(100, 1e-8);
1494
1495        assert_eq!(solver.cg_max_iterations, 100);
1496        assert!((solver.cg_tolerance - 1e-8).abs() < 1e-12);
1497    }
1498
1499    #[test]
1500    fn test_compute_schur_complement_known_matrix() {
1501        use faer::sparse::Triplet;
1502
1503        let solver = SparseSchurComplementSolver::new();
1504
1505        // Create simple 2x2 H_cc (camera block)
1506        let h_cc_triplets = vec![Triplet::new(0, 0, 4.0), Triplet::new(1, 1, 5.0)];
1507        let h_cc = SparseColMat::try_new_from_triplets(2, 2, &h_cc_triplets)
1508            .expect("Test: H_cc matrix creation should succeed");
1509
1510        // Create 2x3 H_cp (coupling block - 1 landmark with 3 DOF)
1511        let h_cp_triplets = vec![Triplet::new(0, 0, 1.0), Triplet::new(1, 1, 2.0)];
1512        let h_cp = SparseColMat::try_new_from_triplets(2, 3, &h_cp_triplets)
1513            .expect("Test: H_cp matrix creation should succeed");
1514
1515        // Create H_pp^{-1} as identity scaled by 0.5
1516        let hpp_inv = vec![Matrix3::new(0.5, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.5)];
1517
1518        // Compute S = H_cc - H_cp * H_pp^{-1} * H_cp^T
1519        // H_cp has [1.0 at (0,0), 2.0 at (1,1), rest zeros]
1520        // H_cp * H_pp^{-1} (with H_pp^{-1} = 0.5*I) gives:
1521        //   Row 0: [0.5, 0, 0]
1522        //   Row 1: [0, 1.0, 0]
1523        // (H_cp * H_pp^{-1}) * H_cp^T:
1524        //   (0,0): 0.5*1 = 0.5, but we sum over all k, so actually just first column contribution
1525        //   The diagonal will be: row·row for each
1526        // Let me recalculate: S(0,0) = 4 - 0.5*1 = 3.5, but actual is 3.75
1527        // Actually the formula computes sum over all landmark DOF
1528        let s = solver
1529            .compute_schur_complement(&h_cc, &h_cp, &hpp_inv)
1530            .expect("Test: Schur complement computation should succeed");
1531
1532        assert_eq!(s.nrows(), 2);
1533        assert_eq!(s.ncols(), 2);
1534        // Verify the actual computed values (diagonal elements of Schur complement)
1535        // S = H_cc - H_cp * H_pp^{-1} * H_cp^T
1536        // H_cp * H_pp^{-1} = [[0.5, 0, 0], [0, 1.0, 0]]
1537        // (H_cp * H_pp^{-1}) * H_cp^T:
1538        //   (0,0) = 0.5*1 = 0.5
1539        //   (1,1) = 1.0*2 = 2.0
1540        // S(0,0) = 4 - 0.5 = 3.5, S(1,1) = 5 - 2.0 = 3.0
1541        assert!((s[(0, 0)] - 3.5).abs() < 1e-10, "S(0,0) = {}", s[(0, 0)]);
1542        assert!((s[(1, 1)] - 3.0).abs() < 1e-10, "S(1,1) = {}", s[(1, 1)]);
1543    }
1544
1545    #[test]
1546    fn test_back_substitute() {
1547        use faer::sparse::Triplet;
1548
1549        let solver = SparseSchurComplementSolver::new();
1550
1551        // Create test data
1552        let delta_c = Mat::from_fn(2, 1, |i, _| (i + 1) as f64); // [1; 2]
1553        let g_p = Mat::from_fn(3, 1, |i, _| (i + 1) as f64); // [1; 2; 3]
1554
1555        // H_cp (2x3)
1556        let h_cp_triplets = vec![Triplet::new(0, 0, 1.0), Triplet::new(1, 1, 1.0)];
1557        let h_cp = SparseColMat::try_new_from_triplets(2, 3, &h_cp_triplets)
1558            .expect("Test: H_cp matrix creation should succeed");
1559
1560        // H_pp^{-1} (identity)
1561        let hpp_inv = vec![Matrix3::identity()];
1562
1563        // Compute δp = H_pp^{-1} * (g_p - H_cp^T * δc)
1564        // H_cp^T * δc = [1*1; 1*2; 0] = [1; 2; 0]
1565        // g_p - result = [1; 2; 3] - [1; 2; 0] = [0; 0; 3]
1566        // H_pp^{-1} * [0; 0; 3] = [0; 0; 3]
1567        let delta_p = solver
1568            .back_substitute(&delta_c, &g_p, &h_cp, &hpp_inv)
1569            .expect("Test: Back substitution should succeed");
1570
1571        assert_eq!(delta_p.nrows(), 3);
1572        assert!((delta_p[(0, 0)]).abs() < 1e-10);
1573        assert!((delta_p[(1, 0)]).abs() < 1e-10);
1574        assert!((delta_p[(2, 0)] - 3.0).abs() < 1e-10);
1575    }
1576
1577    #[test]
1578    fn test_compute_reduced_gradient() {
1579        use faer::sparse::Triplet;
1580
1581        let solver = SparseSchurComplementSolver::new();
1582
1583        // Create test data
1584        let g_c = Mat::from_fn(2, 1, |i, _| (i + 1) as f64); // [1; 2]
1585        let g_p = Mat::from_fn(3, 1, |i, _| (i + 1) as f64); // [1; 2; 3]
1586
1587        // H_cp (2x3)
1588        let h_cp_triplets = vec![Triplet::new(0, 0, 1.0), Triplet::new(1, 1, 1.0)];
1589        let h_cp = SparseColMat::try_new_from_triplets(2, 3, &h_cp_triplets)
1590            .expect("Test: H_cp matrix creation should succeed");
1591
1592        // H_pp^{-1} (2*identity)
1593        let hpp_inv = vec![Matrix3::new(2.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 2.0)];
1594
1595        // Compute g_reduced = g_c - H_cp * H_pp^{-1} * g_p
1596        // H_pp^{-1} * g_p = 2*[1; 2; 3] = [2; 4; 6]
1597        // H_cp * [2; 4; 6] = [1*2; 1*4] = [2; 4]
1598        // g_reduced = [1; 2] - [2; 4] = [-1; -2]
1599        let g_reduced = solver
1600            .compute_reduced_gradient(&g_c, &g_p, &h_cp, &hpp_inv)
1601            .expect("Test: Reduced gradient computation should succeed");
1602
1603        assert_eq!(g_reduced.nrows(), 2);
1604        assert!((g_reduced[(0, 0)] + 1.0).abs() < 1e-10);
1605        assert!((g_reduced[(1, 0)] + 2.0).abs() < 1e-10);
1606    }
1607}