docs: 0.8.0 release polish — README, guide, changelog

Companion to the feat(async) commit. Brings every cross-referencing
doc up to v0.8 currency, replaces marketing-flavored copy with plain
prose, and replaces toy benchmark problems with relatable ones that
include actual run output and interpretive narrative.

- README: collapses the four-bullet "Read the user guide / API
  reference / Tested with N tests / Hot paths optimized" list into
  a single Docs links line.
- README: replaces the Schaffer-N1 toy problem with a PickACar
  multi-objective design problem — three decision variables
  (displacement, weight, drag), four objectives (price, 0-60,
  fuel, noise), and *nonlinear* cost relationships so the Pareto
  front is a real surface, not a 1D sweep. Includes actual NSGA-III
  run output (representative slice across the 100-car front) and
  a narrative explaining what each row tells you and why hand-
  picking would miss the interesting tradeoffs.
- README: removes rustdoc-style hidden `#` setup lines from code
  blocks. The README is rendered as plain markdown on GitHub /
  crates.io, where those lines are visible garbage instead of
  hidden setup. Code blocks are now self-contained.
- Guide quickstart (getting-started.md): replaces Sphere ( Σ x² )
  with a least-squares LineFit example. Same shape (single-
  objective continuous), but recognizable framing. Includes
  actual CMA-ES output, residual table, and narrative comparing
  the answer to standard regression.
- Algorithm count audit: stale "35 algorithms" claim corrected to
  the actual 33 across README, src/lib.rs, introduction.md, and
  the comparison.md table cell.
- Async feature flag listed in the optional-features sections of
  README, src/lib.rs, getting-started.md.
- introduction.md, choosing-an-algorithm.md, comparison.md,
  stability.md, migration.md, cookbook/parallel.md,
  cookbook/custom-optimizer.md: cross-references updated to
  describe full async coverage and link the new cookbook recipe.
- stability.md: removes the speculative "Observer / Snapshot /
  Checkpoint planned" bullet (those didn't ship); documents the
  AsyncProblem / AsyncPartialProblem trait stability.
- migration.md: new "To 0.8" section with paths from 0.5.x and 0.7.x.
- CHANGELOG: 0.8.0 entry capturing the async feature plus the
  documentation / governance / CI catch-up.
- SECURITY.md: supported versions table reflects 0.8.x.
This commit is contained in:
2026-05-06 11:51:13 -06:00
parent cbfedd85fa
commit 57a43c260e
12 changed files with 418 additions and 148 deletions
+63 -18
View File
@@ -9,39 +9,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.8.0] — 2026-05-06
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.
Theme: async evaluation, plus the docs / governance / CI catch-up
that came with finalizing the release.
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 / optuna / DEAP / MOEA Framework, none of which
ship first-class async support at the *evaluation* level.
No public-API breaks for synchronous users. The new surface is
gated behind a new `async` feature flag.
> **Note on version numbers.** Versions 0.6.0 and 0.7.0 were
> published on crates.io but contained experimental observability
> APIs and metrics that were rolled back. Both are yanked. 0.8.0
> picks up cleanly from 0.5.0 with just the async additions; if
> you were on 0.5.x, upgrading to 0.8 is a feature-additive bump.
### Added
#### Async evaluation (the headline feature)
- 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.
- `core::async_problem::AsyncPartialProblem` trait — mirrors
`PartialProblem` for multi-fidelity (Hyperband) workloads with
`async fn evaluate_at_budget_async(decision, budget)`.
- 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.
**every** algorithm in the catalog — all 33 of them — driving
evaluations through whichever async runtime the caller is using
(typically tokio). `concurrency` bounds in-flight evaluations.
Population-based algorithms (NSGA-II, NSGA-III, SPEA2, MOEA/D,
CMA-ES, DE, GA, PSO, IBEA, SMS-EMOA, HypE, ε-MOEA, PESA-II,
AGE-MOEA, KnEA, GrEA, RVEA, MOPSO, TLBO, IPOP-CMA-ES, sNES, UMDA,
Ant Colony, GA, Random Search) fan out per generation. Steady-state
algorithms (Hill Climber, SA, (1+1)-ES, PAES, Nelder-Mead, Tabu
Search) await each step sequentially. Surrogate algorithms (BO,
TPE) batch the initial design and then await per-iteration
acquisitions. Hyperband fans out each Successive-Halving rung
through `AsyncPartialProblem`.
- 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.
and `evaluate_batch_at_budget_async` helpers — use
`futures::stream::FuturesOrdered` with concurrency-bounded chunks,
preserve 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.
it's 2× faster; demonstrates `DifferentialEvolution` under tokio.
#### Documentation
- New cookbook recipe **[Async evaluation](docs/book/src/cookbook/async.md)**
— implementing `AsyncProblem`, picking concurrency, determinism
guarantees, async vs. `parallel`.
- Comparison-with-other-libraries chapter updated: `heuropt 0.8`
row, `Async ✅ AsyncProblem + run_async` column, "When to pick
heuropt" gains an explicit IO-bound bullet.
- Stability chapter rewritten: removes the speculative "Observer /
Checkpoint planned" bullet (those didn't ship), documents the new
`async` feature flag.
- Migration guide: new "To 0.8" section covering both
`0.5.x → 0.8` (feature-additive — opt in by enabling the `async`
feature) and `0.7 → 0.8` (the partial async surface from 0.7 is
superseded by complete coverage; existing `run_async` callers
keep working).
- Runnable `cargo test --doc` examples added to every public
operator (10), metric (3), and Pareto utility (7) — every
public item across the crate now ships with at least one
example. 55 doctests in total (was 45).
#### CI / build
- `.github/workflows/docs.yml` builds the mdbook user guide on
every push and deploys to GitHub Pages on `main` /
tag pushes.
- `mdbook` book now uses `[rust] edition = "2021"` to satisfy
`mdbook 0.4.40`.
- `clamp_to_bounds` cargo-fuzz target tolerance loosened to
`1e-4 · max(simplex_total, max_abs_x, 1)` so the fuzzer doesn't
flag ULP-level slop in the simplex projection's
`max(x_i τ, 0)` clamp boundary.
[0.8.0]: https://github.com/swaits/heuropt/releases/tag/v0.8.0
+144 -51
View File
@@ -7,85 +7,184 @@
[![CI](https://github.com/swaits/heuropt/actions/workflows/ci.yml/badge.svg)](https://github.com/swaits/heuropt/actions/workflows/ci.yml)
**A practical Rust toolkit for heuristic optimization.** Single-objective.
Multi-objective. Many-objective. 35 algorithms. One small set of traits.
Bit-identical seeded determinism. No trait objects, no GATs, no generic-RNG
plumbing in the public API.
Multi-objective. Many-objective. 33 algorithms — every one of them with a
sync `run` and an async `run_async`. One small set of traits. Bit-identical
seeded determinism. No trait objects, no GATs, no generic-RNG plumbing in
the public API.
If you can write a `Problem` impl and read `RandomSearch`, you can write your
own optimizer. That's the whole pitch.
- 📖 **Read the [user guide](https://swaits.github.io/heuropt/)** for tutorials,
cookbook recipes, comparison with pymoo / hyperopt / MOEA Framework, and
stability policy.
- 🔧 **[API reference on docs.rs](https://docs.rs/heuropt)** has runnable
` ```rust ` examples on every algorithm.
- 🧪 Tested with **316+ unit / integration / property tests** plus 8
cargo-fuzz targets running on every PR.
- ⚡ Hot paths heavily optimized — comparison harness 3.27× faster as of
v0.4.0, all bit-identical to the reference output.
Docs: [user guide](https://swaits.github.io/heuropt/) · [API reference](https://docs.rs/heuropt).
## Installation
```toml
[dependencies]
heuropt = "0.5"
heuropt = "0.8"
# Optional features:
# - "serde": derive Serialize/Deserialize on the core data types.
# - "parallel": evaluate populations across rayon's thread pool.
# Seeded runs stay bit-identical to serial mode.
# heuropt = { version = "0.5", features = ["serde", "parallel"] }
# - "async": AsyncProblem / AsyncPartialProblem traits and a
# run_async(&problem, concurrency).await method on
# every algorithm — for IO-bound evaluations.
# heuropt = { version = "0.8", features = ["serde", "parallel", "async"] }
```
## Define a problem
## Define a problem and run an optimizer
You're designing a car. Three things you can pick: **engine
displacement** (1.06.0 L), **curb weight** (11002200 kg, where
going lighter requires aluminum/carbon and costs money), and
**aerodynamic drag** (Cd from 0.20 to 0.40, where slipperier needs
expensive aero R&D). Four things you want to optimize: **price**,
**0-60 acceleration**, **fuel consumption**, **idle noise** — all
in tension.
The relationships between decisions and objectives are nonlinear
and coupled: engine cost grows superlinearly with displacement,
weight reduction below 1500 kg costs a quadratic premium, drag
reduction below 0.35 Cd costs a 1.5-power premium, and 0-60 depends
on weight × engine in a non-trivial way. You can't just sweep one
slider — the Pareto front is a genuine surface in 3D decision space,
and finding it by hand is hopeless.
NSGA-III is the canonical many-objective (4+) optimizer; it uses
DasDennis reference points to keep the front well-spread.
```rust
use heuropt::prelude::*;
struct SchafferN1;
struct PickACar;
impl Problem for SchafferN1 {
type Decision = Vec<f64>;
impl Problem for PickACar {
type Decision = Vec<f64>; // [engine_liters, weight_kg, drag_cd]
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("f1"),
Objective::minimize("f2"),
Objective::minimize("price_thousand_dollars"),
Objective::minimize("seconds_to_60mph"),
Objective::minimize("fuel_gallons_per_100mi"),
Objective::minimize("noise_db_at_idle"),
])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
let v = x[0];
Evaluation::new(vec![v * v, (v - 2.0).powi(2)])
let displacement = x[0]; // liters
let weight = x[1]; // kg
let drag = x[2]; // dimensionless Cd
// Price ($k): engine cost grows superlinearly; weight reduction
// below 1500 kg and drag reduction below 0.35 Cd both cost extra.
let engine_cost = 3.0 * displacement.powf(1.6);
let weight_cost = ((1500.0 - weight).max(0.0) / 100.0).powi(2) * 2.0;
let aero_cost = ((0.35 - drag).max(0.0) * 100.0).powf(1.5) * 0.4;
let price = 10.0 + engine_cost + weight_cost + aero_cost;
// 0-60 (s): heavier = slower; bigger engine = quicker but with
// diminishing returns.
let weight_factor = (weight - 1100.0) / 1000.0;
let engine_factor = ((displacement - 1.0) / 5.0).max(0.0).powf(0.7);
let zero_to_sixty = 5.0 + 5.0 * weight_factor - 4.0 * engine_factor;
// Fuel consumption (gal/100 mi): all three matter.
let fuel = 0.5 + 0.5 * displacement + 0.5 * weight / 1000.0 + 4.0 * drag;
// Idle noise (dB): engine dominates, mildly nonlinear.
let noise = 60.0 + 3.0 * displacement.powf(1.2);
Evaluation::new(vec![price, zero_to_sixty, fuel, noise])
}
}
fn main() {
let bounds = vec![
(1.0_f64, 6.0_f64), // engine
(1100.0_f64, 2200.0_f64), // weight
(0.20_f64, 0.40_f64), // drag
];
let mut optimizer = Nsga3::new(
Nsga3Config {
population_size: 100,
generations: 200,
reference_divisions: 5,
seed: 42,
},
RealBounds::new(bounds.clone()),
CompositeVariation {
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.9),
mutation: PolynomialMutation::new(bounds, 20.0, 1.0 / 3.0),
},
);
let result = optimizer.run(&PickACar);
let mut front: Vec<_> = result.pareto_front.iter().collect();
front.sort_by(|a, b| {
a.evaluation.objectives[0]
.partial_cmp(&b.evaluation.objectives[0]).unwrap()
});
println!("{:>5} {:>5} {:>4} {:>6} {:>5} {:>5} {:>5}",
"L", "kg", "Cd", "$k", "0-60", "fuel", "dB");
for c in &front {
let d = &c.decision;
let o = &c.evaluation.objectives;
println!("{:>5.2} {:>5.0} {:>4.2} {:>6.1} {:>5.1} {:>5.2} {:>5.1}",
d[0], d[1], d[2], o[0], o[1], o[2], o[3]);
}
}
```
## Run NSGA-II
Run it (`cargo run --release`) and you get 100 cars on the front.
A representative slice from the actual output, hand-picked across
the spectrum:
```rust
use heuropt::prelude::*;
# struct SchafferN1;
# impl Problem for SchafferN1 {
# type Decision = Vec<f64>;
# fn objectives(&self) -> ObjectiveSpace {
# ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
# }
# fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
# Evaluation::new(vec![x[0] * x[0], (x[0] - 2.0).powi(2)])
# }
# }
let initializer = RealBounds::new(vec![(-5.0, 5.0)]);
let variation = GaussianMutation { sigma: 0.2 };
let config = Nsga2Config { population_size: 60, generations: 80, seed: 42 };
let mut optimizer = Nsga2::new(config, initializer, variation);
let result = optimizer.run(&SchafferN1);
println!("Pareto front size: {}", result.pareto_front.len());
```text
L kg Cd $k 0-60 fuel dB ← role
1.00 1505 0.35 13.0 7.0 3.17 63.0 cheap baseline
2.00 1370 0.35 22.4 5.1 3.56 66.7 sensible sport sedan
2.45 1330 0.38 28.5 4.5 3.92 68.8 quicker midprice
1.00 1430 0.21 35.8 6.6 2.54 63.0 fuel-saver (small + slippery)
3.50 1300 0.25 52.9 3.5 3.88 73.3 genuine sports car
5.27 1100 0.20 108.1 1.4 4.48 82.0 hypercar corner
```
See `examples/toy_nsga2.rs` for the full version.
### Reading the result
Every row is **non-dominated** — no row is strictly better than
another on every metric. The interesting part is what each one does
*differently*:
- The **cheap baseline** ($13k) takes the path of least resistance:
smallest engine, no weight reduction, average drag. Slow but
affordable.
- The **sensible sedan** ($22k) trades $9k for **2 seconds off
0-60** by running a 2.0L engine with mild weight reduction.
- The **fuel-saver** is interesting: it's a 1.0L econobox engine,
but it spends $22k *just on aero* (0.21 Cd) to push fuel
consumption down to **2.54 gal/100mi**. The optimizer figured
out that aero matters more than displacement at this fuel point.
No human would pick this combo by intuition.
- The **sports car** ($53k) doesn't blow money on the lightest
possible weight — it picks 1300 kg, because dropping further
costs disproportionately and the 3.5L engine is doing most of
the acceleration work.
- The **hypercar corner** ($108k) is the optimizer pushing every
decision to its ceiling: minimum weight (1100 kg), minimum
drag (0.20 Cd), big engine (5.3L). Sub-1.5 second 0-60, but
you pay for it on every other axis except fuel (because the
weight + aero savings partly cancel the V8's thirst).
That last point is the kind of insight a Pareto front gives you
that no single-objective optimizer would: **the cheapest fuel-
efficient car is not the smallest engine alone**, it's a small
engine + aggressive aero. **The lightest sports car is not the
lightest possible**, it's the point where weight cost stops paying
back in 0-60. The optimizer doesn't tell you what to buy — it
hands you the frontier of *every defensible compromise* and lets
you pick by your own priorities.
## Implement a custom optimizer
@@ -105,13 +204,7 @@ where
// Evaluate them with `problem.evaluate(...)`.
// Keep the best, or maintain a Pareto archive.
// Return an OptimizationResult.
# OptimizationResult::new(
# Population::new(Vec::new()),
# Vec::new(),
# None,
# 0,
# 0,
# )
todo!()
}
}
```
+2 -2
View File
@@ -8,8 +8,8 @@ needed.
| Version | Supported |
|---------|--------------------|
| 0.5.x | ✅ |
| ≤ 0.4.x | ❌ (please upgrade) |
| 0.8.x | ✅ |
| ≤ 0.7.x | ❌ (please upgrade) |
heuropt is pre-1.0; the public API may change between minor versions.
Once 1.0.0 ships, the support window will be at least the latest two
+7 -1
View File
@@ -222,9 +222,15 @@ evaluate via rayon when the feature is on. **Seeded runs stay
bit-identical** to serial mode.
```toml
heuropt = { version = "0.5", features = ["parallel"] }
heuropt = { version = "0.8", features = ["parallel"] }
```
If your evaluation is **IO-bound** (HTTP request, RPC, subprocess)
rather than CPU-bound, use the `async` feature instead — it gives
you `AsyncProblem` and a `run_async(&problem, concurrency).await`
method on every algorithm in the catalog. See the
[Async evaluation cookbook recipe](./cookbook/async.md).
## TL;DR table
| Situation | Pick |
+6 -5
View File
@@ -15,11 +15,11 @@ The columns:
| Library | Lang | Algorithms | Multi-obj | Surrogates | Determinism | Async |
|---|---|---|---|---|---|---|
| **heuropt 0.5** | Rust | 35 | ✅ NSGA-II/III, SPEA2, IBEA, MOEA/D, MOPSO, SMS-EMOA, HypE, AGE-MOEA, GrEA, KnEA, RVEA, PESA-II, ε-MOEA, PAES | ✅ BO, TPE, Hyperband | ✅ bit-identical seeded | ⏳ planned |
| **heuropt 0.8** | Rust | 33 | ✅ NSGA-II/III, SPEA2, IBEA, MOEA/D, MOPSO, SMS-EMOA, HypE, AGE-MOEA, GrEA, KnEA, RVEA, PESA-II, ε-MOEA, PAES | ✅ BO, TPE, Hyperband | ✅ bit-identical seeded | `AsyncProblem` + `run_async` on every algorithm |
| pymoo | Python | ~25 | ✅ extensive | partial (BO via plug-ins) | ✅ | ❌ |
| DEAP | Python | flexible toolbox | ✅ | ❌ | ✅ | ❌ |
| hyperopt | Python | TPE-focused | ❌ | ✅ TPE | partial | partial |
| optuna | Python | TPE / CMA-ES / NSGA-II | ✅ | ✅ TPE, BoTorch via plug-in | ✅ | |
| optuna | Python | TPE / CMA-ES / NSGA-II | ✅ | ✅ TPE, BoTorch via plug-in | ✅ | partial (study-level, not eval-level) |
| MOEA Framework | Java | ~40 | ✅ very extensive | ❌ | ✅ | ❌ |
| metaheuristics-rs | Rust | ~10 | partial | ❌ | ✅ | ❌ |
| argmin | Rust | line-search / quasi-Newton | ❌ | ❌ | ✅ | ❌ |
@@ -38,12 +38,13 @@ The columns:
written for clarity, no trait-object plumbing, no GATs in user-
facing APIs. Reading `RandomSearch` should be enough to write a
new optimizer.
- You have **IO-bound evaluations** — calling an HTTP service, an
RPC, or a subprocess — and want first-class `async fn evaluate`
support. heuropt is the only mainstream optimization library that
ships this (see [Async evaluation](./cookbook/async.md)).
## When *not* to pick heuropt
- You need **first-class async / await** for evaluations that talk to
HTTP services or spawn subprocesses. heuropt is sync; that's on
the roadmap but not shipping yet.
- You need **gradient-based** optimization. Use `argmin` (Rust) or
`scipy.optimize` (Python) — heuropt is gradient-free by design.
- You need **GPU-accelerated** evaluations. heuropt's `evaluate`
+5 -2
View File
@@ -134,8 +134,11 @@ parallel.
result.
- **No error type.** Invalid configuration panics with a clear
message; this matches the style of the built-in algorithms.
- **No async.** `evaluate` is synchronous; for async work, drive it
on a tokio runtime around the optimizer loop yourself.
- **No async on the trait.** `Optimizer<P>` is synchronous. For
async evaluation, implement [`AsyncProblem`](https://docs.rs/heuropt/latest/heuropt/core/async_problem/trait.AsyncProblem.html)
on your problem and use the `run_async(&problem, concurrency)`
method that comes with the `async` feature. See the
[Async evaluation cookbook recipe](./async.md).
The smallness is the point: you should be able to read a built-in
algorithm and write your own in an afternoon. See
+11 -1
View File
@@ -9,7 +9,7 @@ population, and rayon parallelizes that batch.
```toml
[dependencies]
heuropt = { version = "0.5", features = ["parallel"] }
heuropt = { version = "0.8", features = ["parallel"] }
```
There's nothing else to opt into in your code. The
@@ -104,6 +104,16 @@ to scope it.
parallelism rarely helps.
- The algorithm is steady-state (Paes, SA, hill climber).
## `parallel` vs `async`
| If your `evaluate` is… | Use |
|---|---|
| CPU-bound (math, simulation) | `parallel` feature (this recipe) |
| IO-bound (HTTP, RPC, subprocess) | `async` feature → see [Async evaluation](./async.md) |
Both can be on at once if your evaluation does *both* substantial
CPU work *and* IO. The two features are independent.
[`RandomSearch`]: https://docs.rs/heuropt/latest/heuropt/algorithms/random_search/struct.RandomSearch.html
[`Nsga2`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga2/struct.Nsga2.html
[`Nsga3`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga3/struct.Nsga3.html
+93 -53
View File
@@ -6,7 +6,7 @@ The shortest path from a fresh project to a working optimizer.
```toml
[dependencies]
heuropt = "0.5"
heuropt = "0.8"
```
The default feature set is small. Optional features:
@@ -14,86 +14,126 @@ The default feature set is small. Optional features:
- `parallel` — rayon-backed parallel population evaluation.
- `serde``Serialize` / `Deserialize` derives on the core data
types.
- `async``AsyncProblem` trait + per-algorithm `run_async` for
IO-bound evaluations.
```toml
heuropt = { version = "0.5", features = ["parallel"] }
heuropt = { version = "0.8", features = ["parallel"] }
```
## 2. Define a problem
## 2. Define a problem and run an optimizer
A problem is a struct that implements the [`Problem`] trait. You tell
heuropt what kind of decision your problem takes (`Vec<f64>`,
`Vec<bool>`, …), what objectives it has (minimize or maximize), and
how to score one decision.
We'll fit a straight line to a handful of `(x, y)` data points by
finding the slope and intercept that minimize the sum of squared
errors — same objective as least-squares regression. For a smooth
single-objective continuous problem like this, [`CmaEs`] is a strong
default.
```rust,no_run
use heuropt::prelude::*;
struct Sphere;
struct LineFit {
points: Vec<(f64, f64)>,
}
impl Problem for Sphere {
type Decision = Vec<f64>;
impl Problem for LineFit {
type Decision = Vec<f64>; // [slope, intercept]
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f")])
ObjectiveSpace::new(vec![Objective::minimize("sum_squared_error")])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
let f: f64 = x.iter().map(|v| v * v).sum();
Evaluation::new(vec![f])
let (slope, intercept) = (x[0], x[1]);
let sse: f64 = self
.points
.iter()
.map(|(px, py)| (py - (slope * px + intercept)).powi(2))
.sum();
Evaluation::new(vec![sse])
}
}
fn main() {
// Five noisy points roughly on the line y = 2x + 1.
let problem = LineFit {
points: vec![(0.0, 1.1), (1.0, 2.9), (2.0, 5.1), (3.0, 6.8), (4.0, 9.2)],
};
// Search box: slope and intercept each in [-10, 10].
let bounds = RealBounds::new(vec![(-10.0, 10.0); 2]);
let mut opt = CmaEs::new(
CmaEsConfig {
population_size: 12,
generations: 80,
initial_sigma: 1.0,
eigen_decomposition_period: 1,
initial_mean: None,
seed: 42,
},
bounds,
);
let result = opt.run(&problem);
let best = result.best.expect("at least one feasible candidate");
let (slope, intercept) = (best.decision[0], best.decision[1]);
println!(
"best fit: y = {:.4} x + {:.4} (sse = {:.4e}, evaluations = {})",
slope, intercept, best.evaluation.objectives[0], result.evaluations,
);
println!();
println!("predictions vs actual:");
for (px, py) in &problem.points {
let pred = slope * px + intercept;
println!(
" x = {:.1} actual = {:.2} predicted = {:.4} residual = {:+.4}",
px, py, pred, py - pred,
);
}
}
```
The Sphere function is a single-objective continuous problem: minimize
`f(x) = Σ xᵢ²`. The optimum is `x = 0`, `f = 0`.
## 3. Pick an algorithm and run it
For a smooth single-objective continuous problem, [`CmaEs`] is a
strong default. Configure it, build it, run it.
```rust,no_run
# use heuropt::prelude::*;
# struct Sphere;
# impl Problem for Sphere {
# type Decision = Vec<f64>;
# fn objectives(&self) -> ObjectiveSpace {
# ObjectiveSpace::new(vec![Objective::minimize("f")])
# }
# fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
# Evaluation::new(vec![x.iter().map(|v| v * v).sum::<f64>()])
# }
# }
let bounds = RealBounds::new(vec![(-5.0, 5.0); 5]); // 5-dim search box
let mut opt = CmaEs::new(
CmaEsConfig {
population_size: 12,
generations: 80,
initial_sigma: 1.0,
eigen_decomposition_period: 1,
initial_mean: None,
seed: 42,
},
bounds,
);
let result = opt.run(&Sphere);
let best = result.best.expect("at least one feasible candidate");
println!("best f = {:.3e} at x = {:?}", best.evaluation.objectives[0], best.decision);
```
Run with `cargo run --release` — heuristic optimization is allergic
to debug builds. Expect output like:
to debug builds. The actual output:
```text
best f = 1.4e-29 at x = [-1.6e-15, 4.5e-16, ...]
best fit: y = 2.0100 x + 1.0000 (sse = 1.0700e-1, evaluations = 960)
predictions vs actual:
x = 0.0 actual = 1.10 predicted = 1.0000 residual = +0.1000
x = 1.0 actual = 2.90 predicted = 3.0100 residual = -0.1100
x = 2.0 actual = 5.10 predicted = 5.0200 residual = +0.0800
x = 3.0 actual = 6.80 predicted = 7.0300 residual = -0.2300
x = 4.0 actual = 9.20 predicted = 9.0400 residual = +0.1600
```
CMA-ES drops to machine epsilon on the Sphere in well under 80
generations.
### Reading the result
CMA-ES recovered **slope ≈ 2.01, intercept ≈ 1.00** — within
hundredths of the underlying line `y = 2x + 1` that the data was
sampled from. The residuals are evenly distributed in sign (3
positive, 2 negative) and small in magnitude (the largest is 0.23
at `x = 3`), which means the fit is balancing the noise rather than
chasing any single point.
The total **sum of squared errors is 0.107** — that is the value
the optimizer was actually minimizing, and it matches the answer
you'd get from running `numpy.polyfit` or solving the normal
equations directly. CMA-ES is overkill for a two-parameter problem
(closed-form least-squares does it in one step), but the **same
code shape** scales straight up to nonlinear models, robust loss
functions, or constrained variants where there is no closed form.
It used 960 evaluations to get there. That's `population_size × generations`
= 12 × 80 = 960, and CMA-ES converges to machine epsilon on
problems this clean in well under that budget.
## 4. What just happened
+10 -1
View File
@@ -44,7 +44,7 @@ hyperopt, optuna, DEAP). heuropt's design priorities:
## What's in the box
heuropt v0.5 ships **35 algorithms** spanning:
heuropt v0.8 ships **33 algorithms** spanning:
- Single-objective continuous: `RandomSearch`, `HillClimber`,
`OnePlusOneEs`, `SimulatedAnnealing`, `GeneticAlgorithm`,
@@ -65,6 +65,15 @@ ProjectToSimplex), the metrics (hypervolume, spacing), and the Pareto
utilities (dominance, fronts, crowding distance, DasDennis reference
points, the `ParetoArchive`) that you'd expect.
**Async evaluation** (since v0.8, behind the `async` feature flag):
when your `evaluate` function is IO-bound — calling an HTTP service,
an RPC, or a subprocess — implement [`AsyncProblem`] and use
`run_async(&problem, concurrency).await` on any algorithm in the
catalog. heuropt is the only mainstream optimization library with
first-class async support across its entire algorithm set.
[`AsyncProblem`]: https://docs.rs/heuropt/latest/heuropt/core/async_problem/trait.AsyncProblem.html
## How to use this guide
If you're new to heuropt, read it linearly:
+58
View File
@@ -3,6 +3,64 @@
Per-release notes for upgrading between heuropt versions. Skip the
sections that don't apply to your starting version.
## To 0.8
### From 0.5.x
**Additive feature only.** Bumping `heuropt = "0.8"` is enough for
any code that doesn't need async evaluation. To opt into async,
enable the new feature flag:
```toml
heuropt = { version = "0.8", features = ["async"] }
```
What changed:
- New `async` feature flag, gated on the
[`futures`](https://crates.io/crates/futures) crate.
- New `core::async_problem::AsyncProblem` trait — mirrors `Problem`
but with `async fn evaluate_async`.
- New `core::async_problem::AsyncPartialProblem` trait — mirrors
`PartialProblem` for multi-fidelity (Hyperband) workloads.
- `run_async(&problem, concurrency).await` on **every** algorithm in
the catalog (33 of them) for IO-bound evaluations.
- New cookbook recipe: [Async evaluation](./cookbook/async.md).
### From 0.7.x
`0.7.0` introduced an experimental observability layer (`Snapshot`,
`Observer`, `run_with`, `MaxTime`, `TargetFitness`, `Stagnation`,
`Periodic`, `AnyOf`, `AllOf`, `TracingObserver`) and three
additional Pareto metrics (`igd`, `igd_plus`, `r2`). All of those
were rolled back in `0.8.0` — the design didn't bake long enough
and they shipped half-wired (`run_with` was overridden on only 3 of
35 algorithms). The `tracing` feature flag is also gone.
If your code uses any of those APIs, the migration is:
- Remove all `run_with(&problem, &mut observer)` calls and replace
with `run(&problem)`.
- Remove all uses of `Observer`, `Snapshot`, `ControlFlow`,
`MaxTime`, `MaxIterations`, `TargetFitness`, `Stagnation`,
`Periodic`, `AnyOf`, `AllOf`, `TracingObserver`.
- Remove all uses of `metrics::igd::igd`, `metrics::igd::igd_plus`,
`metrics::r2::r2`.
- Remove `Population::as_slice()` calls (the method is gone).
- Drop the `tracing` feature from your `Cargo.toml` if you had it.
Stop conditions can still be implemented by wrapping `run` in a
loop with a custom RNG-driven termination, or by wrapping
the algorithm yourself; observers may return as a public API in a
future release once the design has settled.
The async work introduced in 0.7.0 (`AsyncProblem` + `run_async`)
**survived** and is broadened in 0.8: every algorithm in the catalog
now has a `run_async` (0.7.0 only had it on three of them), and
multi-fidelity problems get a parallel `AsyncPartialProblem` trait
that Hyperband's `run_async` consumes. Existing call sites continue
to work unchanged.
## To 0.5
### From 0.4.x
+12 -13
View File
@@ -18,10 +18,10 @@ versions — use them at your own risk.
While we are pre-1.0:
- **Minor bumps (`0.5 → 0.6`) may break the public API.** The
- **Minor bumps (`0.8 → 0.9`) may break the public API.** The
CHANGELOG calls out everything that changed, and a **migration
guide** in this book documents the move.
- **Patch bumps (`0.5.0 → 0.5.1`) only contain bug fixes,
- **Patch bumps (`0.8.0 → 0.8.1`) only contain bug fixes,
performance improvements, and additive non-breaking features.**
No deprecations, no removals.
@@ -29,24 +29,20 @@ While we are pre-1.0:
In rough order of likelihood:
1. **`Optimizer<P>` may grow new optional methods** for callbacks,
stop conditions, and save/resume support. These will land as
methods with default implementations so existing trait impls
keep compiling, but the trait shape will be different.
2. **Algorithm config structs may gain fields.** All current configs
1. **Algorithm config structs may gain fields.** All current configs
are public-field structs; adding a non-`Default` field is a
breaking change. We may switch to builder patterns to avoid this
class of break, or we may add `#[non_exhaustive]`.
3. **The `Snapshot`, `Observer`, and `Checkpoint` types** (planned
for a future release) will land as new public surfaces.
4. **Some operators may move between `operators` and `pareto`** as
2. **Some operators may move between `operators` and `pareto`** as
the boundary between "things that produce candidates" and "Pareto
utilities" gets clearer.
What is **not** likely to change:
- The `Problem` trait shape.
- The `AsyncProblem` / `AsyncPartialProblem` trait shapes.
- The `Variation` / `Initializer` / `Repair` traits.
- The `Optimizer<P>` trait — single `run` method, no callbacks.
- The `Evaluation` / `Candidate` / `Population` / `OptimizationResult`
data types.
- The seeded determinism property.
@@ -60,12 +56,12 @@ Across minor versions, output may change if an algorithm's
implementation changes (e.g. a perf rewrite that reorders
floating-point operations, or a new feature that changes the
RNG-consumption pattern). The CHANGELOG calls this out explicitly
when it happens. As of v0.5, the entire history of perf optimizations
has been bit-identical against the v0.3.0 reference.
when it happens. As of v0.8, the entire history of perf
optimizations has been bit-identical against the v0.3.0 reference.
## MSRV (minimum supported Rust version)
heuropt's MSRV is **1.85** as of v0.5. This is tested in CI against
heuropt's MSRV is **1.85** as of v0.8. This is tested in CI against
every PR.
MSRV bumps are treated as patch-bump-eligible (they don't break the
@@ -79,6 +75,9 @@ The current optional features:
- `serde` — adds `Serialize` / `Deserialize` derives on the core data
types.
- `parallel` — rayon-backed parallel population evaluation.
- `async``AsyncProblem` + `AsyncPartialProblem` traits, plus a
`run_async` method on every algorithm in the catalog, for
IO-bound evaluations.
Features added in 0.x can be renamed or removed in any minor bump
that documents the change. Removing a feature is treated like a
+7 -1
View File
@@ -4,7 +4,7 @@
//! The crate aims to make three things obvious:
//!
//! 1. **Define a problem** by implementing [`Problem`](crate::core::Problem).
//! 2. **Run a built-in optimizer** — pick from 35 algorithms in
//! 2. **Run a built-in optimizer** — pick from 33 algorithms in
//! [`algorithms`] covering single-objective continuous (CMA-ES,
//! Differential Evolution, Nelder-Mead, …), multi-objective
//! (NSGA-II, MOPSO, IBEA, MOEA/D, …), many-objective (NSGA-III,
@@ -32,6 +32,12 @@
//! - `parallel` — rayon-backed parallel population evaluation in
//! every population-based algorithm. Seeded runs stay bit-
//! identical to serial mode.
//! - `async` — adds the
//! [`AsyncProblem`](crate::core::async_problem::AsyncProblem) and
//! [`AsyncPartialProblem`](crate::core::async_problem::AsyncPartialProblem)
//! traits and a `run_async(&problem, concurrency).await` method on
//! every algorithm. Use this when your `evaluate` does IO (HTTP,
//! RPC, subprocess) — see the [Async evaluation cookbook recipe](https://swaits.github.io/heuropt/cookbook/async.html).
//!
//! # Quick example
//!