From aedde7cb61c2a0cdcc43c66e1fca70903c64bd0e Mon Sep 17 00:00:00 2001 From: Hao Zhu Date: Sun, 8 Mar 2026 08:45:29 +0100 Subject: [PATCH 1/4] add .value() method for evaluating expressions at a solution Co-Authored-By: Claude Sonnet 4.6 --- src/expr/eval.rs | 668 +++++++++++++++++++++++++++++++++++++++++ src/expr/expression.rs | 15 + src/expr/mod.rs | 2 + src/lib.rs | 3 +- src/solver/clarabel.rs | 8 +- tests/eval_tests.rs | 306 +++++++++++++++++++ 6 files changed, 1000 insertions(+), 2 deletions(-) create mode 100644 src/expr/eval.rs create mode 100644 tests/eval_tests.rs diff --git a/src/expr/eval.rs b/src/expr/eval.rs new file mode 100644 index 0000000..188c130 --- /dev/null +++ b/src/expr/eval.rs @@ -0,0 +1,668 @@ +//! Expression evaluation given a solution. +//! +//! This module provides the `Evaluable` trait and `Expr::eval()` method, +//! allowing users to compute the value of any expression (not just variables) +//! after solving a problem. +//! +//! # Example +//! +//! ``` +//! use cvxrust::prelude::*; +//! +//! let x = variable(5); +//! let obj = norm2(&x); +//! +//! let solution = Problem::minimize(obj.clone()) +//! .subject_to([x.ge(1.0)]) +//! .solve() +//! .unwrap(); +//! +//! // Evaluate any expression, not just variables +//! let norm_val = obj.value(&solution); +//! let x_vals = x.eval(&solution).unwrap(); +//! ``` + +use nalgebra::DMatrix; + +use super::expression::{Array, Expr, ExprId, IndexSpec}; +use super::shape::Shape; + +/// Trait for types that provide variable values (used for expression evaluation). +/// +/// Implement this trait to allow `Expr::eval()` to look up variable values. +/// The `Solution` type in `cvxrust::solver` implements this trait. +pub trait Evaluable { + /// Get the value of a variable by its ID. + fn get_variable_value(&self, id: ExprId) -> Option<&Array>; +} + +impl Expr { + /// Evaluate this expression given variable values from a solution. + /// + /// This allows computing the value of any expression — not just variables — + /// after solving a problem. It mirrors CVXPY's `expr.value` attribute. + /// + /// # Example + /// + /// ``` + /// use cvxrust::prelude::*; + /// + /// let x = variable(3); + /// let expr = norm2(&x); + /// + /// let solution = Problem::minimize(expr.clone()) + /// .subject_to([x.ge(1.0)]) + /// .solve() + /// .unwrap(); + /// + /// let arr = expr.eval(&solution).unwrap(); + /// ``` + /// Evaluate this expression, returning an `Array`. + /// + /// Panics if a variable in the expression is not present in `ctx`. + /// Use `eval()` for explicit error handling. + /// + /// The returned `Array` supports `[(row, col)]` indexing, matching the + /// ergonomics of the old `&solution[&x]` API: + /// + /// ``` + /// use cvxrust::prelude::*; + /// + /// let x = variable(2); + /// let solution = Problem::minimize(sum(&x)) + /// .subject_to([x.ge(1.0)]) + /// .solve() + /// .unwrap(); + /// + /// let vals = x.value(&solution); + /// println!("{}", vals[(0, 0)]); + /// ``` + pub fn value(&self, ctx: &E) -> Array { + self.eval(ctx).expect("failed to evaluate expression") + } + + /// Evaluate this expression, returning `Result`. + pub fn eval(&self, ctx: &E) -> crate::Result { + match self { + Expr::Variable(v) => ctx + .get_variable_value(v.id) + .cloned() + .ok_or_else(|| crate::CvxError::InvalidProblem("Variable not in solution".into())), + Expr::Constant(c) => Ok(c.value.clone()), + + Expr::Add(a, b) => { + let av = a.eval(ctx)?; + let bv = b.eval(ctx)?; + eval_add(av, bv) + } + Expr::Neg(a) => Ok(eval_neg(a.eval(ctx)?)), + Expr::Mul(a, b) => { + let av = a.eval(ctx)?; + let bv = b.eval(ctx)?; + eval_mul(av, bv) + } + Expr::Sum(a, axis) => eval_sum(a.eval(ctx)?, *axis), + Expr::Reshape(a, shape) => eval_reshape(a.eval(ctx)?, shape), + Expr::Index(a, spec) => eval_index(a.eval(ctx)?, spec), + Expr::VStack(exprs) => { + let arrs: crate::Result> = exprs.iter().map(|e| e.eval(ctx)).collect(); + eval_vstack(arrs?) + } + Expr::HStack(exprs) => { + let arrs: crate::Result> = exprs.iter().map(|e| e.eval(ctx)).collect(); + eval_hstack(arrs?) + } + Expr::Transpose(a) => Ok(eval_transpose(a.eval(ctx)?)), + Expr::Trace(a) => Ok(eval_trace(a.eval(ctx)?)), + Expr::MatMul(a, b) => { + let av = a.eval(ctx)?; + let bv = b.eval(ctx)?; + eval_matmul(av, bv) + } + Expr::Norm1(a) => Ok(Array::Scalar(eval_norm1(&a.eval(ctx)?))), + Expr::Norm2(a) => Ok(Array::Scalar(eval_norm2(&a.eval(ctx)?))), + Expr::NormInf(a) => Ok(Array::Scalar(eval_norminf(&a.eval(ctx)?))), + Expr::Abs(a) => Ok(eval_elementwise(a.eval(ctx)?, f64::abs)), + Expr::Pos(a) => Ok(eval_elementwise(a.eval(ctx)?, |x| x.max(0.0))), + Expr::NegPart(a) => Ok(eval_elementwise(a.eval(ctx)?, |x| (-x).max(0.0))), + Expr::Maximum(exprs) => { + let arrs: crate::Result> = exprs.iter().map(|e| e.eval(ctx)).collect(); + eval_maximum(arrs?) + } + Expr::Minimum(exprs) => { + let arrs: crate::Result> = exprs.iter().map(|e| e.eval(ctx)).collect(); + eval_minimum(arrs?) + } + Expr::QuadForm(x, p) => { + let xv = x.eval(ctx)?; + let pv = p.eval(ctx)?; + eval_quad_form(xv, pv) + } + Expr::SumSquares(a) => { + let av = a.eval(ctx)?; + Ok(Array::Scalar(eval_norm2(&av).powi(2))) + } + Expr::QuadOverLin(x, y) => { + let xv = x.eval(ctx)?; + let yv = y.eval(ctx)?; + eval_quad_over_lin(xv, yv) + } + Expr::Exp(a) => Ok(eval_elementwise(a.eval(ctx)?, f64::exp)), + Expr::Log(a) => Ok(eval_elementwise(a.eval(ctx)?, f64::ln)), + Expr::Entropy(a) => Ok(eval_elementwise(a.eval(ctx)?, |x| { + if x <= 0.0 { + 0.0 + } else { + -x * x.ln() + } + })), + Expr::Power(a, p) => { + let p = *p; + Ok(eval_elementwise(a.eval(ctx)?, move |x| x.powf(p))) + } + Expr::Cumsum(a, axis) => eval_cumsum(a.eval(ctx)?, *axis), + Expr::Diag(a) => Ok(eval_diag(a.eval(ctx)?)), + } + } + +} + +// ---- Array arithmetic helpers ---- + +/// Convert Array to DMatrix (always 2D column-major). +fn arr_to_dense(a: Array) -> DMatrix { + match a { + Array::Dense(m) => m, + Array::Scalar(v) => DMatrix::from_element(1, 1, v), + Array::Sparse(m) => { + let mut dense = DMatrix::zeros(m.nrows(), m.ncols()); + for (row, col, val) in m.triplet_iter() { + dense[(row, col)] = *val; + } + dense + } + } +} + +fn eval_add(a: Array, b: Array) -> crate::Result { + match (a, b) { + (Array::Scalar(x), Array::Scalar(y)) => Ok(Array::Scalar(x + y)), + (Array::Scalar(s), b) => Ok(Array::Dense(arr_to_dense(b).map(|x| x + s))), + (a, Array::Scalar(s)) => Ok(Array::Dense(arr_to_dense(a).map(|x| x + s))), + (a, b) => { + let am = arr_to_dense(a); + let bm = arr_to_dense(b); + if am.nrows() != bm.nrows() || am.ncols() != bm.ncols() { + return Err(crate::CvxError::InvalidProblem( + "Shape mismatch in addition".into(), + )); + } + Ok(Array::Dense(am + bm)) + } + } +} + +fn eval_neg(a: Array) -> Array { + match a { + Array::Scalar(v) => Array::Scalar(-v), + Array::Dense(m) => Array::Dense(-m), + a @ Array::Sparse(_) => Array::Dense(-arr_to_dense(a)), + } +} + +fn eval_mul(a: Array, b: Array) -> crate::Result { + match (a, b) { + (Array::Scalar(x), Array::Scalar(y)) => Ok(Array::Scalar(x * y)), + (Array::Scalar(s), b) => Ok(Array::Dense(arr_to_dense(b) * s)), + (a, Array::Scalar(s)) => Ok(Array::Dense(arr_to_dense(a) * s)), + (a, b) => { + let am = arr_to_dense(a); + let bm = arr_to_dense(b); + if am.nrows() != bm.nrows() || am.ncols() != bm.ncols() { + return Err(crate::CvxError::InvalidProblem( + "Shape mismatch in element-wise multiply".into(), + )); + } + Ok(Array::Dense(am.component_mul(&bm))) + } + } +} + +fn eval_matmul(a: Array, b: Array) -> crate::Result { + let am = arr_to_dense(a); + let bm = arr_to_dense(b); + if am.ncols() != bm.nrows() { + return Err(crate::CvxError::InvalidProblem( + "Shape mismatch in matrix multiply".into(), + )); + } + Ok(Array::Dense(am * bm)) +} + +fn eval_sum(a: Array, axis: Option) -> crate::Result { + match axis { + None => { + let total: f64 = match &a { + Array::Scalar(v) => *v, + Array::Dense(m) => m.sum(), + Array::Sparse(m) => m.values().iter().sum(), + }; + Ok(Array::Scalar(total)) + } + Some(0) => { + // Sum along axis 0 (rows) → column vector of size ncols + let m = arr_to_dense(a); + let result = + DMatrix::from_fn(m.ncols(), 1, |j, _| m.column(j).iter().sum::()); + Ok(Array::Dense(result)) + } + Some(1) => { + // Sum along axis 1 (cols) → column vector of size nrows + let m = arr_to_dense(a); + let result = DMatrix::from_fn(m.nrows(), 1, |i, _| m.row(i).iter().sum::()); + Ok(Array::Dense(result)) + } + Some(ax) => Err(crate::CvxError::InvalidProblem(format!( + "Invalid axis {} for sum", + ax + ))), + } +} + +fn eval_reshape(a: Array, shape: &Shape) -> crate::Result { + let flat: Vec = match a { + Array::Scalar(v) => vec![v], + Array::Dense(m) => m.iter().cloned().collect(), + Array::Sparse(m) => { + let mut v = vec![0.0; m.nrows() * m.ncols()]; + for (r, c, val) in m.triplet_iter() { + v[c * m.nrows() + r] = *val; + } + v + } + }; + let (rows, cols) = (shape.rows(), shape.cols()); + if flat.len() != rows * cols { + return Err(crate::CvxError::InvalidProblem("Reshape size mismatch".into())); + } + if shape.is_scalar() { + Ok(Array::Scalar(flat[0])) + } else { + Ok(Array::Dense(DMatrix::from_vec(rows, cols, flat))) + } +} + +fn eval_index(a: Array, spec: &IndexSpec) -> crate::Result { + let m = arr_to_dense(a); + let nrows = m.nrows(); + let ncols = m.ncols(); + + match spec.ranges.as_slice() { + [Some((start, stop, step))] => { + let indices: Vec = (*start..*stop).step_by(*step).collect(); + // For column vectors, index rows; otherwise index flat (column-major) + let data: Vec = if ncols == 1 { + indices.iter().map(|&i| m[(i, 0)]).collect() + } else { + indices + .iter() + .map(|&i| *m.iter().nth(i).unwrap_or(&0.0)) + .collect() + }; + if data.len() == 1 { + Ok(Array::Scalar(data[0])) + } else { + Ok(Array::Dense(DMatrix::from_vec(data.len(), 1, data))) + } + } + [None] => Ok(Array::Dense(m)), + [row_spec, col_spec] => { + let row_range: Vec = match row_spec { + Some((s, e, step)) => (*s..*e).step_by(*step).collect(), + None => (0..nrows).collect(), + }; + let col_range: Vec = match col_spec { + Some((s, e, step)) => (*s..*e).step_by(*step).collect(), + None => (0..ncols).collect(), + }; + let result = DMatrix::from_fn(row_range.len(), col_range.len(), |i, j| { + m[(row_range[i], col_range[j])] + }); + if result.nrows() == 1 && result.ncols() == 1 { + Ok(Array::Scalar(result[(0, 0)])) + } else { + Ok(Array::Dense(result)) + } + } + _ => Err(crate::CvxError::InvalidProblem( + "Unsupported index spec in eval".into(), + )), + } +} + +fn eval_vstack(arrays: Vec) -> crate::Result { + if arrays.is_empty() { + return Ok(Array::Scalar(0.0)); + } + let mats: Vec> = arrays.into_iter().map(arr_to_dense).collect(); + let ncols = mats[0].ncols(); + let total_rows: usize = mats.iter().map(|m| m.nrows()).sum(); + let mut result = DMatrix::zeros(total_rows, ncols); + let mut row_offset = 0; + for m in mats { + let nrows = m.nrows(); + result.rows_mut(row_offset, nrows).copy_from(&m); + row_offset += nrows; + } + Ok(Array::Dense(result)) +} + +fn eval_hstack(arrays: Vec) -> crate::Result { + if arrays.is_empty() { + return Ok(Array::Scalar(0.0)); + } + let mats: Vec> = arrays.into_iter().map(arr_to_dense).collect(); + let nrows = mats[0].nrows(); + let total_cols: usize = mats.iter().map(|m| m.ncols()).sum(); + let mut result = DMatrix::zeros(nrows, total_cols); + let mut col_offset = 0; + for m in mats { + let ncols = m.ncols(); + result.columns_mut(col_offset, ncols).copy_from(&m); + col_offset += ncols; + } + Ok(Array::Dense(result)) +} + +fn eval_transpose(a: Array) -> Array { + match a { + Array::Scalar(v) => Array::Scalar(v), + Array::Dense(m) => Array::Dense(m.transpose()), + a @ Array::Sparse(_) => Array::Dense(arr_to_dense(a).transpose()), + } +} + +fn eval_trace(a: Array) -> Array { + match a { + Array::Scalar(v) => Array::Scalar(v), + Array::Dense(m) => Array::Scalar(m.trace()), + a @ Array::Sparse(_) => Array::Scalar(arr_to_dense(a).trace()), + } +} + +fn eval_norm1(a: &Array) -> f64 { + match a { + Array::Scalar(v) => v.abs(), + Array::Dense(m) => m.iter().map(|x| x.abs()).sum(), + Array::Sparse(m) => m.values().iter().map(|x| x.abs()).sum(), + } +} + +fn eval_norm2(a: &Array) -> f64 { + match a { + Array::Scalar(v) => v.abs(), + Array::Dense(m) => m.iter().map(|x| x * x).sum::().sqrt(), + Array::Sparse(m) => m.values().iter().map(|x| x * x).sum::().sqrt(), + } +} + +fn eval_norminf(a: &Array) -> f64 { + match a { + Array::Scalar(v) => v.abs(), + Array::Dense(m) => m.iter().map(|x| x.abs()).fold(0.0_f64, f64::max), + Array::Sparse(m) => m.values().iter().map(|x| x.abs()).fold(0.0_f64, f64::max), + } +} + +fn eval_elementwise(a: Array, f: impl Fn(f64) -> f64) -> Array { + match a { + Array::Scalar(v) => Array::Scalar(f(v)), + Array::Dense(m) => Array::Dense(m.map(f)), + a @ Array::Sparse(_) => Array::Dense(arr_to_dense(a).map(f)), + } +} + +fn eval_maximum(arrays: Vec) -> crate::Result { + if arrays.is_empty() { + return Err(crate::CvxError::InvalidProblem( + "maximum of empty list".into(), + )); + } + let mut iter = arrays.into_iter(); + let first = arr_to_dense(iter.next().unwrap()); + let result = iter.try_fold(first, |acc, a| { + let m = arr_to_dense(a); + if acc.nrows() != m.nrows() || acc.ncols() != m.ncols() { + return Err(crate::CvxError::InvalidProblem( + "Shape mismatch in maximum".into(), + )); + } + Ok(acc.zip_map(&m, f64::max)) + })?; + Ok(Array::Dense(result)) +} + +fn eval_minimum(arrays: Vec) -> crate::Result { + if arrays.is_empty() { + return Err(crate::CvxError::InvalidProblem( + "minimum of empty list".into(), + )); + } + let mut iter = arrays.into_iter(); + let first = arr_to_dense(iter.next().unwrap()); + let result = iter.try_fold(first, |acc, a| { + let m = arr_to_dense(a); + if acc.nrows() != m.nrows() || acc.ncols() != m.ncols() { + return Err(crate::CvxError::InvalidProblem( + "Shape mismatch in minimum".into(), + )); + } + Ok(acc.zip_map(&m, f64::min)) + })?; + Ok(Array::Dense(result)) +} + +fn eval_quad_form(x: Array, p: Array) -> crate::Result { + let xm = arr_to_dense(x); + let pm = arr_to_dense(p); + if pm.nrows() != pm.ncols() || xm.nrows() != pm.nrows() { + return Err(crate::CvxError::InvalidProblem( + "Shape mismatch in quad_form".into(), + )); + } + // x' P x + let result = (xm.transpose() * &pm * &xm)[(0, 0)]; + Ok(Array::Scalar(result)) +} + +fn eval_quad_over_lin(x: Array, y: Array) -> crate::Result { + let norm_sq = eval_norm2(&x).powi(2); + let y_val = y.as_scalar().ok_or_else(|| { + crate::CvxError::InvalidProblem("quad_over_lin: denominator must be scalar".into()) + })?; + Ok(Array::Scalar(norm_sq / y_val)) +} + +fn eval_cumsum(a: Array, axis: Option) -> crate::Result { + let mut m = arr_to_dense(a); + match axis { + None | Some(0) => { + // Cumsum along rows + for i in 1..m.nrows() { + for j in 0..m.ncols() { + m[(i, j)] += m[(i - 1, j)]; + } + } + } + Some(1) => { + // Cumsum along columns + for j in 1..m.ncols() { + for i in 0..m.nrows() { + m[(i, j)] += m[(i, j - 1)]; + } + } + } + Some(ax) => { + return Err(crate::CvxError::InvalidProblem(format!( + "Invalid axis {} for cumsum", + ax + ))) + } + } + Ok(Array::Dense(m)) +} + +fn eval_diag(a: Array) -> Array { + let m = arr_to_dense(a); + let (rows, cols) = (m.nrows(), m.ncols()); + if cols == 1 || (rows == 1 && cols != 1) { + // Vector → diagonal matrix + let n = rows.max(cols); + let mut result = DMatrix::zeros(n, n); + for i in 0..n { + let v = if cols == 1 { m[(i, 0)] } else { m[(0, i)] }; + result[(i, i)] = v; + } + Array::Dense(result) + } else { + // Matrix → diagonal vector + let n = rows.min(cols); + let data: Vec = (0..n).map(|i| m[(i, i)]).collect(); + Array::Dense(DMatrix::from_vec(n, 1, data)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::expr::{constant, variable}; + use crate::prelude::*; + use std::collections::HashMap; + + /// Simple test context using a HashMap of variable values. + struct TestCtx(HashMap); + + impl Evaluable for TestCtx { + fn get_variable_value(&self, id: ExprId) -> Option<&Array> { + self.0.get(&id) + } + } + + fn make_var_scalar(val: f64) -> (Expr, TestCtx) { + let x = variable(()); + let id = x.variable_id().unwrap(); + let mut map = HashMap::new(); + map.insert(id, Array::Scalar(val)); + (x, TestCtx(map)) + } + + fn make_var_vec(vals: Vec) -> (Expr, TestCtx) { + let n = vals.len(); + let x = variable(n); + let id = x.variable_id().unwrap(); + let mut map = HashMap::new(); + map.insert(id, Array::from_vec(vals)); + (x, TestCtx(map)) + } + + #[test] + fn test_eval_variable() { + let (x, ctx) = make_var_scalar(3.0); + assert_eq!(x.value(&ctx)[(0, 0)], 3.0); + } + + #[test] + fn test_eval_constant() { + let c = constant(5.0); + let (_, ctx) = make_var_scalar(0.0); + assert_eq!(c.value(&ctx)[(0, 0)], 5.0); + } + + #[test] + fn test_eval_add_scalars() { + let (x, ctx) = make_var_scalar(3.0); + let expr = x.clone() + constant(2.0); + let v = expr.value(&ctx).as_scalar().unwrap(); + assert!((v - 5.0).abs() < 1e-10); + } + + #[test] + fn test_eval_neg() { + let (x, ctx) = make_var_scalar(3.0); + let expr = -&x; + let v = expr.value(&ctx).as_scalar().unwrap(); + assert!((v + 3.0).abs() < 1e-10); + } + + #[test] + fn test_eval_mul_scalar() { + let (x, ctx) = make_var_scalar(4.0); + let expr = &x * 2.5; + let v = expr.value(&ctx).as_scalar().unwrap(); + assert!((v - 10.0).abs() < 1e-10); + } + + #[test] + fn test_eval_norm2() { + let (x, ctx) = make_var_vec(vec![3.0, 4.0]); + let expr = norm2(&x); + let v = expr.value(&ctx).as_scalar().unwrap(); + assert!((v - 5.0).abs() < 1e-10); + } + + #[test] + fn test_eval_norm1() { + let (x, ctx) = make_var_vec(vec![-1.0, 2.0, -3.0]); + let expr = norm1(&x); + let v = expr.value(&ctx).as_scalar().unwrap(); + assert!((v - 6.0).abs() < 1e-10); + } + + #[test] + fn test_eval_sum() { + let (x, ctx) = make_var_vec(vec![1.0, 2.0, 3.0]); + let expr = sum(&x); + let v = expr.value(&ctx).as_scalar().unwrap(); + assert!((v - 6.0).abs() < 1e-10); + } + + #[test] + fn test_eval_sum_squares() { + let (x, ctx) = make_var_vec(vec![1.0, 2.0, 3.0]); + let expr = sum_squares(&x); + let v = expr.value(&ctx).as_scalar().unwrap(); + assert!((v - 14.0).abs() < 1e-10); + } + + #[test] + fn test_eval_abs() { + let (x, ctx) = make_var_scalar(-5.0); + let expr = abs(&x); + let v = expr.value(&ctx).as_scalar().unwrap(); + assert!((v - 5.0).abs() < 1e-10); + } + + #[test] + fn test_eval_exp_log() { + let (x, ctx) = make_var_scalar(1.0); + let e = exp(&x); + let ev = e.value(&ctx).as_scalar().unwrap(); + assert!((ev - std::f64::consts::E).abs() < 1e-10); + let l = log(&x); + let lv = l.value(&ctx).as_scalar().unwrap(); + assert!(lv.abs() < 1e-10); + } + + #[test] + fn test_eval_with_solution() { + let x = variable(()); + let obj = &x * 2.0; + let solution = Problem::minimize(x.clone()) + .subject_to([x.ge(3.0)]) + .solve() + .unwrap(); + // The variable x should be ~3.0, so 2*x ~= 6.0 + let v = obj.value(&solution).as_scalar().unwrap(); + assert!((v - 6.0).abs() < 1e-4); + } +} diff --git a/src/expr/expression.rs b/src/expr/expression.rs index b17960f..00308dc 100644 --- a/src/expr/expression.rs +++ b/src/expr/expression.rs @@ -488,6 +488,21 @@ impl Expr { } } +impl std::ops::Index<(usize, usize)> for Array { + type Output = f64; + + fn index(&self, (row, col): (usize, usize)) -> &f64 { + match self { + Array::Dense(m) => &m[(row, col)], + Array::Scalar(v) => { + assert!(row == 0 && col == 0, "scalar index out of bounds"); + v + } + Array::Sparse(_) => panic!("use Array::Dense for indexing"), + } + } +} + // Convenient From implementations for automatic conversion impl From for Expr { fn from(value: f64) -> Self { diff --git a/src/expr/mod.rs b/src/expr/mod.rs index 179562a..284e663 100644 --- a/src/expr/mod.rs +++ b/src/expr/mod.rs @@ -7,11 +7,13 @@ //! - Constant creation via `constant()` and related functions pub mod constant; +pub mod eval; pub mod expression; pub mod shape; pub mod variable; // Re-export main types +pub use eval::Evaluable; pub use constant::{ constant, constant_dmatrix, constant_matrix, constant_sparse, constant_vec, eye, ones, zeros, IntoConstant, diff --git a/src/lib.rs b/src/lib.rs index b334a4e..31abb21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,7 +80,8 @@ pub mod prelude { // Expression types pub use crate::expr::{ constant, constant_dmatrix, constant_matrix, constant_sparse, constant_vec, eye, ones, - variable, zeros, Array, Expr, ExprId, IntoConstant, Shape, VariableBuilder, VariableExt, + variable, zeros, Array, Evaluable, Expr, ExprId, IntoConstant, Shape, VariableBuilder, + VariableExt, }; // Atoms diff --git a/src/solver/clarabel.rs b/src/solver/clarabel.rs index 251b23a..4c7f380 100644 --- a/src/solver/clarabel.rs +++ b/src/solver/clarabel.rs @@ -10,7 +10,7 @@ use clarabel::solver::{ }; use super::stuffing::{ConeDims, StuffedProblem, VariableMap}; -use crate::expr::{Array, ExprId}; +use crate::expr::{Array, Evaluable, ExprId}; /// Solution status from the solver. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -241,6 +241,12 @@ impl std::ops::Index<&crate::expr::Expr> for Solution { } } +impl Evaluable for Solution { + fn get_variable_value(&self, id: ExprId) -> Option<&Array> { + self.primal.as_ref()?.get(&id) + } +} + /// Solve the stuffed problem using Clarabel. pub fn solve(problem: &StuffedProblem, settings: &Settings) -> Solution { // Convert to Clarabel format diff --git a/tests/eval_tests.rs b/tests/eval_tests.rs new file mode 100644 index 0000000..8a82847 --- /dev/null +++ b/tests/eval_tests.rs @@ -0,0 +1,306 @@ +use cvxrust::prelude::*; + +const TOL: f64 = 1e-4; + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn approx(a: f64, b: f64) -> bool { + (a - b).abs() < TOL +} + +// ── variable access ─────────────────────────────────────────────────────────── + +#[test] +fn test_value_scalar_variable() { + let x = variable(()); + let sol = Problem::minimize(x.clone()) + .subject_to([x.ge(2.0)]) + .solve() + .unwrap(); + + // expr.value(&sol) should give the same result as solution.value(&x) + let via_expr = x.value(&sol).as_scalar().unwrap(); + let via_sol = sol.value(&x); + assert!(approx(via_expr, via_sol)); + assert!(approx(via_expr, 2.0)); +} + +#[test] +fn test_value_vector_variable_indexing() { + let x = variable(3); + let sol = Problem::minimize(sum(&x)) + .subject_to([x.ge(1.0)]) + .solve() + .unwrap(); + + let vals = x.value(&sol); + // Index operator should behave like &solution[&x] + let via_index = &sol[&x]; + assert!(approx(vals[(0, 0)], via_index[(0, 0)])); + assert!(approx(vals[(1, 0)], via_index[(1, 0)])); + assert!(approx(vals[(2, 0)], via_index[(2, 0)])); + // All elements should be ~1.0 + for i in 0..3 { + assert!(approx(vals[(i, 0)], 1.0)); + } +} + +// ── affine expressions ──────────────────────────────────────────────────────── + +#[test] +fn test_value_affine_scale_and_shift() { + // minimize x s.t. x >= 3 → x* = 3 + // expr = 2*x + 1 → value = 7 + let x = variable(()); + let expr = &x * 2.0 + constant(1.0); + let sol = Problem::minimize(x.clone()) + .subject_to([x.ge(3.0)]) + .solve() + .unwrap(); + + let v = expr.value(&sol).as_scalar().unwrap(); + assert!(approx(v, 7.0), "expected 7.0, got {v}"); +} + +#[test] +fn test_value_matmul_residual() { + // minimize ||Ax - b||^2 s.t. x >= 0 + // A = I (2x2), b = [3, 4] → x* = [3, 4], residual = [0, 0] + let a = constant_matrix(vec![1.0, 0.0, 0.0, 1.0], 2, 2); + let b = constant_vec(vec![3.0, 4.0]); + let x = variable(2); + let residual = matmul(&a, &x) - &b; + + let sol = Problem::minimize(sum_squares(&residual)) + .subject_to([x.ge(0.0)]) + .solve() + .unwrap(); + + let r = residual.value(&sol); + assert!(approx(r[(0, 0)], 0.0)); + assert!(approx(r[(1, 0)], 0.0)); +} + +#[test] +fn test_value_sum_of_vector() { + // minimize sum(x) s.t. x >= 2 → x* = [2,2,2], sum = 6 + let x = variable(3); + let sol = Problem::minimize(sum(&x)) + .subject_to([x.ge(2.0)]) + .solve() + .unwrap(); + + let s = sum(&x).value(&sol).as_scalar().unwrap(); + assert!(approx(s, 6.0), "expected 6.0, got {s}"); +} + +// ── nonlinear atoms ─────────────────────────────────────────────────────────── + +#[test] +fn test_value_norm2_matches_objective() { + // minimize norm2(x) s.t. x >= 1 → x* = [1,1], norm2 = sqrt(2) + let x = variable(2); + let obj = norm2(&x); + let sol = Problem::minimize(obj.clone()) + .subject_to([x.ge(1.0)]) + .solve() + .unwrap(); + + let reported = sol.value.unwrap(); + let via_eval = obj.value(&sol).as_scalar().unwrap(); + assert!(approx(reported, via_eval), "reported={reported}, eval={via_eval}"); + assert!(approx(reported, 2f64.sqrt())); +} + +#[test] +fn test_value_norm1() { + // minimize norm1(x) s.t. x >= 1 → x* = [1,1,1], norm1 = 3 + let x = variable(3); + let obj = norm1(&x); + let sol = Problem::minimize(obj.clone()) + .subject_to([x.ge(1.0)]) + .solve() + .unwrap(); + + let v = obj.value(&sol).as_scalar().unwrap(); + assert!(approx(v, 3.0), "expected 3.0, got {v}"); +} + +#[test] +fn test_value_norm_inf() { + // minimize norm_inf(x) s.t. x >= [1, 2] → x* = [1, 2], norm_inf = 2 + let x = variable(2); + let obj = norm_inf(&x); + let sol = Problem::minimize(obj.clone()) + .subject_to([x.ge(constant_vec(vec![1.0, 2.0]))]) + .solve() + .unwrap(); + + let v = obj.value(&sol).as_scalar().unwrap(); + assert!(approx(v, 2.0), "expected 2.0, got {v}"); +} + +#[test] +fn test_value_sum_squares_matches_objective() { + // minimize sum_squares(x) s.t. x >= 2 → x* = [2,2], sum_squares = 8 + let x = variable(2); + let obj = sum_squares(&x); + let sol = Problem::minimize(obj.clone()) + .subject_to([x.ge(2.0)]) + .solve() + .unwrap(); + + let reported = sol.value.unwrap(); + let via_eval = obj.value(&sol).as_scalar().unwrap(); + assert!(approx(reported, via_eval), "reported={reported}, eval={via_eval}"); + assert!(approx(reported, 8.0)); +} + +#[test] +fn test_value_abs() { + // minimize x s.t. x >= -5 → x* = -5, abs(x) = 5 + let x = variable(()); + let sol = Problem::minimize(x.clone()) + .subject_to([x.ge(-5.0)]) + .solve() + .unwrap(); + + let v = abs(&x).value(&sol).as_scalar().unwrap(); + assert!(approx(v, 5.0), "expected 5.0, got {v}"); +} + +// ── eval returns Result ─────────────────────────────────────────────────────── + +#[test] +fn test_eval_missing_variable_returns_err() { + // Create a variable that is never added to any solution + let x = variable(()); + let y = variable(()); // y is not in the solution for x + + let sol = Problem::minimize(x.clone()) + .subject_to([x.ge(1.0)]) + .solve() + .unwrap(); + + // Evaluating y against a solution that only contains x should fail + assert!(y.eval(&sol).is_err()); +} + +#[test] +fn test_eval_returns_ok_for_constant() { + let x = variable(()); + let sol = Problem::minimize(x.clone()) + .subject_to([x.ge(1.0)]) + .solve() + .unwrap(); + + // Constants don't need any variables, so eval always succeeds + let c = constant(42.0); + assert!(c.eval(&sol).is_ok()); + assert!(approx(c.value(&sol).as_scalar().unwrap(), 42.0)); +} + +// ── multiple variables ──────────────────────────────────────────────────────── + +#[test] +fn test_value_expression_with_two_variables() { + // minimize x + y s.t. x >= 1, y >= 2 → x*=1, y*=2 + // evaluate x - y → 1 - 2 = -1 + let x = variable(()); + let y = variable(()); + let obj = x.clone() + y.clone(); + let diff = x.clone() - y.clone(); + + let sol = Problem::minimize(obj) + .subject_to([x.ge(1.0), y.ge(2.0)]) + .solve() + .unwrap(); + + let v = diff.value(&sol).as_scalar().unwrap(); + assert!(approx(v, -1.0), "expected -1.0, got {v}"); +} + +// ── old API / new API parity ────────────────────────────────────────────────── + +/// solution.value(&x) == x.value(&solution).as_scalar() +#[test] +fn test_parity_scalar_variable() { + let x = variable(()); + let sol = Problem::minimize(x.clone()) + .subject_to([x.ge(5.0)]) + .solve() + .unwrap(); + + let old = sol.value(&x); + let new = x.value(&sol).as_scalar().unwrap(); + assert!(approx(old, new), "old={old}, new={new}"); +} + +/// &solution[&x][(i,j)] == x.value(&solution)[(i,j)] for all elements +#[test] +fn test_parity_vector_variable_all_elements() { + let x = variable(4); + let sol = Problem::minimize(sum(&x)) + .subject_to([x.ge(constant_vec(vec![1.0, 2.0, 3.0, 4.0]))]) + .solve() + .unwrap(); + + let old = &sol[&x]; + let new = x.value(&sol); + for i in 0..4 { + assert!(approx(old[(i, 0)], new[(i, 0)]), "mismatch at row {i}"); + } +} + +/// &solution[&x][(i,j)] == x.value(&solution)[(i,j)] for a larger vector +#[test] +fn test_parity_large_vector_variable() { + let x = variable(5); + let sol = Problem::minimize(sum(&x)) + .subject_to([x.ge(constant_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0]))]) + .solve() + .unwrap(); + + let old = &sol[&x]; + let new = x.value(&sol); + for i in 0..5 { + assert!(approx(old[(i, 0)], new[(i, 0)]), "mismatch at row {i}"); + assert!(approx(new[(i, 0)], (i + 1) as f64), "wrong value at row {i}"); + } +} + +// ── value() vs solution.value consistency ──────────────────────────────────── + +#[test] +fn test_value_objective_matches_solution_value() { + // For any solved problem, obj.value(&sol) should equal sol.value + let x = variable(2); + let obj = norm2(&x); + let sol = Problem::minimize(obj.clone()) + .subject_to([x.ge(1.0)]) + .solve() + .unwrap(); + + let via_sol = sol.value.unwrap(); + let via_expr = obj.value(&sol).as_scalar().unwrap(); + assert!( + approx(via_sol, via_expr), + "sol.value={via_sol} != obj.value(&sol)={via_expr}" + ); +} + +#[test] +fn test_value_lp_objective_matches_solution_value() { + let x = variable(3); + let c = constant_vec(vec![1.0, 2.0, 3.0]); + let obj = dot(&c, &x); + let sol = Problem::minimize(obj.clone()) + .subject_to([x.ge(1.0)]) + .solve() + .unwrap(); + + let via_sol = sol.value.unwrap(); + let via_expr = obj.value(&sol).as_scalar().unwrap(); + assert!(approx(via_sol, via_expr), "sol.value={via_sol} != obj.value(&sol)={via_expr}"); + assert!(approx(via_sol, 6.0)); // 1*1 + 2*1 + 3*1 +} From 7d7f8cbdfc9e4afd8307092421575d236675f7fc Mon Sep 17 00:00:00 2001 From: Hao Zhu Date: Sun, 8 Mar 2026 09:33:15 +0100 Subject: [PATCH 2/4] Refactor: update variable retrieval to use .value() method for consistency across examples --- examples/basic_lp.rs | 2 +- examples/lasso.rs | 12 +++++++++--- examples/least_squares.rs | 17 ++++++++++++----- examples/portfolio.rs | 6 +++++- examples/quadratic_program.rs | 2 +- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/examples/basic_lp.rs b/examples/basic_lp.rs index cb463b1..06a3756 100644 --- a/examples/basic_lp.rs +++ b/examples/basic_lp.rs @@ -45,7 +45,7 @@ fn main() { println!(" Status: {:?}", solution.status); println!(" Optimal profit: {:.4}", -solution.value.unwrap()); - let x_vals = &solution[&x]; + let x_vals = x.value(&solution); println!(" x1 = {:.4}", x_vals[(0, 0)]); println!(" x2 = {:.4}", x_vals[(1, 0)]); println!(" x3 = {:.4}", x_vals[(2, 0)]); diff --git a/examples/lasso.rs b/examples/lasso.rs index ec4dfd0..92d7a1c 100644 --- a/examples/lasso.rs +++ b/examples/lasso.rs @@ -38,7 +38,7 @@ fn main() { .solve() .expect("Failed to solve"); - let x_ls_vals = &solution_ls[&x_ls]; + let x_ls_vals = x_ls.value(&solution_ls); println!("Coefficients:"); for i in 0..10 { println!(" x{}: {:.6}", i + 1, x_ls_vals[(i, 0)]); @@ -57,7 +57,7 @@ fn main() { .solve() .expect("Failed to solve"); - let x_vals = &solution[&x]; + let x_vals = x.value(&solution); println!("Coefficients:"); for i in 0..10 { let val = x_vals[(i, 0)]; @@ -66,6 +66,12 @@ fn main() { } println!(" Objective: {:.6}", solution.value.unwrap()); + // Expression values: inspect individual terms + let fit_err = sum_squares(&residual).value(&solution).as_scalar().unwrap(); + let l1_pen = norm1(&x).value(&solution).as_scalar().unwrap(); + println!(" Fit error (expression eval): {:.6}", fit_err); + println!(" L1 penalty (expression eval): {:.6}", l1_pen); + // LASSO with strong regularization println!("\n--- LASSO (lambda = 1.0) ---\n"); @@ -78,7 +84,7 @@ fn main() { .solve() .expect("Failed to solve"); - let x2_vals = &solution2[&x2]; + let x2_vals = x2.value(&solution2); println!("Coefficients:"); for i in 0..10 { let val = x2_vals[(i, 0)]; diff --git a/examples/least_squares.rs b/examples/least_squares.rs index b78ce07..5697247 100644 --- a/examples/least_squares.rs +++ b/examples/least_squares.rs @@ -28,9 +28,16 @@ fn main() { println!(" Status: {:?}", solution.status); println!(" Optimal value: {:.6}", solution.value.unwrap()); - let w_vals = &solution[&w]; - println!(" w0 (intercept) = {:.6}", w_vals[(0, 0)]); - println!(" w1 (slope) = {:.6}", w_vals[(1, 0)]); + // Two equivalent ways to retrieve variable values: + let w_vals = &solution[&w]; // index into solution directly + let w_vals2 = w.value(&solution); // call .value() on the variable + println!(" w0 (intercept) = {:.6} / {:.6}", w_vals[(0, 0)], w_vals2[(0, 0)]); + println!(" w1 (slope) = {:.6} / {:.6}", w_vals[(1, 0)], w_vals2[(1, 0)]); + + // Expression values: evaluate any sub-expression at the solution + let fitted = matmul(&a, &w).value(&solution); + println!(" Fitted values: [{:.2}, {:.2}, {:.2}, {:.2}, {:.2}]", + fitted[(0, 0)], fitted[(1, 0)], fitted[(2, 0)], fitted[(3, 0)], fitted[(4, 0)]); // Constrained least squares (w >= 0) println!("\n--- Constrained Least Squares (w >= 0) ---\n"); @@ -47,7 +54,7 @@ fn main() { println!(" Status: {:?}", solution2.status); println!(" Optimal value: {:.6}", solution2.value.unwrap()); - let w2_vals = &solution2[&w2]; + let w2_vals = w2.value(&solution2); println!(" w0 (intercept) = {:.6}", w2_vals[(0, 0)]); - println!(" w1 (slope) = {:.6}", w2_vals[(1, 0)]); + println!(" w1 (slope) = {:.6}", w2_vals[(1, 0)]); } diff --git a/examples/portfolio.rs b/examples/portfolio.rs index d04139f..cd6bad6 100644 --- a/examples/portfolio.rs +++ b/examples/portfolio.rs @@ -47,13 +47,17 @@ fn main() { // Results println!("Optimal Portfolio:"); - let portfolio = &solution[&x]; + let portfolio = x.value(&solution); let assets = ["A", "B", "C", "D"]; for i in 0..4 { println!(" Asset {}: {:.2}%", assets[i], portfolio[(i, 0)] * 100.0); } + // Expression values: evaluate return and risk directly + let actual_return = dot(&mu, &x).value(&solution).as_scalar().unwrap(); + println!(" Actual return (expression eval): {:.2}%", actual_return * 100.0); + let variance = solution.value.unwrap(); let std_dev = variance.sqrt(); println!("\nPortfolio Statistics:"); diff --git a/examples/quadratic_program.rs b/examples/quadratic_program.rs index 59c298e..f23cbdf 100644 --- a/examples/quadratic_program.rs +++ b/examples/quadratic_program.rs @@ -33,7 +33,7 @@ fn main() { println!(" Status: {:?}", solution.status); println!(" Optimal value: {:.6}", solution.value.unwrap()); - let x_vals = &solution[&x]; + let x_vals = x.value(&solution); println!(" x1 = {:.6}", x_vals[(0, 0)]); println!(" x2 = {:.6}", x_vals[(1, 0)]); println!(" Sum: {:.6}", x_vals[(0, 0)] + x_vals[(1, 0)]); From 6dca4fd0428272120cbe84e45ce18eb6de13b821 Mon Sep 17 00:00:00 2001 From: Hao Zhu Date: Sun, 8 Mar 2026 22:38:12 +0100 Subject: [PATCH 3/4] Fix lint --- examples/least_squares.rs | 24 +++++++++++++++++++----- examples/portfolio.rs | 5 ++++- src/expr/eval.rs | 10 +++++----- src/expr/mod.rs | 2 +- tests/eval_tests.rs | 22 +++++++++++++++++----- 5 files changed, 46 insertions(+), 17 deletions(-) diff --git a/examples/least_squares.rs b/examples/least_squares.rs index 5697247..5a8a814 100644 --- a/examples/least_squares.rs +++ b/examples/least_squares.rs @@ -29,15 +29,29 @@ fn main() { println!(" Optimal value: {:.6}", solution.value.unwrap()); // Two equivalent ways to retrieve variable values: - let w_vals = &solution[&w]; // index into solution directly + let w_vals = &solution[&w]; // index into solution directly let w_vals2 = w.value(&solution); // call .value() on the variable - println!(" w0 (intercept) = {:.6} / {:.6}", w_vals[(0, 0)], w_vals2[(0, 0)]); - println!(" w1 (slope) = {:.6} / {:.6}", w_vals[(1, 0)], w_vals2[(1, 0)]); + println!( + " w0 (intercept) = {:.6} / {:.6}", + w_vals[(0, 0)], + w_vals2[(0, 0)] + ); + println!( + " w1 (slope) = {:.6} / {:.6}", + w_vals[(1, 0)], + w_vals2[(1, 0)] + ); // Expression values: evaluate any sub-expression at the solution let fitted = matmul(&a, &w).value(&solution); - println!(" Fitted values: [{:.2}, {:.2}, {:.2}, {:.2}, {:.2}]", - fitted[(0, 0)], fitted[(1, 0)], fitted[(2, 0)], fitted[(3, 0)], fitted[(4, 0)]); + println!( + " Fitted values: [{:.2}, {:.2}, {:.2}, {:.2}, {:.2}]", + fitted[(0, 0)], + fitted[(1, 0)], + fitted[(2, 0)], + fitted[(3, 0)], + fitted[(4, 0)] + ); // Constrained least squares (w >= 0) println!("\n--- Constrained Least Squares (w >= 0) ---\n"); diff --git a/examples/portfolio.rs b/examples/portfolio.rs index cd6bad6..898f35a 100644 --- a/examples/portfolio.rs +++ b/examples/portfolio.rs @@ -56,7 +56,10 @@ fn main() { // Expression values: evaluate return and risk directly let actual_return = dot(&mu, &x).value(&solution).as_scalar().unwrap(); - println!(" Actual return (expression eval): {:.2}%", actual_return * 100.0); + println!( + " Actual return (expression eval): {:.2}%", + actual_return * 100.0 + ); let variance = solution.value.unwrap(); let std_dev = variance.sqrt(); diff --git a/src/expr/eval.rs b/src/expr/eval.rs index 188c130..8a974bc 100644 --- a/src/expr/eval.rs +++ b/src/expr/eval.rs @@ -164,7 +164,6 @@ impl Expr { Expr::Diag(a) => Ok(eval_diag(a.eval(ctx)?)), } } - } // ---- Array arithmetic helpers ---- @@ -252,8 +251,7 @@ fn eval_sum(a: Array, axis: Option) -> crate::Result { Some(0) => { // Sum along axis 0 (rows) → column vector of size ncols let m = arr_to_dense(a); - let result = - DMatrix::from_fn(m.ncols(), 1, |j, _| m.column(j).iter().sum::()); + let result = DMatrix::from_fn(m.ncols(), 1, |j, _| m.column(j).iter().sum::()); Ok(Array::Dense(result)) } Some(1) => { @@ -283,7 +281,9 @@ fn eval_reshape(a: Array, shape: &Shape) -> crate::Result { }; let (rows, cols) = (shape.rows(), shape.cols()); if flat.len() != rows * cols { - return Err(crate::CvxError::InvalidProblem("Reshape size mismatch".into())); + return Err(crate::CvxError::InvalidProblem( + "Reshape size mismatch".into(), + )); } if shape.is_scalar() { Ok(Array::Scalar(flat[0])) @@ -515,7 +515,7 @@ fn eval_cumsum(a: Array, axis: Option) -> crate::Result { fn eval_diag(a: Array) -> Array { let m = arr_to_dense(a); let (rows, cols) = (m.nrows(), m.ncols()); - if cols == 1 || (rows == 1 && cols != 1) { + if cols == 1 || rows == 1 { // Vector → diagonal matrix let n = rows.max(cols); let mut result = DMatrix::zeros(n, n); diff --git a/src/expr/mod.rs b/src/expr/mod.rs index 284e663..7c4db4c 100644 --- a/src/expr/mod.rs +++ b/src/expr/mod.rs @@ -13,11 +13,11 @@ pub mod shape; pub mod variable; // Re-export main types -pub use eval::Evaluable; pub use constant::{ constant, constant_dmatrix, constant_matrix, constant_sparse, constant_vec, eye, ones, zeros, IntoConstant, }; +pub use eval::Evaluable; pub use expression::{Array, ConstantData, Expr, ExprId, IndexSpec, VariableData}; pub use shape::Shape; pub use variable::{ diff --git a/tests/eval_tests.rs b/tests/eval_tests.rs index 8a82847..a6b25a3 100644 --- a/tests/eval_tests.rs +++ b/tests/eval_tests.rs @@ -108,7 +108,10 @@ fn test_value_norm2_matches_objective() { let reported = sol.value.unwrap(); let via_eval = obj.value(&sol).as_scalar().unwrap(); - assert!(approx(reported, via_eval), "reported={reported}, eval={via_eval}"); + assert!( + approx(reported, via_eval), + "reported={reported}, eval={via_eval}" + ); assert!(approx(reported, 2f64.sqrt())); } @@ -152,7 +155,10 @@ fn test_value_sum_squares_matches_objective() { let reported = sol.value.unwrap(); let via_eval = obj.value(&sol).as_scalar().unwrap(); - assert!(approx(reported, via_eval), "reported={reported}, eval={via_eval}"); + assert!( + approx(reported, via_eval), + "reported={reported}, eval={via_eval}" + ); assert!(approx(reported, 8.0)); } @@ -175,7 +181,7 @@ fn test_value_abs() { fn test_eval_missing_variable_returns_err() { // Create a variable that is never added to any solution let x = variable(()); - let y = variable(()); // y is not in the solution for x + let y = variable(()); // y is not in the solution for x let sol = Problem::minimize(x.clone()) .subject_to([x.ge(1.0)]) @@ -265,7 +271,10 @@ fn test_parity_large_vector_variable() { let new = x.value(&sol); for i in 0..5 { assert!(approx(old[(i, 0)], new[(i, 0)]), "mismatch at row {i}"); - assert!(approx(new[(i, 0)], (i + 1) as f64), "wrong value at row {i}"); + assert!( + approx(new[(i, 0)], (i + 1) as f64), + "wrong value at row {i}" + ); } } @@ -301,6 +310,9 @@ fn test_value_lp_objective_matches_solution_value() { let via_sol = sol.value.unwrap(); let via_expr = obj.value(&sol).as_scalar().unwrap(); - assert!(approx(via_sol, via_expr), "sol.value={via_sol} != obj.value(&sol)={via_expr}"); + assert!( + approx(via_sol, via_expr), + "sol.value={via_sol} != obj.value(&sol)={via_expr}" + ); assert!(approx(via_sol, 6.0)); // 1*1 + 2*1 + 3*1 } From 6da5dff43a02ceb227effb260d92025632c15b97 Mon Sep 17 00:00:00 2001 From: Hao Zhu Date: Sun, 8 Mar 2026 23:32:19 +0100 Subject: [PATCH 4/4] Fix CI --- .github/workflows/ci.yml | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab9f0bf..af58674 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,7 +115,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: "1.70" + toolchain: "1.71" - name: Check build with MSRV run: cargo check --all-features diff --git a/Cargo.toml b/Cargo.toml index 92cc64d..15244f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ repository = "https://github.com/cvxpy/cvxrust" documentation = "https://docs.rs/cvxrust" keywords = ["optimization", "convex", "dcp", "solver", "math"] categories = ["mathematics", "algorithms", "science"] -rust-version = "1.70" +rust-version = "1.71" [dependencies] # Clarabel solver