use oximo_solver::prepare::{LoweringContext, PreparedExpressions};
use oximo_solver::reconstruct::{DualProjection, ObjectiveTransform, normalize_result};
use std::time::{Duration, Instant};
use clarabel::algebra::CscMatrix;
use clarabel::solver::{DefaultSettings, DefaultSolver, IPSolver, SolverStatus, SupportedConeT};
use oximo_core::{
ConstraintId, Model, ModelId, ObjectiveSense, Sense, SocConstraintId, SocForm, Variable,
var_name,
};
use oximo_expr::{LinearTerms, describe_nonlinear_term};
use oximo_solver::{
DualStatus, PrimalStatus, SolutionPoint, SolverError, SolverResult, TerminationStatus,
};
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use crate::{ClarabelDirectSolve, ClarabelOptions};
const PAR_ROW_THRESHOLD: usize = 256;
#[derive(Debug)]
enum Row {
Lin(LinearTerms<'static>),
Soc(SocForm),
}
type Triplets = Vec<(usize, usize, f64)>;
#[derive(Default)]
struct Rows {
a_trip: Triplets,
b: Vec<f64>,
row_duals: Vec<Option<DualProjection<ConstraintId>>>,
}
impl Rows {
fn with_capacity(rows: usize, nonzeros: usize) -> Self {
Self {
a_trip: Vec::with_capacity(nonzeros),
b: Vec::with_capacity(rows),
row_duals: Vec::with_capacity(rows),
}
}
fn push(&mut self, t: &LinearTerms<'_>, scale: f64, rhs: f64) {
let row = self.b.len();
for &(var, coef) in t.coeffs.iter() {
self.a_trip.push((row, var.index(), scale * coef));
}
self.b.push(rhs);
self.row_duals.push(None);
}
fn push_bound(&mut self, col: usize, scale: f64, rhs: f64) {
let row = self.b.len();
self.a_trip.push((row, col, scale));
self.b.push(rhs);
self.row_duals.push(None);
}
fn set_last_dual(&mut self, id: ConstraintId, scale: f64) {
*self.row_duals.last_mut().unwrap() = Some(DualProjection { source: id, scale });
}
}
pub(crate) struct Meta {
num_variables: usize,
sign: f64,
obj_constant: f64,
row_duals: Vec<Option<DualProjection<ConstraintId>>>,
soc_block_starts: Vec<usize>,
n_explicit: usize,
}
pub(crate) struct Problem {
pub(crate) p_mat: CscMatrix<f64>,
pub(crate) q: Vec<f64>,
pub(crate) a_mat: CscMatrix<f64>,
pub(crate) b: Vec<f64>,
pub(crate) cones: Vec<SupportedConeT<f64>>,
pub(crate) meta: Meta,
}
impl Problem {
pub(crate) fn same_structure(&self, other: &Problem) -> bool {
self.q.len() == other.q.len()
&& self.b.len() == other.b.len()
&& self.cones == other.cones
&& self.p_mat.colptr == other.p_mat.colptr
&& self.p_mat.rowval == other.p_mat.rowval
&& self.a_mat.colptr == other.a_mat.colptr
&& self.a_mat.rowval == other.a_mat.rowval
}
}
pub fn solve(model: &Model, opts: &ClarabelOptions) -> Result<SolverResult, SolverError> {
if model.has_active_sos_constraints() {
return Err(SolverError::UnsupportedSos);
}
if model.has_active_indicator_constraints() {
return Err(SolverError::UnsupportedIndicator);
}
let problem = build_problem(model)?;
let settings = build_settings(opts);
let mut solver = DefaultSolver::new(
&problem.p_mat,
&problem.q,
&problem.a_mat,
&problem.b,
&problem.cones,
settings,
)
.map_err(|e| SolverError::Backend(format!("Clarabel setup: {e:?}")))?;
let started = Instant::now();
solver.solve();
let elapsed = started.elapsed();
Ok(read_result(&solver, &problem.meta, model.id(), elapsed))
}
pub(crate) fn build_problem(model: &Model) -> Result<Problem, SolverError> {
if model.has_active_sos_constraints() {
return Err(SolverError::UnsupportedSos);
}
if model.has_active_indicator_constraints() {
return Err(SolverError::UnsupportedIndicator);
}
build_problem_with(model, None)
}
fn build_problem_with(model: &Model, parallel: Option<bool>) -> Result<Problem, SolverError> {
let prepared = LoweringContext::new(model)?;
let kind = prepared.kind();
if !crate::supported(kind) {
return Err(SolverError::UnsupportedKind(kind));
}
let vars = prepared.variables();
reject_semi_domains(vars)?;
let n = vars.len();
let (sign, p_trip, q, obj_constant) = objective_data(&prepared, n)?;
let rows = classify_rows(&prepared, parallel)?;
let socs = prepared.constraints().second_order_cones();
let explicit_forms = explicit_soc_forms(&prepared, socs, parallel)?;
let (row_capacity, nonzero_capacity, soc_capacity) =
translation_capacities(model, &rows, &explicit_forms);
let acc = Rows::with_capacity(row_capacity, nonzero_capacity);
let (mut acc, m_zero, m_nonneg) = linear_rows(model, &rows, acc);
let (soc_sizes, soc_block_starts, n_explicit) =
soc_blocks(&rows, &explicit_forms, &mut acc, soc_capacity);
let Rows { a_trip, b, row_duals } = acc;
let m = b.len();
let a_mat = csc_from_triplets(m, n, a_trip);
let p_mat = csc_from_triplets(n, n, p_trip);
let cones = build_cones(m_zero, m_nonneg, &soc_sizes);
Ok(Problem {
p_mat,
q,
a_mat,
b,
cones,
meta: Meta {
num_variables: n,
sign,
obj_constant,
row_duals,
soc_block_starts,
n_explicit,
},
})
}
fn objective_data(
prepared: &LoweringContext<'_>,
n: usize,
) -> Result<(f64, Triplets, Vec<f64>, f64), SolverError> {
let objective = prepared.objective();
let sign = match objective.as_ref().map(|o| o.sense) {
Some(ObjectiveSense::Maximize) => -1.0,
_ => 1.0,
};
let Some(obj) = objective.as_ref() else {
return Ok((sign, Triplets::new(), vec![0.0; n], 0.0));
};
let quad = prepared.require_quadratic(obj.expr, || "the objective".into())?;
let mut q = vec![0.0; n];
for &(var, coef) in &quad.linear {
q[var.index()] += sign * coef;
}
let p_trip =
quad.hessian.iter().map(|&(row, col, h)| (col.index(), row.index(), sign * h)).collect();
Ok((sign, p_trip, q, quad.constant))
}
#[cfg(any(test, feature = "benchmark-support"))]
fn classify_rows_with(model: &Model, parallel: Option<bool>) -> Result<Vec<Row>, SolverError> {
classify_rows(&LoweringContext::new(model)?, parallel)
}
fn classify_rows(
prepared: &LoweringContext<'_>,
parallel: Option<bool>,
) -> Result<Vec<Row>, SolverError> {
let arena = prepared.arena();
let vars = prepared.variables();
let model_constraints = prepared.constraints();
let constraints = model_constraints.algebraic();
let arena_ref = arena;
let vars_ref = vars;
let expressions: &PreparedExpressions = prepared;
let classify_non_linear = |c: &oximo_core::Constraint| {
expressions.detected_soc(vars_ref, c).map(Row::Soc).ok_or_else(|| SolverError::Nonlinear {
location: format!("constraint {:?}", c.name),
term: describe_nonlinear_term(arena_ref, c.lhs, &|v| var_name(vars_ref, v))
.unwrap_or_else(|| "<nonlinear>".into()),
})
};
let mut rows: Vec<Option<Row>> = (0..constraints.len()).map(|_| None).collect();
let mut pending = Vec::new();
for (index, constraint) in constraints.iter().enumerate() {
match prepared.linear(constraint.lhs).map(oximo_solver::prepare::AffineTerms::into_owned) {
Some(terms) => rows[index] = Some(Row::Lin(terms)),
None => pending.push((index, constraint)),
}
}
let use_parallel =
parallel.unwrap_or(pending.len() >= PAR_ROW_THRESHOLD && rayon::current_num_threads() > 1);
if use_parallel {
let detected: Vec<Result<Row, SolverError>> =
pending.par_iter().map(|(_, c)| classify_non_linear(c)).collect();
for ((index, _), result) in pending.into_iter().zip(detected) {
rows[index] = Some(result?);
}
} else {
for (index, constraint) in pending {
rows[index] = Some(classify_non_linear(constraint)?);
}
}
Ok(rows.into_iter().map(Option::unwrap).collect())
}
fn translation_capacities(
model: &Model,
rows: &[Row],
explicit_forms: &[SocForm],
) -> (usize, usize, usize) {
let vars = model.variables();
let model_constraints = model.constraints();
let constraints = model_constraints.algebraic();
let is_fixed = |v: &Variable| v.lb.is_finite() && v.lb.total_cmp(&v.ub).is_eq();
let mut row_count = 0;
let mut nonzero_count = 0;
for (constraint, row) in constraints.iter().zip(rows) {
let Row::Lin(terms) = row else { continue };
let multiplicity =
usize::from(constraint.as_single().is_some()) + usize::from(constraint.is_range());
row_count += multiplicity;
nonzero_count += multiplicity * terms.coeffs.len();
}
for var in vars.iter() {
let multiplicity = if is_fixed(var) {
1
} else {
usize::from(var.ub.is_finite()) + usize::from(var.lb.is_finite())
};
row_count += multiplicity;
nonzero_count += multiplicity;
}
let detected_forms = rows.iter().filter_map(|row| match row {
Row::Soc(form) => Some(form),
Row::Lin(_) => None,
});
let mut soc_count = 0;
for form in explicit_forms.iter().chain(detected_forms) {
soc_count += 1;
row_count += 1 + form.terms.len();
nonzero_count += form.bound.coeffs.len()
+ form.terms.iter().map(|term| term.coeffs.len()).sum::<usize>();
}
(row_count, nonzero_count, soc_count)
}
fn linear_rows(model: &Model, rows: &[Row], mut acc: Rows) -> (Rows, usize, usize) {
let vars = model.variables();
let model_constraints = model.constraints();
let constraints = model_constraints.algebraic();
let is_fixed = |v: &Variable| v.lb.is_finite() && v.lb.total_cmp(&v.ub).is_eq();
for (i, (con, row)) in constraints.iter().zip(rows).enumerate() {
if let Row::Lin(lt) = row
&& let Some((Sense::Eq, rhs)) = con.as_single()
{
let id = ConstraintId(u32::try_from(i).expect("constraint count overflow"));
acc.push(lt, 1.0, rhs - lt.constant);
acc.set_last_dual(id, -1.0);
}
}
for var in vars.iter().filter(|&v| is_fixed(v)) {
acc.push_bound(var.id.index(), 1.0, var.lb);
}
let m_zero = acc.b.len();
for (i, (con, row)) in constraints.iter().zip(rows).enumerate() {
let Row::Lin(lt) = row else { continue };
let id = ConstraintId(u32::try_from(i).expect("constraint count overflow"));
match con.as_single() {
Some((Sense::Le, rhs)) => {
acc.push(lt, 1.0, rhs - lt.constant);
acc.set_last_dual(id, -1.0);
}
Some((Sense::Ge, rhs)) => {
acc.push(lt, -1.0, -(rhs - lt.constant));
acc.set_last_dual(id, 1.0);
}
None if con.is_range() => {
acc.push(lt, -1.0, -(con.lower - lt.constant));
acc.set_last_dual(id, 1.0);
acc.push(lt, 1.0, con.upper - lt.constant);
acc.set_last_dual(id, -1.0);
}
Some((Sense::Eq, _)) | None => {}
}
}
for var in vars.iter().filter(|&v| !is_fixed(v)) {
if var.ub.is_finite() {
acc.push_bound(var.id.index(), 1.0, var.ub);
}
if var.lb.is_finite() {
acc.push_bound(var.id.index(), -1.0, -var.lb);
}
}
let m_nonneg = acc.b.len() - m_zero;
(acc, m_zero, m_nonneg)
}
fn soc_blocks(
rows: &[Row],
explicit_forms: &[SocForm],
acc: &mut Rows,
soc_capacity: usize,
) -> (Vec<usize>, Vec<usize>, usize) {
let detected_forms = rows.iter().filter_map(|r| match r {
Row::Soc(f) => Some(f),
Row::Lin(_) => None,
});
let n_explicit = explicit_forms.len();
let mut soc_sizes: Vec<usize> = Vec::with_capacity(soc_capacity);
let mut soc_block_starts: Vec<usize> = Vec::with_capacity(soc_capacity);
for form in explicit_forms.iter().chain(detected_forms) {
soc_block_starts.push(acc.b.len());
acc.push(&form.bound, -1.0, form.bound.constant);
for term in &form.terms {
acc.push(term, -1.0, term.constant);
}
soc_sizes.push(1 + form.terms.len());
}
(soc_sizes, soc_block_starts, n_explicit)
}
fn explicit_soc_forms(
prepared: &PreparedExpressions,
socs: &[oximo_core::SocConstraint],
parallel: Option<bool>,
) -> Result<Vec<SocForm>, SolverError> {
let extract = |s: &oximo_core::SocConstraint| prepared.explicit_soc(s);
let use_parallel = parallel.unwrap_or(false);
let explicit_results: Vec<Result<SocForm, SolverError>> = if use_parallel {
socs.par_iter().map(extract).collect()
} else {
socs.iter().map(extract).collect()
};
explicit_results.into_iter().collect()
}
fn build_cones(m_zero: usize, m_nonneg: usize, soc_sizes: &[usize]) -> Vec<SupportedConeT<f64>> {
let mut cones = Vec::with_capacity(2 + soc_sizes.len());
if m_zero > 0 {
cones.push(SupportedConeT::ZeroConeT(m_zero));
}
if m_nonneg > 0 {
cones.push(SupportedConeT::NonnegativeConeT(m_nonneg));
}
cones.extend(soc_sizes.iter().map(|&k| SupportedConeT::SecondOrderConeT(k)));
cones
}
pub(crate) fn read_result(
solver: &DefaultSolver<f64>,
meta: &Meta,
model_id: ModelId,
elapsed: Duration,
) -> SolverResult {
let native_status = solver.solution.status;
let termination = map_status(native_status);
let has_point = status_has_point(native_status);
let mut solutions = Vec::new();
let mut dual: FxHashMap<ConstraintId, f64> = FxHashMap::default();
let mut soc_dual: FxHashMap<SocConstraintId, f64> = FxHashMap::default();
if has_point {
let primal =
oximo_solver::reconstruct::project_dense_primal(&solver.solution.x, meta.num_variables)
.unwrap_or_default();
let objective = ObjectiveTransform { sign: meta.sign, offset: meta.obj_constant }
.restore(solver.solution.obj_val);
solutions.push(SolutionPoint { model_id, primal, objective });
for (r, &z) in solver.solution.z.iter().enumerate() {
if let Some(Some(projection)) = meta.row_duals.get(r) {
projection.accumulate(&mut dual, z, meta.sign);
}
}
for (k, &start) in meta.soc_block_starts.iter().take(meta.n_explicit).enumerate() {
if let Some(&z0) = solver.solution.z.get(start) {
soc_dual.insert(SocConstraintId(u32::try_from(k).expect("SOC count overflow")), z0);
}
}
}
let primal_status = PrimalStatus::infer(&termination, !solutions.is_empty());
let best_bound = if native_status == SolverStatus::Solved {
mapped_objective_bound(meta, solver.solution.obj_val, solver.solution.obj_val_dual)
} else {
None
};
normalize_result(
SolverResult {
model_id,
termination,
primal_status,
dual_status: if native_status == SolverStatus::Solved {
DualStatus::FeasiblePoint
} else if has_point {
DualStatus::Unknown
} else {
DualStatus::NoSolution
},
solutions,
dual,
soc_dual,
reduced_costs: FxHashMap::default(),
best_bound,
gap: None,
solve_time: elapsed,
iterations: u64::from(solver.info.iterations),
node_count: None,
raw_status: Some(format!("{native_status:?}").into()),
raw_log: None,
solver_name: Some(crate::NAME.into()),
solver_version: None,
},
meta.num_variables,
)
}
fn mapped_objective_bound(meta: &Meta, _primal: f64, dual: f64) -> Option<f64> {
let transform = ObjectiveTransform { sign: meta.sign, offset: meta.obj_constant };
transform.restore(dual)
}
fn csc_from_triplets(m: usize, n: usize, mut trip: Triplets) -> CscMatrix<f64> {
trip.sort_unstable_by_key(|&(row, col, _)| (col, row));
let mut colptr = vec![0_usize; n + 1];
let mut rowval: Vec<usize> = Vec::with_capacity(trip.len());
let mut nzval: Vec<f64> = Vec::with_capacity(trip.len());
let mut last: Option<(usize, usize)> = None;
for (row, col, val) in trip {
if last == Some((row, col)) {
*nzval.last_mut().unwrap() += val;
} else {
colptr[col + 1] += 1;
rowval.push(row);
nzval.push(val);
last = Some((row, col));
}
}
for col in 0..n {
colptr[col + 1] += colptr[col];
}
CscMatrix::new(m, n, colptr, rowval, nzval)
}
pub(crate) fn build_settings(o: &ClarabelOptions) -> DefaultSettings<f64> {
let mut s = DefaultSettings {
verbose: o.universal.verbose.unwrap_or(false),
..DefaultSettings::default()
};
if let Some(d) = o.universal.time_limit {
s.time_limit = d.as_secs_f64();
}
if let Some(n) = o.universal.threads {
s.max_threads = n;
}
if let Some(m) = o.direct_solve_method {
s.direct_solve_method = kkt_str(m).to_string();
}
macro_rules! apply_opt {
($($field:ident),* $(,)?) => {
$(if let Some(v) = o.$field { s.$field = v; })*
};
}
apply_opt!(
max_iter,
max_step_fraction,
tol_gap_abs,
tol_gap_rel,
tol_feas,
tol_infeas_abs,
tol_infeas_rel,
tol_ktratio,
reduced_tol_gap_abs,
reduced_tol_gap_rel,
reduced_tol_feas,
reduced_tol_infeas_abs,
reduced_tol_infeas_rel,
reduced_tol_ktratio,
equilibrate_enable,
equilibrate_max_iter,
equilibrate_min_scaling,
equilibrate_max_scaling,
linesearch_backtrack_step,
min_switch_step_length,
min_terminate_step_length,
static_regularization_enable,
static_regularization_constant,
static_regularization_proportional,
dynamic_regularization_enable,
dynamic_regularization_eps,
dynamic_regularization_delta,
iterative_refinement_enable,
iterative_refinement_reltol,
iterative_refinement_abstol,
iterative_refinement_max_iter,
iterative_refinement_stop_ratio,
presolve_enable,
input_sparse_dropzeros,
);
s
}
fn kkt_str(m: ClarabelDirectSolve) -> &'static str {
match m {
ClarabelDirectSolve::Auto => "auto",
ClarabelDirectSolve::Qdldl => "qdldl",
#[cfg(feature = "faer")]
ClarabelDirectSolve::Faer => "faer",
}
}
fn reject_semi_domains(vars: &[Variable]) -> Result<(), SolverError> {
for v in vars {
if v.domain.semi_threshold().is_some() {
return Err(SolverError::Backend(format!(
"variable {} has a semicontinuous/semi-integer domain, \
which Clarabel does not support",
v.name
)));
}
}
Ok(())
}
fn map_status(s: SolverStatus) -> TerminationStatus {
match s {
SolverStatus::Solved => TerminationStatus::Optimal,
SolverStatus::AlmostSolved => TerminationStatus::Feasible,
SolverStatus::PrimalInfeasible => TerminationStatus::Infeasible,
SolverStatus::DualInfeasible => TerminationStatus::Unbounded,
other @ (SolverStatus::AlmostPrimalInfeasible | SolverStatus::AlmostDualInfeasible) => {
TerminationStatus::Other(format!("{other:?}"))
}
SolverStatus::MaxIterations => TerminationStatus::IterationLimit,
SolverStatus::MaxTime => TerminationStatus::TimeLimit,
SolverStatus::NumericalError | SolverStatus::InsufficientProgress => {
TerminationStatus::NumericError
}
SolverStatus::Unsolved => TerminationStatus::NotSolved,
other @ SolverStatus::CallbackTerminated => TerminationStatus::Other(format!("{other:?}")),
}
}
fn status_has_point(status: SolverStatus) -> bool {
matches!(status, SolverStatus::Solved | SolverStatus::AlmostSolved)
}
#[cfg(feature = "benchmark-support")]
#[doc(hidden)]
#[expect(clippy::cast_precision_loss)]
#[allow(clippy::wildcard_imports)]
pub mod benchmark_support {
use oximo_core::constraint::Relate;
use super::*;
pub const ROW_THRESHOLD: usize = PAR_ROW_THRESHOLD;
pub const SOC_THRESHOLD: usize = 1_024;
pub fn row_model(rows: usize, soc: bool) -> Model {
let model = Model::new("clarabel_row_bench");
let x = model.__var("x").build();
let y = model.__var("y").build();
let t = model.__var("t").lb(0.0).build();
model.__minimize(t);
for i in 0..rows {
let lhs = if soc { x.powi(2) + y.powi(2) - t.powi(2) } else { x + 2.0 * y - t };
model.__add_constraint_auto(lhs.le(if soc { 0.0 } else { i as f64 + 10.0 }));
}
model
}
pub fn qp_model(rows: usize) -> Model {
let model = Model::new("clarabel_qp_bench");
let x = model.__var("x").build();
let y = model.__var("y").build();
model.__minimize(x.powi(2) + y.powi(2));
for i in 0..rows {
model.__add_constraint_auto((x + 2.0 * y).le(i as f64 + 10.0));
}
model
}
pub fn explicit_soc_model(count: usize) -> Model {
let model = Model::new("clarabel_explicit_soc_bench");
let x = model.__var("x").build();
let y = model.__var("y").build();
let t = model.__var("t").lb(0.0).build();
for i in 0..count {
model.add_soc_constraint(format!("soc{i}"), [x, y], t);
}
model.__minimize(t);
model
}
pub fn classify(model: &Model, parallel: bool) -> Result<usize, SolverError> {
classify_rows_with(model, Some(parallel)).map(|rows| rows.len())
}
pub fn explicit_socs(model: &Model, parallel: bool) -> Result<usize, SolverError> {
let prepared = LoweringContext::new(model)?;
explicit_soc_forms(&prepared, prepared.constraints().second_order_cones(), Some(parallel))
.map(|forms| forms.len())
}
pub fn translate(model: &Model) -> Result<(usize, usize, usize), SolverError> {
build_problem(model)
.map(|problem| (problem.a_mat.nzval.len(), problem.b.len(), problem.cones.len()))
}
}
#[cfg(test)]
#[expect(clippy::cast_precision_loss)]
mod tests {
use oximo_core::prelude::*;
use oximo_solver::UniversalOptionsExt;
use super::*;
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
#[test]
fn only_solution_statuses_admit_primal_and_dual_points() {
assert!(status_has_point(SolverStatus::Solved));
assert!(status_has_point(SolverStatus::AlmostSolved));
assert!(!status_has_point(SolverStatus::MaxIterations));
assert!(!status_has_point(SolverStatus::MaxTime));
}
#[test]
fn approximate_certificates_do_not_claim_proven_infeasibility() {
assert_eq!(map_status(SolverStatus::Solved), TerminationStatus::Optimal);
assert_eq!(map_status(SolverStatus::AlmostSolved), TerminationStatus::Feasible);
for status in [SolverStatus::AlmostPrimalInfeasible, SolverStatus::AlmostDualInfeasible] {
assert_eq!(map_status(status), TerminationStatus::Other(format!("{status:?}")));
assert!(!status_has_point(status));
}
assert_eq!(map_status(SolverStatus::PrimalInfeasible), TerminationStatus::Infeasible);
assert_eq!(map_status(SolverStatus::DualInfeasible), TerminationStatus::Unbounded);
}
#[test]
fn finite_dual_objective_maps_bound_and_gap() {
let meta = Meta {
num_variables: 0,
sign: -1.0,
obj_constant: 5.0,
row_duals: Vec::new(),
soc_block_starts: Vec::new(),
n_explicit: 0,
};
let bound = mapped_objective_bound(&meta, -10.0, -8.0);
assert_eq!(bound, Some(13.0));
}
#[test]
fn nonfinite_primal_does_not_discard_an_independent_finite_bound() {
let meta = Meta {
num_variables: 0,
sign: 1.0,
obj_constant: 0.0,
row_duals: Vec::new(),
soc_block_starts: Vec::new(),
n_explicit: 0,
};
for (primal, dual) in
[(f64::NAN, 1.0), (1.0, f64::NAN), (f64::INFINITY, 1.0), (1.0, f64::NEG_INFINITY)]
{
assert_eq!(
mapped_objective_bound(&meta, primal, dual),
dual.is_finite().then_some(dual)
);
}
}
#[test]
fn forced_serial_and_parallel_translation_are_identical() {
let m = Model::new("ordered");
variable!(m, x);
variable!(m, y);
variable!(m, t >= 0.0);
for i in 0..PAR_ROW_THRESHOLD + 3 {
if i % 2 == 0 {
m.__add_constraint_auto((x + (i as f64 + 1.0) * y).le(i as f64 + 10.0));
} else {
m.__add_constraint_auto((x.powi(2) + y.powi(2) - t.powi(2)).le(0.0));
}
}
for i in 0..PAR_ROW_THRESHOLD + 3 {
m.add_soc_constraint(format!("soc{i}"), [x, y], t);
}
objective!(m, Min, t);
let serial = build_problem_with(&m, Some(false)).unwrap();
let parallel = build_problem_with(&m, Some(true)).unwrap();
let automatic = build_problem(&m).unwrap();
assert_eq!(serial.p_mat.colptr, parallel.p_mat.colptr);
assert_eq!(serial.p_mat.rowval, parallel.p_mat.rowval);
assert_eq!(serial.p_mat.nzval, parallel.p_mat.nzval);
assert_eq!(serial.q, parallel.q);
assert_eq!(serial.a_mat.colptr, parallel.a_mat.colptr);
assert_eq!(serial.a_mat.rowval, parallel.a_mat.rowval);
assert_eq!(serial.a_mat.nzval, parallel.a_mat.nzval);
assert_eq!(serial.b, parallel.b);
assert_eq!(serial.cones, parallel.cones);
assert_eq!(serial.meta.row_duals, parallel.meta.row_duals);
assert_eq!(serial.meta.soc_block_starts, parallel.meta.soc_block_starts);
assert_eq!(serial.meta.n_explicit, parallel.meta.n_explicit);
assert_eq!(serial.a_mat.colptr, automatic.a_mat.colptr);
assert_eq!(serial.a_mat.rowval, automatic.a_mat.rowval);
assert_eq!(serial.a_mat.nzval, automatic.a_mat.nzval);
assert_eq!(serial.b, automatic.b);
assert_eq!(serial.cones, automatic.cones);
assert_eq!(serial.meta.row_duals, automatic.meta.row_duals);
}
#[test]
fn automatic_large_detected_soc_translation_matches_forced_paths() {
let m = Model::new("automatic_detected_soc");
variable!(m, x);
variable!(m, y);
variable!(m, t >= 0.0);
objective!(m, Min, t);
for _ in 0..PAR_ROW_THRESHOLD + 3 {
m.__add_constraint_auto((x.powi(2) + y.powi(2) - t.powi(2)).le(0.0));
}
let serial = build_problem_with(&m, Some(false)).unwrap();
let automatic = build_problem(&m).unwrap();
let parallel = build_problem_with(&m, Some(true)).unwrap();
assert_eq!(serial.a_mat.colptr, automatic.a_mat.colptr);
assert_eq!(serial.a_mat.rowval, automatic.a_mat.rowval);
assert_eq!(serial.a_mat.nzval, automatic.a_mat.nzval);
assert_eq!(serial.b, automatic.b);
assert_eq!(serial.cones, automatic.cones);
assert_eq!(automatic.a_mat.colptr, parallel.a_mat.colptr);
assert_eq!(automatic.a_mat.rowval, parallel.a_mat.rowval);
assert_eq!(automatic.a_mat.nzval, parallel.a_mat.nzval);
assert_eq!(automatic.b, parallel.b);
assert_eq!(automatic.cones, parallel.cones);
}
#[test]
fn parallel_row_classification_keeps_first_error_order() {
let m = Model::new("errors");
variable!(m, x);
variable!(m, y);
variable!(m, z);
m.__add_constraint("first", (x * y * z).le(1.0));
m.__add_constraint("second", x.exp().le(2.0));
objective!(m, Min, x);
let serial = classify_rows_with(&m, Some(false)).unwrap_err();
let automatic = classify_rows_with(&m, None).unwrap_err();
let parallel = classify_rows_with(&m, Some(true)).unwrap_err();
assert_eq!(serial.to_string(), parallel.to_string());
assert_eq!(serial.to_string(), automatic.to_string());
assert!(serial.to_string().contains("first"));
}
#[test]
fn lp_known_optimum_maximize() {
let m = Model::new("lp");
variable!(m, 0.0 <= x <= 3.0);
variable!(m, 0.0 <= y <= 3.0);
constraint!(m, cap, x + y <= 4.0);
objective!(m, Max, 3.0 * x + 2.0 * y);
assert_eq!(m.kind(), ModelKind::LP);
let res = solve(&m, &ClarabelOptions::default()).unwrap();
assert_eq!(res.termination, TerminationStatus::Optimal);
assert!(close(res.objective().unwrap(), 11.0, 1e-6));
assert!(close(res.value_of(x).unwrap().unwrap(), 3.0, 1e-6));
assert!(close(res.value_of(y).unwrap().unwrap(), 1.0, 1e-6));
}
#[test]
fn lp_range_constraint() {
let m = Model::new("range");
variable!(m, x >= 0.0);
variable!(m, y >= 0.0);
constraint!(m, band, 1.0 <= x + y <= 3.0);
objective!(m, Min, x + y);
let res = solve(&m, &ClarabelOptions::default()).unwrap();
assert_eq!(res.termination, TerminationStatus::Optimal);
assert!(close(res.objective().unwrap(), 1.0, 1e-6));
}
#[test]
fn qp_cvxopt_quickstart() {
let m = Model::new("cvxopt");
variable!(m, x0 >= 0.0);
variable!(m, x1 >= 0.0);
constraint!(m, eq, x0 + x1 == 1.0);
objective!(m, Min, 2.0 * x0.powi(2) + x0 * x1 + x1.powi(2) + x0 + x1);
assert_eq!(m.kind(), ModelKind::QP);
let res = solve(&m, &ClarabelOptions::default()).unwrap();
assert_eq!(res.termination, TerminationStatus::Optimal);
assert!(close(res.value_of(x0).unwrap().unwrap(), 0.25, 1e-5));
assert!(close(res.value_of(x1).unwrap().unwrap(), 0.75, 1e-5));
assert!(close(res.objective().unwrap(), 1.875, 1e-5));
}
#[test]
fn qp_objective_constant_is_added_back() {
let m = Model::new("shift");
variable!(m, -5.0 <= x <= 5.0);
objective!(m, Min, (x - 1.0).powi(2));
assert_eq!(m.kind(), ModelKind::QP);
let res = solve(&m, &ClarabelOptions::default()).unwrap();
assert_eq!(res.termination, TerminationStatus::Optimal);
assert!(close(res.value_of(x).unwrap().unwrap(), 1.0, 1e-5));
assert!(res.objective().unwrap().abs() < 1e-5);
}
#[test]
fn explicit_socp_min_linear_over_disk() {
let m = Model::new("socp");
variable!(m, x);
variable!(m, y);
variable!(m, t >= 0.0);
m.fix(t, 1.0).unwrap();
let disk = m.add_soc_constraint("disk", [x, y], t);
objective!(m, Min, x + y);
assert_eq!(m.kind(), ModelKind::SOCP);
let res = solve(&m, &ClarabelOptions::default()).unwrap();
assert_eq!(res.termination, TerminationStatus::Optimal);
assert!(close(res.objective().unwrap(), -std::f64::consts::SQRT_2, 1e-6));
let z0 = res.soc_dual_of(disk).expect("matching model").expect("SOC dual missing");
assert!(close(z0, std::f64::consts::SQRT_2, 1e-6), "z0 = {z0}");
}
#[test]
fn detected_socp_hypotenuse() {
let m = Model::new("socp_detected");
variable!(m, x);
variable!(m, y);
variable!(m, t >= 0.0);
m.fix(x, 3.0).unwrap();
m.fix(y, 4.0).unwrap();
constraint!(m, cone, x.powi(2) + y.powi(2) <= t.powi(2));
objective!(m, Min, t);
assert_eq!(m.kind(), ModelKind::SOCP);
let res = solve(&m, &ClarabelOptions::default()).unwrap();
assert_eq!(res.termination, TerminationStatus::Optimal);
assert!(close(res.objective().unwrap(), 5.0, 1e-5));
}
#[test]
fn socp_with_quadratic_objective() {
let m = Model::new("socp_qobj");
variable!(m, x);
variable!(m, y);
variable!(m, t >= 0.0);
m.fix(t, 2.0).unwrap();
m.add_soc_constraint("disk", [x, y], t);
objective!(m, Min, x.powi(2) + y);
assert_eq!(m.kind(), ModelKind::SOCP);
let res = solve(&m, &ClarabelOptions::default()).unwrap();
assert_eq!(res.termination, TerminationStatus::Optimal);
assert!(close(res.objective().unwrap(), -2.0, 1e-5));
}
#[test]
fn milp_is_unsupported() {
let m = Model::new("milp");
variable!(m, 0.0 <= x <= 5.0, Int);
objective!(m, Min, x);
let err = solve(&m, &ClarabelOptions::default()).unwrap_err();
assert!(matches!(err, SolverError::UnsupportedKind(ModelKind::MILP)));
}
#[test]
fn qcp_is_unsupported() {
let m = Model::new("qcp");
variable!(m, x >= 0.0);
variable!(m, y >= 0.0);
constraint!(m, c, x * y <= 4.0);
objective!(m, Min, x + y);
let err = solve(&m, &ClarabelOptions::default()).unwrap_err();
assert!(matches!(err, SolverError::UnsupportedKind(ModelKind::QCP)));
}
#[test]
fn nlp_is_unsupported() {
let m = Model::new("nlp");
variable!(m, x >= 0.1);
objective!(m, Min, x.sin());
let err = solve(&m, &ClarabelOptions::default()).unwrap_err();
assert!(matches!(err, SolverError::UnsupportedKind(ModelKind::NLP)));
}
#[test]
fn semi_domain_is_rejected() {
let m = Model::new("semi");
variable!(m, s <= 10.0, SemiCont(2.0));
objective!(m, Min, s);
let err = solve(&m, &ClarabelOptions::default()).unwrap_err();
assert!(matches!(err, SolverError::Backend(_)));
}
#[test]
fn infeasible_lp_is_reported() {
let m = Model::new("infeas");
variable!(m, 0.0 <= x <= 1.0);
constraint!(m, c, x >= 2.0);
objective!(m, Min, x);
let res = solve(&m, &ClarabelOptions::default()).unwrap();
assert_eq!(res.termination, TerminationStatus::Infeasible);
assert!(!res.has_solution());
}
#[test]
fn lp_dual_signs_match_highs() {
fn build() -> Model {
let m = Model::new("duals");
variable!(m, x >= 0.0);
variable!(m, y >= 0.0);
constraint!(m, demand, x + y >= 2.0);
constraint!(m, link, x - y <= 1.0);
objective!(m, Min, 2.0 * x + 3.0 * y);
m
}
let m = build();
let ours = solve(&m, &ClarabelOptions::default()).unwrap();
let reference = oximo_highs::solve(&m, &oximo_highs::HighsOptions::default()).unwrap();
assert_eq!(ours.termination, TerminationStatus::Optimal);
for (id, want) in &reference.dual {
let got = ours.dual.get(id).copied().unwrap_or(0.0);
assert!(close(got, *want, 1e-5), "dual {id:?}: clarabel {got} vs highs {want}");
}
}
fn all_options_set() -> ClarabelOptions {
ClarabelOptions::default()
.threads(1)
.direct_solve_method(ClarabelDirectSolve::Auto)
.max_iter(500)
.max_step_fraction(0.99)
.tol_gap_abs(1e-8)
.tol_gap_rel(1e-8)
.tol_feas(1e-8)
.tol_infeas_abs(1e-8)
.tol_infeas_rel(1e-8)
.tol_ktratio(1e-6)
.reduced_tol_gap_abs(5e-5)
.reduced_tol_gap_rel(5e-5)
.reduced_tol_feas(1e-4)
.reduced_tol_infeas_abs(5e-12)
.reduced_tol_infeas_rel(5e-5)
.reduced_tol_ktratio(1e-4)
.equilibrate_enable(true)
.equilibrate_max_iter(10)
.equilibrate_min_scaling(1e-4)
.equilibrate_max_scaling(1e4)
.linesearch_backtrack_step(0.8)
.min_switch_step_length(1e-1)
.min_terminate_step_length(1e-4)
.static_regularization_enable(true)
.static_regularization_constant(1e-8)
.static_regularization_proportional(1e-30)
.dynamic_regularization_enable(true)
.dynamic_regularization_eps(1e-13)
.dynamic_regularization_delta(2e-7)
.iterative_refinement_enable(true)
.iterative_refinement_reltol(1e-13)
.iterative_refinement_abstol(1e-12)
.iterative_refinement_max_iter(10)
.iterative_refinement_stop_ratio(5.0)
.presolve_enable(true)
.input_sparse_dropzeros(false)
}
#[test]
fn builder_sets_all_fields() {
let o = all_options_set();
assert_eq!(o.universal.threads, Some(1));
assert_eq!(o.direct_solve_method, Some(ClarabelDirectSolve::Auto));
assert_eq!(o.max_iter, Some(500)); assert_eq!(o.tol_gap_abs, Some(1e-8)); assert_eq!(o.reduced_tol_infeas_abs, Some(5e-12));
assert_eq!(o.equilibrate_max_iter, Some(10));
assert_eq!(o.presolve_enable, Some(true)); assert_eq!(o.input_sparse_dropzeros, Some(false));
assert_eq!(o.iterative_refinement_stop_ratio, Some(5.0));
}
#[test]
fn apply_all_options_solves() {
let m = Model::new("lp");
variable!(m, 0.0 <= x <= 3.0);
variable!(m, 0.0 <= y <= 3.0);
constraint!(m, cap, x + y <= 4.0);
objective!(m, Max, 3.0 * x + 2.0 * y);
let res = solve(&m, &all_options_set()).unwrap();
assert_eq!(res.termination, TerminationStatus::Optimal);
assert!(close(res.objective().unwrap(), 11.0, 1e-6));
}
#[test]
fn direct_solve_method_qdldl_solves() {
let m = Model::new("lp");
variable!(m, 0.0 <= x <= 3.0);
variable!(m, 0.0 <= y <= 3.0);
constraint!(m, cap, x + y <= 4.0);
objective!(m, Max, 3.0 * x + 2.0 * y);
let opts = ClarabelOptions::default().direct_solve_method(ClarabelDirectSolve::Qdldl);
let res = solve(&m, &opts).unwrap();
assert_eq!(res.termination, TerminationStatus::Optimal);
assert!(close(res.objective().unwrap(), 11.0, 1e-6));
}
#[cfg(feature = "faer")]
#[test]
fn direct_solve_method_faer_solves() {
let m = Model::new("lp");
variable!(m, 0.0 <= x <= 3.0);
variable!(m, 0.0 <= y <= 3.0);
constraint!(m, cap, x + y <= 4.0);
objective!(m, Max, 3.0 * x + 2.0 * y);
let opts = ClarabelOptions::default().direct_solve_method(ClarabelDirectSolve::Faer);
let res = solve(&m, &opts).unwrap();
assert_eq!(res.termination, TerminationStatus::Optimal);
assert!(close(res.objective().unwrap(), 11.0, 1e-6));
}
#[test]
fn low_max_iter_caps_iterations() {
let m = Model::new("cvxopt");
variable!(m, x0 >= 0.0);
variable!(m, x1 >= 0.0);
constraint!(m, eq, x0 + x1 == 1.0);
objective!(m, Min, 2.0 * x0.powi(2) + x0 * x1 + x1.powi(2) + x0 + x1);
let res = solve(&m, &ClarabelOptions::default().max_iter(1)).unwrap();
assert!(res.iterations <= 1, "iterations = {}", res.iterations);
if res.termination == TerminationStatus::IterationLimit {
assert!(!res.has_solution());
assert_eq!(res.dual_status, DualStatus::NoSolution);
}
}
#[test]
fn threads_maps_to_max_threads() {
let m = Model::new("lp");
variable!(m, 0.0 <= x <= 3.0);
variable!(m, 0.0 <= y <= 3.0);
constraint!(m, cap, x + y <= 4.0);
objective!(m, Max, 3.0 * x + 2.0 * y);
let res = solve(&m, &ClarabelOptions::default().threads(2)).unwrap();
assert_eq!(res.termination, TerminationStatus::Optimal);
assert!(close(res.objective().unwrap(), 11.0, 1e-6));
}
}