Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

Version numbering in this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). We aim to keep the core solver functionality and minor releases in sync between the Rust/Python and Julia implementations. Small fixes that affect one implementation only may result in the patch release versions differing.

## [Unreleased]

### Added

- Warm-start support via `warm_start_skip` setting ([#206](https://github.com/oxfordcontrol/Clarabel.rs/issues/206))

## [0.11.1] - 2025-11-06

### Rust-specific changes
Expand Down
6 changes: 6 additions & 0 deletions src/python/impl_default_py.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,10 @@ pub struct PyDefaultSettings {
#[pyo3(get, set)]
pub input_sparse_dropzeros: bool,

// warm-starting
#[pyo3(get, set)]
pub warm_start_skip: bool,

//chordal decomposition (python must be built with "sdp" feature)
#[pyo3(get, set)]
pub chordal_decomposition_enable: bool,
Expand Down Expand Up @@ -537,6 +541,7 @@ impl From<&DefaultSettings<f64>> for PyDefaultSettings {
iterative_refinement_stop_ratio: set.iterative_refinement_stop_ratio,
presolve_enable: set.presolve_enable,
input_sparse_dropzeros: set.input_sparse_dropzeros,
warm_start_skip: set.warm_start_skip,
#[cfg(feature = "sdp")]
chordal_decomposition_enable: set.chordal_decomposition_enable,
#[cfg(feature = "sdp")]
Expand Down Expand Up @@ -597,6 +602,7 @@ impl PyDefaultSettings {
iterative_refinement_stop_ratio: self.iterative_refinement_stop_ratio,
presolve_enable: self.presolve_enable,
input_sparse_dropzeros: self.input_sparse_dropzeros,
warm_start_skip: self.warm_start_skip,
#[cfg(feature = "sdp")]
chordal_decomposition_enable: self.chordal_decomposition_enable,
#[cfg(feature = "sdp")]
Expand Down
28 changes: 25 additions & 3 deletions src/solver/core/solver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,22 @@ where
pub(crate) phantom: std::marker::PhantomData<T>,
}

impl<T, D, V, R, K, C, I, SO, SE> Solver<T, D, V, R, K, C, I, SO, SE>
where
T: FloatT,
SE: Settings<T>,
I: ClarabelFFI<I>,
{
/// Enable or disable warm-start initialization skipping.
///
/// When `true`, `solve()` will skip `default_start()` and use the current
/// values of `self.variables` as the initial point. Call this after the
/// first (cold) solve to enable warm-starting on subsequent solves.
pub fn set_warm_start_skip(&mut self, skip: bool) {
self.settings.core_mut().warm_start_skip = skip;
}
}

fn _print_banner(out: &mut dyn Write, is_verbose: bool) -> std::io::Result<()> {
if !is_verbose {
return std::io::Result::Ok(());
Expand Down Expand Up @@ -264,9 +280,11 @@ where
timeit! {timers => "solve"; {

// initialize variables to some reasonable starting point
timeit!{timers => "default start"; {
self.default_start();
}}
if !self.settings.core().warm_start_skip {
timeit!{timers => "default start"; {
self.default_start();
}}
}

timeit!{timers => "IP iteration"; {

Expand Down Expand Up @@ -446,6 +464,10 @@ where
notimeit! {timers; {self.info.print_status(&self.settings).unwrap();}}
}

// Cache variables in internal (scaled) form for warm-starting.
// Must be done before post_process, which calls unscale() in place.
self.prev_vars.copy_from(&self.variables);

timeit! {timers => "post-process"; {
//check for "almost" convergence case and then extract solution
self.info.post_process(&self.residuals, &self.settings);
Expand Down
4 changes: 4 additions & 0 deletions src/solver/implementations/default/ffi/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ pub struct DefaultSettingsFFI<T: FloatT> {
pub presolve_enable: bool,
pub input_sparse_dropzeros: bool,

// warm-starting
pub warm_start_skip: bool,

// chordal decomposition
#[cfg(feature = "sdp")]
pub chordal_decomposition_enable: bool,
Expand Down Expand Up @@ -135,6 +138,7 @@ macro_rules! impl_from {
iterative_refinement_stop_ratio: settings.iterative_refinement_stop_ratio,
presolve_enable: settings.presolve_enable,
input_sparse_dropzeros: settings.input_sparse_dropzeros,
warm_start_skip: settings.warm_start_skip,
#[cfg(feature = "sdp")]
chordal_decomposition_enable: settings.chordal_decomposition_enable,
#[cfg(feature = "sdp")]
Expand Down
7 changes: 7 additions & 0 deletions src/solver/implementations/default/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ pub struct DefaultSettings<T: FloatT> {
#[builder(default = "false")]
pub input_sparse_dropzeros: bool,

/// Skip default variable initialization and use externally set variables.
/// When true, the caller must set `solver.variables` (x, s, z, τ, κ)
/// before calling `solve()`. This enables warm-starting from a previous
/// solution.
#[builder(default = "false")]
pub warm_start_skip: bool,

/// enable chordal decomposition.
/// [requires "sdp" feature.]
#[cfg(feature = "sdp")]
Expand Down
244 changes: 244 additions & 0 deletions tests/warm_start.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
#![allow(non_snake_case)]

use clarabel::{algebra::*, solver::*, solver::traits::{Settings, Variables}};

#[allow(clippy::type_complexity)]
fn warm_start_test_data() -> (
CscMatrix<f64>,
Vec<f64>,
CscMatrix<f64>,
Vec<f64>,
Vec<SupportedConeT<f64>>,
) {
// Simple QP: min 0.5 x'Px + q'x s.t. Ax + s = b, s in K
let P = CscMatrix::from(&[
[4., 1.], //
[1., 2.], //
]);
let q = vec![1., 1.];

let A = CscMatrix::from(&[
[1., 1.], //
[1., 0.], //
[0., 1.], //
[-1., 0.], //
[0., -1.], //
]);
let b = vec![1., 0.7, 0.7, 0., 0.];

let cones = vec![NonnegativeConeT(5)];

(P, q, A, b, cones)
}

#[test]
fn test_warm_start_same_problem() {
let (P, q, A, b, cones) = warm_start_test_data();

let settings = DefaultSettingsBuilder::default()
.presolve_enable(false)
.verbose(false)
.build()
.unwrap();

// Cold-start solve
let mut solver = DefaultSolver::new(&P, &q, &A, &b, &cones, settings).unwrap();
solver.solve();

assert_eq!(solver.solution.status, SolverStatus::Solved);
let cold_iterations = solver.solution.iterations;
let cold_x = solver.solution.x.clone();

// Restore internal-form variables cached before unscaling
solver.variables.copy_from(&solver.prev_vars);

// Enable warm-start via convenience method
solver.set_warm_start_skip(true);

// Warm-start solve (same problem, starting from optimal point)
solver.solve();

assert_eq!(solver.solution.status, SolverStatus::Solved);

// Starting from the optimal point should converge faster
assert!(
solver.solution.iterations <= cold_iterations,
"warm-start iterations ({}) should be <= cold-start iterations ({})",
solver.solution.iterations,
cold_iterations
);

// Solutions should match
assert!(
solver.solution.x.dist(&cold_x) <= 1e-6,
"warm-start solution should match cold-start solution"
);
}

#[test]
fn test_warm_start_perturbed_problem() {
let (P, q, A, b, cones) = warm_start_test_data();

let settings = DefaultSettingsBuilder::default()
.presolve_enable(false)
.verbose(false)
.build()
.unwrap();

// Cold-start solve of original problem
let mut solver = DefaultSolver::new(&P, &q, &A, &b, &cones, settings.clone()).unwrap();
solver.solve();
assert_eq!(solver.solution.status, SolverStatus::Solved);
let cold_iterations = solver.solution.iterations;

// Restore internal-form variables
solver.variables.copy_from(&solver.prev_vars);

// Small perturbation to q. The perturbation must be small enough
// that the initial dual residual (proportional to the perturbation)
// can converge below tol_feas before μ hits machine precision.
let q2 = vec![1.001, 0.999];
solver.update_q(&q2).unwrap();

// Enable warm-start via convenience method
solver.set_warm_start_skip(true);

// Warm-start solve of perturbed problem
solver.solve();
assert_eq!(solver.solution.status, SolverStatus::Solved);
let warm_x = solver.solution.x.clone();

// Warm-start should converge faster than cold-start
assert!(
solver.solution.iterations <= cold_iterations,
"warm-start iterations ({}) should be <= cold-start iterations ({})",
solver.solution.iterations,
cold_iterations
);

// Cold-start solve of the same perturbed problem for comparison
let mut solver_cold = DefaultSolver::new(&P, &q2, &A, &b, &cones, settings).unwrap();
solver_cold.solve();
assert_eq!(solver_cold.solution.status, SolverStatus::Solved);

// Both should arrive at the same solution
assert!(
warm_x.dist(&solver_cold.solution.x) <= 1e-6,
"warm-start and cold-start solutions should match for perturbed problem"
);
}

#[test]
fn test_warm_start_skip_default_false() {
let settings = DefaultSettings::<f64>::default();
assert!(
!settings.warm_start_skip,
"warm_start_skip should default to false"
);
}

#[test]
fn test_set_warm_start_skip() {
let (P, q, A, b, cones) = warm_start_test_data();

let settings = DefaultSettingsBuilder::default()
.presolve_enable(false)
.verbose(false)
.build()
.unwrap();

let mut solver = DefaultSolver::new(&P, &q, &A, &b, &cones, settings).unwrap();

// Verify default is off
assert!(!solver.settings().core().warm_start_skip);

// Toggle on
solver.set_warm_start_skip(true);
assert!(solver.settings().core().warm_start_skip);

// Toggle back off
solver.set_warm_start_skip(false);
assert!(!solver.settings().core().warm_start_skip);
}

#[test]
fn test_cold_start_after_warm_start() {
// Verify that disabling warm_start_skip restores normal cold-start behavior
let (P, q, A, b, cones) = warm_start_test_data();

let settings = DefaultSettingsBuilder::default()
.presolve_enable(false)
.verbose(false)
.build()
.unwrap();

// First: cold solve
let mut solver = DefaultSolver::new(&P, &q, &A, &b, &cones, settings.clone()).unwrap();
solver.solve();
assert_eq!(solver.solution.status, SolverStatus::Solved);
let first_cold_x = solver.solution.x.clone();
let first_cold_iters = solver.solution.iterations;

// Second: warm-start re-solve
solver.variables.copy_from(&solver.prev_vars);
solver.set_warm_start_skip(true);
solver.solve();
assert_eq!(solver.solution.status, SolverStatus::Solved);
assert!(solver.solution.iterations <= first_cold_iters);

// Third: disable warm-start, solve again (should behave like cold-start)
solver.set_warm_start_skip(false);
solver.solve();
assert_eq!(solver.solution.status, SolverStatus::Solved);
assert_eq!(solver.solution.iterations, first_cold_iters);
assert!(solver.solution.x.dist(&first_cold_x) <= 1e-6);
}

#[test]
fn test_warm_start_multiple_resolves() {
// Warm-start through a sequence of small perturbations
let (P, q, A, b, cones) = warm_start_test_data();

let settings = DefaultSettingsBuilder::default()
.presolve_enable(false)
.verbose(false)
.build()
.unwrap();

let mut solver = DefaultSolver::new(&P, &q, &A, &b, &cones, settings.clone()).unwrap();
solver.solve();
assert_eq!(solver.solution.status, SolverStatus::Solved);
let cold_iterations = solver.solution.iterations;

// Solve a sequence of slightly perturbed problems
for i in 1..=5 {
let delta = 0.001 * i as f64;
let q_new = vec![1.0 + delta, 1.0 - delta];

solver.variables.copy_from(&solver.prev_vars);
solver.update_q(&q_new).unwrap();
solver.set_warm_start_skip(true);
solver.solve();

assert_eq!(
solver.solution.status,
SolverStatus::Solved,
"failed at perturbation step {i}"
);
assert!(
solver.solution.iterations <= cold_iterations,
"step {i}: warm-start iterations ({}) should be <= cold-start ({})",
solver.solution.iterations,
cold_iterations
);

// Verify against fresh cold-start
let mut solver_ref =
DefaultSolver::new(&P, &q_new, &A, &b, &cones, settings.clone()).unwrap();
solver_ref.solve();
assert!(
solver.solution.x.dist(&solver_ref.solution.x) <= 1e-6,
"step {i}: warm-start solution diverged from cold-start"
);
}
}