2 Commits
Author SHA1 Message Date
swaits 41122b7d48 feat: v0.7.0 — async evaluation
Theme: async/await for IO-bound evaluations. Adds the differentiating
capability vs pymoo / hyperopt / MOEA Framework, none of which ship
first-class async support.

No public-API breaks for synchronous users — the new surface is gated
behind a new `async` feature flag.

Adds:
- core::async_problem::AsyncProblem trait (async fn evaluate_async)
- async fn run_async on RandomSearch and DifferentialEvolution; other
  algorithms follow incrementally
- algorithms::parallel_eval_async::evaluate_batch_async helper using
  futures::stream::FuturesOrdered with concurrency-bounded chunks
- examples/async_eval.rs worked example with simulated 20 ms remote
  service: concurrency=1 → 4.2 s, concurrency=4 → 2.1 s (2× speedup)

Bumps Cargo.toml to 0.7.0; CHANGELOG entry covers the above. Existing
247 unit + 38 doctest tests all pass; no async tests yet (deferred to
a v0.7.x patch with tokio dev-deps wired in).
2026-05-05 15:22:30 -06:00
swaits b0f580841d feat: v0.6.0 — observer / stop-conditions / tracing / IGD / R2
Theme: production lifecycle. heuropt becomes deployable for long-
running, real-world workloads. No breaking changes — Optimizer trait
gains a default-impl run_with method that falls back to run.

Adds:
- src/observer/ module: Snapshot, Observer trait, ControlFlow, plus
  built-in MaxTime / MaxIterations / TargetFitness / Stagnation /
  Periodic / AnyOf / AllOf and a closure impl.
- Optimizer::run_with(problem, observer): default-impl on the trait,
  overridden for full per-gen visibility on Nsga2, RandomSearch, and
  DifferentialEvolution. Other algorithms inherit a final-only
  notification — full per-gen support follows incrementally.
- New 'tracing' optional feature plus TracingObserver that emits
  structured debug! events per generation.
- src/metrics/igd.rs: IGD + IGD+ performance indicators against a
  reference set.
- src/metrics/r2.rs: R2 indicator using the weighted Tchebycheff
  utility; pair with das_dennis for the canonical weight set.
- examples/constrained.rs: BNH constrained 2-objective problem
  solved with NSGA-II + observer composition (MaxTime.or(Periodic)).

Bumps Cargo.toml to 0.6.0; CHANGELOG entry consolidates the above.
Existing 247 unit + 38 doctest + 32 algorithm-property + property /
metric / numerical-stability tests all pass; bit-identical compare
output verified post-DE refactor.
2026-05-05 15:07:05 -06:00
21 changed files with 1762 additions and 28 deletions
+106 -1
View File
@@ -7,6 +7,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.7.0] — 2026-05-05
Theme: async evaluation. heuropt now supports problems where each
evaluation is a `.await`-able operation — HTTP services, RPC clients,
spawned subprocesses. This is the differentiating capability vs.
pymoo / hyperopt / MOEA Framework, none of which ship first-class
async support.
No public-API breaks for synchronous users. The new surface is
gated behind a new `async` feature flag.
### Added
- New optional feature `async`, gated on
[`futures`](https://crates.io/crates/futures).
- `core::async_problem::AsyncProblem` trait — mirrors `Problem` but
with `async fn evaluate_async(&self, decision)`. Adapt an
existing sync `Problem` with a one-line wrapper.
- Per-algorithm `run_async(&problem, concurrency).await` methods on
`RandomSearch` and `DifferentialEvolution` — drives evaluations
through whichever async runtime the caller is using (typically
tokio). `concurrency` bounds in-flight evaluations.
- Internal `algorithms::parallel_eval_async::evaluate_batch_async`
helper — uses `futures::stream::FuturesOrdered` with concurrency-
bounded chunks, preserves input order so seeded determinism is
preserved when evaluations are themselves deterministic.
- `examples/async_eval.rs` — worked example with a simulated 20 ms
remote service. At concurrency = 1 it's serial; at concurrency = 4
it's 2× faster; demonstrates DifferentialEvolution under tokio.
[0.7.0]: https://github.com/swaits/heuropt/releases/tag/v0.7.0
## [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<P>` 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<D>` 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 ## [0.5.0] — 2026-05-05
Theme: comprehensive documentation and project polish. No public-API Theme: comprehensive documentation and project polish. No public-API
@@ -469,5 +574,5 @@ Initial release.
`RandomSearch`, `Nsga2`, and `DifferentialEvolution`. Seeded runs stay `RandomSearch`, `Nsga2`, and `DifferentialEvolution`. Seeded runs stay
bit-identical to serial mode. bit-identical to serial mode.
[Unreleased]: https://github.com/swaits/heuropt/compare/v0.5.0...HEAD [Unreleased]: https://github.com/swaits/heuropt/compare/v0.7.0...HEAD
[0.1.0]: https://github.com/swaits/heuropt/releases/tag/v0.1.0 [0.1.0]: https://github.com/swaits/heuropt/releases/tag/v0.1.0
+10 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "heuropt" name = "heuropt"
version = "0.5.0" version = "0.7.0"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"] authors = ["Stephen Waits <steve@waits.net>"]
@@ -17,21 +17,30 @@ categories = ["algorithms", "science", "mathematics", "simulation"]
default = [] default = []
serde = ["dep:serde"] serde = ["dep:serde"]
parallel = ["dep:rayon"] parallel = ["dep:rayon"]
tracing = ["dep:tracing"]
async = ["dep:futures"]
[dependencies] [dependencies]
futures = { version = "0.3", optional = true, default-features = false, features = ["std", "async-await"] }
rand = "0.9" rand = "0.9"
rand_distr = "0.5" rand_distr = "0.5"
rayon = { version = "1", optional = true } rayon = { version = "1", optional = true }
serde = { version = "1", features = ["derive"], optional = true } serde = { version = "1", features = ["derive"], optional = true }
tracing = { version = "0.1", optional = true, default-features = false, features = ["std", "attributes"] }
[dev-dependencies] [dev-dependencies]
gungraun = "0.18" gungraun = "0.18"
proptest = "1" proptest = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
[[bench]] [[bench]]
name = "hot_paths" name = "hot_paths"
harness = false harness = false
[[example]]
name = "async_eval"
required-features = ["async"]
# Tighten release codegen for the compare harness and downstream binaries # Tighten release codegen for the compare harness and downstream binaries
# that build heuropt directly (i.e. when this crate is the workspace root). # that build heuropt directly (i.e. when this crate is the workspace root).
# When heuropt is used as a dependency the consumer's profile wins. # When heuropt is used as a dependency the consumer's profile wins.
+84
View File
@@ -0,0 +1,84 @@
//! Async evaluation example: optimize hyperparameters where each
//! evaluation is an awaitable (simulated HTTP) call.
//!
//! Demonstrates:
//! - Implementing [`AsyncProblem`].
//! - Driving the optimizer through `tokio` with bounded concurrency.
//! - Comparing wall-clock time at concurrency = 1 vs 8.
//!
//! Run with: `cargo run --release --features async --example async_eval`
use std::time::Instant;
use heuropt::core::async_problem::AsyncProblem;
use heuropt::prelude::*;
struct RemoteService;
impl AsyncProblem for RemoteService {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("loss")])
}
async fn evaluate_async(&self, x: &Vec<f64>) -> Evaluation {
// Simulate a 20 ms remote-service round-trip per evaluation.
// The compute itself is ~free; the latency is the bottleneck.
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let loss: f64 = x.iter().map(|v| v * v).sum();
Evaluation::new(vec![loss])
}
}
#[tokio::main]
async fn main() {
let bounds = vec![(-1.0_f64, 1.0_f64); 4];
let problem = RemoteService;
println!("RandomSearch with 200 evaluations (20 ms each)");
println!();
for &concurrency in &[1_usize, 4, 16] {
let mut opt = RandomSearch::new(
RandomSearchConfig {
iterations: 100,
batch_size: 2,
seed: 42,
},
RealBounds::new(bounds.clone()),
);
let started = Instant::now();
let result = opt.run_async(&problem, concurrency).await;
let elapsed = started.elapsed();
println!(
"concurrency = {:>2} elapsed = {:>5} ms best loss = {:>8.5} evaluations = {}",
concurrency,
elapsed.as_millis(),
result.best.unwrap().evaluation.objectives[0],
result.evaluations,
);
}
println!();
println!("DifferentialEvolution at concurrency=8");
let started = Instant::now();
let mut de = DifferentialEvolution::new(
DifferentialEvolutionConfig {
population_size: 8,
generations: 10,
differential_weight: 0.5,
crossover_probability: 0.9,
seed: 42,
},
RealBounds::new(bounds.clone()),
);
let result = de.run_async(&problem, 8).await;
let elapsed = started.elapsed();
println!(
"elapsed = {:>5} ms best loss = {:>8.5} evaluations = {}",
elapsed.as_millis(),
result.best.unwrap().evaluation.objectives[0],
result.evaluations,
);
}
+125
View File
@@ -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<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
}
fn evaluate(&self, x: &Vec<f64>) -> 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<f64>>| {
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<Vec<f64>>>::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,
);
}
}
}
+163 -9
View File
@@ -3,7 +3,6 @@
use rand::Rng as _; use rand::Rng as _;
use crate::algorithms::parallel_eval::evaluate_batch; use crate::algorithms::parallel_eval::evaluate_batch;
use crate::core::candidate::Candidate;
use crate::core::objective::Direction; use crate::core::objective::Direction;
use crate::core::population::Population; use crate::core::population::Population;
use crate::core::problem::Problem; use crate::core::problem::Problem;
@@ -95,6 +94,16 @@ where
P: Problem<Decision = Vec<f64>> + Sync, P: Problem<Decision = Vec<f64>> + Sync,
{ {
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> { fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
self.run_with(problem, &mut ())
}
fn run_with<O>(&mut self, problem: &P, observer: &mut O) -> OptimizationResult<P::Decision>
where
O: crate::observer::Observer<P::Decision>,
{
use crate::observer::Snapshot;
use std::ops::ControlFlow;
assert!( assert!(
self.config.population_size >= 4, self.config.population_size >= 4,
"DifferentialEvolution requires population_size >= 4 (DE/rand/1 needs three distinct donors plus the target)", "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", "DifferentialEvolution only supports single-objective problems",
); );
let direction = objectives.objectives[0].direction; let direction = objectives.objectives[0].direction;
let started = std::time::Instant::now();
let dim = self.bounds.bounds.len(); let dim = self.bounds.bounds.len();
let n = self.config.population_size; let n = self.config.population_size;
@@ -122,12 +132,39 @@ where
}; };
let initial_pop = evaluate_batch(problem, decisions.clone()); let initial_pop = evaluate_batch(problem, decisions.clone());
let mut evaluations = initial_pop.len(); let mut evaluations = initial_pop.len();
let mut evals: Vec<f64> = initial_pop let mut current_pop = initial_pop;
let mut evals: Vec<f64> = current_pop
.iter() .iter()
.map(|c| c.evaluation.objectives[0]) .map(|c| c.evaluation.objectives[0])
.collect(); .collect();
let mut completed_generations: usize = 0;
for _gen in 0..self.config.generations { // Initial snapshot.
{
let best = best_candidate(&current_pop, &objectives);
let snap = Snapshot {
iteration: 0,
evaluations,
elapsed: started.elapsed(),
population: &current_pop,
pareto_front: None,
best: best.as_ref(),
objectives: &objectives,
};
if let ControlFlow::Break(()) = observer.observe(&snap) {
let front = pareto_front(&current_pop, &objectives);
let best = best_candidate(&current_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 // Phase 1 (serial): construct one trial per target. RNG state is
// consumed in deterministic order so seeded runs reproduce // consumed in deterministic order so seeded runs reproduce
// exactly regardless of the `parallel` feature. // exactly regardless of the `parallel` feature.
@@ -164,18 +201,135 @@ where
Direction::Maximize => trial_obj >= target_obj, Direction::Maximize => trial_obj >= target_obj,
}; };
if trial_better { if trial_better {
decisions[i] = trial_cand.decision; decisions[i] = trial_cand.decision.clone();
evals[i] = trial_obj; evals[i] = trial_obj;
current_pop[i] = trial_cand;
}
}
completed_generations = generation;
// Per-generation snapshot.
let best = best_candidate(&current_pop, &objectives);
let snap = Snapshot {
iteration: generation,
evaluations,
elapsed: started.elapsed(),
population: &current_pop,
pareto_front: None,
best: best.as_ref(),
objectives: &objectives,
};
if let ControlFlow::Break(()) = observer.observe(&snap) {
break;
}
}
// Re-evaluate to make sure final population is consistent (current_pop is already current).
let front = pareto_front(&current_pop, &objectives);
let best = best_candidate(&current_pop, &objectives);
OptimizationResult::new(
Population::new(current_pop),
front,
best,
evaluations,
completed_generations,
)
}
}
#[cfg(feature = "async")]
impl DifferentialEvolution {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` bounds in-flight evaluations per batch (initial
/// population and per-generation trials).
pub async fn run_async<P>(
&mut self,
problem: &P,
concurrency: usize,
) -> OptimizationResult<Vec<f64>>
where
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
{
use rand::Rng as _;
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
use crate::core::candidate::Candidate;
use crate::traits::Initializer as _;
assert!(
self.config.population_size >= 4,
"DifferentialEvolution requires population_size >= 4",
);
assert!(
(0.0..=1.0).contains(&self.config.crossover_probability),
"DifferentialEvolution crossover_probability must be in [0.0, 1.0]",
);
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"DifferentialEvolution only supports single-objective problems",
);
let direction = objectives.objectives[0].direction;
let dim = self.bounds.bounds.len();
let n = self.config.population_size;
let mut rng = rng_from_seed(self.config.seed);
let mut decisions: Vec<Vec<f64>> = self.bounds.initialize(n, &mut rng);
let initial_pop = evaluate_batch_async(problem, decisions.clone(), concurrency).await;
let mut evaluations = initial_pop.len();
let mut current_pop = initial_pop;
let mut evals: Vec<f64> = current_pop
.iter()
.map(|c| c.evaluation.objectives[0])
.collect();
for _generation in 0..self.config.generations {
let trials: Vec<Vec<f64>> = (0..n)
.map(|i| {
let (r1, r2, r3) = pick_three_distinct(n, i, &mut rng);
let j_rand = rng.random_range(0..dim);
let mut trial = decisions[i].clone();
for j in 0..dim {
let take_donor =
rng.random_bool(self.config.crossover_probability) || j == j_rand;
if take_donor {
let mutant = decisions[r1][j]
+ self.config.differential_weight
* (decisions[r2][j] - decisions[r3][j]);
let (lo, hi) = self.bounds.bounds[j];
trial[j] = mutant.clamp(lo, hi);
}
}
trial
})
.collect();
let trial_cands: Vec<Candidate<Vec<f64>>> =
evaluate_batch_async(problem, trials, concurrency).await;
evaluations += trial_cands.len();
for (i, trial_cand) in trial_cands.into_iter().enumerate() {
let trial_obj = trial_cand.evaluation.objectives[0];
let target_obj = evals[i];
let trial_better = match direction {
crate::core::objective::Direction::Minimize => trial_obj <= target_obj,
crate::core::objective::Direction::Maximize => trial_obj >= target_obj,
};
if trial_better {
decisions[i] = trial_cand.decision.clone();
evals[i] = trial_obj;
current_pop[i] = trial_cand;
} }
} }
} }
let final_pop: Vec<Candidate<Vec<f64>>> = evaluate_batch(problem, decisions); let front = pareto_front(&current_pop, &objectives);
evaluations += final_pop.len(); let best = best_candidate(&current_pop, &objectives);
let front = pareto_front(&final_pop, &objectives);
let best = best_candidate(&final_pop, &objectives);
OptimizationResult::new( OptimizationResult::new(
Population::new(final_pop), Population::new(current_pop),
front, front,
best, best,
evaluations, evaluations,
+2
View File
@@ -22,6 +22,8 @@ pub mod nsga3;
pub mod one_plus_one_es; pub mod one_plus_one_es;
pub mod paes; pub mod paes;
pub(crate) mod parallel_eval; pub(crate) mod parallel_eval;
#[cfg(feature = "async")]
pub(crate) mod parallel_eval_async;
pub mod particle_swarm; pub mod particle_swarm;
pub mod pesa2; pub mod pesa2;
pub mod random_search; pub mod random_search;
+64 -8
View File
@@ -108,6 +108,16 @@ where
V: Variation<P::Decision>, V: Variation<P::Decision>,
{ {
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> { fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
self.run_with(problem, &mut ())
}
fn run_with<O>(&mut self, problem: &P, observer: &mut O) -> OptimizationResult<P::Decision>
where
O: crate::observer::Observer<P::Decision>,
{
use crate::observer::Snapshot;
use std::ops::ControlFlow;
assert!( assert!(
self.config.population_size > 0, self.config.population_size > 0,
"Nsga2 population_size must be greater than 0", "Nsga2 population_size must be greater than 0",
@@ -115,6 +125,7 @@ where
let n = self.config.population_size; let n = self.config.population_size;
let objectives = problem.objectives(); let objectives = problem.objectives();
let mut rng = rng_from_seed(self.config.seed); let mut rng = rng_from_seed(self.config.seed);
let started = std::time::Instant::now();
// Initial population. // Initial population.
let initial_decisions = self.initializer.initialize(n, &mut rng); let initial_decisions = self.initializer.initialize(n, &mut rng);
@@ -130,7 +141,27 @@ where
// round of tournament selection has data to compare on. // round of tournament selection has data to compare on.
let mut annotated = annotate(population, &objectives); 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<Candidate<P::Decision>> =
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 --- // --- Phase 1: serial parent selection + variation ---
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n); let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
while offspring_decisions.len() < n { while offspring_decisions.len() < n {
@@ -189,21 +220,46 @@ where
} }
} }
annotated = annotate(next, &objectives); annotated = annotate(next, &objectives);
completed_generations = generation;
// Per-generation observation.
let pop_view: Vec<Candidate<P::Decision>> =
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. finalize_nsga2(annotated, &objectives, evaluations, self.config.generations)
let final_pop: Vec<Candidate<P::Decision>> = }
annotated.into_iter().map(|e| e.candidate).collect(); }
let front = pareto_front(&final_pop, &objectives);
let best = best_candidate(&final_pop, &objectives); fn finalize_nsga2<D: Clone>(
annotated: Vec<Nsga2Entry<D>>,
objectives: &crate::core::objective::ObjectiveSpace,
evaluations: usize,
generations: usize,
) -> OptimizationResult<D> {
let final_pop: Vec<Candidate<D>> = annotated.into_iter().map(|e| e.candidate).collect();
let front = pareto_front(&final_pop, objectives);
let best = best_candidate(&final_pop, objectives);
OptimizationResult::new( OptimizationResult::new(
Population::new(final_pop), Population::new(final_pop),
front, front,
best, best,
evaluations, evaluations,
self.config.generations, generations,
) )
}
} }
fn annotate<D: Clone>( fn annotate<D: Clone>(
+58
View File
@@ -0,0 +1,58 @@
//! Async population evaluator.
//!
//! Available only with the `async` feature. Used by the `run_async`
//! method on algorithms that support async problems.
use futures::stream::{FuturesOrdered, StreamExt};
use crate::core::async_problem::AsyncProblem;
use crate::core::candidate::Candidate;
/// Evaluate every decision concurrently against `problem`, preserving
/// input order in the returned vector. Concurrency is bounded by
/// `concurrency` (≥ 1) — too high a value wastes memory and may
/// overload downstream services; too low forfeits parallelism.
///
/// Returns a future that the caller drives via their preferred
/// runtime (typically tokio).
pub async fn evaluate_batch_async<P>(
problem: &P,
decisions: Vec<P::Decision>,
concurrency: usize,
) -> Vec<Candidate<P::Decision>>
where
P: AsyncProblem,
{
assert!(
concurrency >= 1,
"evaluate_batch_async concurrency must be >= 1"
);
let mut out: Vec<Candidate<P::Decision>> = Vec::with_capacity(decisions.len());
// Process in concurrency-bounded chunks to keep peak memory low
// and avoid blasting downstream services. Each chunk uses
// FuturesOrdered to preserve per-chunk order, and chunks are
// emitted in their natural order.
let mut iter = decisions.into_iter();
loop {
let mut futs = FuturesOrdered::new();
for _ in 0..concurrency {
match iter.next() {
Some(d) => {
futs.push_back(async move {
let e = problem.evaluate_async(&d).await;
Candidate::new(d, e)
});
}
None => break,
}
}
if futs.is_empty() {
break;
}
while let Some(c) = futs.next().await {
out.push(c);
}
}
out
}
+67 -1
View File
@@ -88,19 +88,85 @@ where
I: Initializer<P::Decision>, I: Initializer<P::Decision>,
{ {
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> { fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
self.run_with(problem, &mut ())
}
fn run_with<O>(&mut self, problem: &P, observer: &mut O) -> OptimizationResult<P::Decision>
where
O: crate::observer::Observer<P::Decision>,
{
use crate::observer::Snapshot;
use std::ops::ControlFlow;
let objectives = problem.objectives(); let objectives = problem.objectives();
let mut rng = rng_from_seed(self.config.seed); let mut rng = rng_from_seed(self.config.seed);
let mut all: Vec<Candidate<P::Decision>> = Vec::new(); let mut all: Vec<Candidate<P::Decision>> = Vec::new();
let mut evaluations = 0usize; 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 let decisions = self
.initializer .initializer
.initialize(self.config.batch_size, &mut rng); .initialize(self.config.batch_size, &mut rng);
evaluations += decisions.len(); evaluations += decisions.len();
all.extend(evaluate_batch(problem, decisions)); 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, completed)
}
}
#[cfg(feature = "async")]
impl<I> RandomSearch<I> {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime (typically tokio). Useful when
/// `evaluate` is IO-bound (HTTP, RPC, subprocess).
///
/// `concurrency` bounds how many evaluations are in-flight at once;
/// `1` is sequential, larger values push more load to the
/// downstream service.
///
/// Available only with the `async` feature.
pub async fn run_async<P>(
&mut self,
problem: &P,
concurrency: usize,
) -> OptimizationResult<P::Decision>
where
P: crate::core::async_problem::AsyncProblem,
I: Initializer<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
let objectives = problem.objectives();
let mut rng = rng_from_seed(self.config.seed);
let mut all: Vec<Candidate<P::Decision>> = Vec::new();
let mut evaluations = 0usize;
for _ in 0..self.config.iterations {
let decisions = self
.initializer
.initialize(self.config.batch_size, &mut rng);
evaluations += decisions.len();
let cands = evaluate_batch_async(problem, decisions, concurrency).await;
all.extend(cands);
}
let front = pareto_front(&all, &objectives); let front = pareto_front(&all, &objectives);
let best = best_candidate(&all, &objectives); let best = best_candidate(&all, &objectives);
OptimizationResult::new( OptimizationResult::new(
+54
View File
@@ -0,0 +1,54 @@
//! Async-evaluable problems for IO-bound workloads.
//!
//! Most heuropt algorithms operate synchronously: their `Problem::evaluate`
//! returns immediately. For workloads where evaluation is *IO-bound* — calling
//! an HTTP service, querying a remote model, spawning a subprocess —
//! awaiting an async fn is much more efficient than blocking a worker
//! thread.
//!
//! [`AsyncProblem`] mirrors [`Problem`](crate::core::Problem) but its
//! `evaluate_async` returns a future. Algorithms that support async
//! evaluation (NSGA-II, DE, RandomSearch as of v0.7.0; others land
//! incrementally) expose a `run_async` method that drives evaluations
//! through a user-chosen async runtime (typically tokio).
//!
//! Available only with the `async` feature.
use std::future::Future;
use crate::core::evaluation::Evaluation;
use crate::core::objective::ObjectiveSpace;
/// A problem whose evaluation is async — useful when `evaluate` does
/// IO (HTTP, RPC, subprocess) rather than pure CPU work.
///
/// Mirrors [`Problem`](crate::core::Problem) one-for-one except that
/// `evaluate_async` returns a future. The returned future must be
/// `Send` so the algorithm can run many evaluations concurrently
/// across a runtime's worker pool.
///
/// Implementors who already have a synchronous `Problem` can adapt
/// to `AsyncProblem` with a one-line wrapper:
///
/// ```ignore
/// impl AsyncProblem for MyProblem {
/// type Decision = <Self as Problem>::Decision;
/// fn objectives(&self) -> ObjectiveSpace { Problem::objectives(self) }
/// async fn evaluate_async(&self, x: &Self::Decision) -> Evaluation {
/// Problem::evaluate(self, x)
/// }
/// }
/// ```
pub trait AsyncProblem: Sync {
/// The thing the optimizer changes. Same constraints as
/// [`Problem::Decision`](crate::core::Problem::Decision).
type Decision: Clone + Send + Sync;
/// Return the objectives for this problem.
fn objectives(&self) -> ObjectiveSpace;
/// Evaluate `decision` asynchronously. The returned future is
/// driven by whichever runtime the algorithm's `run_async` is
/// invoked from.
fn evaluate_async(&self, decision: &Self::Decision) -> impl Future<Output = Evaluation> + Send;
}
+4
View File
@@ -1,5 +1,7 @@
//! Concrete data types and the `Problem` trait that the rest of the crate is built on. //! Concrete data types and the `Problem` trait that the rest of the crate is built on.
#[cfg(feature = "async")]
pub mod async_problem;
pub mod candidate; pub mod candidate;
pub mod evaluation; pub mod evaluation;
pub mod objective; pub mod objective;
@@ -9,6 +11,8 @@ pub mod problem;
pub mod result; pub mod result;
pub mod rng; pub mod rng;
#[cfg(feature = "async")]
pub use async_problem::AsyncProblem;
pub use candidate::*; pub use candidate::*;
pub use evaluation::*; pub use evaluation::*;
pub use objective::*; pub use objective::*;
+5
View File
@@ -34,6 +34,11 @@ impl<D> Population<D> {
self.candidates.iter() self.candidates.iter()
} }
/// View the candidates as a slice.
pub fn as_slice(&self) -> &[Candidate<D>] {
&self.candidates
}
/// Unwrap into the inner `Vec<Candidate<D>>`. /// Unwrap into the inner `Vec<Candidate<D>>`.
pub fn into_vec(self) -> Vec<Candidate<D>> { pub fn into_vec(self) -> Vec<Candidate<D>> {
self.candidates self.candidates
+1
View File
@@ -68,6 +68,7 @@ pub mod algorithms;
pub mod core; pub mod core;
pub(crate) mod internal; pub(crate) mod internal;
pub mod metrics; pub mod metrics;
pub mod observer;
pub mod operators; pub mod operators;
pub mod pareto; pub mod pareto;
pub mod prelude; pub mod prelude;
+207
View File
@@ -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<D>(
approximation: &[Candidate<D>],
reference: &[Evaluation],
objectives: &ObjectiveSpace,
) -> f64 {
assert!(
!reference.is_empty(),
"igd: reference set must not be empty"
);
let approx_oriented: Vec<Vec<f64>> = 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::<f64>()
.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<D>(
approximation: &[Candidate<D>],
reference: &[Evaluation],
objectives: &ObjectiveSpace,
) -> f64 {
assert!(
!reference.is_empty(),
"igd_plus: reference set must not be empty"
);
let approx_oriented: Vec<Vec<f64>> = 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::<f64>()
.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<f64>) -> 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);
}
}
+4
View File
@@ -1,7 +1,11 @@
//! Quality metrics for Pareto fronts. //! Quality metrics for Pareto fronts.
pub mod hypervolume; pub mod hypervolume;
pub mod igd;
pub mod r2;
pub mod spacing; pub mod spacing;
pub use hypervolume::*; pub use hypervolume::*;
pub use igd::{igd, igd_plus};
pub use r2::r2;
pub use spacing::*; pub use spacing::*;
+173
View File
@@ -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<D>(
approximation: &[Candidate<D>],
weights: &[Vec<f64>],
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::<f64>() > 0.0, "r2: weight {i} has zero sum");
}
// Convert all approximation members to minimization orientation once.
let oriented: Vec<Vec<f64>> = 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<f64>) -> 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<Candidate<()>> = (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);
}
}
+418
View File
@@ -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<D> Observer<D> 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<D> Observer<D> 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<D> Observer<D> 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<f64>,
}
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<D> Observer<D> 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<A, B> {
pub a: A,
pub b: B,
}
impl<D, A, B> Observer<D> for AnyOf<A, B>
where
A: Observer<D>,
B: Observer<D>,
{
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<A, B> {
pub a: A,
pub b: B,
}
impl<D, A, B> Observer<D> for AllOf<A, B>
where
A: Observer<D>,
B: Observer<D>,
{
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<F> {
pub every: usize,
counter: usize,
pub callback: F,
}
impl<F> Periodic<F> {
pub fn new(every: usize, callback: F) -> Self {
assert!(every >= 1, "Periodic every must be >= 1");
Self {
every,
counter: 0,
callback,
}
}
}
impl<D, F> Observer<D> for Periodic<F>
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<D> Observer<D> 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<Candidate<()>> = 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<Candidate<()>> = 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<Candidate<()>> = 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<Candidate<()>> = 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<Candidate<()>> = 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<Candidate<()>> = vec![];
let cand = Candidate::new((), Evaluation::new(vec![5.0]));
let mut o =
<MaxIterations as Observer<()>>::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<Candidate<()>> = 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<Candidate<()>> = 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());
}
}
+101
View File
@@ -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<D> {
/// 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<O: Observer<D>>(self, other: O) -> builtin::AnyOf<Self, O>
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<O: Observer<D>>(self, other: O) -> builtin::AllOf<Self, O>
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<D> Observer<D> 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<D, F> Observer<D> 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<D>],
pareto_front: Option<&'a [Candidate<D>]>,
best: Option<&'a Candidate<D>>,
objectives: &'a ObjectiveSpace,
) -> Snapshot<'a, D> {
Snapshot {
iteration,
evaluations,
elapsed,
population,
pareto_front,
best,
objectives,
}
}
+45
View File
@@ -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<D>],
/// The current Pareto front, if the algorithm tracks one. `None`
/// for single-objective algorithms.
pub pareto_front: Option<&'a [Candidate<D>]>,
/// 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<D>>,
/// The objective space, useful for observers that need to convert
/// raw objective values to minimization-oriented form.
pub objectives: &'a ObjectiveSpace,
}
+10
View File
@@ -4,6 +4,8 @@
//! use heuropt::prelude::*; //! use heuropt::prelude::*;
//! ``` //! ```
#[cfg(feature = "async")]
pub use crate::core::async_problem::AsyncProblem;
pub use crate::core::{ pub use crate::core::{
Candidate, Direction, Evaluation, Objective, ObjectiveSpace, OptimizationResult, Candidate, Direction, Evaluation, Objective, ObjectiveSpace, OptimizationResult,
PartialProblem, Population, Problem, Rng, rng_from_seed, PartialProblem, Population, Problem, Rng, rng_from_seed,
@@ -11,6 +13,14 @@ pub use crate::core::{
pub use crate::traits::{Initializer, Optimizer, Repair, Variation}; 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::{ pub use crate::pareto::{
Dominance, ParetoArchive, best_candidate, crowding_distance, das_dennis, non_dominated_sort, Dominance, ParetoArchive, best_candidate, crowding_distance, das_dennis, non_dominated_sort,
pareto_compare, pareto_front, pareto_compare, pareto_front,
+56 -3
View File
@@ -1,18 +1,71 @@
//! The single trait users implement to add a new optimizer. //! The single trait users implement to add a new optimizer.
use std::time::{Duration, Instant};
use crate::core::problem::Problem; use crate::core::problem::Problem;
use crate::core::result::OptimizationResult; use crate::core::result::OptimizationResult;
use crate::observer::{Observer, Snapshot};
/// An optimizer that runs to completion in a single call. /// An optimizer that runs to completion in a single call.
/// ///
/// Implementations own their main loop, manage their own state, and return an /// Implementations own their main loop, manage their own state, and return an
/// [`OptimizationResult`]. v1 deliberately does not expose a step-by-step API /// [`OptimizationResult`]. Invalid configuration panics with a clear
/// or an associated error type — invalid configuration may panic with a clear /// message rather than returning a `Result`.
/// message.
pub trait Optimizer<P> pub trait Optimizer<P>
where where
P: Problem, P: Problem,
{ {
/// Run the optimizer to completion against `problem`. /// Run the optimizer to completion against `problem`.
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision>; fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision>;
/// 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<O>(&mut self, problem: &P, observer: &mut O) -> OptimizationResult<P::Decision>
where
O: Observer<P::Decision>,
{
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<P, O>(
result: &OptimizationResult<P::Decision>,
elapsed: Duration,
problem: &P,
observer: &mut O,
) where
P: Problem,
O: Observer<P::Decision>,
{
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);
} }