diff --git a/CHANGELOG.md b/CHANGELOG.md index f292979..567175f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] — 2026-05-05 + +Theme: production lifecycle. heuropt becomes deployable for long- +running, real-world optimization workloads — callbacks, stop +conditions, tracing, and two new performance indicators. + +No breaking changes to the public API. Existing `Optimizer

` impls +keep compiling — `run_with` is added as a default-impl method that +falls back to `run` plus a single final notification. + +### Added + +#### Observer + stop-conditions API + +A new module `heuropt::observer` introduces: + +- `Snapshot<'a, D>` — per-generation observation payload with + `iteration`, `evaluations`, `elapsed`, `population`, + `pareto_front`, `best`, and `objectives`. +- `Observer` trait — single method `observe(&Snapshot) -> + ControlFlow<()>`. Closures of the right shape implement it + automatically. `()` is the no-op observer. +- `Optimizer::run_with(problem, observer)` — new method on the + `Optimizer` trait with a default impl that falls back to `run`. + Algorithms that override `run_with` (so far: `Nsga2`, + `RandomSearch`, `DifferentialEvolution`) call the observer once + per generation; others call it once at the end. Returning + `ControlFlow::Break` halts the optimizer and returns the partial + result. + +#### Built-in observers (`observer::builtin`) + +- `MaxTime(Duration)` — wall-clock cap. +- `MaxIterations(usize)` — generation cap. +- `TargetFitness(f64)` — direction-aware single-objective target. +- `Stagnation { window, tolerance }` — halt when the best fitness + hasn't improved by `tolerance` over `window` generations. +- `Periodic::new(every, |snap| { … })` — call a user closure every + `every` generations. +- `AnyOf` / `AllOf` plus `Observer::or` / `Observer::and` for + composition. +- `TracingObserver` (behind the new `tracing` feature) — emits + structured `debug!` events per generation. + +#### Tracing feature + +New optional feature `tracing`, gated on the +[`tracing`](https://crates.io/crates/tracing) crate. Adds +`TracingObserver` to the prelude when enabled. + +#### Performance indicators + +- `metrics::igd::igd` — Inverted Generational Distance against a + reference set (typically the true Pareto front). +- `metrics::igd::igd_plus` — Pareto-compliant IGD+ variant; adding + a dominated point never improves the score. +- `metrics::r2::r2` — R2 indicator using the weighted Tchebycheff + utility. Pair with `pareto::das_dennis` for the canonical weight + set. + +#### Constrained example + +`examples/constrained.rs` — solves the BNH constrained 2-objective +problem (Binh & Korn 1996) with NSGA-II + the new observer API, +demonstrating `Periodic` progress logging and `MaxTime` / +composition. + +### Changed + +- `Population::as_slice()` — new convenience accessor. + +[0.6.0]: https://github.com/swaits/heuropt/releases/tag/v0.6.0 + ## [0.5.0] — 2026-05-05 Theme: comprehensive documentation and project polish. No public-API @@ -469,5 +542,5 @@ Initial release. `RandomSearch`, `Nsga2`, and `DifferentialEvolution`. Seeded runs stay bit-identical to serial mode. -[Unreleased]: https://github.com/swaits/heuropt/compare/v0.5.0...HEAD +[Unreleased]: https://github.com/swaits/heuropt/compare/v0.6.0...HEAD [0.1.0]: https://github.com/swaits/heuropt/releases/tag/v0.1.0 diff --git a/Cargo.toml b/Cargo.toml index 7ef1879..4255813 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "heuropt" -version = "0.5.0" +version = "0.6.0" edition = "2024" rust-version = "1.85" authors = ["Stephen Waits "] @@ -17,12 +17,14 @@ categories = ["algorithms", "science", "mathematics", "simulation"] default = [] serde = ["dep:serde"] parallel = ["dep:rayon"] +tracing = ["dep:tracing"] [dependencies] rand = "0.9" rand_distr = "0.5" rayon = { version = "1", optional = true } serde = { version = "1", features = ["derive"], optional = true } +tracing = { version = "0.1", optional = true, default-features = false, features = ["std", "attributes"] } [dev-dependencies] gungraun = "0.18" diff --git a/examples/constrained.rs b/examples/constrained.rs new file mode 100644 index 0000000..659e36e --- /dev/null +++ b/examples/constrained.rs @@ -0,0 +1,125 @@ +//! Constrained multi-objective optimization (BNH problem) plus a +//! demo of the observer / stop-condition API. +//! +//! BNH (Binh & Korn 1996) is a 2-variable / 2-objective / 2-constraint +//! multi-objective problem: +//! +//! ```text +//! minimize f1 = 4·x1² + 4·x2² +//! f2 = (x1 − 5)² + (x2 − 5)² +//! subject to +//! g1: (x1 − 5)² + x2² ≤ 25 +//! g2: (x1 − 8)² + (x2 + 3)² ≥ 7.7 +//! 0 ≤ x1 ≤ 5, 0 ≤ x2 ≤ 3 +//! ``` +//! +//! Demonstrates: +//! - Constraint handling via `Evaluation::constrained` (heuropt's +//! default tournament/Pareto comparators prefer feasibles). +//! - The Observer API: a `Stagnation` observer that halts the run +//! once the front stops improving, plus a `Periodic` observer that +//! prints progress every 25 generations. +//! - Composing observers with `.or()`. +//! +//! Run with: `cargo run --release --example constrained` + +use heuropt::prelude::*; + +struct Bnh; + +impl Problem for Bnh { + type Decision = Vec; + + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]) + } + + fn evaluate(&self, x: &Vec) -> Evaluation { + let f1 = 4.0 * x[0] * x[0] + 4.0 * x[1] * x[1]; + let f2 = (x[0] - 5.0).powi(2) + (x[1] - 5.0).powi(2); + + // g1: (x1 − 5)² + x2² ≤ 25 → violation = max(0, lhs − 25) + let g1 = ((x[0] - 5.0).powi(2) + x[1].powi(2) - 25.0).max(0.0); + // g2: (x1 − 8)² + (x2 + 3)² ≥ 7.7 → violation = max(0, 7.7 − lhs) + let g2 = (7.7 - ((x[0] - 8.0).powi(2) + (x[1] + 3.0).powi(2))).max(0.0); + + let total_violation = g1 + g2; + Evaluation::constrained(vec![f1, f2], total_violation) + } +} + +fn main() { + let bounds = vec![(0.0_f64, 5.0_f64), (0.0_f64, 3.0_f64)]; + + // Compose stop conditions: halt after 5 s OR (via .or()) print + // periodic progress every 25 generations. The Periodic observer + // never breaks; it only logs. + let stop = MaxTime::new(std::time::Duration::from_secs(5)); + let progress = Periodic::new(25, |snap: &Snapshot<'_, Vec>| { + let feasible_in_pop = snap + .population + .iter() + .filter(|c| c.evaluation.is_feasible()) + .count(); + let front_size = snap.pareto_front.map(|f| f.len()).unwrap_or(0); + println!( + "gen {:>4} evaluations = {:>6} feasible/pop = {}/{} front = {}", + snap.iteration, + snap.evaluations, + feasible_in_pop, + snap.population.len(), + front_size, + ); + }); + let mut observer = <_ as Observer>>::or(stop, progress); + + let mut opt = Nsga2::new( + Nsga2Config { + population_size: 100, + generations: 250, + seed: 42, + }, + RealBounds::new(bounds.clone()), + CompositeVariation { + crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5), + mutation: PolynomialMutation::new(bounds, 20.0, 1.0 / 2.0), + }, + ); + let result = opt.run_with(&Bnh, &mut observer); + + let total_feasible = result + .population + .iter() + .filter(|c| c.evaluation.is_feasible()) + .count(); + + println!(); + println!("Final state after {} generations:", result.generations); + println!(" total evaluations: {}", result.evaluations); + println!( + " feasible / total pop: {} / {}", + total_feasible, + result.population.len() + ); + println!(" pareto front size: {}", result.pareto_front.len()); + println!(); + println!("Sample of the front (f1, f2):"); + let mut sorted = result.pareto_front.clone(); + sorted.sort_by(|a, b| { + a.evaluation.objectives[0] + .partial_cmp(&b.evaluation.objectives[0]) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let n = sorted.len(); + if n > 0 { + for k in (0..n).step_by((n / 5).max(1)) { + let c = &sorted[k]; + println!( + " f1 = {:>7.3}, f2 = {:>7.3}, violation = {:.3}", + c.evaluation.objectives[0], + c.evaluation.objectives[1], + c.evaluation.constraint_violation, + ); + } + } +} diff --git a/src/algorithms/differential_evolution.rs b/src/algorithms/differential_evolution.rs index 98166e3..796926b 100644 --- a/src/algorithms/differential_evolution.rs +++ b/src/algorithms/differential_evolution.rs @@ -3,7 +3,6 @@ use rand::Rng as _; use crate::algorithms::parallel_eval::evaluate_batch; -use crate::core::candidate::Candidate; use crate::core::objective::Direction; use crate::core::population::Population; use crate::core::problem::Problem; @@ -95,6 +94,16 @@ where P: Problem> + Sync, { fn run(&mut self, problem: &P) -> OptimizationResult { + self.run_with(problem, &mut ()) + } + + fn run_with(&mut self, problem: &P, observer: &mut O) -> OptimizationResult + where + O: crate::observer::Observer, + { + use crate::observer::Snapshot; + use std::ops::ControlFlow; + assert!( self.config.population_size >= 4, "DifferentialEvolution requires population_size >= 4 (DE/rand/1 needs three distinct donors plus the target)", @@ -110,6 +119,7 @@ where "DifferentialEvolution only supports single-objective problems", ); let direction = objectives.objectives[0].direction; + let started = std::time::Instant::now(); let dim = self.bounds.bounds.len(); let n = self.config.population_size; @@ -122,12 +132,39 @@ where }; let initial_pop = evaluate_batch(problem, decisions.clone()); let mut evaluations = initial_pop.len(); - let mut evals: Vec = initial_pop + let mut current_pop = initial_pop; + let mut evals: Vec = current_pop .iter() .map(|c| c.evaluation.objectives[0]) .collect(); + let mut completed_generations: usize = 0; - for _gen in 0..self.config.generations { + // Initial snapshot. + { + let best = best_candidate(¤t_pop, &objectives); + let snap = Snapshot { + iteration: 0, + evaluations, + elapsed: started.elapsed(), + population: ¤t_pop, + pareto_front: None, + best: best.as_ref(), + objectives: &objectives, + }; + if let ControlFlow::Break(()) = observer.observe(&snap) { + let front = pareto_front(¤t_pop, &objectives); + let best = best_candidate(¤t_pop, &objectives); + return OptimizationResult::new( + Population::new(current_pop), + front, + best, + evaluations, + completed_generations, + ); + } + } + + for generation in 1..=self.config.generations { // Phase 1 (serial): construct one trial per target. RNG state is // consumed in deterministic order so seeded runs reproduce // exactly regardless of the `parallel` feature. @@ -164,22 +201,38 @@ where Direction::Maximize => trial_obj >= target_obj, }; if trial_better { - decisions[i] = trial_cand.decision; + decisions[i] = trial_cand.decision.clone(); evals[i] = trial_obj; + current_pop[i] = trial_cand; } } + completed_generations = generation; + + // Per-generation snapshot. + let best = best_candidate(¤t_pop, &objectives); + let snap = Snapshot { + iteration: generation, + evaluations, + elapsed: started.elapsed(), + population: ¤t_pop, + pareto_front: None, + best: best.as_ref(), + objectives: &objectives, + }; + if let ControlFlow::Break(()) = observer.observe(&snap) { + break; + } } - let final_pop: Vec>> = evaluate_batch(problem, decisions); - evaluations += final_pop.len(); - let front = pareto_front(&final_pop, &objectives); - let best = best_candidate(&final_pop, &objectives); + // Re-evaluate to make sure final population is consistent (current_pop is already current). + let front = pareto_front(¤t_pop, &objectives); + let best = best_candidate(¤t_pop, &objectives); OptimizationResult::new( - Population::new(final_pop), + Population::new(current_pop), front, best, evaluations, - self.config.generations, + completed_generations, ) } } diff --git a/src/algorithms/nsga2.rs b/src/algorithms/nsga2.rs index 5652636..2e69340 100644 --- a/src/algorithms/nsga2.rs +++ b/src/algorithms/nsga2.rs @@ -108,6 +108,16 @@ where V: Variation, { fn run(&mut self, problem: &P) -> OptimizationResult { + self.run_with(problem, &mut ()) + } + + fn run_with(&mut self, problem: &P, observer: &mut O) -> OptimizationResult + where + O: crate::observer::Observer, + { + use crate::observer::Snapshot; + use std::ops::ControlFlow; + assert!( self.config.population_size > 0, "Nsga2 population_size must be greater than 0", @@ -115,6 +125,7 @@ where let n = self.config.population_size; let objectives = problem.objectives(); let mut rng = rng_from_seed(self.config.seed); + let started = std::time::Instant::now(); // Initial population. let initial_decisions = self.initializer.initialize(n, &mut rng); @@ -130,7 +141,27 @@ where // round of tournament selection has data to compare on. let mut annotated = annotate(population, &objectives); - for _ in 0..self.config.generations { + // Observer: notify after the initial population. + let mut completed_generations: usize = 0; + let pop_view: Vec> = + annotated.iter().map(|e| e.candidate.clone()).collect(); + let front_view = pareto_front(&pop_view, &objectives); + let snap = Snapshot { + iteration: 0, + evaluations, + elapsed: started.elapsed(), + population: &pop_view, + pareto_front: Some(&front_view), + best: None, + objectives: &objectives, + }; + if let ControlFlow::Break(()) = observer.observe(&snap) { + return finalize_nsga2(annotated, &objectives, evaluations, completed_generations); + } + drop(pop_view); + drop(front_view); + + for generation in 1..=self.config.generations { // --- Phase 1: serial parent selection + variation --- let mut offspring_decisions: Vec = Vec::with_capacity(n); while offspring_decisions.len() < n { @@ -189,23 +220,48 @@ where } } annotated = annotate(next, &objectives); + completed_generations = generation; + + // Per-generation observation. + let pop_view: Vec> = + annotated.iter().map(|e| e.candidate.clone()).collect(); + let front_view = pareto_front(&pop_view, &objectives); + let snap = Snapshot { + iteration: generation, + evaluations, + elapsed: started.elapsed(), + population: &pop_view, + pareto_front: Some(&front_view), + best: None, + objectives: &objectives, + }; + if let ControlFlow::Break(()) = observer.observe(&snap) { + return finalize_nsga2(annotated, &objectives, evaluations, completed_generations); + } } - // Return final state. - let final_pop: Vec> = - annotated.into_iter().map(|e| e.candidate).collect(); - let front = pareto_front(&final_pop, &objectives); - let best = best_candidate(&final_pop, &objectives); - OptimizationResult::new( - Population::new(final_pop), - front, - best, - evaluations, - self.config.generations, - ) + finalize_nsga2(annotated, &objectives, evaluations, self.config.generations) } } +fn finalize_nsga2( + annotated: Vec>, + objectives: &crate::core::objective::ObjectiveSpace, + evaluations: usize, + generations: usize, +) -> OptimizationResult { + let final_pop: Vec> = annotated.into_iter().map(|e| e.candidate).collect(); + let front = pareto_front(&final_pop, objectives); + let best = best_candidate(&final_pop, objectives); + OptimizationResult::new( + Population::new(final_pop), + front, + best, + evaluations, + generations, + ) +} + fn annotate( population: Vec>, objectives: &crate::core::objective::ObjectiveSpace, diff --git a/src/algorithms/random_search.rs b/src/algorithms/random_search.rs index 9f804bc..a0c26b5 100644 --- a/src/algorithms/random_search.rs +++ b/src/algorithms/random_search.rs @@ -88,28 +88,49 @@ where I: Initializer, { fn run(&mut self, problem: &P) -> OptimizationResult { + self.run_with(problem, &mut ()) + } + + fn run_with(&mut self, problem: &P, observer: &mut O) -> OptimizationResult + where + O: crate::observer::Observer, + { + use crate::observer::Snapshot; + use std::ops::ControlFlow; + let objectives = problem.objectives(); let mut rng = rng_from_seed(self.config.seed); let mut all: Vec> = Vec::new(); let mut evaluations = 0usize; + let started = std::time::Instant::now(); + let mut completed: usize = 0; - for _ in 0..self.config.iterations { + for iteration in 1..=self.config.iterations { let decisions = self .initializer .initialize(self.config.batch_size, &mut rng); evaluations += decisions.len(); all.extend(evaluate_batch(problem, decisions)); + completed = iteration; + + let best = best_candidate(&all, &objectives); + let snap = Snapshot { + iteration, + evaluations, + elapsed: started.elapsed(), + population: &all, + pareto_front: None, + best: best.as_ref(), + objectives: &objectives, + }; + if let ControlFlow::Break(()) = observer.observe(&snap) { + break; + } } let front = pareto_front(&all, &objectives); let best = best_candidate(&all, &objectives); - OptimizationResult::new( - Population::new(all), - front, - best, - evaluations, - self.config.iterations, - ) + OptimizationResult::new(Population::new(all), front, best, evaluations, completed) } } diff --git a/src/core/population.rs b/src/core/population.rs index 830c143..13bf342 100644 --- a/src/core/population.rs +++ b/src/core/population.rs @@ -34,6 +34,11 @@ impl Population { self.candidates.iter() } + /// View the candidates as a slice. + pub fn as_slice(&self) -> &[Candidate] { + &self.candidates + } + /// Unwrap into the inner `Vec>`. pub fn into_vec(self) -> Vec> { self.candidates diff --git a/src/lib.rs b/src/lib.rs index d09a3da..c0c8dcf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -68,6 +68,7 @@ pub mod algorithms; pub mod core; pub(crate) mod internal; pub mod metrics; +pub mod observer; pub mod operators; pub mod pareto; pub mod prelude; diff --git a/src/metrics/igd.rs b/src/metrics/igd.rs new file mode 100644 index 0000000..6a309b1 --- /dev/null +++ b/src/metrics/igd.rs @@ -0,0 +1,207 @@ +//! Inverted Generational Distance (IGD) and IGD+ performance indicators. +//! +//! Both quantify how well an approximation set covers a reference set +//! (typically the true Pareto front). Smaller values are better. + +use crate::core::candidate::Candidate; +use crate::core::evaluation::Evaluation; +use crate::core::objective::ObjectiveSpace; + +/// Inverted Generational Distance. +/// +/// For each point in the `reference` set, compute the Euclidean distance +/// to its nearest neighbor in the `approximation` set (in minimization- +/// oriented objective space), then average: +/// +/// ```text +/// IGD(A) = (1 / |R|) · Σ_{r ∈ R} min_{a ∈ A} ‖a − r‖₂ +/// ``` +/// +/// Lower is better. IGD captures both convergence (close to the front) +/// and spread (the approximation must cover the reference). +/// +/// # Panics +/// +/// If `reference` is empty. +/// +/// # Example +/// +/// ``` +/// use heuropt::prelude::*; +/// use heuropt::metrics::igd::igd; +/// +/// let space = ObjectiveSpace::new(vec![ +/// Objective::minimize("f1"), +/// Objective::minimize("f2"), +/// ]); +/// // Approximation: a sparse 2-point front. +/// let approx = [ +/// Candidate::new((), Evaluation::new(vec![0.0, 1.0])), +/// Candidate::new((), Evaluation::new(vec![1.0, 0.0])), +/// ]; +/// // Reference: a dense 3-point sample of the true front. +/// let reference = [ +/// Evaluation::new(vec![0.0, 1.0]), +/// Evaluation::new(vec![0.5, 0.5]), +/// Evaluation::new(vec![1.0, 0.0]), +/// ]; +/// let v = igd(&approx, &reference, &space); +/// // The middle reference point is unfortunately distance √(0.5²+0.5²) = 0.707 +/// // from each approximation point; the boundary points are 0 away. +/// // IGD = (0 + 0.707 + 0) / 3 ≈ 0.236. +/// assert!((v - 0.2357).abs() < 1e-3); +/// ``` +pub fn igd( + approximation: &[Candidate], + reference: &[Evaluation], + objectives: &ObjectiveSpace, +) -> f64 { + assert!( + !reference.is_empty(), + "igd: reference set must not be empty" + ); + let approx_oriented: Vec> = approximation + .iter() + .map(|c| objectives.as_minimization(&c.evaluation.objectives)) + .collect(); + if approx_oriented.is_empty() { + return f64::INFINITY; + } + let mut total = 0.0_f64; + for r in reference { + let r_oriented = objectives.as_minimization(&r.objectives); + let mut min_d = f64::INFINITY; + for a in &approx_oriented { + let d: f64 = a + .iter() + .zip(r_oriented.iter()) + .map(|(x, y)| (x - y).powi(2)) + .sum::() + .sqrt(); + if d < min_d { + min_d = d; + } + } + total += min_d; + } + total / reference.len() as f64 +} + +/// IGD+ — a dominance-respecting variant of IGD. +/// +/// For each reference point `r`, the distance to an approximation +/// point `a` is computed only on objectives where `a` is *worse than* +/// `r` — i.e. on the "violation" component of the gap. This makes +/// IGD+ a Pareto-compliant indicator: adding a dominated point to the +/// approximation never improves the score. +/// +/// ```text +/// IGD+(A) = (1 / |R|) · Σ_{r ∈ R} min_{a ∈ A} ‖max(a − r, 0)‖₂ +/// ``` +/// +/// Lower is better. +/// +/// # Panics +/// +/// If `reference` is empty. +pub fn igd_plus( + approximation: &[Candidate], + reference: &[Evaluation], + objectives: &ObjectiveSpace, +) -> f64 { + assert!( + !reference.is_empty(), + "igd_plus: reference set must not be empty" + ); + let approx_oriented: Vec> = approximation + .iter() + .map(|c| objectives.as_minimization(&c.evaluation.objectives)) + .collect(); + if approx_oriented.is_empty() { + return f64::INFINITY; + } + let mut total = 0.0_f64; + for r in reference { + let r_oriented = objectives.as_minimization(&r.objectives); + let mut min_d = f64::INFINITY; + for a in &approx_oriented { + let d: f64 = a + .iter() + .zip(r_oriented.iter()) + .map(|(x, y)| (x - y).max(0.0).powi(2)) + .sum::() + .sqrt(); + if d < min_d { + min_d = d; + } + } + total += min_d; + } + total / reference.len() as f64 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::objective::Objective; + + fn space_min2() -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]) + } + + fn cand(obj: Vec) -> Candidate<()> { + Candidate::new((), Evaluation::new(obj)) + } + + #[test] + fn igd_perfect_match_is_zero() { + let s = space_min2(); + let approx = [cand(vec![0.0, 1.0]), cand(vec![1.0, 0.0])]; + let reference = [ + Evaluation::new(vec![0.0, 1.0]), + Evaluation::new(vec![1.0, 0.0]), + ]; + let v = igd(&approx, &reference, &s); + assert!(v < 1e-12); + } + + #[test] + fn igd_known_value() { + let s = space_min2(); + let approx = [cand(vec![0.0, 0.0])]; + let reference = [Evaluation::new(vec![1.0, 1.0])]; + let v = igd(&approx, &reference, &s); + assert!((v - 2.0_f64.sqrt()).abs() < 1e-12); + } + + #[test] + fn igd_plus_dominated_point_does_not_improve() { + let s = space_min2(); + let reference = [ + Evaluation::new(vec![0.0, 1.0]), + Evaluation::new(vec![1.0, 0.0]), + ]; + let base = vec![cand(vec![0.5, 0.5])]; + let with_dominated = vec![cand(vec![0.5, 0.5]), cand(vec![1.0, 1.0])]; + let v_base = igd_plus(&base, &reference, &s); + let v_with = igd_plus(&with_dominated, &reference, &s); + // Adding a dominated point should not improve the score. + assert!(v_with >= v_base - 1e-12); + } + + #[test] + fn igd_empty_approximation_is_infinity() { + let s = space_min2(); + let approx: [Candidate<()>; 0] = []; + let reference = [Evaluation::new(vec![0.0, 1.0])]; + assert!(igd(&approx, &reference, &s).is_infinite()); + } + + #[test] + #[should_panic(expected = "reference set must not be empty")] + fn igd_empty_reference_panics() { + let s = space_min2(); + let approx = [cand(vec![0.0, 1.0])]; + let _ = igd::<()>(&approx, &[], &s); + } +} diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index acc2e1c..5e3b2d4 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs @@ -1,7 +1,11 @@ //! Quality metrics for Pareto fronts. pub mod hypervolume; +pub mod igd; +pub mod r2; pub mod spacing; pub use hypervolume::*; +pub use igd::{igd, igd_plus}; +pub use r2::r2; pub use spacing::*; diff --git a/src/metrics/r2.rs b/src/metrics/r2.rs new file mode 100644 index 0000000..53fffdb --- /dev/null +++ b/src/metrics/r2.rs @@ -0,0 +1,173 @@ +//! R2 indicator — a unary quality measure for Pareto fronts. +//! +//! For each weight vector `λ` in a user-supplied set, find the +//! best (smallest) weighted Tchebycheff value across the front; +//! average over all weight vectors. Lower is better. + +use crate::core::candidate::Candidate; +use crate::core::objective::ObjectiveSpace; + +/// R2 indicator using the weighted Tchebycheff utility. +/// +/// ```text +/// R2(A) = (1 / |Λ|) · Σ_{λ ∈ Λ} min_{a ∈ A} max_i { λ_i · |a_i − z*_i| } +/// ``` +/// +/// where `z*` is the ideal point (per-axis minimum across the +/// approximation, in minimization-oriented coordinates) and `Λ` is +/// a set of unit-simplex weight vectors. Lower is better. +/// +/// Use [`das_dennis`](crate::pareto::das_dennis) to generate the +/// canonical structured weight set. +/// +/// # Panics +/// +/// If the approximation is empty, or any weight vector has wrong +/// length / negative entries / zero sum. +/// +/// # Example +/// +/// ``` +/// use heuropt::prelude::*; +/// use heuropt::metrics::r2::r2; +/// +/// let space = ObjectiveSpace::new(vec![ +/// Objective::minimize("f1"), +/// Objective::minimize("f2"), +/// ]); +/// let approx = [ +/// Candidate::new((), Evaluation::new(vec![0.0, 1.0])), +/// Candidate::new((), Evaluation::new(vec![1.0, 0.0])), +/// ]; +/// // Two weight vectors: (1, 0) and (0, 1) — extreme directions. +/// let weights = [vec![1.0, 0.0], vec![0.0, 1.0]]; +/// let v = r2(&approx, &weights, &space); +/// // For each direction, the best front member matches that axis exactly. +/// // R2 = 0 since the ideal point is achieved on each direction. +/// assert!(v < 1e-12); +/// ``` +pub fn r2( + approximation: &[Candidate], + weights: &[Vec], + objectives: &ObjectiveSpace, +) -> f64 { + assert!( + !approximation.is_empty(), + "r2: approximation must not be empty" + ); + assert!(!weights.is_empty(), "r2: weight set must not be empty"); + let m = objectives.len(); + for (i, w) in weights.iter().enumerate() { + assert_eq!( + w.len(), + m, + "r2: weight {i} has wrong length ({} vs {m})", + w.len() + ); + assert!( + w.iter().all(|&v| v >= 0.0), + "r2: weight {i} has a negative entry" + ); + assert!(w.iter().sum::() > 0.0, "r2: weight {i} has zero sum"); + } + + // Convert all approximation members to minimization orientation once. + let oriented: Vec> = approximation + .iter() + .map(|c| objectives.as_minimization(&c.evaluation.objectives)) + .collect(); + + // Ideal point z* (per-axis minimum). + let mut z_star = vec![f64::INFINITY; m]; + for o in &oriented { + for k in 0..m { + if o[k] < z_star[k] { + z_star[k] = o[k]; + } + } + } + + let mut total = 0.0_f64; + for w in weights { + let mut best = f64::INFINITY; + for o in &oriented { + // Weighted Tchebycheff: max_i { w_i · |o_i − z*_i| } + let mut t = 0.0_f64; + for k in 0..m { + let dk = (o[k] - z_star[k]).abs() * w[k]; + if dk > t { + t = dk; + } + } + if t < best { + best = t; + } + } + total += best; + } + total / weights.len() as f64 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::evaluation::Evaluation; + use crate::core::objective::Objective; + use crate::pareto::das_dennis; + + fn space_min2() -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]) + } + + fn cand(obj: Vec) -> Candidate<()> { + Candidate::new((), Evaluation::new(obj)) + } + + #[test] + fn r2_extremes_are_perfect_at_endpoints() { + let s = space_min2(); + let front = [cand(vec![0.0, 1.0]), cand(vec![1.0, 0.0])]; + let weights = [vec![1.0, 0.0], vec![0.0, 1.0]]; + assert!(r2(&front, &weights, &s) < 1e-12); + } + + #[test] + fn r2_dense_dasdennis_finite_for_uniform_front() { + let s = space_min2(); + let weights = das_dennis(2, 5); + let front: Vec> = (0..=10) + .map(|i| { + let t = i as f64 / 10.0; + cand(vec![t, 1.0 - t]) + }) + .collect(); + let v = r2(&front, &weights, &s); + assert!(v.is_finite()); + assert!(v >= 0.0); + } + + #[test] + #[should_panic(expected = "approximation must not be empty")] + fn r2_empty_approximation_panics() { + let s = space_min2(); + let weights = vec![vec![1.0, 0.0]]; + let _: f64 = r2::<()>(&[], &weights, &s); + } + + #[test] + #[should_panic(expected = "weight set must not be empty")] + fn r2_empty_weights_panics() { + let s = space_min2(); + let front = [cand(vec![0.0, 1.0])]; + let _ = r2(&front, &[], &s); + } + + #[test] + #[should_panic(expected = "wrong length")] + fn r2_wrong_dim_weight_panics() { + let s = space_min2(); + let front = [cand(vec![0.0, 1.0])]; + let weights = vec![vec![1.0, 0.0, 0.0]]; + let _ = r2(&front, &weights, &s); + } +} diff --git a/src/observer/builtin.rs b/src/observer/builtin.rs new file mode 100644 index 0000000..2bc131a --- /dev/null +++ b/src/observer/builtin.rs @@ -0,0 +1,418 @@ +//! Built-in observers covering the common stop conditions. + +use std::ops::ControlFlow; +use std::time::Duration; + +use super::{Observer, Snapshot}; +use crate::core::objective::Direction; + +/// Halt after a fixed wall-clock duration since `run_with` started. +/// +/// # Example +/// +/// ``` +/// use heuropt::prelude::*; +/// use std::time::Duration; +/// +/// let stop = MaxTime::new(Duration::from_millis(50)); +/// // pass `&mut stop` to `Optimizer::run_with`. +/// # let _ = stop; +/// ``` +#[derive(Debug, Clone, Copy)] +pub struct MaxTime { + pub limit: Duration, +} + +impl MaxTime { + pub fn new(limit: Duration) -> Self { + Self { limit } + } +} + +impl Observer for MaxTime { + #[inline] + fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> { + if snap.elapsed >= self.limit { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } +} + +/// Halt after a target number of generations. +/// +/// Most algorithms already take a `generations` count in their config, +/// so this is mostly useful for capping algorithms whose configured +/// loop is open-ended (or for testing). +#[derive(Debug, Clone, Copy)] +pub struct MaxIterations { + pub limit: usize, +} + +impl MaxIterations { + pub fn new(limit: usize) -> Self { + Self { limit } + } +} + +impl Observer for MaxIterations { + #[inline] + fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> { + if snap.iteration >= self.limit { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } +} + +/// Halt as soon as the best single-objective fitness reaches `target`. +/// +/// Direction-aware: for `Minimize` axes the target is reached when +/// `best ≤ target`; for `Maximize`, when `best ≥ target`. +/// +/// Multi-objective snapshots (where `Snapshot::best` is `None` or the +/// problem has more than one objective) are silently ignored — this +/// observer never breaks them. +#[derive(Debug, Clone, Copy)] +pub struct TargetFitness { + pub target: f64, +} + +impl TargetFitness { + pub fn new(target: f64) -> Self { + Self { target } + } +} + +impl Observer for TargetFitness { + fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> { + if !snap.objectives.is_single_objective() { + return ControlFlow::Continue(()); + } + let direction = snap.objectives.objectives[0].direction; + if let Some(best) = snap.best + && let Some(&v) = best.evaluation.objectives.first() + { + let hit = match direction { + Direction::Minimize => v <= self.target, + Direction::Maximize => v >= self.target, + }; + if hit { + return ControlFlow::Break(()); + } + } + ControlFlow::Continue(()) + } +} + +/// Halt when the best single-objective fitness has not improved by +/// more than `tolerance` over the last `window` generations. +/// +/// Multi-objective snapshots are silently ignored. +#[derive(Debug, Clone)] +pub struct Stagnation { + pub window: usize, + pub tolerance: f64, + history: std::collections::VecDeque, +} + +impl Stagnation { + pub fn new(window: usize, tolerance: f64) -> Self { + assert!(window > 0, "Stagnation window must be > 0"); + assert!( + tolerance >= 0.0, + "Stagnation tolerance must be non-negative" + ); + Self { + window, + tolerance, + history: std::collections::VecDeque::with_capacity(window + 1), + } + } +} + +impl Observer for Stagnation { + fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> { + if !snap.objectives.is_single_objective() { + return ControlFlow::Continue(()); + } + let direction = snap.objectives.objectives[0].direction; + let v = match snap + .best + .and_then(|c| c.evaluation.objectives.first().copied()) + { + Some(v) => v, + None => return ControlFlow::Continue(()), + }; + // Push to history; cap at window+1 so we always have 1 + window samples. + self.history.push_back(v); + while self.history.len() > self.window + 1 { + self.history.pop_front(); + } + if self.history.len() <= self.window { + return ControlFlow::Continue(()); + } + let oldest = self.history.front().copied().unwrap(); + let newest = self.history.back().copied().unwrap(); + let improvement = match direction { + Direction::Minimize => oldest - newest, + Direction::Maximize => newest - oldest, + }; + if improvement <= self.tolerance { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } +} + +/// Compose two observers — break if **either** signals a break. +#[derive(Debug, Clone, Copy)] +pub struct AnyOf { + pub a: A, + pub b: B, +} + +impl Observer for AnyOf +where + A: Observer, + B: Observer, +{ + fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> { + // Always poll both so stateful observers (Stagnation) update + // their history, then OR the results. + let ra = self.a.observe(snap); + let rb = self.b.observe(snap); + if ra.is_break() || rb.is_break() { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } +} + +/// Compose two observers — break only if **both** signal a break in +/// the same call. +#[derive(Debug, Clone, Copy)] +pub struct AllOf { + pub a: A, + pub b: B, +} + +impl Observer for AllOf +where + A: Observer, + B: Observer, +{ + fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> { + let ra = self.a.observe(snap); + let rb = self.b.observe(snap); + if ra.is_break() && rb.is_break() { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } +} + +/// Call a user closure every `every` generations (default 1 = every +/// generation). Useful for periodic logging without bloating callback +/// frequency. +pub struct Periodic { + pub every: usize, + counter: usize, + pub callback: F, +} + +impl Periodic { + pub fn new(every: usize, callback: F) -> Self { + assert!(every >= 1, "Periodic every must be >= 1"); + Self { + every, + counter: 0, + callback, + } + } +} + +impl Observer for Periodic +where + F: FnMut(&Snapshot<'_, D>), +{ + fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> { + self.counter += 1; + if self.counter >= self.every { + self.counter = 0; + (self.callback)(snap); + } + ControlFlow::Continue(()) + } +} + +/// Tracing-backed observer — emits a structured `debug!` event per +/// generation with iteration / evaluations / elapsed / best fitness. +/// +/// Available only with the `tracing` feature. +#[cfg(feature = "tracing")] +#[derive(Debug, Default, Clone, Copy)] +pub struct TracingObserver; + +#[cfg(feature = "tracing")] +impl Observer for TracingObserver { + fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> { + let best = snap + .best + .and_then(|c| c.evaluation.objectives.first().copied()); + tracing::debug!( + iteration = snap.iteration, + evaluations = snap.evaluations, + elapsed_ms = snap.elapsed.as_millis() as u64, + best = ?best, + front_size = snap.pareto_front.map(|f| f.len()), + "heuropt generation", + ); + ControlFlow::Continue(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::candidate::Candidate; + use crate::core::evaluation::Evaluation; + use crate::core::objective::{Objective, ObjectiveSpace}; + + fn snap_with_best<'a>( + iteration: usize, + elapsed_ms: u64, + best: Option<&'a Candidate<()>>, + objectives: &'a ObjectiveSpace, + empty_pop: &'a [Candidate<()>], + ) -> Snapshot<'a, ()> { + Snapshot { + iteration, + evaluations: 0, + elapsed: Duration::from_millis(elapsed_ms), + population: empty_pop, + pareto_front: None, + best, + objectives, + } + } + + #[test] + fn max_time_breaks_after_limit() { + let space = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop: Vec> = vec![]; + let mut o = MaxTime::new(Duration::from_millis(100)); + let s = snap_with_best(0, 50, None, &space, &pop); + assert!(o.observe(&s).is_continue()); + let s = snap_with_best(1, 100, None, &space, &pop); + assert!(o.observe(&s).is_break()); + } + + #[test] + fn target_fitness_minimize() { + let space = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop: Vec> = vec![]; + let cand = Candidate::new((), Evaluation::new(vec![0.005])); + let mut o = TargetFitness::new(0.01); + let s = snap_with_best(0, 0, Some(&cand), &space, &pop); + assert!(o.observe(&s).is_break()); + } + + #[test] + fn target_fitness_maximize() { + let space = ObjectiveSpace::new(vec![Objective::maximize("f")]); + let pop: Vec> = vec![]; + let cand_below = Candidate::new((), Evaluation::new(vec![0.5])); + let cand_above = Candidate::new((), Evaluation::new(vec![1.5])); + let mut o = TargetFitness::new(1.0); + let s = snap_with_best(0, 0, Some(&cand_below), &space, &pop); + assert!(o.observe(&s).is_continue()); + let s = snap_with_best(1, 0, Some(&cand_above), &space, &pop); + assert!(o.observe(&s).is_break()); + } + + #[test] + fn stagnation_breaks_on_no_improvement() { + let space = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop: Vec> = vec![]; + let mut o = Stagnation::new(3, 1e-6); + + // Five generations of "no improvement" — same value every time. + for i in 0..3 { + let cand = Candidate::new((), Evaluation::new(vec![1.0])); + let s = snap_with_best(i, 0, Some(&cand), &space, &pop); + // First `window` calls just fill history; should not break. + assert!(o.observe(&s).is_continue()); + } + let cand = Candidate::new((), Evaluation::new(vec![1.0])); + let s = snap_with_best(3, 0, Some(&cand), &space, &pop); + assert!(o.observe(&s).is_break()); + } + + #[test] + fn stagnation_does_not_break_on_improvement() { + let space = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop: Vec> = vec![]; + let mut o = Stagnation::new(2, 1e-6); + let values = [1.0, 0.9, 0.8, 0.7]; + for (i, &v) in values.iter().enumerate() { + let cand = Candidate::new((), Evaluation::new(vec![v])); + let s = snap_with_best(i, 0, Some(&cand), &space, &pop); + assert!(o.observe(&s).is_continue(), "iter {i}"); + } + } + + #[test] + fn anyof_breaks_when_either_breaks() { + let space = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop: Vec> = vec![]; + let cand = Candidate::new((), Evaluation::new(vec![5.0])); + let mut o = + >::or(MaxIterations::new(3), TargetFitness::new(1.0)); + for i in 0..3 { + let s = snap_with_best(i, 0, Some(&cand), &space, &pop); + assert!(o.observe(&s).is_continue(), "iter {i}"); + } + // iteration = 3 hits MaxIterations limit → break + let s = snap_with_best(3, 0, Some(&cand), &space, &pop); + assert!(o.observe(&s).is_break()); + } + + #[test] + fn periodic_calls_callback_every_n() { + let space = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop: Vec> = vec![]; + let mut count = 0_usize; + { + let mut o = Periodic::new(3, |_: &Snapshot<'_, ()>| count += 1); + for i in 0..10 { + let s = snap_with_best(i, 0, None, &space, &pop); + let _ = o.observe(&s); + } + } + assert_eq!(count, 3); // every 3rd of 10 = generations 2, 5, 8 + } + + #[test] + fn closure_implements_observer() { + let space = ObjectiveSpace::new(vec![Objective::minimize("f")]); + let pop: Vec> = vec![]; + let mut count = 0_usize; + let mut closure = |_: &Snapshot<'_, ()>| -> ControlFlow<()> { + count += 1; + if count >= 2 { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }; + let s = snap_with_best(0, 0, None, &space, &pop); + assert!(<_ as Observer<()>>::observe(&mut closure, &s).is_continue()); + assert!(<_ as Observer<()>>::observe(&mut closure, &s).is_break()); + } +} diff --git a/src/observer/mod.rs b/src/observer/mod.rs new file mode 100644 index 0000000..38f030f --- /dev/null +++ b/src/observer/mod.rs @@ -0,0 +1,101 @@ +//! Per-generation observation, callbacks, and stop conditions. +//! +//! Algorithms accept an [`Observer`] via [`Optimizer::run_with`] and call +//! it once per generation (where "generation" makes sense for that +//! algorithm — see each algorithm's docs). Returning +//! [`std::ops::ControlFlow::Break`] from an observer halts the optimizer +//! and the partial [`OptimizationResult`] is returned to the caller. +//! +//! Observers can be composed with [`builtin::AnyOf`] / [`builtin::AllOf`]. +//! +//! [`OptimizationResult`]: crate::core::result::OptimizationResult +//! [`Optimizer::run_with`]: crate::traits::Optimizer::run_with + +pub mod builtin; +mod snapshot; + +pub use snapshot::Snapshot; + +use std::ops::ControlFlow; + +use crate::core::candidate::Candidate; +use crate::core::objective::ObjectiveSpace; + +/// A callback invoked by an [`Optimizer`](crate::traits::Optimizer) +/// after every generation. Return [`ControlFlow::Break`] to halt +/// the optimizer; [`ControlFlow::Continue`] to keep going. +/// +/// Implement directly for stateful observers that need to track +/// history (e.g. stagnation detection, convergence trace logging). +/// For simple stop conditions, use the helpers in +/// [`builtin`](crate::observer::builtin). +pub trait Observer { + /// Inspect the latest snapshot. Return [`ControlFlow::Break`] to + /// halt the run; [`ControlFlow::Continue`] to keep going. + fn observe(&mut self, snapshot: &Snapshot<'_, D>) -> ControlFlow<()>; + + /// Compose with another observer that fires when *either* of them + /// signals a break. + fn or>(self, other: O) -> builtin::AnyOf + where + Self: Sized, + { + builtin::AnyOf { a: self, b: other } + } + + /// Compose with another observer that fires when *both* of them + /// signal a break in the same call. + fn and>(self, other: O) -> builtin::AllOf + where + Self: Sized, + { + builtin::AllOf { a: self, b: other } + } +} + +/// `()` is the no-op observer. Used as the default when callers don't +/// want any callbacks (it's what `run` uses internally). +impl Observer for () { + #[inline] + fn observe(&mut self, _: &Snapshot<'_, D>) -> ControlFlow<()> { + ControlFlow::Continue(()) + } +} + +/// Closures of the right shape implement Observer too — short-form +/// for one-liner callbacks. +impl Observer for F +where + F: FnMut(&Snapshot<'_, D>) -> ControlFlow<()>, +{ + #[inline] + fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> { + self(snap) + } +} + +/// Build a snapshot for the "final notification" path of the default +/// `run_with` impl on [`Optimizer`](crate::traits::Optimizer). +/// +/// Algorithm impls that override `run_with` to call the observer per +/// generation should construct their own snapshots inline rather than +/// using this helper, because they have richer per-generation state. +pub fn finalize_snapshot<'a, D>( + iteration: usize, + evaluations: usize, + elapsed: std::time::Duration, + population: &'a [Candidate], + pareto_front: Option<&'a [Candidate]>, + best: Option<&'a Candidate>, + objectives: &'a ObjectiveSpace, +) -> Snapshot<'a, D> { + Snapshot { + iteration, + evaluations, + elapsed, + population, + pareto_front, + best, + objectives, + } +} diff --git a/src/observer/snapshot.rs b/src/observer/snapshot.rs new file mode 100644 index 0000000..fd9b8d0 --- /dev/null +++ b/src/observer/snapshot.rs @@ -0,0 +1,45 @@ +//! Per-generation observation payload passed to [`Observer`](super::Observer). + +use std::time::Duration; + +use crate::core::candidate::Candidate; +use crate::core::objective::ObjectiveSpace; + +/// A view of an optimizer's state at one generation boundary. +/// +/// Borrowed (`&'a ...`) rather than owned so the algorithm doesn't +/// have to clone the whole population on every call. Observers that +/// need to retain values across calls should clone what they need +/// out of the snapshot. +#[derive(Debug)] +pub struct Snapshot<'a, D> { + /// Zero-indexed generation count. The first call is `iteration = 0` + /// for "after the initial population was built and evaluated"; + /// subsequent calls are after generation 1, 2, … + pub iteration: usize, + + /// Total `Problem::evaluate` calls so far, including the initial + /// population. + pub evaluations: usize, + + /// Wall-clock time since `run_with` started. + pub elapsed: Duration, + + /// The current population (whatever the algorithm considers the + /// "live" set this generation). For steady-state algorithms this + /// is the post-replacement population. + pub population: &'a [Candidate], + + /// The current Pareto front, if the algorithm tracks one. `None` + /// for single-objective algorithms. + pub pareto_front: Option<&'a [Candidate]>, + + /// The current best candidate. `Some` for single-objective + /// algorithms; `None` for multi-objective unless the algorithm + /// tracks a notion of best (some don't). + pub best: Option<&'a Candidate>, + + /// The objective space, useful for observers that need to convert + /// raw objective values to minimization-oriented form. + pub objectives: &'a ObjectiveSpace, +} diff --git a/src/prelude.rs b/src/prelude.rs index 02f1b68..5f38f85 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -11,6 +11,14 @@ pub use crate::core::{ pub use crate::traits::{Initializer, Optimizer, Repair, Variation}; +#[cfg(feature = "tracing")] +pub use crate::observer::builtin::TracingObserver; +pub use crate::observer::{ + Observer, Snapshot, + builtin::{AllOf, AnyOf, MaxIterations, MaxTime, Periodic, Stagnation, TargetFitness}, +}; +pub use std::ops::ControlFlow; + pub use crate::pareto::{ Dominance, ParetoArchive, best_candidate, crowding_distance, das_dennis, non_dominated_sort, pareto_compare, pareto_front, diff --git a/src/traits/optimizer.rs b/src/traits/optimizer.rs index 2a8ac76..712b479 100644 --- a/src/traits/optimizer.rs +++ b/src/traits/optimizer.rs @@ -1,18 +1,71 @@ //! The single trait users implement to add a new optimizer. +use std::time::{Duration, Instant}; + use crate::core::problem::Problem; use crate::core::result::OptimizationResult; +use crate::observer::{Observer, Snapshot}; /// An optimizer that runs to completion in a single call. /// /// Implementations own their main loop, manage their own state, and return an -/// [`OptimizationResult`]. v1 deliberately does not expose a step-by-step API -/// or an associated error type — invalid configuration may panic with a clear -/// message. +/// [`OptimizationResult`]. Invalid configuration panics with a clear +/// message rather than returning a `Result`. pub trait Optimizer

where P: Problem, { /// Run the optimizer to completion against `problem`. fn run(&mut self, problem: &P) -> OptimizationResult; + + /// Run with an [`Observer`] called after each generation. + /// + /// The observer can halt the run by returning + /// [`std::ops::ControlFlow::Break`]; the partial result is still + /// returned. Built-in observers in + /// [`heuropt::observer::builtin`](crate::observer::builtin) cover + /// the common stop conditions (`MaxTime`, `TargetFitness`, + /// `Stagnation`, …). + /// + /// **Default impl:** falls back to `run` plus a single final + /// notification. Algorithms that override this method get true + /// per-generation observation; algorithms that don't get a single + /// notification at the end. The trait-level docstring on each + /// algorithm calls out which behavior it supports. + fn run_with(&mut self, problem: &P, observer: &mut O) -> OptimizationResult + where + O: Observer, + { + let started = Instant::now(); + let result = self.run(problem); + let elapsed = started.elapsed(); + notify_final(&result, elapsed, problem, observer); + result + } +} + +/// Helper used by the default `run_with` impl: build a single final- +/// state snapshot and hand it to the observer once. Algorithms that +/// override `run_with` for per-generation reporting don't go through +/// this path — they construct their own per-iteration snapshots. +fn notify_final( + result: &OptimizationResult, + elapsed: Duration, + problem: &P, + observer: &mut O, +) where + P: Problem, + O: Observer, +{ + let objectives = problem.objectives(); + let snap = Snapshot { + iteration: result.generations, + evaluations: result.evaluations, + elapsed, + population: result.population.as_slice(), + pareto_front: Some(result.pareto_front.as_slice()), + best: result.best.as_ref(), + objectives: &objectives, + }; + let _ = observer.observe(&snap); }