Skip to main content

apex_solver/core/
problem.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs::File,
4    io::{Error, Write},
5};
6
7use faer::{Mat, sparse::SparseColMat};
8use nalgebra::DVector;
9use rayon::prelude::*;
10use slotmap::{SecondaryMap, SlotMap};
11
12use crate::{
13    core::CoreResult,
14    core::{
15        CoreError, FactorKey, VarKey,
16        loss_functions::LossFunction,
17        noise::NoiseModel,
18        residual_block::ResidualBlock,
19        variable::{ManifoldVariable, Variable},
20    },
21    factors::Factor,
22    linalg::JacobianMode,
23};
24use apex_manifolds::{LieGroup, ManifoldType, rn, se2, se3, se23, sgal3, sim3, so2, so3};
25
26pub use crate::linearizer::cpu::sparse::SymbolicStructure;
27
28pub struct Problem {
29    pub(crate) total_residual_dimension: usize,
30    pub(crate) jacobian_mode: JacobianMode,
31    pub(crate) variables: SlotMap<VarKey, Box<dyn ManifoldVariable>>,
32    residual_blocks: SlotMap<FactorKey, ResidualBlock>,
33    pub(crate) fixed_variable_indexes: SecondaryMap<VarKey, HashSet<usize>>,
34    pub(crate) variable_bounds: SecondaryMap<VarKey, HashMap<usize, (f64, f64)>>,
35    pub(crate) schur_landmark_keys: HashSet<VarKey>,
36}
37
38impl Default for Problem {
39    fn default() -> Self {
40        Self::new(JacobianMode::Sparse)
41    }
42}
43
44impl Problem {
45    pub fn new(jacobian_mode: JacobianMode) -> Self {
46        Self {
47            total_residual_dimension: 0,
48            jacobian_mode,
49            variables: SlotMap::with_key(),
50            residual_blocks: SlotMap::with_key(),
51            fixed_variable_indexes: SecondaryMap::new(),
52            variable_bounds: SecondaryMap::new(),
53            schur_landmark_keys: HashSet::new(),
54        }
55    }
56
57    /// Mark a variable to be eliminated by the Schur complement solver.
58    ///
59    /// Nothing is eliminated by default — not even `Rn(3)` landmarks. A
60    /// bundle-adjustment problem only uses the Schur path once every landmark
61    /// carries an explicit mark (or the solver is configured with
62    /// auto-detection); unmarked variables are retained and form the reduced
63    /// system. The
64    /// eliminated set is not restricted to 3-DOF landmarks: any DOF works, sizes
65    /// may be mixed within one problem, and the eliminated variables need not be
66    /// adjacent in the variable ordering. That covers inverse-depth
67    /// parameterizations (1 DOF), LiDAR features, and sliding-window
68    /// marginalization of whole poses (6 DOF), as well as classic bundle
69    /// adjustment.
70    ///
71    /// # Precondition
72    ///
73    /// Eliminated variables must be **mutually unconnected** — no factor may
74    /// touch two of them. That is what makes `H_ee` block-diagonal and its
75    /// inverse cheap. Violating it is reported as an error on the first solve,
76    /// naming both variables, rather than silently producing a wrong step.
77    pub fn mark_for_elimination(&mut self, key: VarKey) {
78        self.schur_landmark_keys.insert(key);
79    }
80
81    /// Bundle-adjustment-flavoured alias for [`Self::mark_for_elimination`].
82    #[deprecated(
83        since = "1.6.0",
84        note = "renamed to `mark_for_elimination`: elimination is not restricted to landmarks"
85    )]
86    pub fn mark_as_schur_landmark(&mut self, key: VarKey) {
87        self.mark_for_elimination(key);
88    }
89
90    /// Add a variable with a given manifold type and initial parameter vector.
91    ///
92    /// Returns a stable `VarKey` handle for use in `add_residual_block`, `fix_variable`, etc.
93    pub fn add_variable(&mut self, manifold_type: ManifoldType, params: DVector<f64>) -> VarKey {
94        let var = Self::create_variable(&manifold_type, &params);
95        self.variables.insert(var)
96    }
97
98    /// Add a residual block (factor + optional loss) connecting the given variables.
99    ///
100    /// Returns a `FactorKey` that can be used to remove the block later.
101    pub fn add_residual_block(
102        &mut self,
103        variable_keys: &[VarKey],
104        factor: Box<dyn Factor + Send + Sync>,
105        loss_func: Option<Box<dyn LossFunction + Send + Sync>>,
106    ) -> FactorKey {
107        self.add_residual_block_with_noise(variable_keys, factor, loss_func, NoiseModel::null())
108    }
109
110    /// Register a residual block with a measurement noise model, keeping the
111    /// validation behaviour of [`Self::try_add_residual_block`].
112    ///
113    /// The model carries the square-root information `S`; residuals and
114    /// Jacobians are whitened (`S·r`, `S·J`) before the robust-loss corrector,
115    /// so the optimized objective is `Σ ½·ρ(‖S·r‖²)` — the Ω-weighted
116    /// objective g2o-style benchmarks report. Its dimension must equal the
117    /// factor's `residual_dim()`.
118    pub fn add_residual_block_with_noise(
119        &mut self,
120        variable_keys: &[VarKey],
121        factor: Box<dyn Factor + Send + Sync>,
122        loss_func: Option<Box<dyn LossFunction + Send + Sync>>,
123        noise: NoiseModel,
124    ) -> FactorKey {
125        self.try_add_residual_block_with_noise(variable_keys, factor, loss_func, noise)
126            .unwrap_or_else(|e| panic!("invalid residual block: {e}"))
127    }
128
129    /// [`Self::add_residual_block_with_noise`] returning a typed error.
130    pub fn try_add_residual_block_with_noise(
131        &mut self,
132        variable_keys: &[VarKey],
133        factor: Box<dyn Factor + Send + Sync>,
134        loss_func: Option<Box<dyn LossFunction + Send + Sync>>,
135        noise: NoiseModel,
136    ) -> CoreResult<FactorKey> {
137        if noise.dim() != 0 && noise.dim() != factor.residual_dim() {
138            return Err(CoreError::DimensionMismatch(format!(
139                "noise model covers {} residual rows but the factor produces {}",
140                noise.dim(),
141                factor.residual_dim()
142            )));
143        }
144        if factor.whitens_internally() && !matches!(noise, NoiseModel::Null) {
145            return Err(CoreError::InvalidInput(
146                "this factor applies its own square-root information; register it with \
147                 NoiseModel::null(). Supplying a noise model as well would whiten the \
148                 residual twice."
149                    .into(),
150            ));
151        }
152        self.try_add_residual_block_impl(variable_keys, factor, loss_func, noise)
153    }
154
155    /// Register a residual block, validating it against the referenced
156    /// variables first.
157    ///
158    /// Runs the factor's [`Factor::validate_variables`] hook so shape
159    /// mismatches (e.g. a landmark variable whose parameter count disagrees
160    /// with the factor's observations) surface as a typed registration error
161    /// instead of a panic inside the parallel assembly. Unknown variable keys
162    /// are rejected as well — they would otherwise be silently skipped during
163    /// linearization.
164    pub fn try_add_residual_block(
165        &mut self,
166        variable_keys: &[VarKey],
167        factor: Box<dyn Factor + Send + Sync>,
168        loss_func: Option<Box<dyn LossFunction + Send + Sync>>,
169    ) -> CoreResult<FactorKey> {
170        self.try_add_residual_block_impl(variable_keys, factor, loss_func, NoiseModel::Null)
171    }
172
173    fn try_add_residual_block_impl(
174        &mut self,
175        variable_keys: &[VarKey],
176        factor: Box<dyn Factor + Send + Sync>,
177        loss_func: Option<Box<dyn LossFunction + Send + Sync>>,
178        noise: NoiseModel,
179    ) -> CoreResult<FactorKey> {
180        let mut variables: Vec<&dyn ManifoldVariable> = Vec::with_capacity(variable_keys.len());
181        for &key in variable_keys {
182            let variable = self
183                .variables
184                .get(key)
185                .ok_or_else(|| {
186                    CoreError::Variable(format!(
187                        "residual block references unknown variable key {key:?}"
188                    ))
189                })?
190                .as_ref();
191            variables.push(variable);
192        }
193        factor
194            .validate_variables(&variables)
195            .map_err(CoreError::DimensionMismatch)?;
196
197        // Factor-independent shape invariant: the Jacobian block is laid out
198        // column-major over the connected variables' full local dofs (the
199        // scatter in `linearizer` indexes it that way), and its rows are the
200        // residual rows. A factor declaring any other shape would silently
201        // read/write the wrong arena columns — only a `debug_assert` caught
202        // this before, so release builds assembled corrupted Jacobians.
203        let (jac_rows, jac_cols) = factor.jacobian_shape();
204        let residual_dim = factor.residual_dim();
205        if jac_rows != residual_dim {
206            return Err(CoreError::DimensionMismatch(format!(
207                "factor declares a {jac_rows}×{jac_cols} Jacobian but residual_dim() is \
208                 {residual_dim}; jacobian_shape().0 must equal residual_dim()"
209            )));
210        }
211        let total_dof: usize = variables.iter().map(|v| v.dof()).sum();
212        if jac_cols != total_dof {
213            return Err(CoreError::DimensionMismatch(format!(
214                "factor declares a {jac_rows}×{jac_cols} Jacobian but the registered \
215                 variables have {total_dof} dofs in total; jacobian_shape().1 must equal \
216                 the sum of the connected variables' dofs"
217            )));
218        }
219
220        let new_residual_dimension = residual_dim;
221        let row_start = self.total_residual_dimension;
222        let fk = self.residual_blocks.insert_with_key(|fk| {
223            ResidualBlock::with_noise(fk, row_start, variable_keys, factor, loss_func, noise)
224        });
225        self.total_residual_dimension += new_residual_dimension;
226        Ok(fk)
227    }
228
229    /// Reassign residual row offsets so each eliminated variable's rows form
230    /// one contiguous range.
231    ///
232    /// Chunk-wise Schur elimination sweeps chunks in increasing row order,
233    /// which requires the rows of any one eliminated variable to be adjacent.
234    /// Bundle-adjustment data is usually camera-major — BAL lists observations
235    /// camera by camera — so each landmark's rows arrive scattered across the
236    /// matrix and the sweep cannot run.
237    ///
238    /// Rows are a labelling, not part of the problem: permuting them permutes
239    /// `r` and the rows of `J` together, leaving `JᵀJ`, `Jᵀr` and hence the step
240    /// unchanged. The cost is a sum of squares, so it is unchanged too.
241    ///
242    /// Blocks touching no eliminated variable (priors on retained variables,
243    /// say) are placed first, ahead of every chunk, so they never fall inside a
244    /// chunk's range.
245    ///
246    /// Idempotent, and a no-op when nothing is marked for elimination.
247    /// Returns whether any offset actually moved.
248    pub(crate) fn group_rows_for_elimination(&mut self) -> bool {
249        if self.schur_landmark_keys.is_empty() {
250            return false;
251        }
252
253        // Order: unchunked blocks first, then blocks grouped by the eliminated
254        // variable they touch. Ties broken by the existing offset so the result
255        // is deterministic and idempotent.
256        //
257        // Groups are ordered by the eliminated variable's *column position*
258        // (slotmap iteration order — the same order
259        // `build_variable_index_map` lays columns out in), not by `VarKey`
260        // sort order. Key ordering is version-major, so after a
261        // remove-and-reinsert cycle it can disagree with column order, and
262        // `ChunkLayout::build` emits ranges in column order while demanding
263        // increasing rows — a disagreement it can only report as a layout
264        // failure despite the problem being regroupable.
265        let rank: std::collections::HashMap<VarKey, usize> = self
266            .variables
267            .keys()
268            .enumerate()
269            .map(|(rank, key)| (key, rank))
270            .collect();
271        let mut ordered: Vec<(Option<usize>, usize, FactorKey)> = self
272            .residual_blocks
273            .iter()
274            .map(|(key, block)| {
275                let eliminated = block
276                    .variable_keys
277                    .iter()
278                    .find(|k| self.schur_landmark_keys.contains(k))
279                    .copied()
280                    .and_then(|k| rank.get(&k).copied());
281                (eliminated, block.residual_row_start_idx, key)
282            })
283            .collect();
284        // `None` sorts before `Some`, putting unchunked rows first.
285        ordered.sort_by_key(|(eliminated, offset, _)| (*eliminated, *offset));
286
287        let mut moved = false;
288        let mut row = 0usize;
289        for (_, _, key) in &ordered {
290            if let Some(block) = self.residual_blocks.get_mut(*key) {
291                if block.residual_row_start_idx != row {
292                    block.residual_row_start_idx = row;
293                    moved = true;
294                }
295                row += block.factor.residual_dim();
296            }
297        }
298        debug_assert_eq!(
299            row, self.total_residual_dimension,
300            "regrouping must preserve the total residual dimension"
301        );
302        moved
303    }
304
305    pub fn remove_residual_block(&mut self, block_id: FactorKey) -> Option<ResidualBlock> {
306        if let Some(block) = self.residual_blocks.remove(block_id) {
307            self.total_residual_dimension -= block.factor.residual_dim();
308            Some(block)
309        } else {
310            None
311        }
312    }
313
314    /// Hold tangent-space component `idx` of `var_key` fixed during solving.
315    ///
316    /// # Panics
317    /// If the key is unknown or `idx` is outside the variable's DOF. Use
318    /// [`Self::try_fix_variable`] to handle that as an error.
319    pub fn fix_variable(&mut self, var_key: VarKey, idx: usize) {
320        self.try_fix_variable(var_key, idx)
321            .unwrap_or_else(|e| panic!("invalid variable constraint: {e}"))
322    }
323
324    /// [`Self::fix_variable`] returning a typed error.
325    ///
326    /// An out-of-range `idx` used to be accepted and then silently ignored
327    /// forever, because the constraint is only ever applied by index against a
328    /// tangent vector that never has that component.
329    pub fn try_fix_variable(&mut self, var_key: VarKey, idx: usize) -> CoreResult<()> {
330        let dof = self.variable_dof(var_key)?;
331        if idx >= dof {
332            return Err(CoreError::Variable(format!(
333                "cannot fix component {idx} of a {dof}-DOF variable"
334            )));
335        }
336        if let Some(set) = self.fixed_variable_indexes.get_mut(var_key) {
337            set.insert(idx);
338        } else {
339            self.fixed_variable_indexes
340                .insert(var_key, HashSet::from([idx]));
341        }
342        Ok(())
343    }
344
345    /// Tangent-space dimension of `var_key`, or an error if the key is unknown.
346    fn variable_dof(&self, var_key: VarKey) -> CoreResult<usize> {
347        self.variables
348            .get(var_key)
349            .map(|v| v.dof())
350            .ok_or_else(|| CoreError::Variable(format!("unknown variable key {var_key:?}")))
351    }
352
353    pub fn unfix_variable(&mut self, var_key: VarKey) {
354        self.fixed_variable_indexes.remove(var_key);
355    }
356
357    /// Constrain tangent-space component `idx` of `var_key` to `[lower, upper]`.
358    ///
359    /// # Panics
360    /// If the key is unknown, `idx` is outside the variable's DOF, or the range
361    /// is inverted. Use [`Self::try_set_variable_bounds`] to handle that as an
362    /// error.
363    pub fn set_variable_bounds(
364        &mut self,
365        var_key: VarKey,
366        idx: usize,
367        lower_bound: f64,
368        upper_bound: f64,
369    ) {
370        self.try_set_variable_bounds(var_key, idx, lower_bound, upper_bound)
371            .unwrap_or_else(|e| panic!("invalid variable bounds: {e}"))
372    }
373
374    /// [`Self::set_variable_bounds`] returning a typed error.
375    ///
376    /// An inverted range used to be warned about and then dropped, leaving the
377    /// caller unable to distinguish "bound set" from "bound rejected".
378    pub fn try_set_variable_bounds(
379        &mut self,
380        var_key: VarKey,
381        idx: usize,
382        lower_bound: f64,
383        upper_bound: f64,
384    ) -> CoreResult<()> {
385        let dof = self.variable_dof(var_key)?;
386        if idx >= dof {
387            return Err(CoreError::Variable(format!(
388                "cannot bound component {idx} of a {dof}-DOF variable"
389            )));
390        }
391        // NaN comparisons are always false, so `lower_bound > upper_bound`
392        // alone lets a NaN bound through silently. ±infinity remains valid —
393        // it is the legitimate "unbounded on this side" sentinel.
394        if lower_bound.is_nan() || upper_bound.is_nan() {
395            return Err(CoreError::Variable(format!(
396                "bound [{lower_bound}, {upper_bound}] must not be NaN"
397            )));
398        }
399        if lower_bound > upper_bound {
400            return Err(CoreError::Variable(format!(
401                "lower bound {lower_bound} exceeds upper bound {upper_bound}"
402            )));
403        }
404        if let Some(map) = self.variable_bounds.get_mut(var_key) {
405            map.insert(idx, (lower_bound, upper_bound));
406        } else {
407            self.variable_bounds
408                .insert(var_key, HashMap::from([(idx, (lower_bound, upper_bound))]));
409        }
410        Ok(())
411    }
412
413    pub fn remove_variable_bounds(&mut self, var_key: VarKey) {
414        self.variable_bounds.remove(var_key);
415    }
416
417    fn create_variable(
418        manifold_type: &ManifoldType,
419        params: &DVector<f64>,
420    ) -> Box<dyn ManifoldVariable> {
421        match manifold_type {
422            ManifoldType::SO2 => {
423                Box::new(Variable::new(so2::SO2::from_param_slice(params.as_slice())))
424            }
425            ManifoldType::SO3 => {
426                Box::new(Variable::new(so3::SO3::from_param_slice(params.as_slice())))
427            }
428            ManifoldType::SE2 => {
429                Box::new(Variable::new(se2::SE2::from_param_slice(params.as_slice())))
430            }
431            ManifoldType::SE3 => {
432                Box::new(Variable::new(se3::SE3::from_param_slice(params.as_slice())))
433            }
434            ManifoldType::RN => Box::new(Variable::new(rn::Rn::new(params.clone()))),
435            ManifoldType::SE23 => Box::new(Variable::new(se23::SE23::from_param_slice(
436                params.as_slice(),
437            ))),
438            ManifoldType::SGal3 => Box::new(Variable::new(sgal3::SGal3::from_param_slice(
439                params.as_slice(),
440            ))),
441            ManifoldType::Sim3 => Box::new(Variable::new(sim3::Sim3::from_param_slice(
442                params.as_slice(),
443            ))),
444        }
445    }
446
447    /// Apply fixed indices and bounds to a mutable clone of the variable map.
448    ///
449    /// Optimizers call this after copying `problem.variables` to initialize working state.
450    pub fn apply_constraints_to_variables(
451        &self,
452        variables: &mut SlotMap<VarKey, Box<dyn ManifoldVariable>>,
453    ) {
454        for (key, var) in variables.iter_mut() {
455            if let Some(indexes) = self.fixed_variable_indexes.get(key) {
456                var.set_fixed_indices(indexes.clone());
457            }
458            if let Some(bounds) = self.variable_bounds.get(key) {
459                var.set_bounds(bounds.clone());
460            }
461        }
462    }
463
464    pub fn num_residual_blocks(&self) -> usize {
465        self.residual_blocks.len()
466    }
467
468    /// Total number of scalar residuals across all residual blocks.
469    ///
470    /// This is the row count `m` of the assembled Jacobian.
471    pub fn total_residual_dimension(&self) -> usize {
472        self.total_residual_dimension
473    }
474
475    pub(crate) fn residual_blocks(&self) -> &SlotMap<FactorKey, ResidualBlock> {
476        &self.residual_blocks
477    }
478
479    /// Compute only the residual vector (no Jacobian) for the given variable values.
480    pub fn compute_residual_sparse(
481        &self,
482        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
483    ) -> CoreResult<Mat<f64>> {
484        Ok(self.compute_residual_and_cost_sparse(variables)?.0)
485    }
486
487    /// Compute the residual vector together with the objective value.
488    ///
489    /// The cost is **not** `0.5·‖r‖²` of the returned vector. For blocks carrying a
490    /// robust loss the returned residual is Triggs-corrected — it exists to drive
491    /// the linear system — while the cost is the true robust cost `0.5·ρ(‖r‖²)`.
492    /// Squaring the corrected residual gives a different function, which is what
493    /// made every reported cost and every trust-region ratio wrong for robust
494    /// problems.
495    ///
496    /// For blocks with no loss function the two coincide at `0.5·‖r‖²`.
497    pub fn compute_residual_and_cost_sparse(
498        &self,
499        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
500    ) -> CoreResult<(Mat<f64>, f64)> {
501        let mut workspace = crate::linearizer::AssemblyWorkspace::build(self);
502        self.compute_residual_and_cost_sparse_with_workspace(variables, &mut workspace)
503    }
504
505    /// [`Problem::compute_residual_and_cost_sparse`] reusing a per-solve workspace.
506    ///
507    /// The block ordering and scratch buffers are static for the lifetime of a
508    /// solve, so hot paths (per-iteration step evaluation) pass a workspace built
509    /// once by [`crate::optimizer::initialize_optimization_state`].
510    pub(crate) fn compute_residual_and_cost_sparse_with_workspace(
511        &self,
512        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
513        workspace: &mut crate::linearizer::AssemblyWorkspace,
514    ) -> CoreResult<(Mat<f64>, f64)> {
515        use crate::linearizer::split_by_row_offsets_mut;
516
517        workspace.residual_buf.fill(0.0);
518        let residual_slices =
519            split_by_row_offsets_mut(&mut workspace.residual_buf, &workspace.offsets_lens);
520
521        // Accumulate the per-block cost on the existing parallel pass rather than
522        // making a second traversal.
523        let results: Vec<CoreResult<f64>> = residual_slices
524            .into_par_iter()
525            .zip(workspace.block_order.par_iter())
526            .map(|(slice, key)| {
527                let block = &self.residual_blocks[*key];
528                self.compute_residual_block(block, variables, slice)
529            })
530            .collect();
531        let cost: f64 = results.into_iter().sum::<CoreResult<f64>>()?;
532
533        let n = self.total_residual_dimension;
534        Ok((Mat::from_fn(n, 1, |i, _| workspace.residual_buf[i]), cost))
535    }
536
537    /// Compute residuals and sparse Jacobian.
538    pub fn compute_residual_and_jacobian_sparse(
539        &self,
540        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
541        variable_index_map: &SecondaryMap<VarKey, usize>,
542        symbolic_structure: &SymbolicStructure,
543    ) -> CoreResult<(Mat<f64>, SparseColMat<usize, f64>)> {
544        let mut workspace = crate::linearizer::AssemblyWorkspace::build(self);
545        Ok(crate::linearizer::cpu::sparse::assemble_sparse(
546            self,
547            variables,
548            variable_index_map,
549            symbolic_structure,
550            &mut workspace,
551        )?)
552    }
553
554    /// Compute residuals and dense Jacobian.
555    pub fn compute_residual_and_jacobian_dense(
556        &self,
557        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
558        variable_index_map: &SecondaryMap<VarKey, usize>,
559        total_dof: usize,
560    ) -> CoreResult<(Mat<f64>, Mat<f64>)> {
561        let mut workspace = crate::linearizer::AssemblyWorkspace::build(self);
562        Ok(crate::linearizer::cpu::dense::assemble_dense(
563            self,
564            variables,
565            variable_index_map,
566            total_dof,
567            &mut workspace,
568        )?)
569    }
570
571    /// Evaluate one residual block into `residual_slice`, returning its cost.
572    ///
573    /// With a loss function the slice receives the Triggs-corrected residual while
574    /// the returned cost is `0.5·ρ(s)`; without one, the slice receives the raw
575    /// residual and the cost is `0.5·‖r‖²`.
576    fn compute_residual_block(
577        &self,
578        residual_block: &ResidualBlock,
579        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
580        residual_slice: &mut [f64],
581    ) -> CoreResult<f64> {
582        // Single source of truth for block evaluation — the linearizer's
583        // shared `compute_block_into` (Jacobian-less here, cost only).
584        let (_, cost) =
585            crate::linearizer::compute_block_into(residual_block, variables, residual_slice, None)?;
586        Ok(cost)
587    }
588
589    pub fn log_residual_to_file(
590        &self,
591        residual: &nalgebra::DVector<f64>,
592        filename: &str,
593    ) -> Result<(), Error> {
594        let mut file = File::create(filename)?;
595        writeln!(file, "# Residual vector - {} elements", residual.len())?;
596        for (i, &value) in residual.iter().enumerate() {
597            writeln!(file, "{}: {:.12}", i, value)?;
598        }
599        Ok(())
600    }
601
602    pub fn log_sparse_jacobian_to_file(
603        &self,
604        jacobian: &SparseColMat<usize, f64>,
605        filename: &str,
606    ) -> Result<(), Error> {
607        let mut file = File::create(filename)?;
608        writeln!(
609            file,
610            "# Sparse Jacobian matrix - {} x {} ({} non-zeros)",
611            jacobian.nrows(),
612            jacobian.ncols(),
613            jacobian.compute_nnz()
614        )?;
615        writeln!(file, "# Matrix saved as dimensions and non-zero count only")?;
616        Ok(())
617    }
618
619    pub fn log_variables_to_file(
620        &self,
621        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
622        filename: &str,
623    ) -> Result<(), Error> {
624        let mut file = File::create(filename)?;
625        writeln!(file, "# Variables - {} total", variables.len())?;
626        for (_, var) in variables {
627            let vec = var.to_dvector();
628            write!(file, "[")?;
629            for (i, &v) in vec.iter().enumerate() {
630                write!(file, "{:.12}", v)?;
631                if i < vec.len() - 1 {
632                    write!(file, ", ")?;
633                }
634            }
635            writeln!(file, "]")?;
636        }
637        Ok(())
638    }
639
640    /// Compute per-variable covariances at `variables` and store them on the
641    /// variables themselves.
642    ///
643    /// This re-linearizes the problem at the given point and inverts a clean
644    /// `H = JᵀJ` — no Levenberg-Marquardt damping and no Jacobi scaling, both of
645    /// which are internal solver details and must not appear in the result. See
646    /// [`crate::linalg::covariance`].
647    ///
648    /// `options` selects the factorization algorithm and whether the result is
649    /// multiplied by the estimated noise variance `σ̂²` (scaled) or returned as
650    /// the raw `H⁻¹` (unscaled). Optimizers pass their `covariance_options`
651    /// here.
652    ///
653    /// Returns `None` if covariance estimation fails (most commonly a
654    /// rank-deficient `H` from unfixed gauge freedom), after logging the reason.
655    /// Callers that need to handle the failure should use
656    /// [`Covariance::compute`](crate::linalg::covariance::Covariance::compute)
657    /// directly, which returns a typed error.
658    pub fn compute_and_set_covariances(
659        &self,
660        variables: &mut SlotMap<VarKey, Box<dyn ManifoldVariable>>,
661        options: crate::linalg::covariance::CovarianceOptions,
662    ) -> Option<SecondaryMap<VarKey, Mat<f64>>> {
663        let covariance =
664            match crate::linalg::covariance::Covariance::compute(options, self, variables) {
665                Ok(covariance) => covariance,
666                Err(e) => {
667                    tracing::error!("Covariance estimation failed: {e}");
668                    return None;
669                }
670            };
671
672        let per_var = covariance.per_variable();
673        for (key, cov) in &per_var {
674            if let Some(var) = variables.get_mut(key) {
675                var.set_covariance(cov.clone());
676            }
677        }
678        Some(per_var)
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685    use crate::core::loss_functions::HuberLoss;
686    use crate::factors::pose::{BetweenFactor, PriorFactor};
687    use apex_manifolds::{ManifoldType, se2::SE2, se3::SE3};
688    use nalgebra::{Quaternion, Vector3, dvector};
689
690    type TestResult = Result<(), Box<dyn std::error::Error>>;
691
692    fn create_se2_test_problem() -> Result<(Problem, Vec<VarKey>), Box<dyn std::error::Error>> {
693        let mut problem = Problem::new(JacobianMode::Sparse);
694
695        let poses = [
696            (0.0_f64, 0.0, 0.0),
697            (1.0, 0.0, 0.1),
698            (1.5, 1.0, 0.5),
699            (1.0, 2.0, 1.0),
700            (0.0, 2.5, 1.5),
701            (-1.0, 2.0, 2.0),
702            (-1.5, 1.0, 2.5),
703            (-1.0, 0.0, 3.0),
704            (-0.5, -0.5, -2.8),
705            (0.5, -0.5, -2.3),
706        ];
707
708        let keys: Vec<VarKey> = poses
709            .iter()
710            .map(|&(x, y, t)| problem.add_variable(ManifoldType::SE2, dvector![x, y, t]))
711            .collect();
712
713        for i in 0..9 {
714            let (fx, fy, ft) = poses[i];
715            let (tx, ty, tt) = poses[i + 1];
716            problem.add_residual_block(
717                &[keys[i], keys[i + 1]],
718                Box::new(BetweenFactor::new(SE2::from_xy_angle(
719                    tx - fx,
720                    ty - fy,
721                    tt - ft,
722                ))),
723                Some(Box::new(HuberLoss::new(1.0)?)),
724            );
725        }
726
727        let (fx, fy, ft) = poses[9];
728        let (tx, ty, tt) = poses[0];
729        problem.add_residual_block(
730            &[keys[9], keys[0]],
731            Box::new(BetweenFactor::new(SE2::from_xy_angle(
732                tx - fx,
733                ty - fy,
734                tt - ft,
735            ))),
736            Some(Box::new(HuberLoss::new(1.0)?)),
737        );
738
739        problem.add_residual_block(
740            &[keys[0]],
741            Box::new(PriorFactor::<SE2>::new(SE2::identity())),
742            None,
743        );
744
745        Ok((problem, keys))
746    }
747
748    fn create_se3_test_problem() -> Result<(Problem, Vec<VarKey>), Box<dyn std::error::Error>> {
749        let mut problem = Problem::new(JacobianMode::Sparse);
750
751        // (tx, ty, tz, qx, qy, qz, qw)
752        let poses = [
753            (0.0_f64, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0),
754            (1.0, 0.0, 0.0, 0.0, 0.0, 0.1, 0.995),
755            (1.0, 1.0, 0.0, 0.0, 0.0, 0.2, 0.98),
756            (0.0, 1.0, 0.0, 0.0, 0.0, 0.3, 0.955),
757            (0.0, 0.0, 1.0, 0.1, 0.0, 0.0, 0.995),
758            (1.0, 0.0, 1.0, 0.1, 0.0, 0.1, 0.99),
759            (1.0, 1.0, 1.0, 0.1, 0.0, 0.2, 0.975),
760            (0.0, 1.0, 1.0, 0.1, 0.0, 0.3, 0.95),
761        ];
762
763        let keys: Vec<VarKey> = poses
764            .iter()
765            .map(|&(tx, ty, tz, qx, qy, qz, qw)| {
766                problem.add_variable(ManifoldType::SE3, dvector![tx, ty, tz, qw, qx, qy, qz])
767            })
768            .collect();
769
770        let edges = [
771            (0, 1),
772            (1, 2),
773            (2, 3),
774            (3, 0),
775            (4, 5),
776            (5, 6),
777            (6, 7),
778            (7, 4),
779            (0, 4),
780            (1, 5),
781            (2, 6),
782            (3, 7),
783        ];
784
785        for (f, t) in edges {
786            let fp = poses[f];
787            let tp = poses[t];
788            let rel = SE3::from_translation_quaternion(
789                Vector3::new(tp.0 - fp.0, tp.1 - fp.1, tp.2 - fp.2),
790                Quaternion::new(1.0, 0.0, 0.0, 0.0),
791            );
792            problem.add_residual_block(
793                &[keys[f], keys[t]],
794                Box::new(BetweenFactor::new(rel)),
795                Some(Box::new(HuberLoss::new(1.0)?)),
796            );
797        }
798
799        problem.add_residual_block(
800            &[keys[0]],
801            Box::new(PriorFactor::<SE3>::new(SE3::identity())),
802            None,
803        );
804
805        Ok((problem, keys))
806    }
807
808    #[test]
809    fn test_problem_construction_se2() -> TestResult {
810        let (problem, keys) = create_se2_test_problem()?;
811        assert_eq!(problem.num_residual_blocks(), 11);
812        assert_eq!(problem.total_residual_dimension, 33);
813        assert_eq!(keys.len(), 10);
814        assert_eq!(problem.variables.len(), 10);
815        Ok(())
816    }
817
818    #[test]
819    fn test_problem_construction_se3() -> TestResult {
820        let (problem, keys) = create_se3_test_problem()?;
821        assert_eq!(problem.num_residual_blocks(), 13);
822        assert_eq!(keys.len(), 8);
823        assert_eq!(problem.variables.len(), 8);
824        Ok(())
825    }
826
827    #[test]
828    fn test_add_variable_returns_distinct_keys() {
829        let mut problem = Problem::new(JacobianMode::Sparse);
830        let k0 = problem.add_variable(ManifoldType::SE2, dvector![0.0, 0.0, 0.0]);
831        let k1 = problem.add_variable(ManifoldType::SE2, dvector![1.0, 0.0, 0.1]);
832        assert_ne!(k0, k1);
833        assert_eq!(problem.variables.len(), 2);
834        assert_eq!(problem.variables[k0].dof(), 3);
835    }
836
837    #[test]
838    fn test_variable_all_manifold_types() {
839        let mut p = Problem::new(JacobianMode::Sparse);
840        let so2 = p.add_variable(ManifoldType::SO2, dvector![0.5]);
841        let so3 = p.add_variable(ManifoldType::SO3, dvector![1.0, 0.0, 0.0, 0.0]);
842        let se2 = p.add_variable(ManifoldType::SE2, dvector![1.0, 2.0, 0.5]);
843        let se3 = p.add_variable(
844            ManifoldType::SE3,
845            dvector![1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0],
846        );
847        let rn = p.add_variable(ManifoldType::RN, dvector![5.0, 6.0]);
848
849        assert_eq!(p.variables[so2].manifold_type_name(), "SO2");
850        assert_eq!(p.variables[so3].manifold_type_name(), "SO3");
851        assert_eq!(p.variables[se2].manifold_type_name(), "SE2");
852        assert_eq!(p.variables[se3].manifold_type_name(), "SE3");
853        assert_eq!(p.variables[rn].manifold_type_name(), "Rn");
854    }
855
856    #[test]
857    fn test_residual_block_add_remove() -> TestResult {
858        let mut p = Problem::new(JacobianMode::Sparse);
859        let k0 = p.add_variable(ManifoldType::SE2, dvector![0.0, 0.0, 0.0]);
860        let k1 = p.add_variable(ManifoldType::SE2, dvector![1.0, 0.0, 0.1]);
861
862        let fk1 = p.add_residual_block(
863            &[k0, k1],
864            Box::new(BetweenFactor::new(SE2::from_xy_angle(1.0, 0.0, 0.1))),
865            Some(Box::new(HuberLoss::new(1.0)?)),
866        );
867        let fk2 = p.add_residual_block(
868            &[k0],
869            Box::new(PriorFactor::<SE2>::new(SE2::identity())),
870            None,
871        );
872
873        assert_ne!(fk1, fk2);
874        assert_eq!(p.num_residual_blocks(), 2);
875        assert_eq!(p.total_residual_dimension, 6);
876
877        let removed = p.remove_residual_block(fk1);
878        assert!(removed.is_some());
879        assert_eq!(p.num_residual_blocks(), 1);
880        assert_eq!(p.total_residual_dimension, 3);
881
882        assert!(p.remove_residual_block(fk1).is_none());
883        Ok(())
884    }
885
886    #[test]
887    fn test_fix_unfix_variable() {
888        let mut p = Problem::new(JacobianMode::Sparse);
889        let k0 = p.add_variable(ManifoldType::SE2, dvector![0.0, 0.0, 0.0]);
890        let k1 = p.add_variable(ManifoldType::SE2, dvector![1.0, 0.0, 0.1]);
891
892        p.fix_variable(k0, 0);
893        p.fix_variable(k0, 1);
894        p.fix_variable(k1, 2);
895
896        assert_eq!(p.fixed_variable_indexes[k0].len(), 2);
897        assert_eq!(p.fixed_variable_indexes[k1].len(), 1);
898
899        p.unfix_variable(k0);
900        assert!(!p.fixed_variable_indexes.contains_key(k0));
901        assert!(p.fixed_variable_indexes.contains_key(k1));
902    }
903
904    /// Regrouping rows for elimination must not change the problem.
905    ///
906    /// Row order is a labelling: permuting it permutes `r` and `J`'s rows
907    /// together, so the cost — a sum of squares — is invariant.
908    #[test]
909    fn test_group_rows_for_elimination_preserves_cost() -> TestResult {
910        use crate::linearizer::AssemblyWorkspace;
911
912        let mut p = Problem::new(JacobianMode::Sparse);
913        let a = p.add_variable(ManifoldType::SE2, dvector![0.1, 0.2, 0.05]);
914        let b = p.add_variable(ManifoldType::SE2, dvector![1.0, 0.1, 0.02]);
915        let p0 = p.add_variable(ManifoldType::RN, dvector![0.3, 0.4, 0.5]);
916        let p1 = p.add_variable(ManifoldType::RN, dvector![0.6, 0.7, 0.8]);
917
918        // Camera-major insertion: each landmark's rows end up scattered.
919        for (x, y) in [(a, p0), (a, p1), (b, p0), (b, p1)] {
920            p.add_residual_block(
921                &[x, y],
922                Box::new(crate::factors::pose::BetweenFactor::new(
923                    apex_manifolds::se2::SE2::from_xy_angle(0.5, 0.1, 0.01),
924                )),
925                None,
926            );
927        }
928        p.mark_for_elimination(p0);
929        p.mark_for_elimination(p1);
930
931        let variables = p.variables.clone();
932        let mut ws = AssemblyWorkspace::build(&p);
933        let (_, cost_before) =
934            p.compute_residual_and_cost_sparse_with_workspace(&variables, &mut ws)?;
935
936        assert!(
937            p.group_rows_for_elimination(),
938            "camera-major rows must move"
939        );
940
941        let mut ws = AssemblyWorkspace::build(&p);
942        let (_, cost_after) =
943            p.compute_residual_and_cost_sparse_with_workspace(&variables, &mut ws)?;
944        assert!(
945            (cost_before - cost_after).abs() < 1e-12,
946            "cost changed: {cost_before} -> {cost_after}"
947        );
948
949        // Each eliminated variable's rows are now one contiguous range.
950        for landmark in [p0, p1] {
951            let mut rows: Vec<usize> = p
952                .residual_blocks()
953                .values()
954                .filter(|b| b.variable_keys.contains(&landmark))
955                .flat_map(|b| {
956                    b.residual_row_start_idx..b.residual_row_start_idx + b.factor.residual_dim()
957                })
958                .collect();
959            rows.sort_unstable();
960            let contiguous = rows.windows(2).all(|w| w[1] == w[0] + 1);
961            assert!(
962                contiguous,
963                "rows for {landmark:?} are not contiguous: {rows:?}"
964            );
965        }
966
967        // Idempotent.
968        assert!(
969            !p.group_rows_for_elimination(),
970            "a second call must be a no-op"
971        );
972        Ok(())
973    }
974
975    /// Mutating the problem after grouping must not corrupt the row layout.
976    ///
977    /// A block added post-grouping lands at the end, which can violate the
978    /// unchunked-first invariant the chunk sweep depends on. Regrouping must
979    /// restore a compact, contiguous layout with the total preserved — this is
980    /// what every LM entry relies on when a problem is solved, mutated, and
981    /// solved again.
982    #[test]
983    fn test_group_rows_after_mutation_restores_layout() -> TestResult {
984        use crate::linearizer::AssemblyWorkspace;
985
986        let mut p = Problem::new(JacobianMode::Sparse);
987        let a = p.add_variable(ManifoldType::SE2, dvector![0.1, 0.2, 0.05]);
988        let b = p.add_variable(ManifoldType::SE2, dvector![1.0, 0.1, 0.02]);
989        let p0 = p.add_variable(ManifoldType::RN, dvector![0.3, 0.4, 0.5]);
990        let p1 = p.add_variable(ManifoldType::RN, dvector![0.6, 0.7, 0.8]);
991
992        // Camera-major insertion: each landmark's rows end up scattered.
993        for (x, y) in [(a, p0), (a, p1), (b, p0), (b, p1)] {
994            p.add_residual_block(
995                &[x, y],
996                Box::new(crate::factors::pose::BetweenFactor::new(
997                    apex_manifolds::se2::SE2::from_xy_angle(0.5, 0.1, 0.01),
998                )),
999                None,
1000            );
1001        }
1002        p.mark_for_elimination(p0);
1003        p.mark_for_elimination(p1);
1004        assert!(
1005            p.group_rows_for_elimination(),
1006            "camera-major rows must move"
1007        );
1008        assert!(
1009            !p.group_rows_for_elimination(),
1010            "grouped layout must be stable"
1011        );
1012
1013        // Mutate after grouping: an unchunked block appended at the end.
1014        p.add_residual_block(
1015            &[a, b],
1016            Box::new(crate::factors::pose::BetweenFactor::new(
1017                apex_manifolds::se2::SE2::from_xy_angle(0.2, 0.0, 0.0),
1018            )),
1019            None,
1020        );
1021        assert!(
1022            p.group_rows_for_elimination(),
1023            "the appended unchunked block must move ahead of the chunks"
1024        );
1025
1026        // Layout is compact: every residual row is covered exactly once.
1027        let total = p.total_residual_dimension;
1028        let mut covered = vec![false; total];
1029        for block in p.residual_blocks().values() {
1030            let start = block.residual_row_start_idx;
1031            for (row, slot) in covered
1032                .iter_mut()
1033                .enumerate()
1034                .skip(start)
1035                .take(block.factor.residual_dim())
1036            {
1037                assert!(row < total, "row {row} out of range (total {total})");
1038                assert!(!*slot, "row {row} covered twice");
1039                *slot = true;
1040            }
1041        }
1042        assert!(covered.iter().all(|&c| c), "layout has gaps: {covered:?}");
1043
1044        // Unchunked rows come first.
1045        let touches_eliminated = |keys: &[VarKey]| keys.contains(&p0) || keys.contains(&p1);
1046        let first_chunk_row = p
1047            .residual_blocks()
1048            .values()
1049            .filter(|b| touches_eliminated(&b.variable_keys))
1050            .map(|b| b.residual_row_start_idx)
1051            .min()
1052            .ok_or_else(|| CoreError::Variable("eliminated block missing".to_string()))?;
1053        for block in p.residual_blocks().values() {
1054            if !touches_eliminated(&block.variable_keys) {
1055                assert!(
1056                    block.residual_row_start_idx < first_chunk_row,
1057                    "unchunked block at {} is inside the chunked region (starts at {first_chunk_row})",
1058                    block.residual_row_start_idx
1059                );
1060            }
1061        }
1062
1063        // Cost invariant holds across the regroup.
1064        let variables = p.variables.clone();
1065        let mut ws = AssemblyWorkspace::build(&p);
1066        let (_, cost) = p.compute_residual_and_cost_sparse_with_workspace(&variables, &mut ws)?;
1067        assert!(cost.is_finite(), "cost must stay finite, got {cost}");
1068        assert!(!p.group_rows_for_elimination(), "layout must be stable now");
1069        Ok(())
1070    }
1071
1072    #[test]
1073    fn test_variable_bounds_set_remove() {
1074        let mut p = Problem::new(JacobianMode::Sparse);
1075        let k0 = p.add_variable(ManifoldType::SE2, dvector![0.0, 0.0, 0.0]);
1076        let k1 = p.add_variable(ManifoldType::SE2, dvector![0.0, 0.0, 0.0]);
1077
1078        p.set_variable_bounds(k0, 0, -1.0, 1.0);
1079        p.set_variable_bounds(k0, 1, -2.0, 2.0);
1080        p.set_variable_bounds(k1, 0, 0.0, 5.0);
1081
1082        assert_eq!(p.variable_bounds[k0].len(), 2);
1083        assert_eq!(p.variable_bounds[k1].len(), 1);
1084
1085        p.remove_variable_bounds(k0);
1086        assert!(!p.variable_bounds.contains_key(k0));
1087        assert!(p.variable_bounds.contains_key(k1));
1088    }
1089
1090    #[test]
1091    fn test_set_variable_bounds_invalid_order() {
1092        let mut p = Problem::new(JacobianMode::Sparse);
1093        let k = p.add_variable(ManifoldType::SE2, dvector![0.0, 0.0, 0.0]);
1094
1095        // An inverted range is rejected, not warned about and dropped: the
1096        // caller could not previously tell the two apart.
1097        let Err(err) = p.try_set_variable_bounds(k, 0, 5.0, 1.0) else {
1098            panic!("inverted range must be rejected");
1099        };
1100        assert!(err.to_string().contains("exceeds upper bound"), "{err}");
1101        assert!(!p.variable_bounds.contains_key(k));
1102    }
1103
1104    /// ISSUE-0004 regression: `NaN` bounds pass `lower > upper` silently
1105    /// (every NaN comparison is false), so this must be checked explicitly.
1106    /// `±infinity` remains a valid one-sided-bound sentinel.
1107    #[test]
1108    fn test_set_variable_bounds_rejects_nan() {
1109        let mut p = Problem::new(JacobianMode::Sparse);
1110        let k = p.add_variable(ManifoldType::SE2, dvector![0.0, 0.0, 0.0]);
1111
1112        let Err(err) = p.try_set_variable_bounds(k, 0, f64::NAN, 1.0) else {
1113            panic!("NaN lower bound must be rejected");
1114        };
1115        assert!(err.to_string().contains("NaN"), "{err}");
1116
1117        let Err(err) = p.try_set_variable_bounds(k, 0, -1.0, f64::NAN) else {
1118            panic!("NaN upper bound must be rejected");
1119        };
1120        assert!(err.to_string().contains("NaN"), "{err}");
1121
1122        assert!(!p.variable_bounds.contains_key(k));
1123        assert!(
1124            p.try_set_variable_bounds(k, 0, f64::NEG_INFINITY, f64::INFINITY)
1125                .is_ok()
1126        );
1127    }
1128
1129    /// A component index beyond the variable's DOF can never be applied, so it
1130    /// must be rejected rather than stored and silently ignored forever.
1131    #[test]
1132    fn test_variable_constraints_reject_out_of_range_index() {
1133        let mut p = Problem::new(JacobianMode::Sparse);
1134        let k = p.add_variable(ManifoldType::SE2, dvector![0.0, 0.0, 0.0]); // 3 DOF
1135
1136        let Err(err) = p.try_fix_variable(k, 3) else {
1137            panic!("index 3 is out of range for a 3-DOF variable");
1138        };
1139        assert!(err.to_string().contains("3-DOF"), "{err}");
1140
1141        let Err(err) = p.try_set_variable_bounds(k, 7, -1.0, 1.0) else {
1142            panic!("index 7 is out of range for a 3-DOF variable");
1143        };
1144        assert!(err.to_string().contains("3-DOF"), "{err}");
1145
1146        assert!(!p.fixed_variable_indexes.contains_key(k));
1147        assert!(!p.variable_bounds.contains_key(k));
1148    }
1149
1150    #[test]
1151    fn test_problem_default_equals_new_sparse() {
1152        let d = Problem::default();
1153        let n = Problem::new(JacobianMode::Sparse);
1154        assert_eq!(d.jacobian_mode, n.jacobian_mode);
1155        assert_eq!(d.num_residual_blocks(), 0);
1156    }
1157
1158    #[test]
1159    fn test_compute_residual_sparse_smoke() -> TestResult {
1160        let (problem, _) = create_se2_test_problem()?;
1161        let residual = problem.compute_residual_sparse(&problem.variables)?;
1162        let norm_sq: f64 = (0..residual.nrows())
1163            .map(|i| residual[(i, 0)].powi(2))
1164            .sum();
1165        assert!(norm_sq >= 0.0);
1166        assert_eq!(residual.nrows(), problem.total_residual_dimension);
1167        Ok(())
1168    }
1169
1170    // -------------------------------------------------------------------------
1171    // Robust cost
1172    // -------------------------------------------------------------------------
1173
1174    /// `r = x`, a single scalar residual — lets a test dial ‖r‖ exactly.
1175    struct IdentityFactor;
1176
1177    impl crate::factors::Factor for IdentityFactor {
1178        fn linearize(
1179            &self,
1180            params: &[&[f64]],
1181            residual: &mut [f64],
1182            jacobian: Option<faer::mat::MatMut<'_, f64>>,
1183        ) {
1184            residual[0] = params[0][0];
1185            if let Some(mut jac) = jacobian {
1186                use faer::prelude::ReborrowMut;
1187                *jac.rb_mut().get_mut(0, 0) = 1.0;
1188            }
1189        }
1190        fn residual_dim(&self) -> usize {
1191            1
1192        }
1193        fn jacobian_shape(&self) -> (usize, usize) {
1194            (1, 1)
1195        }
1196    }
1197
1198    /// The cost of a robust block must be `0.5·ρ(s)`, not `0.5·‖r̃‖²` computed
1199    /// from the Triggs-corrected residual.
1200    ///
1201    /// Huber with δ = 1 at ‖r‖ = 2 (s = 4): ρ(s) = 2δ√s − δ² = 3, so the cost is
1202    /// 1.5. The old code reported 1.0 — 33% low.
1203    #[test]
1204    fn robust_cost_is_half_rho_not_half_corrected_residual_squared() -> TestResult {
1205        let mut problem = Problem::new(JacobianMode::Sparse);
1206        let x = problem.add_variable(ManifoldType::RN, dvector![2.0]);
1207        problem.add_residual_block(
1208            &[x],
1209            Box::new(IdentityFactor),
1210            Some(Box::new(HuberLoss::new(1.0)?)),
1211        );
1212
1213        let (residual, cost) = problem.compute_residual_and_cost_sparse(&problem.variables)?;
1214
1215        assert!(
1216            (cost - 1.5).abs() < 1e-12,
1217            "robust cost should be 0.5·ρ(4) = 1.5, got {cost}"
1218        );
1219
1220        // And confirm the old formula really does differ, so this test cannot
1221        // silently start passing for the wrong reason.
1222        let from_corrected_residual = 0.5 * residual.squared_norm_l2();
1223        assert!(
1224            (from_corrected_residual - 1.0).abs() < 1e-12,
1225            "corrected-residual cost should be 1.0, got {from_corrected_residual}"
1226        );
1227        Ok(())
1228    }
1229
1230    /// Without a loss function the two definitions coincide, so non-robust
1231    /// problems must be completely unaffected by the change.
1232    #[test]
1233    fn cost_without_loss_is_half_squared_norm() -> TestResult {
1234        let mut problem = Problem::new(JacobianMode::Sparse);
1235        let x = problem.add_variable(ManifoldType::RN, dvector![3.0]);
1236        problem.add_residual_block(&[x], Box::new(IdentityFactor), None);
1237
1238        let (residual, cost) = problem.compute_residual_and_cost_sparse(&problem.variables)?;
1239        assert!(
1240            (cost - 4.5).abs() < 1e-12,
1241            "expected 0.5·3² = 4.5, got {cost}"
1242        );
1243        assert!((cost - 0.5 * residual.squared_norm_l2()).abs() < 1e-15);
1244        Ok(())
1245    }
1246
1247    /// The cost must equal `0.5·ρ(s)` read straight off the loss function, for
1248    /// every loss, not just Huber.
1249    #[test]
1250    fn robust_cost_matches_loss_function_rho() -> TestResult {
1251        use crate::core::loss_functions::{CauchyLoss, LossFunction};
1252
1253        let value = 2.5;
1254        let mut problem = Problem::new(JacobianMode::Sparse);
1255        let x = problem.add_variable(ManifoldType::RN, dvector![value]);
1256        problem.add_residual_block(
1257            &[x],
1258            Box::new(IdentityFactor),
1259            Some(Box::new(CauchyLoss::new(1.0)?)),
1260        );
1261
1262        let (_, cost) = problem.compute_residual_and_cost_sparse(&problem.variables)?;
1263        let expected = 0.5 * CauchyLoss::new(1.0)?.evaluate(value * value)[0];
1264        assert!(
1265            (cost - expected).abs() < 1e-12,
1266            "expected 0.5·ρ(s) = {expected}, got {cost}"
1267        );
1268        Ok(())
1269    }
1270
1271    #[test]
1272    fn test_variable_covariance_lifecycle() -> TestResult {
1273        use faer::Mat;
1274        let mut p = Problem::new(JacobianMode::Sparse);
1275        let k = p.add_variable(ManifoldType::SE2, dvector![0.0, 0.0, 0.0]);
1276
1277        assert!(p.variables[k].covariance().is_none());
1278        p.variables[k].set_covariance(Mat::identity(3, 3));
1279        let cov = p.variables[k].covariance().ok_or("no cov")?;
1280        assert_eq!(cov.nrows(), 3);
1281        p.variables[k].clear_covariance();
1282        assert!(p.variables[k].covariance().is_none());
1283        Ok(())
1284    }
1285
1286    #[test]
1287    fn test_fixed_indices_stored_correctly() {
1288        let mut p = Problem::new(JacobianMode::Sparse);
1289        let k = p.add_variable(ManifoldType::SE2, dvector![0.0, 0.0, 0.0]);
1290        p.fix_variable(k, 0);
1291        p.fix_variable(k, 2);
1292        assert_eq!(p.fixed_variable_indexes[k].len(), 2);
1293        assert!(p.fixed_variable_indexes[k].contains(&0));
1294        assert!(p.fixed_variable_indexes[k].contains(&2));
1295    }
1296
1297    #[test]
1298    fn test_log_residual_to_file() -> TestResult {
1299        let p = Problem::new(JacobianMode::Sparse);
1300        let res = nalgebra::dvector![1.0, 2.0, 3.0];
1301        let path = std::env::temp_dir().join("apex_test_residual.txt");
1302        p.log_residual_to_file(&res, path.to_str().ok_or("bad path")?)?;
1303        assert!(path.exists());
1304        Ok(())
1305    }
1306
1307    #[test]
1308    fn test_log_variables_to_file() -> TestResult {
1309        let mut p = Problem::new(JacobianMode::Sparse);
1310        p.add_variable(ManifoldType::SE2, dvector![1.0, 2.0, 0.3]);
1311        let vars = p.variables.clone();
1312        let path = std::env::temp_dir().join("apex_test_variables.txt");
1313        p.log_variables_to_file(&vars, path.to_str().ok_or("bad path")?)?;
1314        assert!(path.exists());
1315        Ok(())
1316    }
1317
1318    #[test]
1319    fn test_log_sparse_jacobian_to_file() -> TestResult {
1320        use faer::sparse::SparseColMat;
1321        let p = Problem::new(JacobianMode::Sparse);
1322        let triplets = vec![faer::sparse::Triplet::new(0usize, 0usize, 1.0f64)];
1323        let jac =
1324            SparseColMat::try_new_from_triplets(1, 1, &triplets).map_err(|e| format!("{e:?}"))?;
1325        let path = std::env::temp_dir().join("apex_test_jacobian.txt");
1326        p.log_sparse_jacobian_to_file(&jac, path.to_str().ok_or("bad path")?)?;
1327        assert!(path.exists());
1328        Ok(())
1329    }
1330
1331    #[test]
1332    fn test_apply_tangent_step_se2() -> TestResult {
1333        let mut p = Problem::new(JacobianMode::Sparse);
1334        let k = p.add_variable(ManifoldType::SE2, dvector![0.0, 0.0, 0.0]);
1335        p.variables[k].apply_tangent_step(&[1.0, 2.0, 3.0]);
1336        assert_eq!(p.variables[k].dof(), 3);
1337        Ok(())
1338    }
1339
1340    #[test]
1341    fn test_variable_rn_values() -> TestResult {
1342        let mut p = Problem::new(JacobianMode::Sparse);
1343        let k = p.add_variable(ManifoldType::RN, dvector![5.0, 6.0]);
1344        let vec = p.variables[k].to_dvector();
1345        assert!((vec[0] - 5.0).abs() < 1e-10);
1346        assert!((vec[1] - 6.0).abs() < 1e-10);
1347        Ok(())
1348    }
1349
1350    // -------------------------------------------------------------------------
1351    // Registration-time factor validation
1352    // -------------------------------------------------------------------------
1353
1354    mod validation {
1355        use super::*;
1356        use crate::factors::visual::projection::ProjectionFactor;
1357        use crate::factors::{BundleAdjustment, Factor};
1358        use apex_camera_models::PinholeCamera;
1359        use nalgebra::Matrix2xX;
1360        use rn::Rn;
1361
1362        fn ba_factor(n_landmarks: usize) -> Box<dyn Factor + Send + Sync> {
1363            let observations = Matrix2xX::from_fn(n_landmarks, |r, c| (r + c) as f64);
1364            Box::new(ProjectionFactor::<PinholeCamera, BundleAdjustment>::new(
1365                observations,
1366                PinholeCamera::from([500.0, 500.0, 320.0, 240.0]),
1367            ))
1368        }
1369
1370        fn pose_and_landmarks(problem: &mut Problem, n_landmark_params: usize) -> (VarKey, VarKey) {
1371            let pose = problem.add_variable(
1372                ManifoldType::SE3,
1373                dvector![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0],
1374            );
1375            let landmarks = problem.add_variable(
1376                ManifoldType::RN,
1377                DVector::from_element(n_landmark_params, 1.0),
1378            );
1379            (pose, landmarks)
1380        }
1381
1382        #[test]
1383        fn try_add_accepts_matching_landmark_variable() -> TestResult {
1384            let mut problem = Problem::new(JacobianMode::Sparse);
1385            let (pose, landmarks) = pose_and_landmarks(&mut problem, 9);
1386            let key = problem.try_add_residual_block(&[pose, landmarks], ba_factor(3), None)?;
1387            assert_eq!(problem.residual_blocks().len(), 1);
1388            assert_eq!(problem.total_residual_dimension(), 6);
1389            let _ = key;
1390            Ok(())
1391        }
1392
1393        #[test]
1394        fn try_add_rejects_mismatched_landmark_variable() -> TestResult {
1395            let mut problem = Problem::new(JacobianMode::Sparse);
1396            let (pose, landmarks) = pose_and_landmarks(&mut problem, 12);
1397            let err = problem
1398                .try_add_residual_block(&[pose, landmarks], ba_factor(3), None)
1399                .err()
1400                .ok_or("mismatched landmark count must be rejected")?;
1401            assert!(matches!(err, CoreError::DimensionMismatch(_)), "{err}");
1402            Ok(())
1403        }
1404
1405        #[test]
1406        fn add_residual_block_panics_on_invalid_registration() {
1407            let mut problem = Problem::new(JacobianMode::Sparse);
1408            let (pose, landmarks) = pose_and_landmarks(&mut problem, 12);
1409            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1410                problem.add_residual_block(&[pose, landmarks], ba_factor(3), None);
1411            }));
1412            assert!(result.is_err(), "registration panic expected");
1413        }
1414
1415        #[test]
1416        fn try_add_rejects_unknown_variable_key() -> TestResult {
1417            let mut problem = Problem::new(JacobianMode::Sparse);
1418            let (pose, _landmarks) = pose_and_landmarks(&mut problem, 9);
1419
1420            // A key from a different, larger slot map: its slot index is out of
1421            // bounds for the problem's store, so it can never resolve.
1422            let mut other_store: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1423            let mut foreign_key = None;
1424            for i in 0..5 {
1425                foreign_key =
1426                    Some(other_store.insert(Box::new(Variable::new(Rn::new(dvector![i as f64])))));
1427            }
1428            let foreign_key = foreign_key.ok_or("inserted five keys")?;
1429
1430            let err = problem
1431                .try_add_residual_block(&[pose, foreign_key], ba_factor(3), None)
1432                .err()
1433                .ok_or("unknown variable key must be rejected")?;
1434            assert!(matches!(err, CoreError::Variable(_)), "{err}");
1435            Ok(())
1436        }
1437
1438        // ---------------------------------------------------------------------
1439        // Factor-independent shape invariant (`jacobian_shape` vs the
1440        // residual/variable layout the assembly will actually use)
1441        // ---------------------------------------------------------------------
1442
1443        /// Declares `jacobian_shape().0 != residual_dim()`. Assembly strides the
1444        /// arena by `residual_dim`, so a row mismatch corrupts every column.
1445        struct WrongRowFactor;
1446
1447        impl Factor for WrongRowFactor {
1448            fn linearize(
1449                &self,
1450                _params: &[&[f64]],
1451                _residual: &mut [f64],
1452                _jacobian: Option<faer::mat::MatMut<'_, f64>>,
1453            ) {
1454            }
1455            fn residual_dim(&self) -> usize {
1456                2
1457            }
1458            fn jacobian_shape(&self) -> (usize, usize) {
1459                (3, 1)
1460            }
1461        }
1462
1463        /// Declares `jacobian_shape().1 != Σ dof` — the scatter indexes columns
1464        /// by the variables' dofs, so extra or missing columns read the wrong
1465        /// arena slots.
1466        struct WrongColFactor;
1467
1468        impl Factor for WrongColFactor {
1469            fn linearize(
1470                &self,
1471                _params: &[&[f64]],
1472                _residual: &mut [f64],
1473                _jacobian: Option<faer::mat::MatMut<'_, f64>>,
1474            ) {
1475            }
1476            fn residual_dim(&self) -> usize {
1477                1
1478            }
1479            fn jacobian_shape(&self) -> (usize, usize) {
1480                (1, 2)
1481            }
1482        }
1483
1484        #[test]
1485        fn try_add_rejects_jacobian_rows_not_equal_to_residual_dim() -> TestResult {
1486            let mut problem = Problem::new(JacobianMode::Sparse);
1487            let x = problem.add_variable(ManifoldType::RN, dvector![1.0]);
1488            let err = problem
1489                .try_add_residual_block(&[x], Box::new(WrongRowFactor), None)
1490                .err()
1491                .ok_or("row mismatch must be rejected")?;
1492            assert!(matches!(err, CoreError::DimensionMismatch(_)), "{err}");
1493            Ok(())
1494        }
1495
1496        #[test]
1497        fn try_add_rejects_jacobian_cols_not_matching_total_dof() -> TestResult {
1498            let mut problem = Problem::new(JacobianMode::Sparse);
1499            let x = problem.add_variable(ManifoldType::RN, dvector![1.0]);
1500            let err = problem
1501                .try_add_residual_block(&[x], Box::new(WrongColFactor), None)
1502                .err()
1503                .ok_or("column mismatch must be rejected")?;
1504            assert!(matches!(err, CoreError::DimensionMismatch(_)), "{err}");
1505            Ok(())
1506        }
1507
1508        // ---------------------------------------------------------------------
1509        // MarginalPriorFactor: self-whitening contract
1510        // ---------------------------------------------------------------------
1511
1512        /// One scalar block whose local tangent is `x − x0` with `x0 = 0`.
1513        fn scalar_marginal() -> Box<dyn Factor + Send + Sync> {
1514            Box::new(
1515                crate::factors::marginal::MarginalPriorFactor::new(
1516                    vec![1],
1517                    nalgebra::DMatrix::identity(1, 1),
1518                    nalgebra::DVector::zeros(1),
1519                    Box::new(|params: &[&[f64]], out: &mut [f64]| out[0] = params[0][0]),
1520                )
1521                .unwrap_or_else(|e| panic!("marginal prior construction: {e}")),
1522            )
1523        }
1524
1525        /// The factor folds `sqrt_info` into both the residual and the
1526        /// Jacobian inside `linearize`; a second whitening by an attached
1527        /// noise model would weight the block twice.
1528        #[test]
1529        fn try_add_rejects_noise_model_on_self_whitening_marginal_prior() -> TestResult {
1530            let mut problem = Problem::new(JacobianMode::Sparse);
1531            let x = problem.add_variable(ManifoldType::RN, dvector![1.0]);
1532            let noise = NoiseModel::from_sigmas(&[0.5]).map_err(|e| format!("noise model: {e}"))?;
1533            let err = problem
1534                .try_add_residual_block_with_noise(&[x], scalar_marginal(), None, noise)
1535                .err()
1536                .ok_or("noise model on a self-whitening factor must be rejected")?;
1537            assert!(matches!(err, CoreError::InvalidInput(_)), "{err}");
1538
1539            // The null model stays acceptable.
1540            problem.try_add_residual_block_with_noise(
1541                &[x],
1542                scalar_marginal(),
1543                None,
1544                NoiseModel::Null,
1545            )?;
1546            Ok(())
1547        }
1548    }
1549}