Skip to main content

apex_solver/linalg/sparse/
implicit_schur.rs

1//! # Implicit Schur Complement Solver
2//!
3//! This module implements the **Implicit Schur Complement** method using matrix-free
4//! Preconditioned Conjugate Gradients (PCG) for bundle adjustment.
5//!
6//! ## Explicit vs Implicit Schur Complement
7//!
8//! **Implicit Schur:** This formulation never constructs the reduced camera matrix S
9//! explicitly. Instead, it solves the linear system using a matrix-free approach where
10//! only the matrix-vector product S·x is computed. This is highly memory-efficient for
11//! large-scale problems.
12//!
13//! **Explicit Schur:** The alternative formulation (see [`explicit_schur`](super::explicit_schur))
14//! physically constructs S = B - E C⁻¹ Eᵀ in memory and uses sparse Cholesky factorization.
15//!
16//! ## When to Use Implicit Schur
17//!
18//! - Very large bundle adjustment problems (> 10,000 cameras)
19//! - Memory-constrained environments
20//! - When iterative methods converge well (good preconditioning)
21//! - When the reduced camera system S is too large to store explicitly
22//!
23//! ## Algorithm
24//!
25//! 1. Form Schur complement implicitly: S = H_cc - H_cp * H_pp^{-1} * H_cp^T
26//! 2. Solve S*δc = g_reduced using PCG (matrix-free)
27//! 3. Back-substitute: δp = H_pp^{-1} * (g_p - H_cp^T * δc)
28//!
29//! ## Usage Example
30//!
31//! ```no_run
32//! # use apex_solver::linalg::{SparseSchurComplementSolver, SchurVariant, SchurPreconditioner};
33//! # use apex_solver::linalg::StructureAware;
34//! # use apex_solver::core::VarKey;
35//! # use apex_solver::core::variable::ManifoldVariable;
36//! # use slotmap::{SlotMap, SecondaryMap};
37//! # use std::collections::HashSet;
38//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
39//! # let variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
40//! # let variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
41//! # let landmark_keys: HashSet<VarKey> = HashSet::new();
42//! use apex_solver::linalg::{SparseSchurComplementSolver, SchurVariant, SchurPreconditioner};
43//! use apex_solver::linalg::StructureAware;
44//!
45//! let mut solver = SparseSchurComplementSolver::new()
46//!     .with_variant(SchurVariant::Iterative) // Implicit Schur with PCG
47//!     .with_preconditioner(SchurPreconditioner::SchurJacobi); // Recommended for PCG
48//! solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
49//! # Ok(())
50//! # }
51//! ```
52
53use super::explicit_schur::{SchurBlockStructure, SchurPreconditioner};
54use crate::core::VarKey;
55use crate::core::variable::ManifoldVariable;
56use crate::linalg::{LinAlgError, LinAlgResult, LinearSolver, SparseMode, StructureAware};
57use faer::Mat;
58use faer::sparse::{SparseColMat, Triplet};
59use nalgebra::{DMatrix, DVector, Matrix3};
60use rayon::prelude::*;
61use slotmap::{SecondaryMap, SlotMap};
62use std::collections::HashMap;
63use std::ops::Mul;
64
65/// Iterative Schur complement solver using Preconditioned Conjugate Gradients
66#[derive(Debug, Clone)]
67pub struct IterativeSchurSolver {
68    block_structure: Option<SchurBlockStructure>,
69
70    // CG parameters
71    max_cg_iterations: usize,
72    cg_tolerance: f64,
73
74    // Preconditioner type
75    preconditioner_type: SchurPreconditioner,
76
77    // Cached for matrix-vector products
78    landmark_block_inverses: Vec<Matrix3<f64>>,
79    hessian: Option<SparseColMat<usize, f64>>,
80    gradient: Option<Mat<f64>>,
81
82    // Workspace buffers for Schur operator (avoid repeated allocations)
83    workspace_lm: Vec<f64>,  // landmark DOF sized buffer
84    workspace_cam: Vec<f64>, // camera DOF sized buffer
85
86    // Visibility index: camera_block_idx -> Vec<landmark_block_idx>
87    // This avoids O(cameras * landmarks) iteration in preconditioner computation
88    camera_to_landmark_visibility: Vec<Vec<usize>>,
89}
90
91impl IterativeSchurSolver {
92    /// Create a new iterative Schur solver with default parameters
93    /// Default: Schur-Jacobi preconditioner, 500 max iterations, 1e-9 relative tolerance
94    /// These tighter settings match Ceres Solver behavior for accurate step computation.
95    pub fn new() -> Self {
96        Self {
97            block_structure: None,
98
99            max_cg_iterations: 500, // More iterations for large BA problems
100            cg_tolerance: 1e-9,     // Tighter tolerance for accurate steps
101            preconditioner_type: SchurPreconditioner::SchurJacobi,
102            landmark_block_inverses: Vec::new(),
103            hessian: None,
104            gradient: None,
105            workspace_lm: Vec::new(),
106            workspace_cam: Vec::new(),
107            camera_to_landmark_visibility: Vec::new(),
108        }
109    }
110
111    /// Create solver with custom CG parameters
112    pub fn with_cg_params(max_iterations: usize, tolerance: f64) -> Self {
113        Self {
114            block_structure: None,
115
116            max_cg_iterations: max_iterations,
117            cg_tolerance: tolerance,
118            preconditioner_type: SchurPreconditioner::SchurJacobi,
119            landmark_block_inverses: Vec::new(),
120            hessian: None,
121            gradient: None,
122            workspace_lm: Vec::new(),
123            workspace_cam: Vec::new(),
124            camera_to_landmark_visibility: Vec::new(),
125        }
126    }
127
128    /// Create solver with full configuration
129    pub fn with_config(
130        max_iterations: usize,
131        tolerance: f64,
132        preconditioner: SchurPreconditioner,
133    ) -> Self {
134        Self {
135            block_structure: None,
136
137            max_cg_iterations: max_iterations,
138            cg_tolerance: tolerance,
139            preconditioner_type: preconditioner,
140            landmark_block_inverses: Vec::new(),
141            hessian: None,
142            gradient: None,
143            workspace_lm: Vec::new(),
144            workspace_cam: Vec::new(),
145            camera_to_landmark_visibility: Vec::new(),
146        }
147    }
148
149    /// Initialize workspace buffers based on problem dimensions
150    fn init_workspaces(&mut self) {
151        if let Some(structure) = &self.block_structure {
152            let lm_dof = structure.landmark_dof;
153            let cam_dof = structure.camera_dof;
154
155            if self.workspace_lm.len() != lm_dof {
156                self.workspace_lm = vec![0.0; lm_dof];
157            }
158            if self.workspace_cam.len() != cam_dof {
159                self.workspace_cam = vec![0.0; cam_dof];
160            }
161        }
162    }
163
164    /// Apply Schur complement operator: S*x = (H_cc - H_cp * H_pp^{-1} * H_cp^T) * x
165    ///
166    /// This computes the matrix-vector product without explicitly forming S.
167    /// Uses workspace buffers to avoid allocations during PCG iterations.
168    fn apply_schur_operator_fast(
169        &self,
170        x: &Mat<f64>,
171        result: &mut Mat<f64>,
172        temp_lm: &mut [f64],
173        temp_cam: &mut [f64],
174    ) -> LinAlgResult<()> {
175        let structure = self
176            .block_structure
177            .as_ref()
178            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not initialized".into()))?;
179
180        let hessian = self
181            .hessian
182            .as_ref()
183            .ok_or_else(|| LinAlgError::InvalidInput("Hessian not computed".into()))?;
184
185        let symbolic = hessian.symbolic();
186        let (cam_start, cam_end) = structure.camera_col_range();
187        let (lm_start, lm_end) = structure.landmark_col_range();
188        let cam_dof = structure.camera_dof;
189
190        // Clear workspace buffers
191        temp_lm.iter_mut().for_each(|v| *v = 0.0);
192        temp_cam.iter_mut().for_each(|v| *v = 0.0);
193
194        // Fused Step 1+2: result = H_cc * x AND temp_lm = H_cp^T * x
195        // Process camera columns once, extracting both products
196        for col in cam_start..cam_end {
197            let local_col = col - cam_start;
198            let x_val = x[(local_col, 0)];
199            let row_indices = symbolic.row_idx_of_col_raw(col);
200            let col_values = hessian.val_of_col(col);
201
202            for (idx, &row) in row_indices.iter().enumerate() {
203                let val = col_values[idx];
204                if row >= cam_start && row < cam_end {
205                    // H_cc contribution
206                    let local_row = row - cam_start;
207                    result[(local_row, 0)] += val * x_val;
208                } else if row >= lm_start && row < lm_end {
209                    // H_cp^T contribution (camera col -> landmark row)
210                    let local_row = row - lm_start;
211                    temp_lm[local_row] += val * x_val;
212                }
213            }
214        }
215
216        // Step 3: Apply H_pp^{-1} in-place: temp_lm = H_pp^{-1} * temp_lm
217        for (block_idx, (_, start_col, _)) in structure.landmark_blocks.iter().enumerate() {
218            let inv_block = &self.landmark_block_inverses[block_idx];
219            let local_start = start_col - lm_start;
220
221            // Read input values
222            let in0 = temp_lm[local_start];
223            let in1 = temp_lm[local_start + 1];
224            let in2 = temp_lm[local_start + 2];
225
226            // Apply 3x3 inverse block
227            temp_lm[local_start] =
228                inv_block[(0, 0)] * in0 + inv_block[(0, 1)] * in1 + inv_block[(0, 2)] * in2;
229            temp_lm[local_start + 1] =
230                inv_block[(1, 0)] * in0 + inv_block[(1, 1)] * in1 + inv_block[(1, 2)] * in2;
231            temp_lm[local_start + 2] =
232                inv_block[(2, 0)] * in0 + inv_block[(2, 1)] * in1 + inv_block[(2, 2)] * in2;
233        }
234
235        // Step 4: temp_cam = H_cp * temp_lm (iterate over landmark columns)
236        for col in lm_start..lm_end {
237            let local_col = col - lm_start;
238            let lm_val = temp_lm[local_col];
239            let row_indices = symbolic.row_idx_of_col_raw(col);
240            let col_values = hessian.val_of_col(col);
241
242            for (idx, &row) in row_indices.iter().enumerate() {
243                if row >= cam_start && row < cam_end {
244                    let local_row = row - cam_start;
245                    temp_cam[local_row] += col_values[idx] * lm_val;
246                }
247            }
248        }
249
250        // Step 5: result = result - temp_cam = H_cc*x - H_cp*H_pp^{-1}*H_cp^T*x
251        for i in 0..cam_dof {
252            result[(i, 0)] -= temp_cam[i];
253        }
254
255        Ok(())
256    }
257
258    /// Extract H_cp^T and multiply with vector: (H_cp^T) * x
259    fn extract_camera_landmark_transpose_mvp(
260        &self,
261        hessian: &SparseColMat<usize, f64>,
262        x: &Mat<f64>,
263    ) -> LinAlgResult<Mat<f64>> {
264        let structure = self
265            .block_structure
266            .as_ref()
267            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not initialized".into()))?;
268        let (cam_start, cam_end) = structure.camera_col_range();
269        let (lm_start, lm_end) = structure.landmark_col_range();
270        let lm_dof = structure.landmark_dof;
271
272        let mut result = Mat::<f64>::zeros(lm_dof, 1);
273        let symbolic = hessian.symbolic();
274
275        // H_cp^T * x = iterate over camera columns, accumulate into landmark rows
276        for col in cam_start..cam_end {
277            let local_col = col - cam_start;
278            let row_indices = symbolic.row_idx_of_col_raw(col);
279            let col_values = hessian.val_of_col(col);
280
281            for (idx, &row) in row_indices.iter().enumerate() {
282                if row >= lm_start && row < lm_end {
283                    let local_row = row - lm_start;
284                    result[(local_row, 0)] += col_values[idx] * x[(local_col, 0)];
285                }
286            }
287        }
288
289        Ok(result)
290    }
291
292    /// Extract H_cp and multiply with vector
293    fn extract_camera_landmark_mvp(
294        &self,
295        hessian: &SparseColMat<usize, f64>,
296        x: &Mat<f64>,
297    ) -> LinAlgResult<Mat<f64>> {
298        let structure = self
299            .block_structure
300            .as_ref()
301            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not initialized".into()))?;
302        let (cam_start, cam_end) = structure.camera_col_range();
303        let (lm_start, lm_end) = structure.landmark_col_range();
304        let cam_dof = structure.camera_dof;
305
306        let mut result = Mat::<f64>::zeros(cam_dof, 1);
307        let symbolic = hessian.symbolic();
308
309        // H_cp * x: iterate over landmark columns
310        for col in lm_start..lm_end {
311            let local_col = col - lm_start;
312            let row_indices = symbolic.row_idx_of_col_raw(col);
313            let col_values = hessian.val_of_col(col);
314
315            for (idx, &row) in row_indices.iter().enumerate() {
316                if row >= cam_start && row < cam_end {
317                    let local_row = row - cam_start;
318                    result[(local_row, 0)] += col_values[idx] * x[(local_col, 0)];
319                }
320            }
321        }
322
323        Ok(result)
324    }
325
326    /// Apply H_pp^{-1} using cached block inverses
327    fn apply_landmark_inverse(&self, input: &Mat<f64>, output: &mut Mat<f64>) -> LinAlgResult<()> {
328        let structure = self
329            .block_structure
330            .as_ref()
331            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not initialized".into()))?;
332
333        for (block_idx, (_, start_col, _)) in structure.landmark_blocks.iter().enumerate() {
334            let inv_block = &self.landmark_block_inverses[block_idx];
335            let local_start = start_col - structure.landmark_col_range().0;
336
337            for i in 0..3 {
338                let mut sum = 0.0;
339                for j in 0..3 {
340                    sum += inv_block[(i, j)] * input[(local_start + j, 0)];
341                }
342                output[(local_start + i, 0)] = sum;
343            }
344        }
345
346        Ok(())
347    }
348
349    /// Compute block-Jacobi preconditioner: inverts camera diagonal blocks of H_cc only
350    ///
351    /// Instead of scalar diagonal (1/H_ii), this inverts the full camera blocks.
352    /// For cameras with 6 DOF (SE3), this creates 6×6 inverse blocks.
353    ///
354    /// NOTE: This is NOT the true Schur-Jacobi preconditioner. It only uses
355    /// diagonal blocks of H_cc, not the Schur complement S. For better convergence,
356    /// use `compute_schur_jacobi_preconditioner()` instead.
357    fn compute_block_preconditioner(&self) -> LinAlgResult<Vec<DMatrix<f64>>> {
358        let structure = self
359            .block_structure
360            .as_ref()
361            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not initialized".into()))?;
362
363        let hessian = self
364            .hessian
365            .as_ref()
366            .ok_or_else(|| LinAlgError::InvalidInput("Hessian not computed".into()))?;
367
368        let symbolic = hessian.symbolic();
369
370        let mut precond_blocks = Vec::with_capacity(structure.camera_blocks.len());
371
372        for (_, start_col, size) in &structure.camera_blocks {
373            // Extract the diagonal block for this camera
374            let mut block = DMatrix::<f64>::zeros(*size, *size);
375
376            for local_col in 0..*size {
377                let global_col = start_col + local_col;
378                let row_indices = symbolic.row_idx_of_col_raw(global_col);
379                let col_values = hessian.val_of_col(global_col);
380
381                for (idx, &global_row) in row_indices.iter().enumerate() {
382                    if global_row >= *start_col && global_row < start_col + size {
383                        let local_row = global_row - start_col;
384                        block[(local_row, local_col)] = col_values[idx];
385                    }
386                }
387            }
388
389            // Invert with regularization for numerical stability
390            let inv_block = match block.clone().try_inverse() {
391                Some(inv) => inv,
392                None => {
393                    // Add regularization and retry
394                    let reg = 1e-6 * block.diagonal().iter().sum::<f64>().abs() / *size as f64;
395                    let reg = reg.max(1e-8);
396                    for i in 0..*size {
397                        block[(i, i)] += reg;
398                    }
399                    block
400                        .try_inverse()
401                        .unwrap_or_else(|| DMatrix::identity(*size, *size))
402                }
403            };
404
405            precond_blocks.push(inv_block);
406        }
407
408        Ok(precond_blocks)
409    }
410
411    /// Apply block-Jacobi preconditioner: z = M^{-1} * r
412    ///
413    /// For each camera block, multiply by the inverse block matrix.
414    fn apply_block_preconditioner(
415        &self,
416        r: &Mat<f64>,
417        precond_blocks: &[DMatrix<f64>],
418    ) -> LinAlgResult<Mat<f64>> {
419        let structure = self
420            .block_structure
421            .as_ref()
422            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not initialized".into()))?;
423
424        let cam_dof = structure.camera_dof;
425        let mut z = Mat::<f64>::zeros(cam_dof, 1);
426        let cam_start = structure.camera_col_range().0;
427
428        for (block_idx, (_, start_col, size)) in structure.camera_blocks.iter().enumerate() {
429            let local_start = start_col - cam_start;
430            let inv_block = &precond_blocks[block_idx];
431
432            // Extract r block as DVector
433            let mut r_block = DVector::<f64>::zeros(*size);
434            for i in 0..*size {
435                r_block[i] = r[(local_start + i, 0)];
436            }
437
438            // Apply inverse: z_block = M^{-1} * r_block
439            let z_block = inv_block * r_block;
440
441            // Write back to z
442            for i in 0..*size {
443                z[(local_start + i, 0)] = z_block[i];
444            }
445        }
446
447        Ok(z)
448    }
449
450    /// Compute TRUE Schur-Jacobi preconditioner: diagonal blocks of the Schur complement S
451    ///
452    /// This is what Ceres Solver uses for SCHUR_JACOBI preconditioner.
453    ///
454    /// For each camera i:
455    ///   S[i,i] = H_cc[i,i] - Σ_j H_cp[i,j] * H_pp[j,j]^{-1} * H_cp[i,j]^T
456    ///
457    /// where the sum is over all landmarks j observed by camera i.
458    ///
459    /// This preconditioner captures the effect of point elimination on each camera block,
460    /// leading to much faster PCG convergence (typically 20-40 iterations vs 100+).
461    fn compute_schur_jacobi_preconditioner(&self) -> LinAlgResult<Vec<DMatrix<f64>>> {
462        let structure = self
463            .block_structure
464            .as_ref()
465            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not initialized".into()))?;
466
467        let hessian = self
468            .hessian
469            .as_ref()
470            .ok_or_else(|| LinAlgError::InvalidInput("Hessian not computed".into()))?;
471
472        let symbolic = hessian.symbolic();
473
474        // Borrow visibility index for use in parallel iterator
475        let visibility = &self.camera_to_landmark_visibility;
476
477        // Compute S[i,i] for each camera in parallel
478        // S[i,i] = H_cc[i,i] - Σ_j H_cp[i,j] * H_pp[j,j]^{-1} * H_cp[i,j]^T
479        // Using visibility index: only iterate over connected landmarks (O(observations) instead of O(cameras * landmarks))
480        let precond_blocks: Vec<DMatrix<f64>> = structure
481            .camera_blocks
482            .par_iter()
483            .enumerate()
484            .map(|(cam_idx, (_, cam_col_start, cam_size))| {
485                // Step 1: Extract H_cc[i,i] diagonal block
486                let mut s_ii = DMatrix::<f64>::zeros(*cam_size, *cam_size);
487
488                for local_col in 0..*cam_size {
489                    let global_col = cam_col_start + local_col;
490                    let row_indices = symbolic.row_idx_of_col_raw(global_col);
491                    let col_values = hessian.val_of_col(global_col);
492
493                    for (idx, &global_row) in row_indices.iter().enumerate() {
494                        if global_row >= *cam_col_start && global_row < cam_col_start + cam_size {
495                            let local_row = global_row - cam_col_start;
496                            s_ii[(local_row, local_col)] = col_values[idx];
497                        }
498                    }
499                }
500
501                // Step 2: For each landmark OBSERVED by this camera (visibility-indexed)
502                // This is the key optimization: O(avg_landmarks_per_camera) instead of O(all_landmarks)
503                let visible_landmarks = if cam_idx < visibility.len() {
504                    &visibility[cam_idx]
505                } else {
506                    &[] as &[usize]
507                };
508
509                for &lm_block_idx in visible_landmarks {
510                    if lm_block_idx >= structure.landmark_blocks.len() {
511                        continue;
512                    }
513                    let (_, lm_col_start, _) = &structure.landmark_blocks[lm_block_idx];
514
515                    // Extract H_cp[i,j] block (cam_size x 3)
516                    let mut h_cp = DMatrix::<f64>::zeros(*cam_size, 3);
517
518                    for col_offset in 0..3 {
519                        let global_col = lm_col_start + col_offset;
520                        let row_indices = symbolic.row_idx_of_col_raw(global_col);
521                        let col_values = hessian.val_of_col(global_col);
522
523                        for (idx, &global_row) in row_indices.iter().enumerate() {
524                            if global_row >= *cam_col_start && global_row < cam_col_start + cam_size
525                            {
526                                let local_row = global_row - cam_col_start;
527                                h_cp[(local_row, col_offset)] = col_values[idx];
528                            }
529                        }
530                    }
531
532                    // Get H_pp[j,j]^{-1} from cached inverses
533                    let hpp_inv = &self.landmark_block_inverses[lm_block_idx];
534
535                    // Compute contribution: H_cp * H_pp^{-1} * H_cp^T
536                    // First: temp = H_cp * H_pp^{-1} (cam_size x 3)
537                    let mut temp = DMatrix::<f64>::zeros(*cam_size, 3);
538                    for i in 0..*cam_size {
539                        for j in 0..3 {
540                            let mut sum = 0.0;
541                            for k in 0..3 {
542                                sum += h_cp[(i, k)] * hpp_inv[(k, j)];
543                            }
544                            temp[(i, j)] = sum;
545                        }
546                    }
547
548                    // Then: contribution = temp * H_cp^T (cam_size x cam_size)
549                    for i in 0..*cam_size {
550                        for j in 0..*cam_size {
551                            let mut sum = 0.0;
552                            for k in 0..3 {
553                                sum += temp[(i, k)] * h_cp[(j, k)];
554                            }
555                            s_ii[(i, j)] -= sum;
556                        }
557                    }
558                }
559
560                // Step 3: Invert S[i,i] with regularization if needed
561                match s_ii.clone().try_inverse() {
562                    Some(inv) => inv,
563                    None => {
564                        // Add regularization and retry
565                        let trace = s_ii.trace();
566                        let reg = (1e-6 * trace.abs() / *cam_size as f64).max(1e-8);
567                        for i in 0..*cam_size {
568                            s_ii[(i, i)] += reg;
569                        }
570                        s_ii.try_inverse()
571                            .unwrap_or_else(|| DMatrix::identity(*cam_size, *cam_size))
572                    }
573                }
574            })
575            .collect();
576
577        Ok(precond_blocks)
578    }
579
580    /// Solve S*x = b using Preconditioned Conjugate Gradients with block preconditioner
581    /// Uses optimized Schur operator with workspace buffers to minimize allocations.
582    fn solve_pcg_block(
583        &self,
584        b: &Mat<f64>,
585        precond_blocks: &[DMatrix<f64>],
586        workspace_lm: &mut [f64],
587        workspace_cam: &mut [f64],
588    ) -> LinAlgResult<Mat<f64>> {
589        let cam_dof = b.nrows();
590        let mut x = Mat::<f64>::zeros(cam_dof, 1);
591
592        // r = b - S*x (x starts at 0, so r = b)
593        let mut r = b.clone();
594
595        // z = M^{-1} * r (block preconditioner)
596        let mut z = self.apply_block_preconditioner(&r, precond_blocks)?;
597
598        let mut p = z.clone();
599        let mut rz_old = 0.0;
600        for i in 0..cam_dof {
601            rz_old += r[(i, 0)] * z[(i, 0)];
602        }
603
604        // Compute initial residual norm for relative convergence
605        let b_norm: f64 = (0..cam_dof)
606            .map(|i| b[(i, 0)] * b[(i, 0)])
607            .sum::<f64>()
608            .sqrt();
609        let tol = self.cg_tolerance * b_norm.max(1.0);
610
611        // Ap buffer (reused each iteration)
612        let mut ap = Mat::<f64>::zeros(cam_dof, 1);
613
614        for iter in 0..self.max_cg_iterations {
615            // Ap = S * p (using fast operator with workspace buffers)
616            // Reset ap to zeros
617            for i in 0..cam_dof {
618                ap[(i, 0)] = 0.0;
619            }
620            self.apply_schur_operator_fast(&p, &mut ap, workspace_lm, workspace_cam)?;
621
622            // alpha = (r^T z) / (p^T Ap)
623            let mut p_ap = 0.0;
624            for i in 0..cam_dof {
625                p_ap += p[(i, 0)] * ap[(i, 0)];
626            }
627
628            if p_ap.abs() < 1e-20 {
629                tracing::debug!("PCG: p^T*A*p near zero at iteration {}", iter);
630                break;
631            }
632
633            let alpha = rz_old / p_ap;
634
635            // x = x + alpha * p
636            for i in 0..cam_dof {
637                x[(i, 0)] += alpha * p[(i, 0)];
638            }
639
640            // r = r - alpha * Ap
641            for i in 0..cam_dof {
642                r[(i, 0)] -= alpha * ap[(i, 0)];
643            }
644
645            // Check convergence
646            let r_norm: f64 = (0..cam_dof)
647                .map(|i| r[(i, 0)] * r[(i, 0)])
648                .sum::<f64>()
649                .sqrt();
650
651            if r_norm < tol {
652                tracing::debug!(
653                    "PCG converged in {} iterations (residual={:.2e})",
654                    iter + 1,
655                    r_norm
656                );
657                break;
658            }
659
660            // z = M^{-1} * r (block preconditioner)
661            z = self.apply_block_preconditioner(&r, precond_blocks)?;
662
663            // beta = (r_{k+1}^T z_{k+1}) / (r_k^T z_k)
664            let mut rz_new = 0.0;
665            for i in 0..cam_dof {
666                rz_new += r[(i, 0)] * z[(i, 0)];
667            }
668
669            if rz_old.abs() < 1e-30 {
670                break;
671            }
672
673            let beta = rz_new / rz_old;
674
675            // p = z + beta * p
676            for i in 0..cam_dof {
677                p[(i, 0)] = z[(i, 0)] + beta * p[(i, 0)];
678            }
679
680            rz_old = rz_new;
681        }
682
683        Ok(x)
684    }
685
686    /// Extract 3x3 diagonal blocks from H_pp and invert them with numerical robustness
687    ///
688    /// This function uses parallel processing for the block inversions (156K+ blocks).
689    /// Each block's condition number is checked and regularization applied as needed.
690    fn invert_landmark_blocks(&mut self, hessian: &SparseColMat<usize, f64>) -> LinAlgResult<()> {
691        let structure = self
692            .block_structure
693            .as_ref()
694            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not initialized".into()))?;
695
696        let symbolic = hessian.symbolic();
697
698        // Step 1: Extract all 3x3 blocks (sequential - requires sparse matrix access)
699        let blocks: Vec<(usize, Matrix3<f64>)> = structure
700            .landmark_blocks
701            .iter()
702            .enumerate()
703            .map(|(i, (_, start_col, _))| {
704                let mut block = Matrix3::<f64>::zeros();
705
706                for local_col in 0..3 {
707                    let global_col = start_col + local_col;
708                    let row_indices = symbolic.row_idx_of_col_raw(global_col);
709                    let col_values = hessian.val_of_col(global_col);
710
711                    for (idx, &row) in row_indices.iter().enumerate() {
712                        if row >= *start_col && row < start_col + 3 {
713                            let local_row = row - start_col;
714                            block[(local_row, local_col)] = col_values[idx];
715                        }
716                    }
717                }
718
719                (i, block)
720            })
721            .collect();
722
723        // Step 2: Invert all blocks in parallel
724        // Thresholds for numerical robustness
725        const CONDITION_THRESHOLD: f64 = 1e10;
726        const MIN_EIGENVALUE_THRESHOLD: f64 = 1e-12;
727        const REGULARIZATION_SCALE: f64 = 1e-6;
728
729        let results: Vec<Result<Matrix3<f64>, (usize, String)>> = blocks
730            .par_iter()
731            .map(|(i, block)| {
732                // Check conditioning and apply regularization if needed
733                let eigenvalues = block.symmetric_eigenvalues();
734                let min_ev = eigenvalues.min();
735                let max_ev = eigenvalues.max();
736
737                if min_ev < MIN_EIGENVALUE_THRESHOLD {
738                    // Severely ill-conditioned: add strong regularization
739                    let reg = REGULARIZATION_SCALE + max_ev * REGULARIZATION_SCALE;
740                    let regularized = block + Matrix3::identity() * reg;
741                    regularized.try_inverse().ok_or_else(|| {
742                        (
743                            *i,
744                            format!("singular even with regularization (min_ev={:.2e})", min_ev),
745                        )
746                    })
747                } else if max_ev / min_ev > CONDITION_THRESHOLD {
748                    // Ill-conditioned: add moderate regularization
749                    let extra_reg = max_ev * REGULARIZATION_SCALE;
750                    let regularized = block + Matrix3::identity() * extra_reg;
751                    regularized.try_inverse().ok_or_else(|| {
752                        (
753                            *i,
754                            format!("ill-conditioned (cond={:.2e})", max_ev / min_ev),
755                        )
756                    })
757                } else {
758                    // Well-conditioned: standard inversion
759                    block
760                        .try_inverse()
761                        .ok_or_else(|| (*i, "singular".to_string()))
762                }
763            })
764            .collect();
765
766        // Step 3: Collect results and check for errors
767        self.landmark_block_inverses.clear();
768        self.landmark_block_inverses.reserve(results.len());
769
770        for result in results {
771            match result {
772                Ok(inv) => self.landmark_block_inverses.push(inv),
773                Err((i, msg)) => {
774                    return Err(LinAlgError::SingularMatrix(format!(
775                        "Landmark block {} {}",
776                        i, msg
777                    )));
778                }
779            }
780        }
781
782        Ok(())
783    }
784
785    /// Build camera->landmark visibility index from H_cp structure
786    ///
787    /// This scans the Hessian to find which landmarks each camera observes,
788    /// enabling O(observations) preconditioner computation instead of O(cameras * landmarks).
789    fn build_visibility_index(&mut self, hessian: &SparseColMat<usize, f64>) -> LinAlgResult<()> {
790        let structure = self
791            .block_structure
792            .as_ref()
793            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not initialized".into()))?;
794
795        let symbolic = hessian.symbolic();
796        let (cam_start, cam_end) = structure.camera_col_range();
797        let num_cameras = structure.camera_blocks.len();
798
799        // Build a map from global camera row -> camera block index
800        let mut cam_row_to_block: HashMap<usize, usize> = HashMap::new();
801        for (cam_idx, (_, start_col, size)) in structure.camera_blocks.iter().enumerate() {
802            for offset in 0..*size {
803                cam_row_to_block.insert(start_col + offset, cam_idx);
804            }
805        }
806
807        // Initialize visibility: one vec per camera
808        let mut visibility: Vec<Vec<usize>> = vec![Vec::new(); num_cameras];
809
810        // Scan landmark columns to find camera connections
811        for (lm_block_idx, (_, lm_col_start, _)) in structure.landmark_blocks.iter().enumerate() {
812            // Check first column of this landmark block (all 3 columns have same row pattern)
813            let global_col = *lm_col_start;
814            if global_col >= hessian.ncols() {
815                continue;
816            }
817
818            let row_indices = symbolic.row_idx_of_col_raw(global_col);
819
820            // Find which cameras observe this landmark
821            for &row in row_indices {
822                if row >= cam_start
823                    && row < cam_end
824                    && let Some(&cam_idx) = cam_row_to_block.get(&row)
825                {
826                    // Only add if not already present (avoid duplicates)
827                    if visibility[cam_idx].last() != Some(&lm_block_idx) {
828                        visibility[cam_idx].push(lm_block_idx);
829                    }
830                }
831            }
832        }
833
834        self.camera_to_landmark_visibility = visibility;
835        Ok(())
836    }
837
838    /// Internal solve using the already-cached Hessian and gradient.
839    /// This avoids rebuilding the Hessian which would lose the damping from solve_augmented_equation.
840    fn solve_with_cached_hessian(&mut self) -> LinAlgResult<Mat<f64>> {
841        let hessian = self
842            .hessian
843            .as_ref()
844            .ok_or_else(|| LinAlgError::InvalidInput("Hessian not cached".into()))?
845            .clone();
846        let gradient = self
847            .gradient
848            .as_ref()
849            .ok_or_else(|| LinAlgError::InvalidInput("Gradient not cached".into()))?
850            .clone();
851
852        // Extract structure info
853        let structure = self
854            .block_structure
855            .as_ref()
856            .ok_or_else(|| LinAlgError::InvalidInput("Block structure not initialized".into()))?;
857        let cam_dof = structure.camera_dof;
858        let lm_dof = structure.landmark_dof;
859        let (cam_start, _cam_end) = structure.camera_col_range();
860        let (lm_start, _lm_end) = structure.landmark_col_range();
861
862        // Invert landmark blocks
863        self.invert_landmark_blocks(&hessian)?;
864
865        // Build visibility index for efficient preconditioner computation
866        self.build_visibility_index(&hessian)?;
867
868        // Extract reduced RHS: g_c - H_cp * H_pp^{-1} * g_p
869        let mut g_reduced = Mat::<f64>::zeros(cam_dof, 1);
870        for i in 0..cam_dof {
871            g_reduced[(i, 0)] = gradient[(cam_start + i, 0)];
872        }
873
874        let mut g_lm = Mat::<f64>::zeros(lm_dof, 1);
875        for i in 0..lm_dof {
876            g_lm[(i, 0)] = gradient[(lm_start + i, 0)];
877        }
878
879        let mut temp = Mat::<f64>::zeros(lm_dof, 1);
880        self.apply_landmark_inverse(&g_lm, &mut temp)?;
881
882        let correction = self.extract_camera_landmark_mvp(&hessian, &temp)?;
883        for i in 0..cam_dof {
884            g_reduced[(i, 0)] -= correction[(i, 0)];
885        }
886
887        // Initialize workspace buffers if needed
888        self.init_workspaces();
889
890        // Solve S*δc = g_reduced using PCG with appropriate preconditioner
891        let precond_blocks = match self.preconditioner_type {
892            SchurPreconditioner::SchurJacobi => {
893                // True Schur-Jacobi: diagonal blocks of S (Ceres-style, best convergence)
894                self.compute_schur_jacobi_preconditioner()?
895            }
896            SchurPreconditioner::BlockDiagonal => {
897                // Block diagonal of H_cc only (faster to compute, worse convergence)
898                self.compute_block_preconditioner()?
899            }
900            SchurPreconditioner::None => {
901                // Identity preconditioner (for debugging)
902                let structure = self.block_structure.as_ref().ok_or_else(|| {
903                    LinAlgError::InvalidInput("Block structure not initialized".into())
904                })?;
905                structure
906                    .camera_blocks
907                    .iter()
908                    .map(|(_, _, size)| DMatrix::identity(*size, *size))
909                    .collect()
910            }
911        };
912
913        // Use workspace buffers for PCG iterations
914        let mut workspace_lm = std::mem::take(&mut self.workspace_lm);
915        let mut workspace_cam = std::mem::take(&mut self.workspace_cam);
916
917        let delta_cam = self.solve_pcg_block(
918            &g_reduced,
919            &precond_blocks,
920            &mut workspace_lm,
921            &mut workspace_cam,
922        )?;
923
924        // Restore workspace buffers
925        self.workspace_lm = workspace_lm;
926        self.workspace_cam = workspace_cam;
927
928        // Back-substitute for landmarks
929        let hcp_t_delta_cam = self.extract_camera_landmark_transpose_mvp(&hessian, &delta_cam)?;
930
931        let mut rhs_lm = Mat::<f64>::zeros(lm_dof, 1);
932        for i in 0..lm_dof {
933            rhs_lm[(i, 0)] = g_lm[(i, 0)] - hcp_t_delta_cam[(i, 0)];
934        }
935
936        let mut delta_lm = Mat::<f64>::zeros(lm_dof, 1);
937        self.apply_landmark_inverse(&rhs_lm, &mut delta_lm)?;
938
939        // Combine camera and landmark updates
940        let total_dof = cam_dof + lm_dof;
941        let mut delta = Mat::<f64>::zeros(total_dof, 1);
942
943        for i in 0..cam_dof {
944            delta[(cam_start + i, 0)] = delta_cam[(i, 0)];
945        }
946        for i in 0..lm_dof {
947            delta[(lm_start + i, 0)] = delta_lm[(i, 0)];
948        }
949
950        Ok(delta)
951    }
952}
953
954impl Default for IterativeSchurSolver {
955    fn default() -> Self {
956        Self::new()
957    }
958}
959
960impl StructureAware for IterativeSchurSolver {
961    fn initialize_structure(
962        &mut self,
963        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
964        variable_index_map: &SecondaryMap<VarKey, usize>,
965        schur_landmark_keys: &std::collections::HashSet<VarKey>,
966    ) -> LinAlgResult<()> {
967        let mut structure = SchurBlockStructure::new();
968
969        for (key, variable) in variables {
970            let start_col = *variable_index_map.get(key).ok_or_else(|| {
971                LinAlgError::InvalidInput(format!("VarKey {:?} not in index map", key))
972            })?;
973            let size = variable.dof();
974
975            if schur_landmark_keys.contains(&key) {
976                structure.landmark_blocks.push((key, start_col, size));
977                structure.landmark_dof += size;
978
979                if size != 3 {
980                    return Err(LinAlgError::InvalidInput(format!(
981                        "Landmark {:?} has DOF {}, expected 3",
982                        key, size
983                    )));
984                }
985                structure.num_landmarks += 1;
986            } else {
987                structure.camera_blocks.push((key, start_col, size));
988                structure.camera_dof += size;
989            }
990        }
991
992        structure.camera_blocks.sort_by_key(|(_, col, _)| *col);
993        structure.landmark_blocks.sort_by_key(|(_, col, _)| *col);
994
995        if structure.camera_blocks.is_empty() {
996            return Err(LinAlgError::InvalidInput(
997                "No camera variables found".into(),
998            ));
999        }
1000        if structure.landmark_blocks.is_empty() {
1001            return Err(LinAlgError::InvalidInput(
1002                "No landmark variables found".into(),
1003            ));
1004        }
1005
1006        self.block_structure = Some(structure);
1007        Ok(())
1008    }
1009}
1010
1011impl LinearSolver<SparseMode> for IterativeSchurSolver {
1012    fn solve_normal_equation(
1013        &mut self,
1014        residuals: &Mat<f64>,
1015        jacobian: &SparseColMat<usize, f64>,
1016    ) -> LinAlgResult<Mat<f64>> {
1017        // Build H = J^T * J, g = -J^T * r
1018        let jt = jacobian
1019            .transpose()
1020            .to_col_major()
1021            .map_err(|e| LinAlgError::MatrixConversion(format!("Transpose failed: {:?}", e)))?;
1022        let hessian = jt.mul(jacobian);
1023        let jtr = jacobian.transpose().mul(residuals);
1024        let mut gradient = Mat::<f64>::zeros(jtr.nrows(), 1);
1025        for i in 0..jtr.nrows() {
1026            gradient[(i, 0)] = -jtr[(i, 0)];
1027        }
1028
1029        self.hessian = Some(hessian);
1030        self.gradient = Some(gradient);
1031
1032        // Solve using the cached Hessian
1033        self.solve_with_cached_hessian()
1034    }
1035
1036    fn solve_augmented_equation(
1037        &mut self,
1038        residuals: &Mat<f64>,
1039        jacobian: &SparseColMat<usize, f64>,
1040        lambda: f64,
1041    ) -> LinAlgResult<Mat<f64>> {
1042        // Build H = J^T * J + λI
1043        let jt = jacobian
1044            .transpose()
1045            .to_col_major()
1046            .map_err(|e| LinAlgError::MatrixConversion(format!("Transpose failed: {:?}", e)))?;
1047        let jtr = jt.mul(residuals);
1048        let mut hessian = jacobian
1049            .transpose()
1050            .to_col_major()
1051            .map_err(|e| LinAlgError::MatrixConversion(format!("Transpose failed: {:?}", e)))?
1052            .mul(jacobian);
1053
1054        // Add damping to diagonal
1055        let n = hessian.ncols();
1056        let symbolic = hessian.symbolic();
1057        let mut triplets = Vec::new();
1058
1059        for col in 0..n {
1060            let row_indices = symbolic.row_idx_of_col_raw(col);
1061            let col_values = hessian.val_of_col(col);
1062
1063            for (idx, &row) in row_indices.iter().enumerate() {
1064                triplets.push(Triplet::new(row, col, col_values[idx]));
1065            }
1066
1067            // Add lambda to diagonal
1068            triplets.push(Triplet::new(col, col, lambda));
1069        }
1070
1071        hessian = SparseColMat::try_new_from_triplets(n, n, &triplets).map_err(|e| {
1072            LinAlgError::InvalidInput(format!("Failed to build damped Hessian: {:?}", e))
1073        })?;
1074
1075        let mut gradient = Mat::<f64>::zeros(jtr.nrows(), 1);
1076        for i in 0..jtr.nrows() {
1077            gradient[(i, 0)] = -jtr[(i, 0)];
1078        }
1079
1080        self.hessian = Some(hessian);
1081        self.gradient = Some(gradient.clone());
1082
1083        // Solve using the cached damped Hessian (don't call solve_normal_equation
1084        // which would rebuild the Hessian without damping)
1085        self.solve_with_cached_hessian()
1086    }
1087
1088    fn get_hessian(&self) -> Option<&SparseColMat<usize, f64>> {
1089        self.hessian.as_ref()
1090    }
1091
1092    fn get_gradient(&self) -> Option<&Mat<f64>> {
1093        self.gradient.as_ref()
1094    }
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100    use crate::core::VarKey;
1101    use crate::core::variable::Variable;
1102    use apex_manifolds::{LieGroup, rn, se3};
1103    use slotmap::{SecondaryMap, SlotMap};
1104
1105    type TestResult = Result<(), Box<dyn std::error::Error>>;
1106
1107    type TestSetup = (
1108        SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1109        SecondaryMap<VarKey, usize>,
1110        SparseColMat<usize, f64>,
1111        faer::Mat<f64>,
1112        std::collections::HashSet<VarKey>,
1113    );
1114
1115    /// Build the same 2-camera + 3-landmark test setup used in explicit_schur tests.
1116    /// Jacobian: 36 rows × 21 cols  (H_cc = 3·I₁₂, H_pp = 4·I₃ — positive definite)
1117    fn create_schur_test_setup() -> Result<TestSetup, Box<dyn std::error::Error>> {
1118        let se3_id = DVector::from_vec(vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
1119        let pt_zero = DVector::from_vec(vec![0.0, 0.0, 0.0]);
1120
1121        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1122        let cam0 = variables.insert(Box::new(Variable::new(se3::SE3::from_param_slice(
1123            se3_id.as_slice(),
1124        ))));
1125        let cam1 = variables.insert(Box::new(Variable::new(se3::SE3::from_param_slice(
1126            se3_id.as_slice(),
1127        ))));
1128        let pt0 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_zero.clone()))));
1129        let pt1 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_zero.clone()))));
1130        let pt2 = variables.insert(Box::new(Variable::new(rn::Rn::new(pt_zero.clone()))));
1131
1132        let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
1133        variable_index_map.insert(cam0, 0);
1134        variable_index_map.insert(cam1, 6);
1135        variable_index_map.insert(pt0, 12);
1136        variable_index_map.insert(pt1, 15);
1137        variable_index_map.insert(pt2, 18);
1138
1139        // Jacobian: 2 cameras × 3 landmarks × 6 rows_per_obs = 36 rows, 21 cols
1140        let cam_cols = [0usize, 6];
1141        let lm_cols = [12usize, 15, 18];
1142        let mut triplets: Vec<Triplet<usize, usize, f64>> = Vec::new();
1143        for (ci, &cam_col) in cam_cols.iter().enumerate() {
1144            for (li, &lm_col) in lm_cols.iter().enumerate() {
1145                let row_base = (ci * 3 + li) * 6;
1146                for k in 0..6 {
1147                    triplets.push(Triplet::new(row_base + k, cam_col + k, 1.0));
1148                    triplets.push(Triplet::new(row_base + k, lm_col + (k % 3), 1.0));
1149                }
1150            }
1151        }
1152
1153        let jacobian = SparseColMat::try_new_from_triplets(36, 21, &triplets)?;
1154        let residuals = faer::Mat::from_fn(36, 1, |i, _| (i % 5) as f64 * 0.1);
1155
1156        let mut landmark_keys = std::collections::HashSet::new();
1157        landmark_keys.insert(pt0);
1158        landmark_keys.insert(pt1);
1159        landmark_keys.insert(pt2);
1160
1161        Ok((
1162            variables,
1163            variable_index_map,
1164            jacobian,
1165            residuals,
1166            landmark_keys,
1167        ))
1168    }
1169
1170    #[test]
1171    fn test_iterative_schur_creation() {
1172        let solver = IterativeSchurSolver::new();
1173        // Default: 500 max iterations, 1e-9 tolerance, Schur-Jacobi preconditioner
1174        assert_eq!(solver.max_cg_iterations, 500);
1175        assert_eq!(solver.cg_tolerance, 1e-9);
1176        assert_eq!(solver.preconditioner_type, SchurPreconditioner::SchurJacobi);
1177    }
1178
1179    #[test]
1180    fn test_with_custom_params() {
1181        let solver = IterativeSchurSolver::with_cg_params(100, 1e-8);
1182        assert_eq!(solver.max_cg_iterations, 100);
1183        assert_eq!(solver.cg_tolerance, 1e-8);
1184        // Should still use default Schur-Jacobi preconditioner
1185        assert_eq!(solver.preconditioner_type, SchurPreconditioner::SchurJacobi);
1186    }
1187
1188    #[test]
1189    fn test_with_full_config() {
1190        let solver =
1191            IterativeSchurSolver::with_config(200, 1e-10, SchurPreconditioner::BlockDiagonal);
1192        assert_eq!(solver.max_cg_iterations, 200);
1193        assert_eq!(solver.cg_tolerance, 1e-10);
1194        assert_eq!(
1195            solver.preconditioner_type,
1196            SchurPreconditioner::BlockDiagonal
1197        );
1198    }
1199
1200    // -------------------------------------------------------------------------
1201    // New tests for uncovered code paths
1202    // -------------------------------------------------------------------------
1203
1204    /// Test Default impl equals new()
1205    #[test]
1206    fn test_iterative_schur_default() {
1207        let solver = IterativeSchurSolver::default();
1208        assert_eq!(solver.max_cg_iterations, 500);
1209        assert_eq!(solver.cg_tolerance, 1e-9);
1210        assert!(solver.block_structure.is_none());
1211        assert!(solver.hessian.is_none());
1212        assert!(solver.landmark_block_inverses.is_empty());
1213    }
1214
1215    /// Test initialize_structure() partitions 2 cameras + 3 landmarks
1216    #[test]
1217    fn test_iterative_schur_initialize_structure() -> TestResult {
1218        let (variables, variable_index_map, _, _, landmark_keys) = create_schur_test_setup()?;
1219        let mut solver = IterativeSchurSolver::new();
1220        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1221
1222        let bs = solver
1223            .block_structure
1224            .as_ref()
1225            .ok_or("block_structure is None")?;
1226        assert_eq!(bs.camera_blocks.len(), 2);
1227        assert_eq!(bs.landmark_blocks.len(), 3);
1228        assert_eq!(bs.camera_dof, 12);
1229        assert_eq!(bs.landmark_dof, 9);
1230        Ok(())
1231    }
1232
1233    /// Test full solve_normal_equation pipeline
1234    #[test]
1235    fn test_iterative_schur_solve_normal_equation() -> TestResult {
1236        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1237            create_schur_test_setup()?;
1238        let mut solver = IterativeSchurSolver::with_cg_params(500, 1e-6);
1239        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1240
1241        let delta =
1242            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1243        assert_eq!(delta.nrows(), 21);
1244        assert_eq!(delta.ncols(), 1);
1245        Ok(())
1246    }
1247
1248    /// Test solve_augmented_equation (LM damping) pipeline
1249    #[test]
1250    fn test_iterative_schur_solve_augmented_equation() -> TestResult {
1251        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1252            create_schur_test_setup()?;
1253        let mut solver = IterativeSchurSolver::with_cg_params(500, 1e-6);
1254        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1255
1256        let delta = LinearSolver::<SparseMode>::solve_augmented_equation(
1257            &mut solver,
1258            &residuals,
1259            &jacobian,
1260            0.1,
1261        )?;
1262        assert_eq!(delta.nrows(), 21);
1263        Ok(())
1264    }
1265
1266    /// Test get_hessian() and get_gradient() after solve
1267    #[test]
1268    fn test_iterative_schur_get_hessian_gradient() -> TestResult {
1269        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1270            create_schur_test_setup()?;
1271        let mut solver = IterativeSchurSolver::with_cg_params(500, 1e-6);
1272        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1273
1274        assert!(LinearSolver::<SparseMode>::get_hessian(&solver).is_none());
1275        assert!(LinearSolver::<SparseMode>::get_gradient(&solver).is_none());
1276
1277        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1278
1279        let h = LinearSolver::<SparseMode>::get_hessian(&solver);
1280        let g = LinearSolver::<SparseMode>::get_gradient(&solver);
1281        assert!(h.is_some());
1282        assert!(g.is_some());
1283        let h = h.ok_or("hessian is None")?;
1284        let g = g.ok_or("gradient is None")?;
1285        assert_eq!(h.nrows(), 21);
1286        assert_eq!(g.nrows(), 21);
1287        Ok(())
1288    }
1289
1290    /// Test landmark_block_inverses populated after solve
1291    #[test]
1292    fn test_iterative_schur_invert_landmark_blocks() -> TestResult {
1293        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1294            create_schur_test_setup()?;
1295        let mut solver = IterativeSchurSolver::with_cg_params(500, 1e-6);
1296        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1297
1298        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1299
1300        // Should have one 3×3 inverse per landmark (3 landmarks)
1301        assert_eq!(solver.landmark_block_inverses.len(), 3);
1302        Ok(())
1303    }
1304
1305    /// Test visibility index populated after solve
1306    #[test]
1307    fn test_iterative_schur_build_visibility_index() -> TestResult {
1308        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1309            create_schur_test_setup()?;
1310        let mut solver = IterativeSchurSolver::with_cg_params(500, 1e-6);
1311        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1312
1313        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1314
1315        // 2 cameras should each have visibility to some landmarks
1316        assert_eq!(solver.camera_to_landmark_visibility.len(), 2);
1317        for cam_visibility in &solver.camera_to_landmark_visibility {
1318            assert!(!cam_visibility.is_empty());
1319        }
1320        Ok(())
1321    }
1322
1323    /// Test workspace buffers are allocated after solve
1324    #[test]
1325    fn test_iterative_schur_workspace_init() -> TestResult {
1326        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1327            create_schur_test_setup()?;
1328        let mut solver = IterativeSchurSolver::with_cg_params(500, 1e-6);
1329        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1330
1331        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1332
1333        assert_eq!(solver.workspace_lm.len(), 9); // landmark DOF
1334        assert_eq!(solver.workspace_cam.len(), 12); // camera DOF
1335        Ok(())
1336    }
1337
1338    /// Test BlockDiagonal preconditioner path
1339    #[test]
1340    fn test_iterative_schur_block_diagonal_preconditioner() -> TestResult {
1341        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1342            create_schur_test_setup()?;
1343        let mut solver =
1344            IterativeSchurSolver::with_config(500, 1e-6, SchurPreconditioner::BlockDiagonal);
1345        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1346
1347        let delta =
1348            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1349        assert_eq!(delta.nrows(), 21);
1350        Ok(())
1351    }
1352
1353    /// Test SchurJacobi preconditioner path
1354    #[test]
1355    fn test_iterative_schur_schur_jacobi_preconditioner() -> TestResult {
1356        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1357            create_schur_test_setup()?;
1358        let mut solver =
1359            IterativeSchurSolver::with_config(500, 1e-6, SchurPreconditioner::SchurJacobi);
1360        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1361
1362        let delta =
1363            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1364        assert_eq!(delta.nrows(), 21);
1365        Ok(())
1366    }
1367
1368    /// Test None preconditioner path
1369    #[test]
1370    fn test_iterative_schur_no_preconditioner() -> TestResult {
1371        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1372            create_schur_test_setup()?;
1373        let mut solver = IterativeSchurSolver::with_config(500, 1e-6, SchurPreconditioner::None);
1374        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1375
1376        let delta =
1377            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1378        assert_eq!(delta.nrows(), 21);
1379        Ok(())
1380    }
1381
1382    /// Test two solves with different λ produce different updates
1383    #[test]
1384    fn test_iterative_schur_augmented_lambda_effect() -> TestResult {
1385        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1386            create_schur_test_setup()?;
1387
1388        let mut s1 = IterativeSchurSolver::with_cg_params(500, 1e-6);
1389        s1.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1390        let d1 = LinearSolver::<SparseMode>::solve_augmented_equation(
1391            &mut s1, &residuals, &jacobian, 0.001,
1392        )?;
1393
1394        let mut s2 = IterativeSchurSolver::with_cg_params(500, 1e-6);
1395        s2.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1396        let d2 = LinearSolver::<SparseMode>::solve_augmented_equation(
1397            &mut s2, &residuals, &jacobian, 100.0,
1398        )?;
1399
1400        let norm_diff: f64 = (0..21).map(|i| (d1[(i, 0)] - d2[(i, 0)]).powi(2)).sum();
1401        assert!(
1402            norm_diff > 1e-10,
1403            "Different λ should yield different updates"
1404        );
1405        Ok(())
1406    }
1407
1408    /// Test solve without initialize_structure returns error
1409    #[test]
1410    fn test_iterative_schur_solve_without_init_returns_error() -> TestResult {
1411        let triplets: Vec<Triplet<usize, usize, f64>> = vec![Triplet::new(0, 0, 1.0)];
1412        let jacobian =
1413            SparseColMat::try_new_from_triplets(1, 1, &triplets).map_err(|e| format!("{e:?}"))?;
1414        let residuals = faer::Mat::from_fn(1, 1, |_, _| 1.0);
1415        let mut solver = IterativeSchurSolver::new();
1416
1417        let result =
1418            LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian);
1419        assert!(result.is_err());
1420        Ok(())
1421    }
1422
1423    // -------------------------------------------------------------------------
1424    // New tests for previously uncovered code paths
1425    // -------------------------------------------------------------------------
1426
1427    /// Test apply_landmark_inverse applies the cached block inverses correctly.
1428    #[test]
1429    fn test_apply_landmark_inverse() -> TestResult {
1430        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1431            create_schur_test_setup()?;
1432        let mut solver = IterativeSchurSolver::with_cg_params(500, 1e-6);
1433        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1434        // Run a solve to populate landmark_block_inverses
1435        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1436
1437        // Input vector - landmark DOF (9 for 3 landmarks × 3)
1438        let input = faer::Mat::from_fn(9, 1, |i, _| (i + 1) as f64);
1439        let mut output = faer::Mat::zeros(9, 1);
1440        solver.apply_landmark_inverse(&input, &mut output)?;
1441
1442        // The output should be non-zero (since the landmark blocks are non-trivial)
1443        let norm: f64 = (0..9).map(|i| output[(i, 0)].powi(2)).sum::<f64>().sqrt();
1444        assert!(
1445            norm > 0.0,
1446            "apply_landmark_inverse should produce non-zero output"
1447        );
1448        Ok(())
1449    }
1450
1451    /// Test extract_camera_landmark_transpose_mvp produces a landmark-DOF output.
1452    #[test]
1453    fn test_extract_camera_landmark_transpose_mvp() -> TestResult {
1454        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1455            create_schur_test_setup()?;
1456        let mut solver = IterativeSchurSolver::with_cg_params(500, 1e-6);
1457        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1458        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1459
1460        let hessian = solver.hessian.as_ref().ok_or("hessian is None")?;
1461
1462        // Camera-DOF input vector (12 entries for 2 cameras × 6 DOF)
1463        let x = faer::Mat::from_fn(12, 1, |i, _| (i as f64) * 0.1);
1464        let result = solver.extract_camera_landmark_transpose_mvp(hessian, &x)?;
1465
1466        // Result should have landmark DOF = 9 rows
1467        assert_eq!(result.nrows(), 9);
1468        assert_eq!(result.ncols(), 1);
1469        Ok(())
1470    }
1471
1472    /// Test extract_camera_landmark_mvp produces a camera-DOF output.
1473    #[test]
1474    fn test_extract_camera_landmark_mvp() -> TestResult {
1475        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1476            create_schur_test_setup()?;
1477        let mut solver = IterativeSchurSolver::with_cg_params(500, 1e-6);
1478        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1479        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1480
1481        let hessian = solver.hessian.as_ref().ok_or("hessian is None")?;
1482
1483        // Landmark-DOF input vector (9 entries for 3 landmarks × 3 DOF)
1484        let x = faer::Mat::from_fn(9, 1, |i, _| (i as f64) * 0.1);
1485        let result = solver.extract_camera_landmark_mvp(hessian, &x)?;
1486
1487        // Result should have camera DOF = 12 rows
1488        assert_eq!(result.nrows(), 12);
1489        assert_eq!(result.ncols(), 1);
1490        Ok(())
1491    }
1492
1493    /// Test apply_block_preconditioner produces correct-dimensional output.
1494    #[test]
1495    fn test_apply_block_preconditioner() -> TestResult {
1496        let (variables, variable_index_map, jacobian, residuals, landmark_keys) =
1497            create_schur_test_setup()?;
1498        let mut solver =
1499            IterativeSchurSolver::with_config(500, 1e-6, SchurPreconditioner::BlockDiagonal);
1500        solver.initialize_structure(&variables, &variable_index_map, &landmark_keys)?;
1501        LinearSolver::<SparseMode>::solve_normal_equation(&mut solver, &residuals, &jacobian)?;
1502
1503        // Compute the block preconditioner blocks
1504        let precond_blocks = solver.compute_block_preconditioner()?;
1505        // Should have one block per camera (2 cameras)
1506        assert_eq!(precond_blocks.len(), 2);
1507
1508        // Apply to a camera-DOF residual vector
1509        let r = faer::Mat::from_fn(12, 1, |i, _| (i + 1) as f64);
1510        let z = solver.apply_block_preconditioner(&r, &precond_blocks)?;
1511        assert_eq!(z.nrows(), 12);
1512        assert_eq!(z.ncols(), 1);
1513
1514        // The output should be non-zero
1515        let norm: f64 = (0..12).map(|i| z[(i, 0)].powi(2)).sum::<f64>().sqrt();
1516        assert!(norm > 0.0, "Preconditioned vector should be non-zero");
1517        Ok(())
1518    }
1519
1520    /// Test initialize_structure returns Err when no camera variables are present.
1521    #[test]
1522    fn test_implicit_initialize_structure_no_cameras_returns_error() {
1523        use crate::core::variable::Variable;
1524        use apex_manifolds::rn;
1525        use nalgebra::DVector;
1526
1527        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1528        let k = variables.insert(Box::new(Variable::new(rn::Rn::new(DVector::zeros(3)))));
1529        let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
1530        variable_index_map.insert(k, 0);
1531        let mut landmark_keys = std::collections::HashSet::new();
1532        landmark_keys.insert(k);
1533
1534        let mut solver = IterativeSchurSolver::new();
1535        let result = solver.initialize_structure(&variables, &variable_index_map, &landmark_keys);
1536        assert!(
1537            result.is_err(),
1538            "Expected Err when no camera variables present"
1539        );
1540    }
1541
1542    /// Test initialize_structure returns Err when no landmark variables are present.
1543    #[test]
1544    fn test_implicit_initialize_structure_no_landmarks_returns_error() {
1545        use crate::core::variable::Variable;
1546        use apex_manifolds::se3;
1547
1548        let mut variables: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1549        let _k = variables.insert(Box::new(Variable::new(se3::SE3::from_param_slice(&[
1550            1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
1551        ]))));
1552        let mut variable_index_map: SecondaryMap<VarKey, usize> = SecondaryMap::new();
1553        variable_index_map.insert(_k, 0);
1554        let landmark_keys = std::collections::HashSet::<VarKey>::new(); // no landmarks
1555
1556        let mut solver = IterativeSchurSolver::new();
1557        let result = solver.initialize_structure(&variables, &variable_index_map, &landmark_keys);
1558        assert!(
1559            result.is_err(),
1560            "Expected Err when no landmark variables present"
1561        );
1562    }
1563}