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.
This commit is contained in:
2026-05-05 15:07:05 -06:00
parent fa3f2e8fb0
commit b0f580841d
16 changed files with 1381 additions and 36 deletions
+69 -13
View File
@@ -108,6 +108,16 @@ where
V: Variation<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!(
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<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 ---
let mut offspring_decisions: Vec<P::Decision> = 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<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.
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);
OptimizationResult::new(
Population::new(final_pop),
front,
best,
evaluations,
self.config.generations,
)
finalize_nsga2(annotated, &objectives, evaluations, self.config.generations)
}
}
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(
Population::new(final_pop),
front,
best,
evaluations,
generations,
)
}
fn annotate<D: Clone>(
population: Vec<Candidate<D>>,
objectives: &crate::core::objective::ObjectiveSpace,