From a6076771022564d3bf2374c5140a2e174c87b5b3 Mon Sep 17 00:00:00 2001
From: Pantelis Sopasakis
Date: Mon, 16 Mar 2026 17:08:37 +0000
Subject: [PATCH 1/7] Return best PANOC half-step on early exit
Issues:
- addresses #325
About:
- cache the feasible half-step with the lowest FPR seen so far
- return that cached point instead of the last half-step
- report the corresponding best FPR and recomputed cost
- add tests for best-half-step caching and premature termination
---
src/core/panoc/panoc_cache.rs | 47 +++++++++++++++++++++++
src/core/panoc/panoc_engine.rs | 15 +++++++-
src/core/panoc/panoc_optimizer.rs | 63 +++++++++++++++++++++++++++++--
3 files changed, 121 insertions(+), 4 deletions(-)
diff --git a/src/core/panoc/panoc_cache.rs b/src/core/panoc/panoc_cache.rs
index 9bd38f70..d6f829f4 100644
--- a/src/core/panoc/panoc_cache.rs
+++ b/src/core/panoc/panoc_cache.rs
@@ -20,6 +20,8 @@ pub struct PANOCCache {
/// only if we need to check the AKKT-specific termination conditions
pub(crate) gradient_u_previous: Option>,
pub(crate) u_half_step: Vec,
+ /// Keeps track of best point so far
+ pub(crate) best_u_half_step: Vec,
pub(crate) gradient_step: Vec,
pub(crate) direction_lbfgs: Vec,
pub(crate) u_plus: Vec,
@@ -29,6 +31,8 @@ pub struct PANOCCache {
pub(crate) gamma: f64,
pub(crate) tolerance: f64,
pub(crate) norm_gamma_fpr: f64,
+ /// Keeps track of best FPR so far
+ pub(crate) best_norm_gamma_fpr: f64,
pub(crate) tau: f64,
pub(crate) lipschitz_constant: f64,
pub(crate) sigma: f64,
@@ -66,6 +70,7 @@ impl PANOCCache {
gradient_u: vec![0.0; problem_size],
gradient_u_previous: None,
u_half_step: vec![0.0; problem_size],
+ best_u_half_step: vec![0.0; problem_size],
gamma_fpr: vec![0.0; problem_size],
direction_lbfgs: vec![0.0; problem_size],
gradient_step: vec![0.0; problem_size],
@@ -73,6 +78,7 @@ impl PANOCCache {
gamma: 0.0,
tolerance,
norm_gamma_fpr: f64::INFINITY,
+ best_norm_gamma_fpr: f64::INFINITY,
lbfgs: lbfgs::Lbfgs::new(problem_size, lbfgs_memory_size)
.with_cbfgs_alpha(DEFAULT_CBFGS_ALPHA)
.with_cbfgs_epsilon(DEFAULT_CBFGS_EPSILON)
@@ -175,6 +181,8 @@ impl PANOCCache {
/// and `gamma` to 0.0
pub fn reset(&mut self) {
self.lbfgs.reset();
+ self.best_u_half_step.fill(0.0);
+ self.best_norm_gamma_fpr = f64::INFINITY;
self.lhs_ls = 0.0;
self.rhs_ls = 0.0;
self.tau = 1.0;
@@ -185,6 +193,14 @@ impl PANOCCache {
self.gamma = 0.0;
}
+ /// Store the current half step if it improves the best fixed-point residual so far.
+ pub(crate) fn cache_best_half_step(&mut self) {
+ if self.norm_gamma_fpr < self.best_norm_gamma_fpr {
+ self.best_norm_gamma_fpr = self.norm_gamma_fpr;
+ self.best_u_half_step.copy_from_slice(&self.u_half_step);
+ }
+ }
+
/// Sets the CBFGS parameters `alpha` and `epsilon`
///
/// Read more in: D.-H. Li and M. Fukushima, “On the global convergence of the BFGS
@@ -211,3 +227,34 @@ impl PANOCCache {
self
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::PANOCCache;
+
+ #[test]
+ fn t_cache_best_half_step() {
+ let mut cache = PANOCCache::new(2, 1e-6, 3);
+
+ cache.u_half_step.copy_from_slice(&[1.0, 2.0]);
+ cache.norm_gamma_fpr = 3.0;
+ cache.cache_best_half_step();
+
+ assert_eq!(3.0, cache.best_norm_gamma_fpr);
+ assert_eq!(&[1.0, 2.0], &cache.best_u_half_step[..]);
+
+ cache.u_half_step.copy_from_slice(&[10.0, 20.0]);
+ cache.norm_gamma_fpr = 5.0;
+ cache.cache_best_half_step();
+
+ assert_eq!(3.0, cache.best_norm_gamma_fpr);
+ assert_eq!(&[1.0, 2.0], &cache.best_u_half_step[..]);
+
+ cache.u_half_step.copy_from_slice(&[-1.0, -2.0]);
+ cache.norm_gamma_fpr = 2.0;
+ cache.cache_best_half_step();
+
+ assert_eq!(2.0, cache.best_norm_gamma_fpr);
+ assert_eq!(&[-1.0, -2.0], &cache.best_u_half_step[..]);
+ }
+}
diff --git a/src/core/panoc/panoc_engine.rs b/src/core/panoc/panoc_engine.rs
index bb4ad484..dc90dc3d 100644
--- a/src/core/panoc/panoc_engine.rs
+++ b/src/core/panoc/panoc_engine.rs
@@ -95,6 +95,12 @@ where
cache.norm_gamma_fpr = matrix_operations::norm2(&cache.gamma_fpr);
}
+ /// Score the current feasible half step and cache it if it is the best so far.
+ pub(crate) fn cache_best_half_step(&mut self, u_current: &[f64]) {
+ self.compute_fpr(u_current);
+ self.cache.cache_best_half_step();
+ }
+
/// Computes a gradient step; does not compute the gradient
fn gradient_step(&mut self, u_current: &[f64]) {
// take a gradient step:
@@ -295,6 +301,13 @@ where
Ok(())
}
+
+ /// Compute the cost value at the best cached feasible half step.
+ pub(crate) fn cost_value_at_best_half_step(&mut self) -> Result {
+ let mut cost = 0.0;
+ (self.problem.cost)(&self.cache.best_u_half_step, &mut cost)?;
+ Ok(cost)
+ }
}
/// Implementation of the `step` and `init` methods of [trait.AlgorithmEngine.html]
@@ -320,7 +333,7 @@ where
self.cache.cache_previous_gradient();
// compute the fixed point residual
- self.compute_fpr(u_current);
+ self.cache_best_half_step(u_current);
// exit if the exit conditions are satisfied (||gamma*fpr|| < eps and,
// if activated, ||gamma*r + df - df_prev|| < eps_akkt)
diff --git a/src/core/panoc/panoc_optimizer.rs b/src/core/panoc/panoc_optimizer.rs
index 602abd45..98f87667 100644
--- a/src/core/panoc/panoc_optimizer.rs
+++ b/src/core/panoc/panoc_optimizer.rs
@@ -152,6 +152,11 @@ where
}
}
+ // Score the latest feasible half step before exiting: if we stopped
+ // because of time or iteration limits, it may be better than the last
+ // one that was fully evaluated inside `step`.
+ self.panoc_engine.cache_best_half_step(u);
+
// check for possible NaN/inf
if !matrix_operations::is_finite(u) {
return Err(SolverError::NotFiniteComputation);
@@ -168,15 +173,17 @@ where
// copy u_half_step into u (the algorithm should return u_bar,
// because it's always feasible, while u may violate the constraints)
- u.copy_from_slice(&self.panoc_engine.cache.u_half_step);
+ u.copy_from_slice(&self.panoc_engine.cache.best_u_half_step);
+
+ let best_cost_value = self.panoc_engine.cost_value_at_best_half_step()?;
// export solution status (exit status, num iterations and more)
Ok(SolverStatus::new(
exit_status,
num_iter,
now.elapsed(),
- self.panoc_engine.cache.norm_gamma_fpr,
- self.panoc_engine.cache.cost_value,
+ self.panoc_engine.cache.best_norm_gamma_fpr,
+ best_cost_value,
))
}
}
@@ -335,4 +342,54 @@ mod tests {
assert!(status.iterations() < max_iters);
assert!(status.norm_fpr() < tolerance);
}
+
+ #[test]
+ fn t_panoc_optimizer_premature_exit_returns_best_previous_half_step() {
+ let tolerance = 1e-6;
+ let radius = 0.05;
+ let n_dimension = 3;
+ let lbfgs_memory = 10;
+
+ let mut found_nonlast_best_half_step = false;
+
+ for max_iters in 2..=25 {
+ let bounds = constraints::Ball2::new(None, radius);
+ let problem = Problem::new(
+ &bounds,
+ mocks::hard_quadratic_gradient,
+ mocks::hard_quadratic_cost,
+ );
+ let mut panoc_cache = PANOCCache::new(n_dimension, tolerance, lbfgs_memory);
+ let mut panoc = PANOCOptimizer::new(problem, &mut panoc_cache).with_max_iter(max_iters);
+ let mut u_solution = [-20.0, 10.0, 0.2];
+
+ let status = panoc.solve(&mut u_solution).unwrap();
+
+ let distance_to_last_half_step =
+ crate::matrix_operations::norm_inf_diff(&u_solution, &panoc_cache.u_half_step);
+
+ if status.exit_status() == ExitStatus::NotConvergedIterations
+ && distance_to_last_half_step > 1e-12
+ {
+ found_nonlast_best_half_step = true;
+
+ unit_test_utils::assert_nearly_equal_array(
+ &u_solution,
+ &panoc_cache.best_u_half_step,
+ 1e-12,
+ 1e-12,
+ "returned solution should equal the best cached half step",
+ );
+ assert!(
+ status.norm_fpr() < panoc_cache.norm_gamma_fpr,
+ "returned FPR should be strictly better than the last half step"
+ );
+ }
+ }
+
+ assert!(
+ found_nonlast_best_half_step,
+ "did not find a premature exit where the best half step differs from the last one"
+ );
+ }
}
From ab0b9f2f07db371aefd440902e03834ce91be889 Mon Sep 17 00:00:00 2001
From: Pantelis Sopasakis
Date: Mon, 16 Mar 2026 17:09:50 +0000
Subject: [PATCH 2/7] update PR tempalte
---
.github/pull_request_template.md | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 6a6a323d..9703b739 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -8,8 +8,12 @@
- Closes #1
- Addresses #2
+## Target versions
-## TODOs
+- OpEn version ...
+- opengen version ...
+
+## Checklist
- [ ] Documentation
- [ ] All tests must pass
From e95d8bcf8f4ac6ab5123d345341b99049a7f8b34 Mon Sep 17 00:00:00 2001
From: Pantelis Sopasakis
Date: Mon, 16 Mar 2026 17:28:39 +0000
Subject: [PATCH 3/7] [ci skip] update changelog (v0.11.1)
---
CHANGELOG.md | 12 ++++++++++++
open-codegen/.gitignore | 1 +
2 files changed, 13 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d992bded..54a206f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
Note: This is the main Changelog file for the Rust solver. The Changelog file for the Python interface (`opengen`) can be found in [/open-codegen/CHANGELOG.md](open-codegen/CHANGELOG.md)
+
+## [v0.11.1] - Unreleased
+
+
+### Fixed
+
+- Return best PANOC half-step on early exit (issue #325)
+
+
@@ -330,6 +341,7 @@ This is a breaking API change.
--------------------- -->
+[v0.11.1]: https://github.com/alphaville/optimization-engine/compare/v0.11.0...v0.11.1
[v0.11.0]: https://github.com/alphaville/optimization-engine/compare/v0.10.0...v0.11.0
[v0.10.0]: https://github.com/alphaville/optimization-engine/compare/v0.9.1...v0.10.0
[v0.9.1]: https://github.com/alphaville/optimization-engine/compare/v0.9.0...v0.9.1
diff --git a/open-codegen/.gitignore b/open-codegen/.gitignore
index c31bdd56..104b7eb7 100644
--- a/open-codegen/.gitignore
+++ b/open-codegen/.gitignore
@@ -5,3 +5,4 @@ venv
build
dist
x4356
+TOKEN
\ No newline at end of file
From e2867a4062e0a15b01fff12a4c7059d607b41c20 Mon Sep 17 00:00:00 2001
From: Pantelis Sopasakis
Date: Mon, 16 Mar 2026 17:29:32 +0000
Subject: [PATCH 4/7] [ci skip] Cargo.toml: bump version 0.11.1
---
Cargo.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Cargo.toml b/Cargo.toml
index 450ec90f..e4850148 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -42,7 +42,7 @@ homepage = "https://alphaville.github.io/optimization-engine/"
repository = "https://github.com/alphaville/optimization-engine"
# Version of this crate (SemVer)
-version = "0.11.0"
+version = "0.11.1"
edition = "2018"
From c731e32a9afa62263d11c103fae316a81a1de22e Mon Sep 17 00:00:00 2001
From: Pantelis Sopasakis
Date: Mon, 16 Mar 2026 19:01:46 +0000
Subject: [PATCH 5/7] Code optimization: caching certain quantities
- cache gradient and projection-distance squared norms in PANOC
- derive squared FPR values inline from norm_gamma_fpr (already computed)
- update PANOC tests accordingly
---
src/core/panoc/panoc_cache.rs | 7 +++++++
src/core/panoc/panoc_engine.rs | 38 ++++++++++++++++++++--------------
2 files changed, 30 insertions(+), 15 deletions(-)
diff --git a/src/core/panoc/panoc_cache.rs b/src/core/panoc/panoc_cache.rs
index d6f829f4..7711a349 100644
--- a/src/core/panoc/panoc_cache.rs
+++ b/src/core/panoc/panoc_cache.rs
@@ -33,6 +33,8 @@ pub struct PANOCCache {
pub(crate) norm_gamma_fpr: f64,
/// Keeps track of best FPR so far
pub(crate) best_norm_gamma_fpr: f64,
+ pub(crate) gradient_u_norm_sq: f64,
+ pub(crate) gradient_step_u_half_step_diff_norm_sq: f64,
pub(crate) tau: f64,
pub(crate) lipschitz_constant: f64,
pub(crate) sigma: f64,
@@ -79,6 +81,8 @@ impl PANOCCache {
tolerance,
norm_gamma_fpr: f64::INFINITY,
best_norm_gamma_fpr: f64::INFINITY,
+ gradient_u_norm_sq: 0.0,
+ gradient_step_u_half_step_diff_norm_sq: 0.0,
lbfgs: lbfgs::Lbfgs::new(problem_size, lbfgs_memory_size)
.with_cbfgs_alpha(DEFAULT_CBFGS_ALPHA)
.with_cbfgs_epsilon(DEFAULT_CBFGS_EPSILON)
@@ -183,6 +187,9 @@ impl PANOCCache {
self.lbfgs.reset();
self.best_u_half_step.fill(0.0);
self.best_norm_gamma_fpr = f64::INFINITY;
+ self.norm_gamma_fpr = f64::INFINITY;
+ self.gradient_u_norm_sq = 0.0;
+ self.gradient_step_u_half_step_diff_norm_sq = 0.0;
self.lhs_ls = 0.0;
self.rhs_ls = 0.0;
self.tau = 1.0;
diff --git a/src/core/panoc/panoc_engine.rs b/src/core/panoc/panoc_engine.rs
index dc90dc3d..23b6722d 100644
--- a/src/core/panoc/panoc_engine.rs
+++ b/src/core/panoc/panoc_engine.rs
@@ -129,12 +129,19 @@ where
.for_each(|((grad_step, u), grad)| *grad_step = *u - gamma * *grad);
}
+ /// Cache the squared norm of the current gradient.
+ fn cache_gradient_norm(&mut self) {
+ self.cache.gradient_u_norm_sq = matrix_operations::norm2_squared(&self.cache.gradient_u);
+ }
+
/// Computes a projection on `gradient_step`
fn half_step(&mut self) {
let cache = &mut self.cache;
// u_half_step ← projection(gradient_step)
cache.u_half_step.copy_from_slice(&cache.gradient_step);
self.problem.constraints.project(&mut cache.u_half_step);
+ cache.gradient_step_u_half_step_diff_norm_sq =
+ matrix_operations::norm2_squared_diff(&cache.gradient_step, &cache.u_half_step);
}
/// Computes an LBFGS direction; updates `cache.direction_lbfgs`
@@ -163,7 +170,7 @@ where
// rhs ← cost + LIP_EPS * |f| - + (L/2/gamma) ||gamma_fpr||^2
cost_value + LIPSCHITZ_UPDATE_EPSILON * cost_value.abs() - inner_prod_grad_fpr
- + (GAMMA_L_COEFF / (2.0 * gamma)) * (cache.norm_gamma_fpr.powi(2))
+ + (GAMMA_L_COEFF / (2.0 * gamma)) * cache.norm_gamma_fpr * cache.norm_gamma_fpr
}
/// Updates the estimate of the Lipscthiz constant
@@ -227,16 +234,13 @@ where
let cache = &mut self.cache;
// dist squared ← norm(gradient step - u half step)^2
- let dist_squared =
- matrix_operations::norm2_squared_diff(&cache.gradient_step, &cache.u_half_step);
-
// rhs_ls ← f - (gamma/2) * norm(gradf)^2
// + 0.5 * dist squared / gamma
// - sigma * norm_gamma_fpr^2
let fbe = cache.cost_value
- - 0.5 * cache.gamma * matrix_operations::norm2_squared(&cache.gradient_u)
- + 0.5 * dist_squared / cache.gamma;
- let sigma_fpr_sq = cache.sigma * cache.norm_gamma_fpr.powi(2);
+ - 0.5 * cache.gamma * cache.gradient_u_norm_sq
+ + 0.5 * cache.gradient_step_u_half_step_diff_norm_sq / cache.gamma;
+ let sigma_fpr_sq = cache.sigma * cache.norm_gamma_fpr * cache.norm_gamma_fpr;
cache.rhs_ls = fbe - sigma_fpr_sq;
}
@@ -253,20 +257,15 @@ where
// point `u_plus`
(self.problem.cost)(&self.cache.u_plus, &mut self.cache.cost_value)?;
(self.problem.gradf)(&self.cache.u_plus, &mut self.cache.gradient_u)?;
+ self.cache_gradient_norm();
self.gradient_step_uplus(); // gradient_step ← u_plus - gamma * gradient_u
self.half_step(); // u_half_step ← project(gradient_step)
- // Compute: dist_squared ← norm(gradient_step - u_half_step)^2
- let dist_squared = matrix_operations::norm2_squared_diff(
- &self.cache.gradient_step,
- &self.cache.u_half_step,
- );
-
// Update the LHS of the line search condition
self.cache.lhs_ls = self.cache.cost_value
- - 0.5 * gamma * matrix_operations::norm2_squared(&self.cache.gradient_u)
- + 0.5 * dist_squared / self.cache.gamma;
+ - 0.5 * gamma * self.cache.gradient_u_norm_sq
+ + 0.5 * self.cache.gradient_step_u_half_step_diff_norm_sq / self.cache.gamma;
Ok(self.cache.lhs_ls > self.cache.rhs_ls)
}
@@ -276,6 +275,7 @@ where
u_current.copy_from_slice(&self.cache.u_half_step); // set u_current ← u_half_step
(self.problem.cost)(u_current, &mut self.cache.cost_value)?; // cost value
(self.problem.gradf)(u_current, &mut self.cache.gradient_u)?; // compute gradient
+ self.cache_gradient_norm();
self.gradient_step(u_current); // updated self.cache.gradient_step
self.half_step(); // updates self.cache.u_half_step
@@ -366,6 +366,7 @@ where
self.cache.reset();
(self.problem.cost)(u_current, &mut self.cache.cost_value)?; // cost value
self.estimate_loc_lip(u_current)?; // computes the gradient as well! (self.cache.gradient_u)
+ self.cache_gradient_norm();
self.cache.gamma = GAMMA_L_COEFF / f64::max(self.cache.lipschitz_constant, MIN_L_ESTIMATE);
self.cache.sigma = (1.0 - GAMMA_L_COEFF) / (4.0 * self.cache.gamma);
self.gradient_step(u_current); // updated self.cache.gradient_step
@@ -550,6 +551,13 @@ mod tests {
panoc_engine.cache.cost_value = 24.0;
panoc_engine.cache.gamma = 2.34;
panoc_engine.cache.gradient_u.copy_from_slice(&[2.4, -9.7]);
+ panoc_engine.cache.gradient_u_norm_sq =
+ crate::matrix_operations::norm2_squared(&panoc_engine.cache.gradient_u);
+ panoc_engine.cache.gradient_step_u_half_step_diff_norm_sq =
+ crate::matrix_operations::norm2_squared_diff(
+ &panoc_engine.cache.gradient_step,
+ &panoc_engine.cache.u_half_step,
+ );
panoc_engine.cache.sigma = 0.066;
panoc_engine.cache.norm_gamma_fpr = 2.5974;
From 63a7d3712ff5fd19536b798e494796ab6e3e642f Mon Sep 17 00:00:00 2001
From: Pantelis Sopasakis
Date: Mon, 16 Mar 2026 19:10:39 +0000
Subject: [PATCH 6/7] Cargo-fmt + reuse cached PANOC cost during Lipschitz
updates
- remove the redundant evaluation in
- add a unit test that counts cost function calls
---
src/core/panoc/panoc_engine.rs | 48 +++++++++++++++++++++++++++++-----
1 file changed, 41 insertions(+), 7 deletions(-)
diff --git a/src/core/panoc/panoc_engine.rs b/src/core/panoc/panoc_engine.rs
index 23b6722d..63ad862d 100644
--- a/src/core/panoc/panoc_engine.rs
+++ b/src/core/panoc/panoc_engine.rs
@@ -179,9 +179,7 @@ where
// Compute the cost at the half step
(self.problem.cost)(&self.cache.u_half_step, &mut cost_u_half_step)?;
-
- // Compute the cost at u_current (save it in `cache.cost_value`)
- (self.problem.cost)(u_current, &mut self.cache.cost_value)?;
+ debug_assert!(matrix_operations::is_finite(&[self.cache.cost_value]));
let mut it_lipschitz_search = 0;
@@ -237,8 +235,7 @@ where
// rhs_ls ← f - (gamma/2) * norm(gradf)^2
// + 0.5 * dist squared / gamma
// - sigma * norm_gamma_fpr^2
- let fbe = cache.cost_value
- - 0.5 * cache.gamma * cache.gradient_u_norm_sq
+ let fbe = cache.cost_value - 0.5 * cache.gamma * cache.gradient_u_norm_sq
+ 0.5 * cache.gradient_step_u_half_step_diff_norm_sq / cache.gamma;
let sigma_fpr_sq = cache.sigma * cache.norm_gamma_fpr * cache.norm_gamma_fpr;
cache.rhs_ls = fbe - sigma_fpr_sq;
@@ -263,8 +260,7 @@ where
self.half_step(); // u_half_step ← project(gradient_step)
// Update the LHS of the line search condition
- self.cache.lhs_ls = self.cache.cost_value
- - 0.5 * gamma * self.cache.gradient_u_norm_sq
+ self.cache.lhs_ls = self.cache.cost_value - 0.5 * gamma * self.cache.gradient_u_norm_sq
+ 0.5 * self.cache.gradient_step_u_half_step_diff_norm_sq / self.cache.gamma;
Ok(self.cache.lhs_ls > self.cache.rhs_ls)
@@ -381,12 +377,14 @@ where
/* --------------------------------------------------------------------------------------------- */
#[cfg(test)]
mod tests {
+ use std::cell::Cell;
use crate::constraints;
use crate::core::panoc::panoc_engine::PANOCEngine;
use crate::core::panoc::*;
use crate::core::Problem;
use crate::mocks;
+ use crate::FunctionCallResult;
#[test]
fn t_compute_fpr() {
@@ -573,4 +571,40 @@ mod tests {
"rhs_ls is wrong",
);
}
+
+ #[test]
+ fn t_update_lipschitz_constant_reuses_cached_cost_at_current_iterate() {
+ let n = 2;
+ let mem = 5;
+ let bounds = constraints::NoConstraints::new();
+ let cost_calls = Cell::new(0usize);
+ let cost = |u: &[f64], c: &mut f64| -> FunctionCallResult {
+ cost_calls.set(cost_calls.get() + 1);
+ *c = 0.5 * crate::matrix_operations::norm2_squared(u);
+ Ok(())
+ };
+ let grad = |u: &[f64], g: &mut [f64]| -> FunctionCallResult {
+ g.copy_from_slice(u);
+ Ok(())
+ };
+ let problem = Problem::new(&bounds, grad, cost);
+ let mut panoc_cache = PANOCCache::new(n, 1e-6, mem);
+ let mut panoc_engine = PANOCEngine::new(problem, &mut panoc_cache);
+
+ let u_current = [1.0, -2.0];
+ panoc_engine.cache.cost_value = 2.5;
+ panoc_engine.cache.u_half_step.copy_from_slice(&[0.1, -0.1]);
+ panoc_engine.cache.gradient_u.copy_from_slice(&u_current);
+ panoc_engine.cache.gamma = 0.5;
+ panoc_engine.cache.lipschitz_constant = 1.9;
+ panoc_engine.compute_fpr(&u_current);
+
+ panoc_engine.update_lipschitz_constant(&u_current).unwrap();
+
+ assert_eq!(
+ 1,
+ cost_calls.get(),
+ "update_lipschitz_constant should only evaluate the half-step cost"
+ );
+ }
}
From f0b8034c8027040227ca4f80267746a3bc81f4c8 Mon Sep 17 00:00:00 2001
From: Pantelis Sopasakis
Date: Mon, 23 Mar 2026 13:45:04 +0000
Subject: [PATCH 7/7] [ci skip] update changelog
---
CHANGELOG.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54a206f1..f3933a30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,10 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
Note: This is the main Changelog file for the Rust solver. The Changelog file for the Python interface (`opengen`) can be found in [/open-codegen/CHANGELOG.md](open-codegen/CHANGELOG.md)
+
-## [v0.11.1] - Unreleased
+## [v0.11.1] - 2026-03-23
### Fixed