Skip to content

Commit 25ea76c

Browse files
authored
Merge pull request #388 from alphaville/feature/325-best-inner-solution
Return best inner solution
2 parents 9b0de89 + f0b8034 commit 25ea76c

7 files changed

Lines changed: 210 additions & 26 deletions

File tree

.github/pull_request_template.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@
88
- Closes #1
99
- Addresses #2
1010

11+
## Target versions
1112

12-
## TODOs
13+
- OpEn version ...
14+
- opengen version ...
15+
16+
## Checklist
1317

1418
- [ ] Documentation
1519
- [ ] All tests must pass

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
77

88
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)
99

10+
11+
<!-- ---------------------
12+
v0.11.1
13+
--------------------- -->
14+
## [v0.11.1] - 2026-03-23
15+
16+
17+
### Fixed
18+
19+
- Return best PANOC half-step on early exit (issue #325)
20+
21+
1022
<!-- ---------------------
1123
v0.11.0
1224
--------------------- -->
@@ -330,6 +342,7 @@ This is a breaking API change.
330342
--------------------- -->
331343

332344
<!-- Releases -->
345+
[v0.11.1]: https://github.com/alphaville/optimization-engine/compare/v0.11.0...v0.11.1
333346
[v0.11.0]: https://github.com/alphaville/optimization-engine/compare/v0.10.0...v0.11.0
334347
[v0.10.0]: https://github.com/alphaville/optimization-engine/compare/v0.9.1...v0.10.0
335348
[v0.9.1]: https://github.com/alphaville/optimization-engine/compare/v0.9.0...v0.9.1

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ homepage = "https://alphaville.github.io/optimization-engine/"
4242
repository = "https://github.com/alphaville/optimization-engine"
4343

4444
# Version of this crate (SemVer)
45-
version = "0.11.0"
45+
version = "0.11.1"
4646

4747
edition = "2018"
4848

open-codegen/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ venv
55
build
66
dist
77
x4356
8+
TOKEN

src/core/panoc/panoc_cache.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ pub struct PANOCCache {
2020
/// only if we need to check the AKKT-specific termination conditions
2121
pub(crate) gradient_u_previous: Option<Vec<f64>>,
2222
pub(crate) u_half_step: Vec<f64>,
23+
/// Keeps track of best point so far
24+
pub(crate) best_u_half_step: Vec<f64>,
2325
pub(crate) gradient_step: Vec<f64>,
2426
pub(crate) direction_lbfgs: Vec<f64>,
2527
pub(crate) u_plus: Vec<f64>,
@@ -29,6 +31,10 @@ pub struct PANOCCache {
2931
pub(crate) gamma: f64,
3032
pub(crate) tolerance: f64,
3133
pub(crate) norm_gamma_fpr: f64,
34+
/// Keeps track of best FPR so far
35+
pub(crate) best_norm_gamma_fpr: f64,
36+
pub(crate) gradient_u_norm_sq: f64,
37+
pub(crate) gradient_step_u_half_step_diff_norm_sq: f64,
3238
pub(crate) tau: f64,
3339
pub(crate) lipschitz_constant: f64,
3440
pub(crate) sigma: f64,
@@ -66,13 +72,17 @@ impl PANOCCache {
6672
gradient_u: vec![0.0; problem_size],
6773
gradient_u_previous: None,
6874
u_half_step: vec![0.0; problem_size],
75+
best_u_half_step: vec![0.0; problem_size],
6976
gamma_fpr: vec![0.0; problem_size],
7077
direction_lbfgs: vec![0.0; problem_size],
7178
gradient_step: vec![0.0; problem_size],
7279
u_plus: vec![0.0; problem_size],
7380
gamma: 0.0,
7481
tolerance,
7582
norm_gamma_fpr: f64::INFINITY,
83+
best_norm_gamma_fpr: f64::INFINITY,
84+
gradient_u_norm_sq: 0.0,
85+
gradient_step_u_half_step_diff_norm_sq: 0.0,
7686
lbfgs: lbfgs::Lbfgs::new(problem_size, lbfgs_memory_size)
7787
.with_cbfgs_alpha(DEFAULT_CBFGS_ALPHA)
7888
.with_cbfgs_epsilon(DEFAULT_CBFGS_EPSILON)
@@ -175,6 +185,11 @@ impl PANOCCache {
175185
/// and `gamma` to 0.0
176186
pub fn reset(&mut self) {
177187
self.lbfgs.reset();
188+
self.best_u_half_step.fill(0.0);
189+
self.best_norm_gamma_fpr = f64::INFINITY;
190+
self.norm_gamma_fpr = f64::INFINITY;
191+
self.gradient_u_norm_sq = 0.0;
192+
self.gradient_step_u_half_step_diff_norm_sq = 0.0;
178193
self.lhs_ls = 0.0;
179194
self.rhs_ls = 0.0;
180195
self.tau = 1.0;
@@ -185,6 +200,14 @@ impl PANOCCache {
185200
self.gamma = 0.0;
186201
}
187202

203+
/// Store the current half step if it improves the best fixed-point residual so far.
204+
pub(crate) fn cache_best_half_step(&mut self) {
205+
if self.norm_gamma_fpr < self.best_norm_gamma_fpr {
206+
self.best_norm_gamma_fpr = self.norm_gamma_fpr;
207+
self.best_u_half_step.copy_from_slice(&self.u_half_step);
208+
}
209+
}
210+
188211
/// Sets the CBFGS parameters `alpha` and `epsilon`
189212
///
190213
/// Read more in: D.-H. Li and M. Fukushima, “On the global convergence of the BFGS
@@ -211,3 +234,34 @@ impl PANOCCache {
211234
self
212235
}
213236
}
237+
238+
#[cfg(test)]
239+
mod tests {
240+
use super::PANOCCache;
241+
242+
#[test]
243+
fn t_cache_best_half_step() {
244+
let mut cache = PANOCCache::new(2, 1e-6, 3);
245+
246+
cache.u_half_step.copy_from_slice(&[1.0, 2.0]);
247+
cache.norm_gamma_fpr = 3.0;
248+
cache.cache_best_half_step();
249+
250+
assert_eq!(3.0, cache.best_norm_gamma_fpr);
251+
assert_eq!(&[1.0, 2.0], &cache.best_u_half_step[..]);
252+
253+
cache.u_half_step.copy_from_slice(&[10.0, 20.0]);
254+
cache.norm_gamma_fpr = 5.0;
255+
cache.cache_best_half_step();
256+
257+
assert_eq!(3.0, cache.best_norm_gamma_fpr);
258+
assert_eq!(&[1.0, 2.0], &cache.best_u_half_step[..]);
259+
260+
cache.u_half_step.copy_from_slice(&[-1.0, -2.0]);
261+
cache.norm_gamma_fpr = 2.0;
262+
cache.cache_best_half_step();
263+
264+
assert_eq!(2.0, cache.best_norm_gamma_fpr);
265+
assert_eq!(&[-1.0, -2.0], &cache.best_u_half_step[..]);
266+
}
267+
}

src/core/panoc/panoc_engine.rs

Lines changed: 76 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ where
9595
cache.norm_gamma_fpr = matrix_operations::norm2(&cache.gamma_fpr);
9696
}
9797

98+
/// Score the current feasible half step and cache it if it is the best so far.
99+
pub(crate) fn cache_best_half_step(&mut self, u_current: &[f64]) {
100+
self.compute_fpr(u_current);
101+
self.cache.cache_best_half_step();
102+
}
103+
98104
/// Computes a gradient step; does not compute the gradient
99105
fn gradient_step(&mut self, u_current: &[f64]) {
100106
// take a gradient step:
@@ -123,12 +129,19 @@ where
123129
.for_each(|((grad_step, u), grad)| *grad_step = *u - gamma * *grad);
124130
}
125131

132+
/// Cache the squared norm of the current gradient.
133+
fn cache_gradient_norm(&mut self) {
134+
self.cache.gradient_u_norm_sq = matrix_operations::norm2_squared(&self.cache.gradient_u);
135+
}
136+
126137
/// Computes a projection on `gradient_step`
127138
fn half_step(&mut self) {
128139
let cache = &mut self.cache;
129140
// u_half_step ← projection(gradient_step)
130141
cache.u_half_step.copy_from_slice(&cache.gradient_step);
131142
self.problem.constraints.project(&mut cache.u_half_step);
143+
cache.gradient_step_u_half_step_diff_norm_sq =
144+
matrix_operations::norm2_squared_diff(&cache.gradient_step, &cache.u_half_step);
132145
}
133146

134147
/// Computes an LBFGS direction; updates `cache.direction_lbfgs`
@@ -157,7 +170,7 @@ where
157170

158171
// rhs ← cost + LIP_EPS * |f| - <gradfx, gamma_fpr> + (L/2/gamma) ||gamma_fpr||^2
159172
cost_value + LIPSCHITZ_UPDATE_EPSILON * cost_value.abs() - inner_prod_grad_fpr
160-
+ (GAMMA_L_COEFF / (2.0 * gamma)) * (cache.norm_gamma_fpr.powi(2))
173+
+ (GAMMA_L_COEFF / (2.0 * gamma)) * cache.norm_gamma_fpr * cache.norm_gamma_fpr
161174
}
162175

163176
/// Updates the estimate of the Lipscthiz constant
@@ -166,9 +179,7 @@ where
166179

167180
// Compute the cost at the half step
168181
(self.problem.cost)(&self.cache.u_half_step, &mut cost_u_half_step)?;
169-
170-
// Compute the cost at u_current (save it in `cache.cost_value`)
171-
(self.problem.cost)(u_current, &mut self.cache.cost_value)?;
182+
debug_assert!(matrix_operations::is_finite(&[self.cache.cost_value]));
172183

173184
let mut it_lipschitz_search = 0;
174185

@@ -221,16 +232,12 @@ where
221232
let cache = &mut self.cache;
222233

223234
// dist squared ← norm(gradient step - u half step)^2
224-
let dist_squared =
225-
matrix_operations::norm2_squared_diff(&cache.gradient_step, &cache.u_half_step);
226-
227235
// rhs_ls ← f - (gamma/2) * norm(gradf)^2
228236
// + 0.5 * dist squared / gamma
229237
// - sigma * norm_gamma_fpr^2
230-
let fbe = cache.cost_value
231-
- 0.5 * cache.gamma * matrix_operations::norm2_squared(&cache.gradient_u)
232-
+ 0.5 * dist_squared / cache.gamma;
233-
let sigma_fpr_sq = cache.sigma * cache.norm_gamma_fpr.powi(2);
238+
let fbe = cache.cost_value - 0.5 * cache.gamma * cache.gradient_u_norm_sq
239+
+ 0.5 * cache.gradient_step_u_half_step_diff_norm_sq / cache.gamma;
240+
let sigma_fpr_sq = cache.sigma * cache.norm_gamma_fpr * cache.norm_gamma_fpr;
234241
cache.rhs_ls = fbe - sigma_fpr_sq;
235242
}
236243

@@ -247,20 +254,14 @@ where
247254
// point `u_plus`
248255
(self.problem.cost)(&self.cache.u_plus, &mut self.cache.cost_value)?;
249256
(self.problem.gradf)(&self.cache.u_plus, &mut self.cache.gradient_u)?;
257+
self.cache_gradient_norm();
250258

251259
self.gradient_step_uplus(); // gradient_step ← u_plus - gamma * gradient_u
252260
self.half_step(); // u_half_step ← project(gradient_step)
253261

254-
// Compute: dist_squared ← norm(gradient_step - u_half_step)^2
255-
let dist_squared = matrix_operations::norm2_squared_diff(
256-
&self.cache.gradient_step,
257-
&self.cache.u_half_step,
258-
);
259-
260262
// Update the LHS of the line search condition
261-
self.cache.lhs_ls = self.cache.cost_value
262-
- 0.5 * gamma * matrix_operations::norm2_squared(&self.cache.gradient_u)
263-
+ 0.5 * dist_squared / self.cache.gamma;
263+
self.cache.lhs_ls = self.cache.cost_value - 0.5 * gamma * self.cache.gradient_u_norm_sq
264+
+ 0.5 * self.cache.gradient_step_u_half_step_diff_norm_sq / self.cache.gamma;
264265

265266
Ok(self.cache.lhs_ls > self.cache.rhs_ls)
266267
}
@@ -270,6 +271,7 @@ where
270271
u_current.copy_from_slice(&self.cache.u_half_step); // set u_current ← u_half_step
271272
(self.problem.cost)(u_current, &mut self.cache.cost_value)?; // cost value
272273
(self.problem.gradf)(u_current, &mut self.cache.gradient_u)?; // compute gradient
274+
self.cache_gradient_norm();
273275
self.gradient_step(u_current); // updated self.cache.gradient_step
274276
self.half_step(); // updates self.cache.u_half_step
275277

@@ -295,6 +297,13 @@ where
295297

296298
Ok(())
297299
}
300+
301+
/// Compute the cost value at the best cached feasible half step.
302+
pub(crate) fn cost_value_at_best_half_step(&mut self) -> Result<f64, SolverError> {
303+
let mut cost = 0.0;
304+
(self.problem.cost)(&self.cache.best_u_half_step, &mut cost)?;
305+
Ok(cost)
306+
}
298307
}
299308

300309
/// Implementation of the `step` and `init` methods of [trait.AlgorithmEngine.html]
@@ -320,7 +329,7 @@ where
320329
self.cache.cache_previous_gradient();
321330

322331
// compute the fixed point residual
323-
self.compute_fpr(u_current);
332+
self.cache_best_half_step(u_current);
324333

325334
// exit if the exit conditions are satisfied (||gamma*fpr|| < eps and,
326335
// if activated, ||gamma*r + df - df_prev|| < eps_akkt)
@@ -353,6 +362,7 @@ where
353362
self.cache.reset();
354363
(self.problem.cost)(u_current, &mut self.cache.cost_value)?; // cost value
355364
self.estimate_loc_lip(u_current)?; // computes the gradient as well! (self.cache.gradient_u)
365+
self.cache_gradient_norm();
356366
self.cache.gamma = GAMMA_L_COEFF / f64::max(self.cache.lipschitz_constant, MIN_L_ESTIMATE);
357367
self.cache.sigma = (1.0 - GAMMA_L_COEFF) / (4.0 * self.cache.gamma);
358368
self.gradient_step(u_current); // updated self.cache.gradient_step
@@ -367,12 +377,14 @@ where
367377
/* --------------------------------------------------------------------------------------------- */
368378
#[cfg(test)]
369379
mod tests {
380+
use std::cell::Cell;
370381

371382
use crate::constraints;
372383
use crate::core::panoc::panoc_engine::PANOCEngine;
373384
use crate::core::panoc::*;
374385
use crate::core::Problem;
375386
use crate::mocks;
387+
use crate::FunctionCallResult;
376388

377389
#[test]
378390
fn t_compute_fpr() {
@@ -537,6 +549,13 @@ mod tests {
537549
panoc_engine.cache.cost_value = 24.0;
538550
panoc_engine.cache.gamma = 2.34;
539551
panoc_engine.cache.gradient_u.copy_from_slice(&[2.4, -9.7]);
552+
panoc_engine.cache.gradient_u_norm_sq =
553+
crate::matrix_operations::norm2_squared(&panoc_engine.cache.gradient_u);
554+
panoc_engine.cache.gradient_step_u_half_step_diff_norm_sq =
555+
crate::matrix_operations::norm2_squared_diff(
556+
&panoc_engine.cache.gradient_step,
557+
&panoc_engine.cache.u_half_step,
558+
);
540559
panoc_engine.cache.sigma = 0.066;
541560
panoc_engine.cache.norm_gamma_fpr = 2.5974;
542561

@@ -552,4 +571,40 @@ mod tests {
552571
"rhs_ls is wrong",
553572
);
554573
}
574+
575+
#[test]
576+
fn t_update_lipschitz_constant_reuses_cached_cost_at_current_iterate() {
577+
let n = 2;
578+
let mem = 5;
579+
let bounds = constraints::NoConstraints::new();
580+
let cost_calls = Cell::new(0usize);
581+
let cost = |u: &[f64], c: &mut f64| -> FunctionCallResult {
582+
cost_calls.set(cost_calls.get() + 1);
583+
*c = 0.5 * crate::matrix_operations::norm2_squared(u);
584+
Ok(())
585+
};
586+
let grad = |u: &[f64], g: &mut [f64]| -> FunctionCallResult {
587+
g.copy_from_slice(u);
588+
Ok(())
589+
};
590+
let problem = Problem::new(&bounds, grad, cost);
591+
let mut panoc_cache = PANOCCache::new(n, 1e-6, mem);
592+
let mut panoc_engine = PANOCEngine::new(problem, &mut panoc_cache);
593+
594+
let u_current = [1.0, -2.0];
595+
panoc_engine.cache.cost_value = 2.5;
596+
panoc_engine.cache.u_half_step.copy_from_slice(&[0.1, -0.1]);
597+
panoc_engine.cache.gradient_u.copy_from_slice(&u_current);
598+
panoc_engine.cache.gamma = 0.5;
599+
panoc_engine.cache.lipschitz_constant = 1.9;
600+
panoc_engine.compute_fpr(&u_current);
601+
602+
panoc_engine.update_lipschitz_constant(&u_current).unwrap();
603+
604+
assert_eq!(
605+
1,
606+
cost_calls.get(),
607+
"update_lipschitz_constant should only evaluate the half-step cost"
608+
);
609+
}
555610
}

0 commit comments

Comments
 (0)