pub mod archive;
pub mod penalty_tnlp;
pub mod sampling;
use crate::cli::{Args, MinimaArgs, MinimaMethod, ProblemSource};
use crate::seeded_tnlp::SeededTnlp;
use crate::solve_report::{status_to_solve_result_num, InputDescriptor, ReportBuilder};
use archive::{scaled_distance, Archive};
use penalty_tnlp::{Kernel, PenaltyTnlp, TunnelTnlp};
use pounce_algorithm::application::IpoptApplication;
use pounce_common::types::{Index, Number};
use pounce_nlp::return_codes::ApplicationReturnStatus;
use pounce_nlp::tnlp::{BoundsInfo, IndexStyle, SparsityRequest, StartingPoint, TNLP};
use sampling::{clip, Sampler};
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::rc::Rc;
const BOUND_INF: Number = 1e19;
const PSD_MAX_N: usize = 256;
#[derive(Clone, Copy, Debug)]
enum Stop {
TargetReached,
Converged,
BudgetExhausted,
}
impl Stop {
fn as_str(self) -> &'static str {
match self {
Stop::TargetReached => "target_reached",
Stop::Converged => "converged",
Stop::BudgetExhausted => "budget_exhausted",
}
}
}
struct SolveOut {
success: bool,
x: Vec<Number>,
}
struct Driver<'a> {
app: &'a mut IpoptApplication,
base: Rc<RefCell<dyn TNLP>>,
capture: Rc<RefCell<Option<Vec<Number>>>>,
cfg: &'a MinimaArgs,
n: usize,
m: usize,
nnz_h: usize,
index_offset: usize,
x0: Vec<Number>,
x_l: Vec<Number>,
x_u: Vec<Number>,
has_box: bool,
l_scale: Vec<Number>,
sampler: Sampler,
archive: Archive,
stagnant: usize,
n_solves: usize,
max_solves: usize,
n_samples: usize,
max_samples: usize,
psd_skipped_logged: bool,
}
impl<'a> Driver<'a> {
fn run_solve(
&mut self,
solve_tnlp: Rc<RefCell<dyn TNLP>>,
exact_hessian: bool,
) -> Result<SolveOut, Stop> {
if self.n_solves >= self.max_solves {
return Err(Stop::BudgetExhausted);
}
self.n_solves += 1;
let line = if exact_hessian {
"hessian_approximation exact\n"
} else {
"hessian_approximation limited-memory\n"
};
let _ = self.app.options_mut().read_from_str(line, true);
*self.capture.borrow_mut() = None;
let status = self.app.optimize_tnlp(solve_tnlp);
let success = matches!(
status,
ApplicationReturnStatus::SolveSucceeded
| ApplicationReturnStatus::SolvedToAcceptableLevel
);
match self.capture.borrow_mut().take() {
Some(x) if success => Ok(SolveOut { success: true, x }),
_ => Ok(SolveOut {
success: false,
x: Vec::new(),
}),
}
}
fn solve_seeded(&mut self, seed_x: &[Number], exact: bool) -> Result<SolveOut, Stop> {
let t: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SeededTnlp::new(
Rc::clone(&self.base),
seed_x.to_vec(),
)));
self.run_solve(t, exact)
}
fn solve_penalty(&mut self, seed_x: &[Number], kernel: Kernel) -> Result<SolveOut, Stop> {
let pen: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(PenaltyTnlp::new(
Rc::clone(&self.base),
kernel,
)));
let t: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SeededTnlp::new(pen, seed_x.to_vec())));
self.run_solve(t, false)
}
fn solve_tunnel(
&mut self,
seed_x: &[Number],
f_ref: Number,
pole: Kernel,
) -> Result<SolveOut, Stop> {
let tun: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TunnelTnlp::new(
Rc::clone(&self.base),
f_ref,
pole,
)));
let t: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SeededTnlp::new(tun, seed_x.to_vec())));
self.run_solve(t, false)
}
fn clean_f(&mut self, x: &[Number]) -> Option<Number> {
self.base.borrow_mut().eval_f(x, true)
}
fn in_bounds(&self, x: &[Number]) -> bool {
x.iter()
.zip(&self.x_l)
.zip(&self.x_u)
.all(|((&xi, &lo), &hi)| coord_in_bounds(xi, lo, hi))
}
fn obj_hessian_dense(&mut self, x: &[Number]) -> Option<Vec<Number>> {
if self.n > PSD_MAX_N || self.nnz_h == 0 {
return None;
}
let nnz = self.nnz_h;
let mut irow = vec![0 as Index; nnz];
let mut jcol = vec![0 as Index; nnz];
{
let mut b = self.base.borrow_mut();
if !b.eval_h(
None,
false,
1.0,
None,
false,
SparsityRequest::Structure {
irow: &mut irow,
jcol: &mut jcol,
},
) {
return None;
}
}
let lam = vec![0.0; self.m];
let mut vals = vec![0.0; nnz];
{
let mut b = self.base.borrow_mut();
if !b.eval_h(
Some(x),
true,
1.0,
Some(&lam),
true,
SparsityRequest::Values { values: &mut vals },
) {
return None;
}
}
let n = self.n;
let mut dense = vec![0.0; n * n];
for k in 0..nnz {
let i = irow[k] as usize - self.index_offset;
let j = jcol[k] as usize - self.index_offset;
dense[i * n + j] += vals[k];
if i != j {
dense[j * n + i] += vals[k];
}
}
Some(dense)
}
fn is_minimum(&mut self, x: &[Number]) -> bool {
if self.n > PSD_MAX_N {
if !self.psd_skipped_logged {
eprintln!(
"pounce: --minima saddle-rejection (PSD) check skipped — n={} exceeds the \
dense-eigendecomposition cap ({PSD_MAX_N}); accepting converged points as minima.",
self.n
);
self.psd_skipped_logged = true;
}
return true;
}
let dense = match self.obj_hessian_dense(x) {
Some(d) => d,
None => return true,
};
let n = self.n;
let mut w = vec![0.0; n];
let mut v = vec![0.0; n * n];
if !pounce_sensitivity::symmetric_eigen(&dense, n, &mut w, &mut v) {
return true;
}
let min_eig = w.iter().cloned().fold(f64::INFINITY, f64::min);
min_eig >= -self.cfg.psd_tol
}
fn resolve_lengths(
&self,
spec: Option<f64>,
frac_default: f64,
frac_override: Option<f64>,
fallback: f64,
) -> Vec<Number> {
match spec {
Some(s) => vec![s; self.n],
None => {
let frac = frac_override.unwrap_or(frac_default);
if self.has_box {
self.l_scale.iter().map(|&l| frac * l).collect()
} else {
vec![fallback; self.n]
}
}
}
}
fn auto_amplitude(&mut self, center: &[Number], sigma: &[Number], margin: f64) -> Option<f64> {
let h = self.obj_hessian_dense(center)?;
let n = self.n;
let mut s_mat = vec![0.0; n * n];
for i in 0..n {
for j in 0..n {
s_mat[i * n + j] = sigma[i] * sigma[j] * h[i * n + j];
}
}
let mut w = vec![0.0; n];
let mut v = vec![0.0; n * n];
if !pounce_sensitivity::symmetric_eigen(&s_mat, n, &mut w, &mut v) {
return None;
}
let mu_min = w.iter().cloned().fold(f64::INFINITY, f64::min);
Some(margin * mu_min.max(1e-12))
}
fn sample(&mut self, jitter: f64) -> Vec<Number> {
let x0 = self.x0.clone();
let lo = self.x_l.clone();
let hi = self.x_u.clone();
self.sampler.sample(&x0, &lo, &hi, self.has_box, jitter)
}
fn consider(
&mut self,
mut x: Vec<Number>,
mut success: bool,
polish: bool,
) -> Result<bool, Stop> {
if success && polish {
let r = self.solve_seeded(&x, true)?;
success = r.success;
if success {
x = r.x;
}
}
if !success {
return self.reject();
}
let fval = match self.clean_f(&x) {
Some(f) => f,
None => return self.reject(),
};
let finite = x.iter().all(|v| v.is_finite()) && fval.is_finite();
let accepted =
finite && self.in_bounds(&x) && self.is_minimum(&x) && !self.archive.is_known(&x);
if accepted {
self.archive.add(x, fval);
self.stagnant = 0;
if self.archive.len() >= self.cfg.n_minima {
return Err(Stop::TargetReached);
}
Ok(true)
} else {
self.reject()
}
}
fn reject(&mut self) -> Result<bool, Stop> {
self.stagnant += 1;
if self.stagnant >= self.cfg.patience {
return Err(Stop::Converged);
}
Ok(false)
}
fn note_sample(&mut self) -> Result<(), Stop> {
if self.n_samples >= self.max_samples {
return Err(Stop::BudgetExhausted);
}
self.n_samples += 1;
Ok(())
}
fn run(&mut self) -> Stop {
let res = match self.cfg.method {
MinimaMethod::Multistart => self.run_multistart(),
MinimaMethod::Mlsl => self.run_mlsl(),
MinimaMethod::Basinhopping => self.run_basinhopping(),
MinimaMethod::Flooding => self.run_flooding(),
MinimaMethod::Deflation => self.run_deflation(),
MinimaMethod::Tunneling => self.run_tunneling(),
};
res.err().unwrap_or(Stop::BudgetExhausted)
}
fn run_multistart(&mut self) -> Result<(), Stop> {
let jitter = self.cfg.restart_jitter.unwrap_or(1.0);
let x0 = self.x0.clone();
let r = self.solve_seeded(&x0, true)?;
self.consider(r.x, r.success, false)?;
loop {
let s = self.sample(jitter);
let r = self.solve_seeded(&s, true)?;
self.consider(r.x, r.success, false)?;
}
}
fn run_mlsl(&mut self) -> Result<(), Stop> {
let batch = self.cfg.samples_per_round.unwrap_or(20);
let gamma = self.cfg.gamma.unwrap_or(2.0);
let jitter = self.cfg.restart_jitter.unwrap_or(1.0);
let n = self.n;
let diag = (n as f64).sqrt();
let mut pool_x: Vec<Vec<Number>> = Vec::new();
let mut pool_f: Vec<Number> = Vec::new();
let x0 = self.x0.clone();
let r = self.solve_seeded(&x0, true)?;
self.consider(r.x, r.success, false)?;
loop {
for _ in 0..batch {
self.note_sample()?;
let s = self.sample(jitter);
let f = self.clean_f(&s).unwrap_or(f64::INFINITY);
pool_x.push(s);
pool_f.push(f);
}
let bign = pool_x.len();
let ne = bign.max(2) as f64;
let radius = gamma * diag * (ne.ln() / ne).powf(1.0 / n as f64);
let mut order: Vec<usize> = (0..bign).collect();
order.sort_by(|&a, &b| {
pool_f[a]
.partial_cmp(&pool_f[b])
.unwrap_or(std::cmp::Ordering::Equal)
});
for i in order {
let si = pool_x[i].clone();
let fi = pool_f[i];
let better_near = (0..bign).any(|j| {
j != i
&& pool_f[j] < fi
&& scaled_distance(&si, &pool_x[j], &self.l_scale) < radius
});
if better_near || self.archive.near_any(&si, radius) {
continue;
}
let r = self.solve_seeded(&si, true)?;
self.consider(r.x, r.success, false)?;
}
}
}
fn run_basinhopping(&mut self) -> Result<(), Stop> {
let step = self.cfg.step.unwrap_or(0.5);
let temperature = self.cfg.temperature.unwrap_or(1.0);
let x0 = self.x0.clone();
let r = self.solve_seeded(&x0, true)?;
let mut cur = if r.success { r.x.clone() } else { x0.clone() };
let mut cur_f = self.clean_f(&cur).unwrap_or(f64::INFINITY);
self.consider(r.x, r.success, false)?;
loop {
let mut trial = self.sampler.perturb(&cur, &[step]);
clip(&mut trial, &self.x_l, &self.x_u, self.has_box);
let r = self.solve_seeded(&trial, true)?;
if !r.success {
self.consider(r.x, false, false)?;
continue;
}
let new_x = r.x.clone();
self.consider(r.x, true, false)?;
let new_f = self.clean_f(&new_x).unwrap_or(f64::INFINITY);
let accept_uphill = self.sampler.uniform() < (-(new_f - cur_f) / temperature).exp();
if new_f < cur_f || accept_uphill {
cur = new_x;
cur_f = new_f;
}
}
}
fn run_flooding(&mut self) -> Result<(), Stop> {
let sigma = self.resolve_lengths(self.cfg.sigma, 0.1, self.cfg.sigma_frac, 0.5);
let inv_sigma2: Vec<f64> = sigma.iter().map(|&s| 1.0 / (s * s)).collect();
let amp_spec = self.cfg.amplitude;
let margin = self.cfg.amp_margin.unwrap_or(2.0);
let bump_factor = 3.0;
let bump_cap = 1e3;
let fallback_amp = 2.0;
let jitter = self.cfg.restart_jitter.unwrap_or(0.5);
let x0 = self.x0.clone();
let mut base_amp: Vec<f64> = Vec::new();
let mut mult: Vec<f64> = Vec::new();
let mut start = x0.clone();
let mut last_center: Option<usize> = None;
let mut fails = 0usize;
loop {
let centers = self.archive.xs.clone();
while base_amp.len() < centers.len() {
let k = base_amp.len();
let a = match amp_spec {
Some(a) => a,
None => self
.auto_amplitude(¢ers[k], &sigma, margin)
.unwrap_or(fallback_amp),
};
base_amp.push(a);
mult.push(1.0);
}
let eff: Vec<f64> = (0..centers.len()).map(|k| base_amp[k] * mult[k]).collect();
let polish = !centers.is_empty();
let solve_out = if centers.is_empty() {
self.solve_seeded(&start, true)?
} else {
let kernel = Kernel::Gauss {
centers: centers.clone(),
amps: eff,
inv_sigma2: inv_sigma2.clone(),
};
self.solve_penalty(&start, kernel)?
};
let accepted = self.consider(solve_out.x, solve_out.success, polish)?;
if accepted {
if let Some(last) = self.archive.xs.last() {
start = last.clone();
}
last_center = Some(self.archive.xs.len() - 1);
fails = 0;
} else if let Some(lc) = last_center {
if mult[lc] < bump_cap && fails < 8 {
mult[lc] *= bump_factor;
let scale: Vec<f64> = sigma.iter().map(|&s| 0.05 * s).collect();
start = self.sampler.perturb(¢ers[lc], &scale);
fails += 1;
continue;
}
start = self.sample(jitter);
last_center = None;
fails = 0;
} else {
start = self.sample(jitter);
last_center = None;
fails = 0;
}
}
}
fn run_deflation(&mut self) -> Result<(), Stop> {
let eta = self.cfg.eta.unwrap_or(1.0);
let power = self.cfg.power.unwrap_or(2.0);
let soft = self.cfg.soft.unwrap_or(1e-3);
let length = self.resolve_lengths(self.cfg.length, 0.1, self.cfg.length_frac, 0.5);
let inv_len2: Vec<f64> = length.iter().map(|&l| 1.0 / (l * l)).collect();
let jitter = self.cfg.restart_jitter.unwrap_or(0.5);
let q = power / 2.0;
let mut start = self.x0.clone();
loop {
let centers = self.archive.xs.clone();
let polish = !centers.is_empty();
let mut s = start.clone();
if !centers.is_empty() && self.archive.is_known(&s) {
let scale: Vec<f64> = length.iter().map(|&l| 0.1 * l).collect();
s = self.sampler.perturb(&s, &scale);
}
let solve_out = if centers.is_empty() {
self.solve_seeded(&s, true)?
} else {
let kernel = Kernel::Pole {
centers: centers.clone(),
eta,
q,
soft,
inv_len2: inv_len2.clone(),
};
self.solve_penalty(&s, kernel)?
};
let accepted = self.consider(solve_out.x, solve_out.success, polish)?;
if accepted {
if let Some(last) = self.archive.xs.last() {
start = last.clone();
}
} else {
start = self.sample(jitter);
}
}
}
fn run_tunneling(&mut self) -> Result<(), Stop> {
let eta = self.cfg.eta.unwrap_or(1.0);
let power = self.cfg.power.unwrap_or(2.0);
let soft = self.cfg.soft.unwrap_or(1e-3);
let length = self.resolve_lengths(self.cfg.length, 0.1, self.cfg.length_frac, 0.5);
let inv_len2: Vec<f64> = length.iter().map(|&l| 1.0 / (l * l)).collect();
let jitter = self.cfg.restart_jitter.unwrap_or(0.75);
let q = power / 2.0;
let x0 = self.x0.clone();
let r = self.solve_seeded(&x0, true)?;
self.consider(r.x, r.success, false)?;
loop {
let centers = self.archive.xs.clone();
let f_ref = match self.archive.fs.last() {
Some(&f) => f,
None => self.clean_f(&x0).unwrap_or(0.0),
};
let anchor = self
.archive
.xs
.last()
.cloned()
.unwrap_or_else(|| x0.clone());
let jit = vec![jitter; self.n];
let mut start = self.sampler.perturb(&anchor, &jit);
clip(&mut start, &self.x_l, &self.x_u, self.has_box);
let kernel = Kernel::Pole {
centers: centers.clone(),
eta,
q,
soft,
inv_len2: inv_len2.clone(),
};
let r = self.solve_tunnel(&start, f_ref, kernel)?;
self.consider(r.x, r.success, true)?;
}
}
}
struct Minimum {
x: Vec<Number>,
objective: Number,
}
pub fn run(
app: &mut IpoptApplication,
base: &Rc<RefCell<dyn TNLP>>,
cfg: &MinimaArgs,
args: &Args,
sol_path: Option<&Path>,
) -> ExitCode {
let info = match base.borrow_mut().get_nlp_info() {
Some(i) => i,
None => {
eprintln!("pounce: --minima could not read problem dimensions");
return ExitCode::from(2);
}
};
let n = info.n as usize;
let m = info.m as usize;
let nnz_h = info.nnz_h_lag as usize;
let index_offset = match info.index_style {
IndexStyle::Fortran => 1,
IndexStyle::C => 0,
};
let mut x_l = vec![0.0; n];
let mut x_u = vec![0.0; n];
let mut g_l = vec![0.0; m];
let mut g_u = vec![0.0; m];
base.borrow_mut().get_bounds_info(BoundsInfo {
x_l: &mut x_l,
x_u: &mut x_u,
g_l: &mut g_l,
g_u: &mut g_u,
});
let mut x0 = vec![0.0; n];
{
let mut z_l = vec![0.0; n];
let mut z_u = vec![0.0; n];
let mut lambda = vec![0.0; m];
base.borrow_mut().get_starting_point(StartingPoint {
init_x: true,
x: &mut x0,
init_z: false,
z_l: &mut z_l,
z_u: &mut z_u,
init_lambda: false,
lambda: &mut lambda,
});
}
let has_box = (0..n).all(|i| x_l[i] > -BOUND_INF && x_u[i] < BOUND_INF);
let l_scale: Vec<Number> = (0..n)
.map(|i| {
let w = x_u[i] - x_l[i];
if has_box && w > 0.0 {
w
} else {
1.0
}
})
.collect();
let capture: Rc<RefCell<Option<Vec<Number>>>> = Rc::new(RefCell::new(None));
{
let cap = Rc::clone(&capture);
app.set_on_converged(Box::new(move |data, _cq, nlp, _pd| {
if let Some(curr) = data.borrow().curr.clone() {
let x = nlp.borrow().lift_x_to_full(&*curr.x);
*cap.borrow_mut() = Some(x);
}
}));
}
let max_solves = cfg.max_solves.unwrap_or(8 * cfg.n_minima);
let batch = cfg.samples_per_round.unwrap_or(20).max(1);
let max_samples = max_solves.saturating_mul(batch);
println!(
"Searching for up to {} minima via `{}` (max {} solves, seed {})...",
cfg.n_minima,
cfg.method.as_str(),
max_solves,
cfg.seed
);
let mut driver = Driver {
app,
base: Rc::clone(base),
capture,
cfg,
n,
m,
nnz_h,
index_offset,
x0,
x_l: x_l.clone(),
x_u: x_u.clone(),
has_box,
l_scale: l_scale.clone(),
sampler: Sampler::new(cfg.seed, cfg.sobol),
archive: Archive::new(cfg.dedup, l_scale.clone()),
stagnant: 0,
n_solves: 0,
max_solves,
n_samples: 0,
max_samples,
psd_skipped_logged: false,
};
let stop = driver.run();
let n_solves = driver.n_solves;
let order = driver.archive.order_by_objective();
let minima: Vec<Minimum> = order
.iter()
.map(|&i| Minimum {
x: driver.archive.xs[i].clone(),
objective: driver.archive.fs[i],
})
.collect();
let best_obj = order.first().map(|&i| driver.archive.fs[i]);
print_table(&minima, &l_scale, stop, n_solves);
if let Some(sp) = sol_path {
write_sol_files(sp, &minima, m);
}
if let Some(json_path) = &args.json_output {
write_json_report(json_path, args, cfg, stop, n_solves, &minima, n, m, &info);
}
if best_obj.is_some() {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
fn print_table(minima: &[Minimum], l_scale: &[Number], stop: Stop, n_solves: usize) {
println!();
println!(
"find-minima: {} distinct minim{} in {} solves ({})",
minima.len(),
if minima.len() == 1 { "um" } else { "a" },
n_solves,
stop.as_str()
);
if minima.is_empty() {
println!(" (no accepted minima — try raising --max-solves or --patience)");
return;
}
println!(" rank objective dist-to-best");
let best = &minima[0].x;
for (rank, mn) in minima.iter().enumerate() {
let d = scaled_distance(&mn.x, best, l_scale);
println!(" {rank:>4} {:>16.8e} {:>14.6e}", mn.objective, d);
}
}
fn write_sol_files(sol_path: &Path, minima: &[Minimum], m: usize) {
let zeros = vec![0.0; m];
for (rank, mn) in minima.iter().enumerate() {
let path = if rank == 0 {
sol_path.to_path_buf()
} else {
sibling_sol_path(sol_path, rank)
};
let message = format!(
"POUNCE {} find-minima rank {rank}: Solve_Succeeded",
env!("CARGO_PKG_VERSION")
);
let payload = crate::nl_writer::SolutionFile {
message: &message,
x: &mn.x,
lambda: &zeros,
solve_result_num: status_to_solve_result_num(ApplicationReturnStatus::SolveSucceeded),
suffixes: &[],
};
match crate::nl_writer::write_sol_file(&path, &payload) {
Ok(_) => eprintln!("pounce: wrote {}", path.display()),
Err(e) => eprintln!("pounce: failed to write {}: {e}", path.display()),
}
}
}
fn sibling_sol_path(sol_path: &Path, rank: usize) -> PathBuf {
let mut stub = sol_path.to_path_buf();
stub.set_extension(""); let base = stub.to_string_lossy().into_owned();
PathBuf::from(format!("{base}.min{rank:03}.sol"))
}
#[allow(clippy::too_many_arguments)]
fn write_json_report(
json_path: &Path,
args: &Args,
cfg: &MinimaArgs,
stop: Stop,
n_solves: usize,
minima: &[Minimum],
n: usize,
m: usize,
info: &pounce_nlp::tnlp::NlpInfo,
) {
let input = match &args.problem {
ProblemSource::Builtin(name) => InputDescriptor::Builtin { name: name.clone() },
ProblemSource::NlFile(p) => InputDescriptor::NlFile {
path: p.clone(),
size_bytes: std::fs::metadata(p).ok().map(|md| md.len()),
},
};
let mut builder = ReportBuilder::new(args.json_detail, input);
builder.problem.n_variables = n as Index;
builder.problem.n_constraints = m as Index;
builder.problem.n_objectives = 1;
builder.problem.nnz_jac_g = Some(info.nnz_jac_g);
builder.problem.nnz_h_lag = Some(info.nnz_h_lag);
if let Some(best) = minima.first() {
builder.solution.status = ApplicationReturnStatus::SolveSucceeded;
builder.solution.solve_result_num =
status_to_solve_result_num(ApplicationReturnStatus::SolveSucceeded);
builder.solution.objective = best.objective;
builder.solution.x = best.x.clone();
builder.solution.lambda = vec![0.0; m];
}
let report = builder.finish();
let mut value = match serde_json::to_value(&report) {
Ok(v) => v,
Err(e) => {
eprintln!("pounce: failed to serialize minima report: {e}");
return;
}
};
let minima_json: Vec<serde_json::Value> = minima
.iter()
.map(|mn| {
serde_json::json!({
"x": mn.x,
"objective": mn.objective,
})
})
.collect();
let values: Vec<Number> = minima.iter().map(|mn| mn.objective).collect();
if let serde_json::Value::Object(map) = &mut value {
map.insert(
"minima".to_string(),
serde_json::json!({
"method": cfg.method.as_str(),
"status": stop.as_str(),
"n_solves": n_solves,
"n_minima": minima.len(),
"minima": minima_json,
"values": values,
}),
);
}
match serde_json::to_string_pretty(&value) {
Ok(s) => match std::fs::write(json_path, s) {
Ok(_) => eprintln!("pounce: wrote {}", json_path.display()),
Err(e) => eprintln!(
"pounce: failed to write JSON report to {}: {e}",
json_path.display()
),
},
Err(e) => eprintln!("pounce: failed to render minima report: {e}"),
}
}
fn coord_in_bounds(xi: Number, lo: Number, hi: Number) -> bool {
const ATOL: Number = 1e-9;
const RTOL: Number = 1e-6;
let tol_lo = ATOL + RTOL * lo.abs().max(1.0);
let tol_hi = ATOL + RTOL * hi.abs().max(1.0);
xi >= lo - tol_lo && xi <= hi + tol_hi
}
#[cfg(test)]
mod tests {
use super::coord_in_bounds;
#[test]
fn accepts_interior_point() {
assert!(coord_in_bounds(0.0, -1.0, 1.0));
assert!(coord_in_bounds(250.0, 0.0, 500.0));
}
#[test]
fn accepts_bound_relaxed_point_at_large_magnitude() {
assert!(coord_in_bounds(500.000005, 0.0, 500.0));
assert!(coord_in_bounds(-500.000005, -500.0, 0.0));
}
#[test]
fn rejects_genuinely_outside_point() {
assert!(!coord_in_bounds(500.1, 0.0, 500.0));
assert!(!coord_in_bounds(-1.1, -1.0, 1.0));
}
}