From 57a43c260e10227b6970f63f727ed6963ab74c58 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 6 May 2026 10:31:50 -0600 Subject: [PATCH] =?UTF-8?q?docs:=200.8.0=20release=20polish=20=E2=80=94=20?= =?UTF-8?q?README,=20guide,=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 81 +++++++-- README.md | 195 +++++++++++++++------ SECURITY.md | 4 +- docs/book/src/choosing-an-algorithm.md | 8 +- docs/book/src/comparison.md | 11 +- docs/book/src/cookbook/custom-optimizer.md | 7 +- docs/book/src/cookbook/parallel.md | 12 +- docs/book/src/getting-started.md | 146 +++++++++------ docs/book/src/introduction.md | 11 +- docs/book/src/migration.md | 58 ++++++ docs/book/src/stability.md | 25 ++- src/lib.rs | 8 +- 12 files changed, 418 insertions(+), 148 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7397dbe..f8bbe87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6ebd745..f842b5b 100644 --- a/README.md +++ b/README.md @@ -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.0–6.0 L), **curb weight** (1100–2200 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 +Das–Dennis reference points to keep the front well-spread. ```rust use heuropt::prelude::*; -struct SchafferN1; +struct PickACar; -impl Problem for SchafferN1 { - type Decision = Vec; +impl Problem for PickACar { + type Decision = Vec; // [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) -> 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; -# fn objectives(&self) -> ObjectiveSpace { -# ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]) -# } -# fn evaluate(&self, x: &Vec) -> 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!() } } ``` diff --git a/SECURITY.md b/SECURITY.md index 55b5072..a8d58dc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/docs/book/src/choosing-an-algorithm.md b/docs/book/src/choosing-an-algorithm.md index 0437320..fb472f1 100644 --- a/docs/book/src/choosing-an-algorithm.md +++ b/docs/book/src/choosing-an-algorithm.md @@ -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 | diff --git a/docs/book/src/comparison.md b/docs/book/src/comparison.md index 9e79263..c8919c0 100644 --- a/docs/book/src/comparison.md +++ b/docs/book/src/comparison.md @@ -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` diff --git a/docs/book/src/cookbook/custom-optimizer.md b/docs/book/src/cookbook/custom-optimizer.md index 73d224d..39ff7a1 100644 --- a/docs/book/src/cookbook/custom-optimizer.md +++ b/docs/book/src/cookbook/custom-optimizer.md @@ -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

` 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 diff --git a/docs/book/src/cookbook/parallel.md b/docs/book/src/cookbook/parallel.md index b8b16d0..9be7aaa 100644 --- a/docs/book/src/cookbook/parallel.md +++ b/docs/book/src/cookbook/parallel.md @@ -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 diff --git a/docs/book/src/getting-started.md b/docs/book/src/getting-started.md index b7ea406..fda4900 100644 --- a/docs/book/src/getting-started.md +++ b/docs/book/src/getting-started.md @@ -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`, `Vec`, …), 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; +impl Problem for LineFit { + type Decision = Vec; // [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) -> 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; -# fn objectives(&self) -> ObjectiveSpace { -# ObjectiveSpace::new(vec![Objective::minimize("f")]) -# } -# fn evaluate(&self, x: &Vec) -> Evaluation { -# Evaluation::new(vec![x.iter().map(|v| v * v).sum::()]) -# } -# } -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 diff --git a/docs/book/src/introduction.md b/docs/book/src/introduction.md index 1d423f7..ee11a4c 100644 --- a/docs/book/src/introduction.md +++ b/docs/book/src/introduction.md @@ -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, Das–Dennis 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: diff --git a/docs/book/src/migration.md b/docs/book/src/migration.md index d9c2c86..0a6ef7e 100644 --- a/docs/book/src/migration.md +++ b/docs/book/src/migration.md @@ -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 diff --git a/docs/book/src/stability.md b/docs/book/src/stability.md index 0da4dd7..d0f26af 100644 --- a/docs/book/src/stability.md +++ b/docs/book/src/stability.md @@ -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

` 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

` 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 diff --git a/src/lib.rs b/src/lib.rs index d09a3da..ab5bea7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 //!