Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Apex Solver is a high-performance Rust library for nonlinear least squares optimization on Lie groups, aimed at SLAM, bundle adjustment and computer vision.

It bridges the gap between theoretical robotics and practical implementation:

  • Manifold-aware optimization — full Lie group support (SO(2), SO(3), SE(2), SE(3), SE₂(3), SGal(3), Sim(3), ℝⁿ) with analytic Jacobians via the apex-manifolds crate.
  • Three optimization algorithms — Levenberg–Marquardt (with Ceres-compatible Nielsen and Marquardt damping policies), Gauss–Newton and Dog Leg behind a unified Optimizer trait.
  • Four sparse linear solvers — sparse Cholesky and QR, plus explicit and iterative (matrix-free PCG) Schur complement solvers for large-scale bundle adjustment.
  • ~40 factors — pose, visual, IMU preintegration, LiDAR, GNSS, range and bearing, motion models and marginalization, each documented with its error and Jacobian in the Factor Reference.
  • 15 robust loss functions — Huber, Cauchy, Tukey, Welsch, Barron and more, with correct Triggs correction of residuals and Jacobians.
  • Zero-copy sparse linear algebra — parameters live in contiguous nalgebra storage viewed by faer without copying; unsafe is forbidden crate-wide.
  • Ecosystem I/O — G2O, TORO and BAL file formats via the apex-io crate.
  • Live visualization — integrated Rerun support for watching poses, landmarks, cost curves and sparsity patterns during a solve.

Where to go next

Installation & Quick Start

Installation

[dependencies]
apex-solver = "1.5.0"

Optional feature for live Rerun visualization:

[dependencies]
apex-solver = { version = "1.5.0", features = ["visualization"] }

Solving a pose graph from a G2O file

use apex_solver::core::problem::Problem;
use apex_solver::factors::pose::BetweenFactor;
use apex_solver::{G2oLoader, JacobianMode, ManifoldType};
use apex_solver::optimizer::levenberg_marquardt::{LevenbergMarquardt, LevenbergMarquardtConfig};
use nalgebra::dvector;
use std::collections::HashMap;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Load pose graph from G2O file
    let graph = G2oLoader::load("data/odometry/3d/sphere2500.g2o")?;

    // Create optimization problem
    let mut problem = Problem::new(JacobianMode::Sparse);
    let mut var_keys = HashMap::new();

    // Add SE3 poses as variables -- returns stable VarKey handles
    for (&id, vertex) in &graph.vertices_se3 {
        let quat = vertex.pose.rotation_quaternion();
        let trans = vertex.pose.translation();
        let se3_data = dvector![trans.x, trans.y, trans.z, quat.w, quat.i, quat.j, quat.k];
        let key = problem.add_variable(ManifoldType::SE3, se3_data);
        var_keys.insert(id, key);
    }

    // Add between factors (relative pose constraints) using VarKey handles
    for edge in &graph.edges_se3 {
        let k_from = var_keys[&edge.from];
        let k_to = var_keys[&edge.to];
        problem.add_residual_block(
            &[k_from, k_to],
            Box::new(BetweenFactor::new(edge.measurement.clone())),
            None,  // Optional: add HuberLoss for robustness
        );
    }

    // Configure and run optimizer
    let config = LevenbergMarquardtConfig::new()
        .with_max_iterations(100)
        .with_cost_tolerance(1e-6)
        .with_compute_covariances(true);  // Enable uncertainty estimation

    let mut solver = LevenbergMarquardt::with_config(config);
    let result = solver.optimize(&mut problem)?;

    println!("Status: {:?}", result.status);
    println!("Initial cost: {:.3e}", result.initial_cost);
    println!("Final cost: {:.3e}", result.final_cost);
    println!("Iterations: {}", result.iterations);

    Ok(())
}

Result:

Status: CostToleranceReached
Initial cost: 1.280e+05
Final cost: 2.130e+01
Iterations: 5

Solving a BAL bundle adjustment problem

The bundle_adjustment binary solves BAL datasets with the iterative Schur solver and self-calibration:

cargo run --release --bin bundle_adjustment -- \
    data/bundle_adjustment/trafalgar/problem-257-65132-pre.txt

Programmatically the same shape is: SE(3) pose variable per camera, Rn(3) variable per landmark, optional Rn(3) intrinsics per camera (mark_for_elimination on the point keys), ProjectionFactor with a HuberLoss. See Problem Construction next.

Problem Construction

Variables and keys

Variables are stored in a slotmap-backed arena. add_variable returns a stable, generational VarKey; keep the key and pass it wherever a factor needs the variable. There are no string lookups on the hot path.

#![allow(unused)]
fn main() {
use apex_solver::{ManifoldType};
use nalgebra::dvector;

let pose = problem.add_variable(ManifoldType::SE3,
    dvector![tx, ty, tz, qw, qx, qy, qz]);
let point = problem.add_variable(ManifoldType::RN, dvector![x, y, z]);
}

Supported manifold types: SO2, SO3, SE2, SE3, SE23, SGal3, Sim3, RN. Parameters are stored in the manifold’s representation size (e.g. 7 doubles for SE(3)); optimization happens in the tangent space (6 DOF for SE(3)) via a right-perturbation retraction.

That distinction runs through everything downstream: Jacobians have tangent width, the step is applied with rather than addition, and the linear system is built in tangent coordinates. See From Factor Graph to Linear System.

Residual blocks

A residual block binds one factor to the variables it reads:

#![allow(unused)]
fn main() {
problem.add_residual_block(&[k_from, k_to], Box::new(between_factor), loss);
}
  • The factor implements Factor::linearize(params, residual, jacobian) — see Factors.
  • loss is an optional Box<dyn LossFunction>; see Robust Loss Functions.
  • To weight the block by its measurement uncertainty, use add_residual_block_with_noise and pass a NoiseModel. Without one the block is unweighted, which is only correct when every residual in the graph is already in comparable units.
  • Use try_add_residual_block for a Result-returning registration that runs the factor’s validate_variables hook — shape mismatches are caught at registration time instead of during parallel evaluation.

Gauge freedom

A pose graph is unconstrained up to a global rigid transform. Anchor it by fixing DOFs of one pose:

#![allow(unused)]
fn main() {
for dof in 0..6 {
    problem.fix_variable(pose_keys[0], dof);
}
}

Alternatively, register a PriorFactor on the first pose.

Schur landmarks

Schur-complement solvers eliminate landmark blocks. Mark the variables to eliminate:

#![allow(unused)]
fn main() {
problem.mark_for_elimination(point_key);
}

Every Schur solver (ExplicitSparseSchur, ExplicitDenseSchur, ImplicitSparseSchur) can also classify automatically from manifold type and size when the ordering is explicitly opted in:

#![allow(unused)]
fn main() {
use apex_solver::linalg::SchurOrdering;

let ordering = SchurOrdering::default().with_auto_detect(true);
}

Auto-detection is off by default: Rn(3) is ambiguous between 3-D landmarks and self-calibration intrinsics ([focal, k1, k2]), and eliminating intrinsics as landmarks silently corrupts the Schur complement.

What elimination buys, and which Schur solver to pick, is in Linear Solvers & the Schur Complement.

Factor Reference

A factor is one measurement’s contribution to the objective: given the variables it connects, it produces a residual vector and its Jacobian. Apex Solver ships about forty of them, grouped by the sensor they model.

The contract

#![allow(unused)]
fn main() {
pub trait Factor: Send + Sync {
    fn linearize(
        &self,
        params: &[&[f64]],
        residual: &mut [f64],
        jacobian: Option<faer::mat::MatMut<'_, f64>>,
    );
    fn residual_dim(&self) -> usize;
    fn jacobian_shape(&self) -> (usize, usize);
    fn validate_variables(&self, variables: &[&dyn ManifoldVariable]) -> Result<(), String> { Ok(()) }
    fn whitens_internally(&self) -> bool { false }
}
}

linearize writes into caller-owned buffers and allocates nothing — it runs once per factor per iteration, in parallel across disjoint buffers. params arrives as one zero-copy &[f64] slice per connected variable, in the order the block was registered. jacobian is column-major with shape jacobian_shape(), whose columns are the minimal (tangent) dimensions, not the parameter dimensions: an SE3 block is 7 parameters but 6 columns.

Four conventions worth knowing

Poses are body-in-world. T_wb places the body in the world, so pose.translation() is the body’s world position and a body-frame direction is . Some camera factors take a world-to-camera pose instead; each says so.

Jacobians are with respect to the right perturbation. For a Lie group variable the solver updates , so every Jacobian column is at . Take these derivatives from apex-manifolds — every group reports the Jacobians of its own operations (act, compose, log, right_plus, right_minus) through optional output arguments. Re-deriving a block such as by hand creates a second copy of a convention that can silently drift from the group’s.

A factor either whitens internally or takes a NoiseModel — never both. Most return the raw residual and let the noise model supplied at registration whiten it. A few cannot, because their weighting is bound up with the measurement itself; they report whitens_internally() == true and must be registered with NoiseModel::null(). Registering one with a noise model is rejected rather than silently double-weighted.

validate_variables is the real shape check. [profile.test] inherits release, so the debug_assert!s factors also carry are compiled out of cargo test. Every factor implements this hook, so a mismatched registration is a typed error instead of a panic inside the parallel assembly.

Writing your own

Implement the trait, then test it in this order — the order matters:

  1. A hand-computed residual. Pick a configuration whose answer you can work out on paper and assert it. This is the only test that pins your convention; everything below is self-referential without it.
  2. Finite differences on the Jacobian, away from the solution. Perturb through the manifold’s right_plus, not the raw parameters. Evaluate at a point where the residual is not near zero: residual comparisons pass through , which tends to the identity as the residual tends to zero, so a Jacobian checked only at the truth can be badly wrong and still pass.
  3. A solved graph. tests/factor_coverage.rs has one scenario per factor, and a guard test that reads these modules’ pub use lines and fails if an exported factor has no scenario.

Step 1 is not optional. A finite-difference test compares a Jacobian against its own residual, so a residual that is self-consistently wrong passes it — which is exactly how a BearingRangeFactor that rotated the wrong way, and a GpsAsyncFactor missing gravity, both survived in this repo.

Index

GroupModuleCovers
Pose & priorsfactors::poserelative constraints, anchors
Visualfactors::visualreprojection, stereo, epipolar, calibration
IMUfactors::imupreintegration on SE₂(3) and SGal(3)
LiDARfactors::lidarICP, LOAM edge/plane, GICP
GNSS & navigationfactors::navigationGNSS, barometer, attitude
Range & bearingfactors::rangingUWB, radar, bearing-only
Motion modelsfactors::motionZUPT, ZARU, nonholonomic, planar
Marginalizationfactors::marginalsliding-window priors

Factors are addressed by their module path — factors::visual::StereoFactor, factors::imu::se23::ImuFactor — so an import says which sensor a measurement came from. apex_solver::prelude re-exports only the handful nearly every program needs.

Noise Models

A factor produces a raw residual in measurement units — metres, pixels, radians. A noise model converts it into a statistically comparable quantity so that a 2 cm range error and a 0.5 px reprojection error can be summed in one objective. This is the piece that makes the least-squares problem a maximum likelihood estimate rather than an arbitrary weighted sum.

What it stores

NoiseModel (core::noise) holds the square-root information, not the covariance. Writing for the information matrix and for its factor, whitening is

so that

which is the Mahalanobis distance. Storing rather than means whitening is a multiply, with no factorization on the hot path.

Three representations, chosen by what the measurement actually needs:

VariantStoresCost
NullnothingZero — no allocation, no arithmetic
Diagonal(DVector)one entry per residual rowOne multiply per row
Dense(DMatrix)full dim × dim factorTriangular multiply

Diagonal covers the block-diagonal that most SLAM graphs use. Reach for Dense only when residual components are genuinely correlated.

Constructing one

#![allow(unused)]
fn main() {
use apex_solver::core::noise::NoiseModel;

// From standard deviations — the common case. sqrt_info_i = 1/σ_i.
let noise = NoiseModel::from_sigmas(&[0.05, 0.05, 0.02])?;

// From square-root information directly (1/σ per row).
let noise = NoiseModel::from_diagonal_sqrt_info(&[20.0, 20.0, 50.0])?;

// From a full information matrix Ω (symmetric PSD).
let noise = NoiseModel::from_information(omega)?;

// From an already-factored S.
let noise = NoiseModel::from_sqrt_info(s)?;

// Unweighted.
let noise = NoiseModel::null();
}

Register it with the block:

#![allow(unused)]
fn main() {
problem.add_residual_block_with_noise(&[key_a, key_b], factor, loss, noise);
}

Rank-deficient and indefinite information

Real datasets carry information matrices that are not positive definite. A g2o file may encode zero roll/pitch information for a planar robot, giving a rank-deficient ; floating-point round-trips can leave slightly negative eigenvalues.

from_information handles this by clamping negative eigenvalues to zero — the nearest-PSD projection — and taking the symmetric square root , which satisfies . Clamped directions whiten to zero, i.e. the measurement stops constraining them.

That clamp is silent, and on a genuinely ill-formed it deletes real constraints rather than empty ones. When the difference matters, use the reporting form:

#![allow(unused)]
fn main() {
let (noise, repair) = NoiseModel::from_information_reporting(omega)?;
if repair.is_material() {
    // `repair.clamped_directions`, `repair.min_eigenvalue` and the scale it is
    // judged against tell you whether this was floating-point noise or a
    // measurement whose Ω is wrong.
}
}

InformationRepair reports how many directions were clamped, the smallest eigenvalue before clamping, and the largest eigenvalue it is judged against — relative_indefiniteness is the ratio that distinguishes round-off from a broken measurement.

RepairStrategy then decides what to do with a materially-repaired edge:

StrategyEffect
Clamp (default)Keep the PSD projection. The objective stays the Ω-weighted χ², minus the clamped directions
UnitWeightSubstitute an identity weight for that edge. Trades χ² for unweighted cost — the reported objective must be labelled accordingly

This is not hypothetical: the cubicle pose-graph dataset ships indefinite on some edges, and switching those to unit weight moves the unweighted cost by a factor of ~7. Whichever you choose, say which objective you are reporting.

Whiten internally, or take a noise model — never both

Most factors return a raw residual and let the registered NoiseModel whiten it. Some cannot: their weighting is inseparable from the measurement — GICP’s combined point covariance, a smart factor’s internal elimination, an IMU preintegration’s propagated information matrix. Those whiten as they go and report Factor::whitens_internally() == true.

Pairing an internally-whitened factor with a non-null noise model whitens twice and silently over-weights the block. The two conventions are indistinguishable by inspecting a residual, so registration rejects the combination outright rather than letting it through:

#![allow(unused)]
fn main() {
// Rejected at registration — the factor already applied its information.
problem.try_add_residual_block_with_noise(&keys, gicp_factor, None, dense_noise)?;

// Correct.
problem.try_add_residual_block_with_noise(&keys, gicp_factor, None, NoiseModel::null())?;
}

Where whitening happens in the pipeline

Whitening runs after the factor linearizes and before the robust loss — see From Factor Graph to Linear System. The ordering is not incidental: a robust loss decides whether a residual is an outlier, and that is a question about standard deviations, not raw units. A 3 px reprojection error is an outlier only relative to the measurement’s σ.

Choosing σ

The noise model is a modelling decision, not a tuning knob, and getting it wrong biases the estimate rather than just slowing convergence:

  • Too small σ (over-confident) — the factor dominates and pulls the solution toward that measurement, over-riding better-informed ones.
  • Too large σ (under-confident) — the factor is effectively ignored.
  • Relative scale is what matters. Multiplying every σ in the graph by a constant leaves the optimum unchanged; it only rescales the cost. What changes the answer is the ratio between different sensors’ models.

For a robust loss to work as intended, σ should reflect the inlier distribution — the loss then handles the tail. Inflating σ to “cover” outliers and also applying a robust loss down-weights inliers twice.

Robust Loss Functions

Outliers dominate the ordinary least-squares objective. Apex Solver ships 15 robust kernels — Huber, Cauchy, Tukey, Welsch, Fair, GemanMcClure, Andrews, Trimmed, L1, Lp, Barron variants and more — all in apex_solver::core::loss_functions.

Usage

#![allow(unused)]
fn main() {
use apex_solver::core::loss_functions::HuberLoss;

problem.add_residual_block(
    &[k_pose, k_landmark],
    Box::new(projection_factor),
    Some(Box::new(HuberLoss::new(1.345)?)),
);
}

What the solver actually computes

For a squared residual norm , the robust objective is

Minimizing is equivalent to an iteratively reweighted least-squares problem whose residual and Jacobian are

with from the Triggs correction. Apex Solver applies exactly this correction in-place during block evaluation, and reports the cost as — not as the norm of the corrected residual. The two are different functions; conflating them silently corrupts every reported cost and trust-region ratio on robust problems.

From Factor Graph to Linear System

This chapter follows one optimizer iteration end to end: how the factor graph is turned into J and r, how each factor’s contribution is whitened, robustified and scattered into the global system, and how the solved step gets back onto the manifold. It describes what the code in core/ and linearizer/ actually does.

The graph

A Problem holds two arenas (core::problem):

  • Variables — the unknowns, each a manifold element (SE3, SO3, Rn, …), stored in a SlotMap and addressed by a stable VarKey.
  • Residual blocks — the factors, each a ResidualBlock holding one boxed Factor, the VarKeys it connects, an optional NoiseModel, and an optional robust LossFunction.

This is the factor graph: variables are nodes, residual blocks are the (hyper-)edges connecting them. There is no separate graph structure.

#![allow(unused)]
fn main() {
let pose_i = problem.add_variable(ManifoldType::SE3, pose_i_params);
let pose_j = problem.add_variable(ManifoldType::SE3, pose_j_params);

problem.add_residual_block(
    &[pose_i, pose_j],           // the edge's endpoints
    Box::new(BetweenFactor::new(measurement)),
    Some(Box::new(HuberLoss::new(1.0)?)),
);
}

Each block is assigned a residual_row_start_idx — its offset in the stacked residual vector — and each variable a column offset in the tangent space. Those two offsets are what turn a graph into a matrix.

What the objective actually is

Stacking every block’s residual gives , and the problem is

where is the whitened residual of block and its robust loss (identity when none is set). The three sections below are exactly the three transformations between “what the factor computes” and “what the solver sees”.

Step 1 — the factor linearizes itself

Every factor implements one method (factors::Factor):

#![allow(unused)]
fn main() {
fn linearize(
    &self,
    params: &[&[f64]],                         // one slice per connected variable
    residual: &mut [f64],                      // output, length residual_dim()
    jacobian: Option<faer::mat::MatMut<'_, f64>>, // output, jacobian_shape()
);
}

Given the current parameter values, it writes

where is the predicted measurement, the observation, and whatever difference the measurement space requires — subtraction for a Euclidean measurement, between + log for a pose measurement.

The Jacobian is with respect to the tangent space at the current estimate, not the parameter vector. For an SE3 variable stored as 7 parameters the block has 6 columns, and it is the derivative of the residual with respect to a local perturbation applied as . This is what makes the linear system well-posed on a manifold: the 7-parameter quaternion representation is over-parameterised and would give a singular .

Factors must obtain these derivatives from the manifold, not re-derive them. Every group in apex-manifolds reports the Jacobians of its own operations (act, compose, log, right_plus, between) through optional output arguments. A hand-written [R | −R[p]ₓ] is a second copy of a convention that can silently drift from the group’s — factors::common deliberately contains no manifold derivatives for this reason.

The buffers are pre-allocated and reused across iterations; linearize never allocates.

Step 2 — whitening by the noise model

A measurement with covariance must be weighted by its information. The NoiseModel stores the square-root information and applies it (core::noise):

so that — the Mahalanobis distance. A Diagonal model is a per-row scale; a Dense model is a triangular multiply.

Whitening happens before the robust loss, in both the block-evaluation path and the assembly path, and the two are documented to keep the same ordering. The loss must see the whitened norm, because “is this residual an outlier?” is a question about standard deviations, not raw units.

A factor either whitens internally or takes a NoiseModel — never both. Some factors’ weighting is inseparable from the measurement (GICP’s combined point covariance, a smart factor’s internal elimination, an IMU preintegration’s information matrix), so they whiten as they go and report whitens_internally() == true. Registering one of those with a non-null noise model would whiten twice and silently over-weight the block; the two conventions are indistinguishable by inspecting a residual, so registration rejects the combination outright.

Step 3 — the robust loss becomes a reweighting

A robust loss turns the problem into a different least-squares problem rather than changing the solver. core::corrector implements Ceres’s corrector: given and the loss derivatives ,

so that and the gradient is correct. The rank-one term is what makes this more than naive iteratively-reweighted least squares: it carries the curvature of , so the Gauss-Newton model of the robustified problem stays accurate.

Ordering matters and is load-bearing: the Jacobian correction reads the un-corrected residual, so correct_jacobian_in_place runs before correct_residual_in_place. Reversing them silently changes the rank-one term.

After this step the solver sees an ordinary least-squares problem in — every optimizer and linear solver downstream is unaware that a robust loss was involved at all.

Step 4 — scattering into the global system

Each block has produced a small dense Jacobian, where cols is the sum of its variables’ DOFs. Assembly places those numbers into the global (linearizer::cpu):

  • block ’s rows go at its residual_row_start_idx,
  • the sub-block for variable goes at ’s column offset.

The result is block-sparse: a row touches only the variables its factor connects, which is precisely the graph’s adjacency.

The sparsity pattern is symbolic and built once. SymbolicStructure computes the pattern and a value permutation before the iteration loop, and every subsequent iteration only refills values in that fixed layout — the pattern is a property of the graph, not of the estimate. Recomputing it per iteration is the single most common way to make a solver slow.

Assembly is parallelised over blocks with rayon into disjoint row ranges, so no two threads write the same row. This is why the visiting order is load-bearing: values are pushed positionally against the symbolic pattern’s pairs, so any disagreement between the two orders would permute ’s rows against — a wrong system that still factorizes cleanly.

Fixed variables (gauge freedom)

A pose-graph or BA problem is invariant under a global transformation, so is singular unless something is pinned. fix_variable holds individual tangent components fixed:

#![allow(unused)]
fn main() {
for dof in 0..6 {
    problem.fix_variable(first_pose, dof);   // anchor the gauge
}
}

Fixed components are excluded from the column space, so they never enter the linear system and cannot move.

Step 5 — solve, and return to the manifold

The optimizer now has and , and forms

Which linear system gets solved is the optimizer’s business (Optimizers) and how it is solved is the backend’s (Linear Solvers). Both produce a tangent-space step .

The step is not added to the parameters. It is applied through the manifold’s right-plus:

for each variable’s own group, using the same right convention the Jacobians were taken with. Mixing conventions here — taking a left-Jacobian and applying a right update — produces a step that is wrong by a factor of the adjoint, and it still converges, just slowly and to a slightly different place.

The loop

build symbolic structure          once, from the graph's adjacency
─── per iteration ────────────────────────────────────────────────
  for each residual block (parallel):
      factor.linearize(params) ──────────► r_i, J_i     tangent-space
      noise.whiten(...)        ──────────► r̃_i, J̃_i     Mahalanobis
      corrector.correct(...)   ──────────► r̂_i, Ĵ_i     robustified
      scatter into J, r at (row_start, col_offsets)
  ─────────────────────────────────────────────────────────────────
  solve for h   (Gauss-Newton / LM / Dog Leg × linear backend)
  x ← x ⊞ h     per-variable right-plus
  check convergence

Every iteration repeats steps 1–5 at the new estimate: the residuals, the Jacobians and the robust weights are all recomputed, while the sparsity pattern and every cached symbolic structure are reused.

Why this is efficient

  • Symbolic once, numeric many. The pattern of , of , and the permutation linking them are computed once and reused, so an iteration is a parallel value gather plus a parallel product.
  • Blocks are independent. Linearization is embarrassingly parallel; only the scatter needs disjoint ranges, which the row layout guarantees.
  • No allocation in the hot path. Factor buffers, the assembly arena and the solver workspaces are allocated once and reused.
  • Manifold parameters stay contiguous. They live in nalgebra storage that faer views without copying, so no conversion happens between assembly and the linear solve.
  • Structure is exploited, not discovered. The Schur partition, the elimination groups and the chunk layout all come from the graph’s adjacency, which is known before the first iteration.

Optimizers

This chapter documents what the three optimizers in src/optimizer/ actually do — the equations as implemented, the defaults as coded, and the deviations from textbook forms with the reason the code gives for each. Every formula names the module it comes from.

The problem all three solve

Given residual blocks over manifold variables, Problem defines

Variables live on manifolds, so the step is applied with rather than addition: , with the per-group right-plus from apex-manifolds. The Jacobian is therefore taken with respect to the tangent space, and every matrix below has tangent dimension, not parameter dimension. An SE3 variable contributes 6 columns, not 7.

Two quantities recur:

H drops the term of the true Hessian, which is the Gauss-Newton approximation and is accurate when residuals are small or nearly linear (gauss_newton.rs).

Sign convention. LinearSolver::get_gradient returns , not the right-hand side (linalg/mod.rs). Each optimizer negates it before forming the linear system. tests/linear_solver_contract.rs pins this for every backend, because getting it backwards produces a step that ascends.


Gauss-Newton

optimizer/gauss_newton.rs. Solves the normal equations exactly and takes the full step, with no step-size control at all (gauss_newton.rs):

There is no damping, no trust region, and no line search. Convergence is quadratic near a solution where the Gauss-Newton approximation holds, and the method diverges when started far away or when is ill-conditioned. Use it when you already know the problem is well-conditioned and the initial guess is close; otherwise use LM.

Options

FieldDefaultMeaning
linear_solver_typeSparseCholeskyWhich backend solves the normal equations
min_diagonal1e-10Conditioning floor
max_iterations50Iteration cap (Ceres’s default)
cost_tolerance1e-6Relative cost-change stop
parameter_tolerance1e-8Relative step-size stop
gradient_tolerance1e-10First-order optimality stop
use_jacobi_scalingfalseColumn equilibration
max_condition_numberNoneOptional conditioning guard
min_cost_thresholdNoneEarly exit once
compute_covariancesfalsePopulate SolverResult::covariances
timeoutNoneWall-clock cap

The schur_* fields (schur_variant, schur_preconditioner, schur_cg_max_iterations, schur_cg_tolerance, schur_cg_q_tolerance) exist on all three configs and are forwarded to the linear solver — see Linear Solvers.


Levenberg–Marquardt

optimizer/levenberg_marquardt.rs. The default optimizer, and the one for_bundle_adjustment() presets.

The damped system

D is not the identity. It is the clamped diagonal of the Hessian (linalg/mod.rs):

This is Ceres’s LevenbergMarquardtStrategy: damping each column in proportion to its own curvature makes the step invariant to a rescaling of the parameters, which uniform is not. Damping::identity sets and recovers plain exactly.

The lower clamp matters: without it a column with no curvature would receive no damping at all, leaving that direction unconstrained.

Step quality

After solving, the step is scored by the gain ratio. Predicted reduction comes from the quadratic model (optimizer/mod.rs):

evaluated with the un-damped — hessian_vec_product is documented to never carry , because damping in the model would corrupt (linalg/mod.rs). Then (optimizer/mod.rs):

with two guards the textbook ratio does not have:

  • → . A negative predicted reduction means the model says the step increases cost, so it is rejected outright rather than producing a misleading positive ratio.
  • → if the actual reduction was positive, else .

The step is accepted when min_relative_decrease, default 1e-3 (levenberg_marquardt.rs), matching Ceres. This is not the textbook .

Damping update — two rules

Selected by damping_update (levenberg_marquardt.rs).

Nielsen (default, what Ceres uses). On an accepted step:

On a rejected step and , so consecutive failures escalate geometrically. Reads damping_nu; ignores damping_increase_factor, damping_decrease_factor, min_step_quality, good_step_quality.

Marquardt. Three bands:

A rejected step always increases whatever was — the code notes that leaving it unchanged would recompute the identical step next iteration and stall (levenberg_marquardt.rs). Afterwards is clamped to [damping_min, damping_max].

Options

FieldDefaultNotes
damping (λ₀)1e-4Ceres’s initial_trust_region_radius = 1e4 corresponds to λ = 1/radius
damping_min / damping_max1e-12 / 1e12Clamp after every update
damping_increase_factor10.0Marquardt only
damping_decrease_factor0.3Marquardt only
damping_nu (ν₀)2.0Nielsen only
min_diagonal / max_diagonal1e-6 / 1e32The clamp on D
min_relative_decrease1e-3Acceptance threshold on ρ
good_step_quality0.75Marquardt only
min_step_quality0.0Marquardt only
max_consecutive_rejected_steps5Then StalledNoProgress
max_iterations50Ceres’s default
cost_tolerance1e-6
parameter_tolerance1e-8
gradient_tolerance1e-10
use_jacobi_scalingfalseOff because it is incompatible with Schur block structure (levenberg_marquardt.rs)

Note the scaling default differs between optimizers: LM defaults to false, Dog Leg to true. Enable it for LM on mixed-scale problems solved with Cholesky or QR.

for_bundle_adjustment() preset

levenberg_marquardt.rs overrides: ImplicitSparseSchur linear solver, SchurJacobi preconditioner, λ₀ = 1e-3 (the plain default is 1e-4), max_iterations = 20, cost_tolerance = 1e-6, parameter_tolerance = 1e-8, gradient_tolerance = 1e-10. The solver choice is justified by the measured table in that comment; see Benchmarks.


Dog Leg

optimizer/dog_leg.rs. Powell’s dog leg over a spherical trust region of radius , interpolating between the steepest-descent and Gauss-Newton directions.

Cauchy point

The unconstrained minimiser along (dog_leg.rs):

with a guard: if then .

Step selection — three cases

From dog_leg.rs, given :

Case 1 — GN step fits. If , take (StepType::GaussNewton).

Case 2 — Cauchy point already outside. If , take the steepest-descent direction scaled to the boundary (StepType::SteepestDescent):

Case 3 — interpolate. Otherwise walk from toward to the boundary. With , solve , i.e. with

The implementation does not use the textbook quadratic root. It uses Ceres’s cancellation-avoiding branch (dog_leg.rs), with :

Choosing by the sign of avoids catastrophic cancellation in the subtraction. Fallbacks: when (geometrically impossible, handled anyway) or (degenerate ). Finally is clamped to and (StepType::DogLeg).

Trust-region update

dog_leg.rs. Growth is Ceres’s rule, not a fixed doubling:

and is left unchanged in between.

is measured in the scaled space when Jacobi scaling is on — which is Dog Leg’s default. The code documents why: the radius bounds the scaled step, so feeding the un-scaled norm would compare two different units and grow by an arbitrary factor (dog_leg.rs).

Step reuse

On a rejected step Dog Leg caches the GN step, Cauchy point, gradient and , and reuses them next iteration (dog_leg.rs). Only changed, so the expensive linear solve does not have to be redone. The cache is invalidated on any accepted step, because the parameters moved. Controlled by enable_step_reuse (default true).

Options

FieldDefaultNotes
trust_region_radius (Δ₀)1e4Ceres-style large initial radius
trust_region_min / max1e-12 / 1e12Δ floor triggers TrustRegionRadiusTooSmall
trust_region_increase_factor3.0The 3 in max(Δ, 3‖h‖)
trust_region_decrease_factor0.5
good_step_quality0.75Grow above this
poor_step_quality0.25Shrink below this
min_step_quality0.0Acceptance threshold
use_jacobi_scalingtrueElliptical trust region
initial_mu / min_mu / max_mu1e-4 / 1e-8 / 1.0Adaptive regularisation for a singular
mu_increase_factor10.0μ shrinks as μ / (0.5·factor) on success
enable_step_reusetrueSee above

Jacobi column scaling

When use_jacobi_scaling is on, columns are equilibrated before the solve (optimizer/mod.rs):

The solve happens in the scaled space and the step is mapped back with . The in the denominator keeps the scaling finite for a structurally empty column.

Ordering matters and the code is explicit about it: the predicted reduction is computed before un-scaling (levenberg_marquardt.rs), because the solver’s cached gradient and Hessian are the scaled ones and all three vectors must live in the same space. The predicted reduction is a value of the quadratic model and is invariant under the change of variables, so it is equally valid for the un-scaled step.


Convergence criteria

All three optimizers share check_convergence (optimizer/mod.rs), evaluated in this order.

Always checked, before anything else:

  1. InvalidNumericalValues — any of new cost, step norm or gradient norm is NaN or infinite.
  2. Timeout — elapsed ≥ timeout.
  3. MaxIterationsReached — iteration ≥ max_iterations.

Only after an accepted step (a rejected step cannot terminate the solve):

  1. GradientToleranceReached — .
  2. From iteration 1 onward, ParameterToleranceReached: — a relative test, so it scales with the parameter magnitude.
  3. From iteration 1 onward, CostToleranceReached: .
  4. MinCostThresholdReached — optional cutoff.
  5. TrustRegionRadiusTooSmall — LM and Dog Leg only.

LM additionally reports StalledNoProgress after max_consecutive_rejected_steps consecutive rejections (levenberg_marquardt.rs): damping has grown until every trial step is negligible, so the remaining budget cannot change the cost.

Output

All three return SolverResult<T> (optimizer/mod.rs):

FieldMeaning
parametersFinal values
statusThe OptimizationStatus above
initial_cost / final_cost at start and end
iterationsOuter iterations performed
elapsed_timeWall clock for the solve
convergence_infoPer-iteration history, when recorded
covariancesPer-variable tangent-space covariance; None unless compute_covariances

Note that several statuses are successful terminations, not failures: Converged, CostToleranceReached, GradientToleranceReached, ParameterToleranceReached and StalledNoProgress are all treated as converged by the benchmark harness. MaxIterationsReached means the budget ran out with the solve still improving.

Linear Solvers

Every optimizer iteration reduces to one linear solve. This chapter documents the backends in src/linalg/: what each one computes, the equations as implemented, and how to configure them. Formulas name the module they come from.

Two traits define the contract (linalg/mod.rs):

MethodReturns
solve_normal_equation(r, J) solving
solve_augmented_equation(r, J, damping) solving
hessian_vec_product(v) for the un-damped
get_gradient() — note the sign
get_hessian() when the backend happens to hold one; None is valid

get_hessian() returning None is not a failure. A backend that eliminates straight from never materialises , and callers must degrade to hessian_vec_product rather than error — that is what makes the matrix-free paths expressible.

JacobianMode picks the assembly: Sparse (default) or Dense (below ~500 DOF). LinearSolverType then picks the algorithm within that mode, and the optimizer rejects mismatched combinations (levenberg_marquardt.rs).

Normal equations

For the non-Schur backends, and are formed once per iteration (sparse/normal_eq.rs). The sparsity of , of , and the value permutation linking them are cached across iterations, so each evaluation is a parallel value gather plus faer’s parallel sparse product. The cache is keyed on ’s sparsity, which must not change between iterations.

Sparse Cholesky

SparseCholeskySolver — factorization of (or ). Symbolic factorization cached, numeric pass repeated. The default for pose graphs.

Sparse QR

SparseQRSolver — factorizes directly rather than forming , which squares the condition number. More robust on ill-conditioned problems, at a higher per-iteration cost.

Dense Cholesky / QR

DenseCholeskySolver, DenseQRSolver — for JacobianMode::Dense. Both route their SPD solves through one shared solve_spd helper (dense/cholesky.rs) so the factorization and error mapping exist in exactly one place.


The Schur complement

Why

In bundle adjustment most variables are landmarks. Ladybug has 1,723 cameras and 156,502 landmarks: 15,507 retained DOF against 469,506 eliminated. Landmarks are mutually unconnected — no factor touches two of them — so the landmark block of the Hessian is block-diagonal and can be eliminated cheaply, leaving a system in the cameras alone.

The algebra

Partition the variables into an eliminated set (e, Ceres’s “group 0”) and a retained set (k, “group 1”). The system becomes (linalg/schur/partition.rs):

Eliminating gives the reduced system:

and back-substitution recovers the eliminated half:

Nothing here mentions cameras or points — the partition is agnostic to what the variables mean, which is why the same code covers classic BA, inverse-depth parameterisations, LiDAR features and marginalization.

The precondition, and how it is enforced

The elimination is exact only when is block-diagonal, i.e. the eliminated variables are mutually unconnected. Violating it yields a wrong step with no other symptom, so it is checked rather than assumed:

  • Solvers that form call SchurPartition::verify_block_diagonal (partition.rs) — one pass over the eliminated columns’ nonzeros.
  • The matrix-free path never forms , so it checks against ’s rows instead: a residual row touching two eliminated variables is the same violation (sparse/schur/implicit.rs, ensure_structure).

Both report a typed error naming the two offending variables.

Damping under Schur

is applied to both blocks before elimination — damping the reduced system instead would not be the same problem (levenberg_marquardt.rs, and explicit.rs):

Landmark block inversion

is formed blockwise. Every Schur solver shares one regularized-retry policy, EliminatedBlocks::invert_in_place (partition.rs), so a landmark is regularized identically regardless of which solver eliminated it.

Choosing what to eliminate

SchurOrdering (linalg/schur/ordering.rs) classifies variables. Manual marks always apply:

#![allow(unused)]
fn main() {
problem.mark_for_elimination(landmark_key);
}

Auto-classification by manifold type and DOF is available but off by default:

#![allow(unused)]
fn main() {
let ordering = SchurOrdering::new().with_auto_detect(true);
}

The default is off for a specific reason (ordering.rs): Rn(3) is also how self-calibration represents intrinsics [focal, k1, k2]. Auto-detecting “any Rn(3) is a landmark” would silently eliminate intrinsic blocks and corrupt . Turn it on only when you know every Rn(3) in the problem is a landmark.


The four Schur solvers

LinearSolverTypeSub-configModeCeres equivalent
ExplicitSparseSchurExplicitSchurVariant::SparseSparseSPARSE_SCHUR
ExplicitSparseSchurExplicitSchurVariant::ChunkedSparseSchurEliminator
ExplicitSparseSchurExplicitSchurVariant::IterativeSparseITERATIVE_SCHUR + use_explicit_schur_complement
ImplicitSparseSchurpreconditionerSparseITERATIVE_SCHUR
ExplicitDenseSchur—DenseDENSE_SCHUR

ExplicitSchurVariant::Sparse

Forms , builds explicitly, factorizes with sparse Cholesky. The most accurate: it solves the reduced system exactly, and is the reference the other paths are measured against.

is accumulated into a dense buffer and filtered back to sparse (explicit.rs). That buffer is the current scaling limit — 1.9 GB at Ladybug’s 15,507 retained DOF.

ExplicitSchurVariant::Chunked

Algebraically identical to Sparse — the benchmark confirms bit-identical RMSE on all four datasets — but builds chunk by chunk directly from , never forming , , or the value permutation. On the largest BAL problem those account for ~24 GB of the ~32 GB needed before elimination can start.

Per chunk (the rows sharing one eliminated variable), with and :

This trades time for memory and is slower in wall clock — 1.2×–2.1× the Sparse variant. The chunk sweep is inherently serial: reading rows out of a column-major without building a CSR copy relies on one forward-only cursor per retained column, which forces chunks to be visited in increasing row order. The path it replaces is fully parallel, so on a many-core machine the lost parallelism outweighs the saved work. Requires each eliminated variable’s rows to be contiguous, which Problem::group_rows_for_elimination arranges.

ExplicitSchurVariant::Iterative

Forms explicitly like Sparse, then solves it with PCG instead of Cholesky. Carries the same memory cost while solving less exactly, so it buys nothing on memory — but it measures 0.5×–1.3× Sparse on time.

ImplicitSparseSchur — the matrix-free path

Forms neither nor . Writing , PCG only ever needs ’s action on a vector, and expanding that action so it reads alone gives the whole solver (implicit.rs):

Four passes over ’s nonzeros, no intermediate matrix. This is why it exists: for BA carries a dense block for every pair of cameras sharing a landmark — fill-in does not have — so cost scales with rather than . Ceres states the same cost model for ITERATIVE_SCHUR.

Everything else it needs also comes from (linalg/schur/jacobian_ops.rs): the gradient , the damping diagonal , and the blocks as column dot products.

get_hessian() returns None here; the quadratic model is served exactly as .

This is what for_bundle_adjustment() selects.

ExplicitDenseSchur

The same explicit construction over a dense Hessian, for JacobianMode::Dense. Targets a few thousand DOF — a dense for the smallest BAL dataset would be ~485k × 485k.


Preconditioners

SchurPreconditioner applies to both PCG paths. The three match Ceres’s options for ITERATIVE_SCHUR:

This crateCeresBuilt from
NoneIDENTITY— (unpreconditioned)
BlockDiagonalJACOBIdiagonal blocks of (i.e. )
SchurJacobi (default)SCHUR_JACOBIdiagonal blocks of itself

SchurJacobi is the true one — it includes the elimination correction, so it preconditions rather than the unreduced retained block:

summed over the eliminated blocks visible to — captured by a visibility index cached per sparsity pattern, so this costs rather than . Each block is inverted with the shared retry policy; a block that will not invert falls back to identity, because a preconditioner is a convergence aid and must not fail the solve.

PCG and the forcing sequence

Both PCG paths share one loop (linalg/schur/pcg.rs), starting from — every caller solves for a Newton step, not a general system that would benefit from a warm start.

Two stopping rules, and the second matters more. The residual rule is

but CG minimises the quadratic model , and that value is available for free from vectors already on hand:

A trust-region optimizer does not need the system solved accurately; it needs a step that reduces the model. Insisting on a small residual on an ill-conditioned burns iterations long after the model stops moving — measured on Ladybug, every solve ran the full iteration cap without the residual rule ever firing. So, following Ceres, the solve also stops on the forcing sequence with :

The shape tightens the requirement as iterations accumulate, so early Newton steps are cheap and approximate while later ones — where the optimizer is close and needs accuracy — are solved harder.

defaults to 1e-2, deliberately tighter than Ceres’s 1e-1, because 1e-1 measured 9.7% worse RMSE on Ladybug and that loss did not recover with more optimizer iterations. The sweep is tabulated at DEFAULT_ETA (pcg.rs). Set with_schur_cg_q_tolerance(0.0) to disable the rule and get an exact solve — what the cross-solver agreement tests do.

PcgResult::termination reports which rule fired: Residual, QuadraticModel, MaxIterations (the step is truncated — worth logging) or Breakdown.

Configuration

#![allow(unused)]
fn main() {
use apex_solver::linalg::{LinearSolverType, ExplicitSchurVariant, SchurPreconditioner};
use apex_solver::optimizer::levenberg_marquardt::LevenbergMarquardtConfig;

// The BA preset: matrix-free, Schur-Jacobi, forcing sequence on.
let config = LevenbergMarquardtConfig::for_bundle_adjustment();

// Exact reduced solve instead, when the step must not depend on a tolerance.
let exact = LevenbergMarquardtConfig::for_bundle_adjustment()
    .with_linear_solver_type(LinearSolverType::ExplicitSparseSchur)
    .with_schur_variant(ExplicitSchurVariant::Sparse);

// Memory-bound: never form JᵀJ, accept the slower serial sweep.
let low_memory = LevenbergMarquardtConfig::for_bundle_adjustment()
    .with_linear_solver_type(LinearSolverType::ExplicitSparseSchur)
    .with_schur_variant(ExplicitSchurVariant::Chunked);

// Tune the iterative paths.
let tuned = LevenbergMarquardtConfig::for_bundle_adjustment()
    .with_schur_preconditioner(SchurPreconditioner::SchurJacobi)
    .with_schur_cg_params(200, 1e-6)   // max iterations, residual tolerance
    .with_schur_cg_q_tolerance(1e-2);  // forcing sequence; 0.0 disables
}

Mark what to eliminate on the problem itself:

#![allow(unused)]
fn main() {
let point = problem.add_variable(ManifoldType::RN, position);
problem.mark_for_elimination(point);
}

Which to use

SituationChoice
Large BA (the default)ImplicitSparseSchur — fastest on the three large BAL sets, forms neither nor
The step must be exactExplicitSparseSchur / Sparse — no tolerance dependence
Small problemsExplicitSparseSchur / Sparse — direct factorization wins when is small
will not fitExplicitSparseSchur / Chunked — same answer, serial, memory-lean
Dense mode, few thousand DOFExplicitDenseSchur
Pose graphsNot Schur at all — SparseCholesky

Measured numbers for each are in Benchmarks.


Covariance estimation

Covariance::compute re-linearizes the problem at a point, forms a clean and inverts via sparse Cholesky or dense SVD:

#![allow(unused)]
fn main() {
use apex_solver::linalg::covariance::{Covariance, CovarianceAlgorithm, CovarianceOptions};

let cov = Covariance::compute(
    CovarianceOptions::new(CovarianceAlgorithm::SparseCholesky),
    &problem,
    &variables,
)?;
let block = cov.block(key); // dof × dof marginal in tangent space
}

The covariance is a property of the problem at a point — it never consults the optimizer’s last linear system.

Pose & Priors

factors::pose — relative constraints and anchors. No sensor model: these express graph topology.

Throughout, is the right perturbation , its inverse , and , the right and left Jacobians of the group. The identity used everywhere below is


BetweenFactor<T>

A relative measurement between two variables of the same Lie group. Odometry edges and loop closures are both this factor, the latter usually with a robust loss.

Blocks — any T: LieGroup. Residual , Jacobian .

Error

With measurement ,

zero exactly when the estimated relative transform equals the measured one.

Jacobian

Write . Perturbing the second variable, , gives directly

Perturbing the first, , moves the inverse to the left of , and it has to be commuted through:

so

For SE(3), and

The implementation never writes these forms out: it chains the Jacobians that between, compose and log report, so it stays correct for every group.

#![allow(unused)]
fn main() {
use apex_solver::factors::pose::BetweenFactor;

problem.add_residual_block_with_noise(
    &[key_i, key_j],
    Box::new(BetweenFactor::new(measured_relative_pose)),
    Some(Box::new(HuberLoss::new(1.345)?)),
    NoiseModel::from_sigmas(&[0.05, 0.05, 0.05, 0.01, 0.01, 0.02])?,
);
}

PriorFactor<T>

Anchors one variable to a known value in the tangent space. Prefer this to Problem::fix_variable: fixed indices are honoured when the step is applied, but the linear system still treats those coordinates as free, so variables sharing factors with a “fixed” one are systematically under-corrected.

Blocks . Residual , Jacobian square.

Error and Jacobian

This is a BetweenFactor with an identity measurement and a constant first argument, and is implemented as exactly that — the two share one chain and cannot drift apart. Because the residual never touches the parameters, there is no quaternion double-cover ambiguity.


EuclideanPriorFactor

Registration rejects any manifold but Rn — on a Lie group this would difference raw parameters, which is not a tangent-space error. Use it to anchor velocities, biases and intrinsics.


PoseRotationPrior / PoseTranslationPrior

Constrain one half of an SE(3) pose and leave the other free.

The translation block is , not , because the right perturbation moves the origin by in the world frame.

These are not a decomposition of PriorFactor<SE3>: the translation rows here are the world-frame difference, whereas the prior’s are the coupled SE(3) logarithm , which mixes rotation into translation through . Stacking both does not reproduce the full prior.

PoseRotationPrior is the usual rotation-only loop-closure initializer; PoseTranslationPrior is how a GNSS position fix attaches to an SE(3) pose.

Visual

factors::visual — everything that goes through a camera.

All projection factors share a smooth cheirality penalty. A landmark behind the camera cannot be projected, but returning zero would make an invalid configuration look optimal, so they emit

with , — large enough to dominate any plausible in-image residual, small enough not to ill-condition the normal equations, and carrying a gradient that pushes the point back in front.

Two derivatives recur throughout. For a world-to-camera pose acting directly, :

For an inverted pose, , a right perturbation gives :

Both come from SE3::act, which reports them itself.


ProjectionFactor<CAM, OP>

Monocular reprojection over observations of one pose, generic over the camera model and over which blocks are optimized.

Blocks , selected at compile time by OP: OptimizationConfig — BundleAdjustment, SelfCalibration, OnlyPose, OnlyLandmarks, OnlyIntrinsics, PoseAndIntrinsics, LandmarksAndIntrinsics. Unused blocks cost nothing; the const-generic flags remove their columns entirely.

Residual , Jacobian .

Error

Jacobian

The camera model supplies (2×3) and (2×) analytically. For a pinhole model,

Nine models are available (Pinhole, RadTan, KannalaBrandt, DoubleSphere, Eucm, Ucm, Fov, …).


ExtrinsicProjectionFactor<CAM>

Reprojection with the camera-to-body transform as a variable. On a real rig the extrinsics are the least trustworthy part of the setup and are shared by every observation from that camera, so they belong in the graph where many observations determine them.

Blocks — residual 2, Jacobian . Both poses read the way a calibration file does: body-in-world, camera-in-body.

Error

Jacobian

With (2×6),

The factor is , which compose reports directly. The extrinsics block gets unchanged because .


TimeOffsetProjectionFactor<CAM>

Reprojection with the camera-to-IMU clock offset as a variable. The two clocks differ by a constant lag ; unmodelled it is indistinguishable from an extrinsic rotation error at constant angular rate, and 20 ms at 1 m/s is 2 cm of position error.

Blocks — residual 2, Jacobian .

Error

A first-order expansion of the state to the exposure instant,

then and the same reprojection as above. The state is SE23 because it already carries ; is the bias-corrected gyro sample, a measurement, since the graph has no angular rate to estimate.

Jacobian

The SE23 tangent pushes to the exposure pose’s SE(3) tangent as

and the offset column is the exposure pose’s own tangent velocity,

Both are then chained with exactly as above. Note the offset column vanishes when and : a stationary platform cannot observe , which the tests assert directly.


StereoFactor

Rectified stereo, so disparity fixes depth in a single observation.

Blocks — residual 3, Jacobian .

Error

Jacobian

chained with and . Calibration is fixed at construction — this is the one camera factor not generic over CameraModel.


InverseDepthFactor<CAM>

A landmark parameterized in its anchor camera by pixel and inverse depth , which stays well-conditioned for distant, low-parallax points where a Euclidean 3D point blows up.

Blocks — residual 2, Jacobian .

The anchor block’s Jacobian carries , which is what makes the parameterization behave as : the inverse depth stays finite and its derivative bounded where would not.


SmartProjectionFactor<CAM>

Structure-less: connects poses to a landmark that is never a variable. The point is re-triangulated by DLT from the current poses at every linearization, then eliminated exactly by the implicit Schur complement.

Blocks poses — residual , Jacobian . Whitens internally; register with NoiseModel::null().

Jacobian

Writing (2×6) and (2×3),

The triangulated point satisfies the first-order optimality condition , so this dense is the total derivative of the re-triangulated residual, and

is exactly the Schur complement. Degenerate geometry (pure rotation, rank-deficient DLT) returns a bounded penalty; status() reports which.


EssentialMatrixFactor / EssentialMatrixConstraint

2D–2D epipolar geometry over a relative pose, for initialization and loop-closure verification before any landmark exists. Points are normalized camera coordinates.

The direction normalization contributes , which is rank 2 — both factors are scale-free by construction. Only the translation direction is observable from epipolar geometry, so a graph containing only these has a one-dimensional null space; the constraint’s six rows are effectively rank 5.


DepthFactor<ONESIDED> / HomogeneousPointFactor

Both act on a homogeneous 4D landmark , the parameterization that keeps points at infinity representable.

DepthFactor — an RGB-D-style depth reading

Blocks — residual 1, Jacobian .

With ONESIDED = true (OneSidedDepthFactor) both residual and Jacobian are zeroed when , penalizing only points that come too close — a free-space constraint rather than a depth measurement.

HomogeneousPointFactor — a unary prior on the dehomogenized position

Blocks — residual 3, Jacobian .

Both whiten internally.

IMU

factors::imu — inertial preintegration. Two factors per group and nothing else, so a choice here is two questions rather than a catalogue.

Preintegration

Integrating raw IMU samples between keyframes would re-integrate the whole interval every time the state changes. Preintegration instead accumulates the measurements into a relative quantity that does not depend on the states — only on the bias linearization point — so a keyframe pair can be re-evaluated in constant time.

ImuPreintegration::new(measurements, params, t0, t1, &bias_ref) accumulates the delta , its covariance, and the bias-correction Jacobians . Everything it produces is gravity-free; the factors reinstate gravity when they build the gravity-corrected state.

Two axes

Which group? se23 models a keyframe as ; sgal3 adds a time coordinate, , making the inter-keyframe interval an estimated quantity — pick it for time-offset or rolling-shutter calibration, se23 otherwise.

Combined or not? This is the split GTSAM draws between ImuFactor and CombinedImuFactor, and it decides the residual dimension.

ImuFactorCombinedImuFactor
se239D, (SE23, SE23, bias)15D, (SE23, bias, SE23, bias)
sgal310D, (SGal3, SGal3, bias)16D, (SGal3, bias, SGal3, bias)

ImuFactor shares one bias variable across the interval and leaves its evolution to a separate edge; CombinedImuFactor takes a bias per keyframe and embeds the random walk in six extra residual rows. Doing both counts that uncertainty twice.

Weighting follows the same split: the shared-bias form uses a information built from measurement noise alone, the combined form the full including the random walk.

A keyframe is a single variable on the group, not separate pose and velocity blocks — the optimizer’s update is then a group right-plus, and the pose/velocity coupling inertial integration produces is the group’s job. The practical consequence is that aiding measurements attach to that state: an SE(3)-only factor cannot connect to an SE23 variable, while PriorFactor<SE23> and MarginalPriorFactor are generic and do.

Error

Folding gravity into the state rather than the residual is what makes the comparison a plain group right-minus. The bias correction is the first-order update of the preintegrated delta for a bias away from its linearization point,

The combined form appends (6 rows).

Jacobian

All four factors are thin wrappers over one evaluate, which returns the residual and three tangent-space blocks. Write for the two Jacobians right_minus reports and for the tangent Jacobian of right_plus. Then

The comes from differentiating with respect to the left argument: .

The bias block expands to

in the tangent order.

The lift from to the gravity-corrected state is a constant matrix, independent of the linearization point, because shares ’s rotation and shifts the rest by gravity terms only:

The combined form appends the bias-walk rows, whose Jacobian is constant:

Every above is reported by the group’s own right_plus, right_minus and adjoint; none of it is hand-derived.

Bias evolution

The non-combined factors need a companion edge, which factors::imu::bias builds as a BetweenFactor<Rn> with zero measurement:

#![allow(unused)]
fn main() {
use apex_solver::factors::imu::{se23, bias_random_walk, bias_random_walk_noise};

problem.add_residual_block(
    &[state_i, state_j, bias],
    Box::new(se23::ImuFactor::new(preintegration)),
    None,
);
problem.add_residual_block_with_noise(
    &[bias, bias_next],
    Box::new(bias_random_walk()),
    None,
    bias_random_walk_noise(&params, dt)?,
);
}

An initial bias prior (an EuclideanPriorFactor on ) is required for observability in every configuration.

SGal(3) and the time row

SGal(3)’s tangent carries a time coordinate, so the residual gains a row , weighted by (with_time_sigma, default 100 µs). That row is the reason to choose SGal(3): it makes the interval a quantity the optimizer can move.

Known limitation. SGal(3)’s group law is — the left operand’s time couples the right operand’s velocity into translation. The residual composes , so it depends on the absolute , while the preintegrated delta corresponds to . A single interval with is correct; chaining these factors across keyframes on a common absolute clock is not. Prefer the se23 factors for multi-keyframe chains until the residual is re-derived in terms of .

LiDAR

factors::lidar — scan registration. Correspondences are established upstream: these factors evaluate one already-matched pair, so the data association is the front end’s job.

Two graph topologies coexist deliberately. The two-pose family (distance_field, edge, and the LidarPlaneFactor alias) registers a scan against a reference frame with the query point baked into the factor. The pose-and-point family (plane, point_to_point, gicp) treats the body-frame point as a variable. Pick the one matching how your front end parameterizes the scan.

The derivative every pose-and-point factor needs is SE3::act’s:


IcpFactor<F> — point-to-field

Aligns a query point against a distance or occupancy field defined in another frame. Generic over F: DistanceField, which returns the field value and its gradient at a query point.

Blocks — residual 1, Jacobian . Whitens internally with .

Error

Dividing by converts the field value into an approximate metric distance to the surface, so one is meaningful across fields of different scaling.

Jacobian

The residual and Jacobian are zeroed where the field query fails or — an undefined gradient carries no information, and a huge one is numerical noise.


LidarPlaneFactor — LOAM point-to-plane, two-pose

Not a distinct residual: it is the type alias IcpFactor<PrecomputedPlane>, where the field is a plane through with unit normal :

so and the Jacobian above specializes exactly. Build one with lidar_plane_factor_isotropic.


LidarEdgeFactor — LOAM point-to-line

The only genuinely distinct LiDAR residual here.

Blocks — residual 3, Jacobian . Whitens internally.

Error

With a matched edge line through with unit direction ,

the vector rejection of the offset onto the line’s orthogonal complement. The scalar cross-product form would measure the same distance but is non-differentiable at its own zero, which is precisely where the optimizer converges.

Jacobian

The projector is constant, so , chained with the same blocks as IcpFactor.


PointToPlaneFactor — plane as a measurement

Blocks — residual 1, Jacobian . Takes an external NoiseModel.

for a target plane with .

One constraint per correspondence, along the normal only — which is why three non-parallel planes are needed to fix a translation.


PoseToPointFactor

Blocks — residual 3, Jacobian .

The plain point-to-point correspondence, and the special case of GICP.


GicpFactor — plane-to-plane

Generalized ICP: the same point-to-point residual, but Mahalanobis-whitened by the combined surface covariance, so tangential directions (flat in both clouds) contribute almost nothing and the normal direction dominates. Plane-to-plane registration emerges from point-to-point with the right metric.

Blocks — residual 3, Jacobian . Whitens internally.

Error

is the symmetric inverse square root, with eigenvalues below clamped to zero so a rank-deficient covariance yields a pseudo-inverse root rather than non-finite entries.

Jacobian

is evaluated once at construction from a caller-supplied rotation hint and then held fixed, so the rotation-dependence of is dropped from the Jacobian. That is deliberate: callers rebuild correspondences (and these factors) every scan-matching iteration, so the frozen covariance never goes stale, and freezing it keeps the residual/Jacobian pair exactly consistent.

GNSS & Navigation

factors::navigation — satellite positioning and the auxiliary sensors that bound inertial drift.


GpsFactor — position fix with a lever arm and frame transform

The receiver reports position in its frame, and the antenna is not at the body origin. Both matter: the frame transform is often unsurveyed, and the lever arm couples attitude into the position measurement.

Blocks — residual 3, Jacobian . Whitens internally.

Error

with the sensor-to-antenna lever arm in the sensor frame.

Jacobian

then scaled by . Note the attitude column is proportional to the lever arm: with a position fix says nothing about orientation.

Observability. Each pose owns three translation degrees of freedom and receives one 3D fix, so the poses can absorb any frame transform — with fully free the graph has a six-dimensional null space and a zero-cost family of solutions. Survey the frame rotation (or anchor enough poses) before estimating it.


GpsAsyncFactor — a fix that lands between keyframes

GNSS epochs rarely coincide with keyframes. This propagates the keyframe state forward with an IMU preintegration to the fix timestamp, then applies the same position error.

Blocks — residual 3, Jacobian . Whitens internally.

Error

then and the same projection into the GNSS frame as GpsFactor.

The term is easy to lose: every preintegrated quantity is gravity-free, so gravity has to be reinstated here exactly as the IMU factors do when they build their gravity-corrected state. Omitting it biases the predicted antenna position by 0.44 m after only 0.3 s, growing quadratically.


GpsVelocityFactor

Blocks — residual 3, Jacobian .

A Doppler-derived velocity in the local tangent frame, on a plain variable. For a raw range-rate along a satellite line of sight use DopplerFactor instead.


PseudorangeFactor

Raw ranging to one satellite, before any position solution has been formed.

Blocks — receiver position and clock bias, the latter in metres (already range-equivalent). Residual 1, Jacobian .

The position and clock columns are separable only when the line-of-sight directions span three dimensions — the reason a fix needs four satellites, and why poor geometry is penalized rather than silently accepted.


DopplerFactor

Range rate along the same line of sight.

Blocks — residual 1, Jacobian .

The position column carries the derivative of itself,

which is small at typical satellite ranges — velocity is what this measurement really informs.


BarometricFactor

Blocks — pose and a slowly-varying altimeter bias. Residual 1, Jacobian .

The pose block is the third row of , not , because the right perturbation moves the origin by .

Height and bias are only separable if the bias is otherwise constrained — give it a prior and a random walk, or the pair drifts together.


AttitudeFactor

Constrains rotation so a known world direction maps onto the direction the sensor measures in the body frame — gravity from an accelerometer at rest, or the field from a magnetometer.

Blocks — residual 3, Jacobian .

The Jacobian is rank 2: rotation about the reference direction leaves it unchanged, so one such factor pins two of the three rotational degrees of freedom. Add a gravity factor and a magnetometer factor for a full AHRS.

Range & Bearing

factors::ranging — distance and direction measurements: UWB, radar, sonar, acoustic angle-of-arrival, and bearing-only landmark observation.

Bearing lives here rather than under visual because a direction measurement is not camera-specific — and keeping the two bearing residuals adjacent is what keeps them on one SE(3) convention.

Convention. Poses are body-in-world, so the body-frame direction to a world landmark is

Getting this backwards ( instead of ) produces a residual that is self-consistent with its own Jacobian, so finite differences pass and only a hand-computed value catches it.


PosePoseRangeFactor

Distance between two poses’ origins — a UWB or acoustic baseline.

Blocks — residual 1, Jacobian .

The rotation columns are exactly zero: a range between origins carries no attitude information. Below the direction is undefined and the Jacobian is zeroed rather than divided by zero.


PosePointRangeFactor

Distance from a pose to a landmark.

Blocks — residual 1, Jacobian .

One constraint per measurement, along the line of sight only — trilateration needs three non-collinear anchors.


BearingFactor

Direction-only observation, on the unit sphere .

Blocks — residual 2, Jacobian . Whitens internally.

Error

where is an orthonormal basis for the tangent plane at is a 3-vector confined to a 2-manifold, so using it directly would give a structurally rank-2 residual with three rows — a singular information block.

Jacobian

With and the normalization derivative ,

all pre-multiplied by .


BearingRangeFactor

Bearing and range together — a radar or sonar return, or a camera plus depth.

Blocks — residual 4, Jacobian . Takes an external NoiseModel.

The three bearing rows are the raw direction difference rather than a tangent-plane projection, so they are structurally rank 2 — give the bearing rows a common and let the noise model handle it, or use BearingFactor plus PosePointRangeFactor if you need a full-rank information block.

Bearing rows share BearingFactor’s derivatives; the range row is PosePointRangeFactor’s.

Motion Models

factors::motion — what the platform’s own dynamics assert, with no sensor involved.

That makes them unusually cheap and unusually easy to misapply. Each is valid only while its condition holds, so it is added by whatever detects that condition: a stationarity detector for ZUPT and ZARU, vehicle knowledge for the other two. Applied where the assumption is violated they are confidently wrong, in the way only a near-zero-noise constraint can be.


ZeroVelocityFactor — ZUPT

The platform is at rest. Applying this bounds the drift inertial integration would otherwise accumulate through a stop, at no sensor cost.

Blocks — residual 3, Jacobian .

Velocity is stored in the world frame, so the residual is it verbatim; the Jacobian block is because the group’s right perturbation moves it by .


ZeroAngularRateFactor — ZARU

While at rest, the gyroscope reads its own bias. This is what makes a stop observe the bias rather than merely stop accumulating it, so it complements ZUPT rather than duplicating it.

Blocks — residual 3, Jacobian .

with the gyro reading averaged over the stationary interval. The accelerometer half is untouched — at rest the accelerometer reads gravity, not its bias, so a stop says nothing about without an attitude reference.


NonholonomicFactor

A wheeled vehicle does not slide sideways or leave the ground: in the body frame its velocity has no lateral or vertical component.

Blocks — residual 2, Jacobian .

taking rows and . The block is what makes this informative about attitude: a heading error turns forward motion into apparent lateral slip, so the constraint corrects the heading, not just the velocity.

Two nearly-free constraints per state, and on a ground vehicle they bound the lateral drift inertial integration is worst at. They are violated by skidding, by lifting a wheel, and by any vehicle that can translate sideways.


PlanarMotionFactor

A vehicle confined to a horizontal plane: fixed height, no roll, no pitch.

Blocks — residual 3, Jacobian .

The two tilt rows are the world- components of the body’s and axes, not roll and pitch angles. They vanish on exactly the same set, but Euler extraction introduces a gimbal singularity and a discontinuity that a least-squares residual should not carry; these rows are smooth everywhere.

with and . The rotation blocks follow from and .

Three constraints per pose, removing the three least-observable degrees of freedom in a 6-DOF estimate of an indoor or road vehicle.

Marginalization

factors::marginal — summarizing variables a sliding window has dropped.


MarginalPriorFactor

When a window marginalizes old states, the eliminated joint distribution is summarized as a Gaussian over the remaining variables. This factor carries that Gaussian in linear container form: the Jacobian is a constant matrix, fixed at the linearization point where the marginal was computed.

Blocks any number, any manifolds — the factor is manifold-agnostic. Residual , Jacobian .

Error

where

  • is the concatenated local tangent of the connected variables relative to the marginal’s linearization point , computed by a caller-supplied local_log closure — this is what keeps the factor independent of which manifolds it connects;
  • is the square-root information of the marginal, ;
  • encodes the information vector, so that the quadratic reproduced is .

Where it comes from

Marginalizing a set out of a joint information matrix leaves the Schur complement over the remaining set :

Factor and set .

The catch

The Jacobian is constant, which is the whole point of a container — but it is only exact at . As the estimate drifts away from the linearization point the prior becomes progressively wrong, and worse, it is wrong in a way that looks confident. Re-marginalize and rebuild the factor when the estimate moves far from .

The partial-pose priors that often accompany marginalization live in factors::pose: they anchor a pose for loop-closure initialization rather than summarize eliminated variables.

Visualization with Rerun

The visualization feature streams poses, landmarks, cost curves, damping trajectories and Hessian sparsity patterns to a live Rerun viewer while the optimizer runs.

[dependencies]
apex-solver = { version = "1.5.0", features = ["visualization"] }
#![allow(unused)]
fn main() {
use apex_solver::observers::{RerunObserver, VisualizationConfig};

let config = VisualizationConfig::new()
    .with_show_cameras(true)
    .with_show_landmarks(true)
    .with_show_plots(true);

let mut solver = LevenbergMarquardt::with_config(lm_config);
solver.add_observer(RerunObserver::with_config(true, config)?);
let result = solver.optimize(&mut problem)?;
}

Start the viewer first (rerun on the command line or rerun::Session), then run your binary. Every iteration appears live: camera frustums, landmark points, the cost curve, and the sparsity pattern of the current Hessian.

The visualize_optimization and visualize_graph_file examples show complete setups for bundle adjustment and pose graphs respectively:

cargo run --release --example visualize_optimization --features visualization

Benchmarks & Performance

Headline numbers (Apple M4, release profile, median of 5 runs; methodology and full tables in the repository’s doc/performance.md): timing covers optimize() only; costs are computed by the harness directly from the input files so values are comparable across implementations.

Pose graph optimization (LM)

datasetverticesedgesapex timefinal χ²
ring434459~4 ms2.2e-2
M35003 5005 453~60 ms1.5
city1000010 00020 687~110 ms4.4
sphere25002 5004 949~190 ms21.3
parking-garage1 6616 275~85 ms0.62
torus3D5 0009 048~930 ms236
cubicle5 75016 869~990 ms1 692

Bundle adjustment (LM + iterative Schur, reprojection RMSE)

datasetcameraspointsobservationstimefinal RMSE
Trafalgar25765 132225 911~4 s0.81 px
Dubrovnik356226 7301 255 268~48 s0.79 px
Ladybug1 723156 502678 718~77 s0.88 px
Venice1 778993 9235 001 946~56 s0.75 px

Reproducing

cargo bench --bench odometry_pose_benchmark   # pose graphs (Rust + C++ solvers)
APEX_BENCH_RUST_ONLY=1 cargo bench --bench bundle_adjustment_benchmark   # apex rows only
bash benches/tools/run_repeated.sh odometry_pose_benchmark 5            # N repeats, archived CSVs

Kernel-level micro-benchmarks (normal-equation formation, assembly, PCG) live in benches/micro_kernels.rs and run under Criterion:

cargo bench --bench micro_kernels

Migrating from 1.4

1.5.0 changes the public API. Code written against 1.4.0 will not compile until you make the edits below. Full detail in the changelog.

1. PriorFactor is a tangent-space anchor

PriorFactor now anchors in the manifold’s tangent space, , and is generic over the manifold — no quaternion double-cover ambiguity, no dropped rotation–translation coupling, correct SE(2) angle wrap. The old ambient parameter-space factor is renamed EuclideanPriorFactor and is restricted to Rn variables at registration (anything else returns a DimensionMismatch error). Struct-literal construction is gone:

#![allow(unused)]
fn main() {
// 1.4.0 — ambient
problem.add_residual_block(&[k], Box::new(PriorFactor { data: pose7 }), loss);

// 1.5.0 — tangent anchor on SE(3)
problem.add_residual_block(&[k], Box::new(PriorFactor::<SE3>::new(prior_pose)), loss);
}

For the old behaviour on an Rn variable, use EuclideanPriorFactor::new(data).

2. Feature flags: rosbag, download, cli

apex-io’s rosbag feature is now opt-in (it was built unconditionally). Depending on apex-io no longer compiles rusqlite (bundled SQLite), mcap, zstd, lz4_flex, serde_norway, byteorder, hex. Bag I/O users must opt in:

apex-io = { version = "0.4", features = ["rosbag"] }
# via the solver crate:
apex-solver = { version = "1.5", features = ["rosbag"] }

download (dataset fetching) stays on by default; --no-default-features disables it, in which case the ensure_*_dataset helpers only serve already-downloaded files. clap is behind the default-on cli feature in both crates; the apex-solver bins/examples likewise require cli. The bag_* binaries need --features rosbag, download_datasets needs --features download.

3. LinearSolver::solve_augmented_equation takes &Damping

The augmented system is now with — Ceres’ LevenbergMarquardtStrategy. Custom LinearSolver implementations must update the signature:

#![allow(unused)]
fn main() {
// 1.4.0
solver.solve_augmented_equation(&residuals, &jacobian, lambda)?;

// 1.5.0 — same numerics as the old uniform λI
solver.solve_augmented_equation(&residuals, &jacobian, &Damping::identity(lambda))?;
}

4. Schur solver renames

The Schur solvers were consolidated around explicit/implicit names mirroring Ceres. Old names are gone outright — no deprecated aliases:

1.4.01.5.0
SparseSchurComplementSolverExplicitSparseSchur
IterativeSchurSolverImplicitSparseSchur
—ExplicitDenseSchur (new)
SchurVariantExplicitSchurVariant (Sparse, Iterative, Chunked)
LinearSolverType::SparseSchurComplementLinearSolverType::ExplicitSparseSchur

SchurPartition, EliminatedBlocks, SchurOrdering and SchurPreconditioner moved to the top-level src/linalg/schur/ module (previously under src/linalg/sparse/). LinearSolverType gained ImplicitSparseSchur and ExplicitDenseSchur discriminants; the old SchurVariant::Iterative special-case is gone.

5. for_bundle_adjustment selects ImplicitSparseSchur

LevenbergMarquardtConfig::for_bundle_adjustment now selects the matrix-free implicit Schur solver instead of ExplicitSparseSchur — 2.2× faster across the four BAL datasets. The step is inexact by construction; restore the exact reduced solve with:

#![allow(unused)]
fn main() {
config.with_linear_solver_type(LinearSolverType::ExplicitSparseSchur)
}

Numerics changed everywhere else too: Levenberg-Marquardt now damps with from (was from ), and step acceptance in both LM and Dog Leg is gated on min_relative_decrease. Iterates change on every problem. To restore the old behaviour:

#![allow(unused)]
fn main() {
LevenbergMarquardtConfig::new().with_diagonal_bounds(1.0, 1.0).with_damping(1e-3)
}

Mathematical Cookbooks (workspace crates)

The deep mathematical derivations live with the crates they document, as mdBook cookbooks rendered with KaTeX:

The published site (this one) aggregates them: