10 Commits
Author SHA1 Message Date
swaits 57a43c260e 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.
2026-05-06 11:51:13 -06:00
swaits cbfedd85fa feat(async): add run_async to every algorithm in the catalog
Async coverage was incomplete in 0.7 (only RandomSearch and
DifferentialEvolution had run_async). 0.8 closes the gap: every one
of the 33 algorithms now exposes
run_async(&problem, concurrency).await, gated on the async feature.

- Population-based algorithms fan out per-generation evaluations
  through evaluate_batch_async with concurrency-bounded
  FuturesOrdered chunks.
- Steady-state algorithms (HillClimber, SimulatedAnnealing,
  OnePlusOneEs, Paes, NelderMead) await each step sequentially;
  they accept the concurrency parameter for API uniformity.
- TabuSearch fans out the K-neighbor batch each step.
- Surrogate algorithms (BayesianOpt, Tpe) batch the initial design
  and await per-iteration acquisitions sequentially so the surrogate
  can update between picks.
- Hyperband uses a new AsyncPartialProblem trait (mirroring
  PartialProblem for multi-fidelity workloads) and a parallel
  evaluate_batch_at_budget_async helper; each Successive-Halving
  rung fans out its budgeted evaluations.

All paths preserve seeded determinism: RNG draws happen on the main
task in the same order as the sync path, and only the evaluations
are concurrent.

Adds a dedicated cookbook recipe at docs/book/src/cookbook/async.md
with a worked example (DifferentialEvolution under tokio) and
guidance on picking concurrency. Cross-references in SUMMARY.md
and cookbook.md are updated to surface the new recipe.

The follow-up docs commit reconciles the rest of the user guide
and README to describe the new feature; this commit is the bare
async surface.
2026-05-06 11:51:13 -06:00
swaits d1288aa623 ci(docs): re-enable GitHub Pages deploy
Pages is now enabled on the repo (Settings → Pages → 'Build and
deployment: GitHub Actions'), so the workflow can use the standard
configure-pages → upload-pages-artifact → deploy-pages chain
without needing the GITHUB_TOKEN to enable Pages itself.

PR builds run the build job (catches mdbook breakage) but skip the
deploy job, so PRs don't republish the live site.
2026-05-06 09:04:13 -06:00
swaits af226e3d3b feat: drop heuropt-plot companion crate
Removes the heuropt-plot subcrate, the visualize example that used
it, and the related workspace plumbing (root [workspace] table, the
[workspace] override added to fuzz/Cargo.toml to detach from it,
heuropt-plot dev-dep, CHANGELOG mention).

The visualization concern is better served as an independent third-
party project than as a companion crate in this repo. No effect on
heuropt's public API or the async work in 0.8.0.
2026-05-06 09:04:13 -06:00
swaits cfd5207fb6 ci: drop Pages deploy + loosen simplex-projection fuzz tolerance
Two CI fixes; the previous `enablement: true` attempt didn't work
because the default GITHUB_TOKEN can write to Pages but can't enable
it on a repo that doesn't yet have it configured.

1. .github/workflows/docs.yml: drop the Pages deploy job entirely.
   Build mdbook on every push and upload it as a CI artifact. When
   Pages is enabled manually (Settings → Pages → 'Build and
   deployment: GitHub Actions'), this file can grow back a deploy
   job using actions/configure-pages + actions/deploy-pages.

2. fuzz/fuzz_targets/clamp_to_bounds.rs: the simplex projection's τ
   computation operates on values up to `simplex_total · 1e6` per
   the input filter, so its FP precision floor is ~1e-4 of the
   input scale. Outputs near the `max(x_i − τ, 0)` clamp boundary
   can flip between 0 and a small positive value across
   re-applications without that being a correctness bug. The fuzz
   target is meant to catch *gross* non-idempotence (the all-zeros
   bug that the v0.4 cleanup fixed), not ULP-level slop. Loosen the
   per-element tolerance to `1e-4 · max(simplex_total, max|x_i|, 1)`.
   Verified clean over a 10 M-run soak.
2026-05-06 08:32:22 -06:00
swaits ae1daf687d ci(docs): auto-enable GitHub Pages on first run
The Docs workflow was failing on `actions/configure-pages@v5` with
"Get Pages site failed" because Pages isn't enabled on the repo
yet. Setting `enablement: true` lets the action auto-enable it so
the deploy can proceed without a manual Settings → Pages click.
2026-05-06 08:18:23 -06:00
swaits c1bc3b0528 docs(rustdoc): add runnable examples across operators, metrics, and Pareto utilities
Completes the rustdoc audit — every public item now has at least one
```rust example block in its docstring, exercised by
`cargo test --doc` (55 doctests, all passing).

- Operators: BitFlipMutation, SwapMutation, RealBounds,
  GaussianMutation, BoundedGaussianMutation,
  SimulatedBinaryCrossover, PolynomialMutation, LevyMutation,
  ClampToBounds, ProjectToSimplex.
- Metrics: hypervolume_2d, hypervolume_nd, spacing.
- Pareto utilities: pareto_compare, pareto_front, best_candidate,
  non_dominated_sort, crowding_distance, das_dennis,
  ParetoArchive.

Each example is short (5-15 lines) and self-contained — copy-paste
into a fresh project and it runs.
2026-05-06 08:16:04 -06:00
swaits d564f862d7 ci: fix mdbook edition + isolate fuzz crate from workspace
mdbook 0.4.40 (the version pinned in .github/workflows/docs.yml)
doesn't recognize edition = '2024' under [rust], failing the docs
build. Drop to '2021' for the in-book code blocks; the heuropt
crate itself stays on Rust 2024.

Adding [workspace] to the root Cargo.toml made fuzz/Cargo.toml
inherit it, but fuzz isn't in the members list — every fuzz-smoke
job failed with 'current package believes it's in a workspace when
it's not'. Add an empty [workspace] table at the top of
fuzz/Cargo.toml so cargo treats fuzz as the root of its own
workspace and stops walking up.
2026-05-06 08:15:55 -06:00
swaits 5b5fe50df3 feat(heuropt-plot): v0.1.0 — SVG visualization companion crate
Adds heuropt-plot, a tiny SVG-only plotter that takes heuropt
results and emits scatter plots (pareto_front_svg) and line plots
(convergence_svg). No heavy 'plotters' or 'tiny-skia' dep — hand-
rolled SVG so the crate adds <100 KB to a build.

Workspace setup: root Cargo.toml gains [workspace] with members =
['.', 'heuropt-plot']. heuropt-plot has its own version (0.1.0) and
publishes independently against heuropt 0.8+.

Adds examples/visualize.rs that wires it up: NSGA-II on Schaffer
N.1, plain run() (no observer plumbing), final-front SVG written to
disk.
2026-05-06 07:58:08 -06:00
swaits 6368ca5f3d feat(async): AsyncProblem trait + run_async on RandomSearch and DifferentialEvolution
Adds the headline async/await capability for IO-bound evaluations
(HTTP services, RPC clients, spawned subprocesses) — the
differentiator vs pymoo / hyperopt / MOEA Framework.

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

- core::async_problem::AsyncProblem trait (async fn evaluate_async).
- algorithms::parallel_eval_async::evaluate_batch_async helper using
  futures::stream::FuturesOrdered with concurrency-bounded chunks;
  preserves input order so seeded determinism holds when evaluations
  are themselves deterministic.
- run_async on RandomSearch and DifferentialEvolution.
- examples/async_eval.rs: simulated 20 ms remote service. concurrency=1
  → 4.2 s, concurrency=4 → 2.1 s (2× speedup).

Bumps Cargo.toml to 0.8.0; CHANGELOG entry covers the above plus a
note that 0.6.0/0.7.0 on crates.io are yanked experimentals and 0.8
picks up cleanly from 0.5.
2026-05-06 07:55:56 -06:00
71 changed files with 4776 additions and 138 deletions
+7
View File
@@ -4,6 +4,8 @@ on:
push:
branches: [main]
tags: ["v*.*.*"]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
@@ -11,6 +13,8 @@ permissions:
pages: write
id-token: write
# Only one Pages deploy at a time. Don't cancel a running deploy
# (otherwise we can leave the Pages site partially updated).
concurrency:
group: pages
cancel-in-progress: false
@@ -38,6 +42,9 @@ jobs:
deploy:
name: Deploy to GitHub Pages
# Only deploy on pushes to main / tag pushes / manual runs.
# PR builds get the build-and-upload step but no deploy.
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
environment:
+84 -1
View File
@@ -7,6 +7,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.8.0] — 2026-05-06
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.
### 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
**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`
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.
#### 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
## [0.5.0] — 2026-05-05
Theme: comprehensive documentation and project polish. No public-API
@@ -469,5 +552,5 @@ Initial release.
`RandomSearch`, `Nsga2`, and `DifferentialEvolution`. Seeded runs stay
bit-identical to serial mode.
[Unreleased]: https://github.com/swaits/heuropt/compare/v0.5.0...HEAD
[Unreleased]: https://github.com/swaits/heuropt/compare/v0.8.0...HEAD
[0.1.0]: https://github.com/swaits/heuropt/releases/tag/v0.1.0
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "heuropt"
version = "0.5.0"
version = "0.8.0"
edition = "2024"
rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"]
@@ -17,8 +17,10 @@ categories = ["algorithms", "science", "mathematics", "simulation"]
default = []
serde = ["dep:serde"]
parallel = ["dep:rayon"]
async = ["dep:futures"]
[dependencies]
futures = { version = "0.3", optional = true, default-features = false, features = ["std", "async-await"] }
rand = "0.9"
rand_distr = "0.5"
rayon = { version = "1", optional = true }
@@ -27,11 +29,16 @@ serde = { version = "1", features = ["derive"], optional = true }
[dev-dependencies]
gungraun = "0.18"
proptest = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
[[bench]]
name = "hot_paths"
harness = false
[[example]]
name = "async_eval"
required-features = ["async"]
# Tighten release codegen for the compare harness and downstream binaries
# 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.
+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
+1 -1
View File
@@ -31,4 +31,4 @@ use-boolean-and = true
enable = true
[rust]
edition = "2024"
edition = "2021"
+1
View File
@@ -12,6 +12,7 @@
- [Recipes](./cookbook.md)
- [Parallelize evaluation with rayon](./cookbook/parallel.md)
- [Async evaluation (HTTP / RPC / subprocess)](./cookbook/async.md)
- [Tune a model with expensive evaluations](./cookbook/expensive-evaluations.md)
- [Compare two algorithms on your problem](./cookbook/compare.md)
- [Optimize a permutation (TSP-style)](./cookbook/permutation.md)
+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`
+6 -2
View File
@@ -7,8 +7,12 @@ project.
## Recipes
- [Parallelize evaluation with rayon](./cookbook/parallel.md) — when
your `evaluate` is non-trivial, the `parallel` feature pays for
itself almost immediately.
your `evaluate` is non-trivial CPU work, the `parallel` feature
pays for itself almost immediately.
- [Async evaluation](./cookbook/async.md) — when your `evaluate` is
IO-bound (HTTP / RPC / subprocess), the `async` feature lets the
optimizer await many evaluations concurrently. The differentiating
feature vs other optimization libraries.
- [Tune a model with expensive evaluations](./cookbook/expensive-evaluations.md)
`BayesianOpt`, `Tpe`, and `Hyperband` for the 50500-eval
regime.
+165
View File
@@ -0,0 +1,165 @@
# Async evaluation
When your `evaluate` does **IO** — calls an HTTP service, sends an
RPC, spawns a subprocess — `await`-ing it from the optimizer is
much more efficient than blocking a thread per evaluation. heuropt
ships first-class async support behind the `async` feature flag.
This is the differentiating capability vs pymoo / hyperopt /
optuna / DEAP / MOEA Framework — none of those have a native async
evaluation path.
## Enable the feature
```toml
[dependencies]
heuropt = { version = "0.8", features = ["async"] }
# Pick whatever async runtime you want; heuropt itself depends only on
# `futures`. The example below uses tokio.
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
```
## Implement `AsyncProblem`
It mirrors the regular [`Problem`] trait one-for-one — same
`Decision` type, same `objectives()`, but `evaluate` is replaced
with `evaluate_async` returning a future.
```rust,no_run
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 {
// Real workload: HTTP call to a model-scoring service, an RPC,
// a subprocess. Here we just sleep to model 20 ms latency.
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let loss: f64 = x.iter().map(|v| v * v).sum();
Evaluation::new(vec![loss])
}
}
```
## Run the optimizer with `run_async`
`run_async(&problem, concurrency).await` is provided by **every**
algorithm in the catalog as of v0.8. `concurrency` caps how many
evaluations are in-flight at once.
```rust,no_run
# 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 {
# Evaluation::new(vec![x.iter().map(|v| v * v).sum::<f64>()])
# }
# }
#[tokio::main]
async fn main() {
let bounds = vec![(-1.0_f64, 1.0_f64); 4];
let mut opt = DifferentialEvolution::new(
DifferentialEvolutionConfig {
population_size: 16,
generations: 50,
differential_weight: 0.5,
crossover_probability: 0.9,
seed: 42,
},
RealBounds::new(bounds),
);
let r = opt.run_async(&RemoteService, /* concurrency */ 8).await;
println!("best: {}", r.best.unwrap().evaluation.objectives[0]);
}
```
## Picking `concurrency`
Concurrency is the maximum in-flight evaluation count. Tradeoffs:
| Setting | Effect |
|---|---|
| `1` | Sequential; equivalent to a sync run with extra overhead |
| `pop_size` | Full per-generation parallelism; fastest if your service tolerates it |
| `< pop_size` | Bounded — useful if your downstream service has a rate limit or finite worker pool |
The bigger you go, the more memory the in-flight futures hold and
the more load you put on the downstream service. A reasonable
starting point is `min(pop_size, 16)` and increase only if the
downstream service is comfortable.
## Determinism
Same seed produces the same final result whether you use `run` or
`run_async`, **provided your async `evaluate_async` is itself
deterministic**. heuropt drives the RNG and selection on the main
task; only the evaluations are concurrent, and the
`evaluate_batch_async` helper preserves input order before feeding
results back to the algorithm.
## What the worked example shows
`examples/async_eval.rs` runs `RandomSearch` (200 evaluations × 20 ms
each) at `concurrency = 1, 4, 16` and `DifferentialEvolution` at
`concurrency = 8`. On a recent machine:
```text
RandomSearch with 200 evaluations (20 ms each)
concurrency = 1 elapsed ≈ 4250 ms (sequential 200 × 20 ms)
concurrency = 4 elapsed ≈ 2100 ms (2× speedup, batch_size=2 caps it)
concurrency = 16 elapsed ≈ 2100 ms (same — batch_size dominates)
DifferentialEvolution at concurrency=8
elapsed ≈ 230 ms (8 ants run in parallel each generation)
```
Run it yourself: `cargo run --release --features async --example async_eval`.
## Which algorithms support `run_async`?
**All 33** algorithms in the catalog. The shape of the async path
depends on the algorithm:
- **Population-based / batch-evaluating** — NSGA-II, NSGA-III, SPEA2,
MOEA/D, IBEA, SMS-EMOA, HypE, ε-MOEA, PESA-II, AGE-MOEA, KnEA,
GrEA, RVEA, MOPSO, GA, DE, PSO, CMA-ES, IPOP-CMA-ES, sNES, TLBO,
UMDA, Ant Colony, Random Search. Each generation's offspring
evaluations are fanned out concurrently up to `concurrency`.
- **Steady-state (one-eval-per-step)** — Hill Climber, Simulated
Annealing, (1+1)-ES, PAES, Nelder-Mead. The `concurrency`
parameter is accepted for API uniformity but evaluation order is
inherently sequential.
- **Tabu Search** — fans out the K-neighbor batch each step.
- **Surrogate (BO, TPE)** — fans out the initial design batch, then
awaits per-iteration acquisitions sequentially (the surrogate
must update before the next point is chosen).
- **Hyperband** — uses the separate
[`AsyncPartialProblem`](https://docs.rs/heuropt/latest/heuropt/core/async_problem/trait.AsyncPartialProblem.html)
trait (multi-fidelity); each Successive-Halving rung's evaluations
fan out concurrently.
## Async vs `parallel`
| If your `evaluate` is… | Use |
|---|---|
| CPU-bound (math, simulation) | `parallel` feature → see [Parallelize evaluation](./parallel.md) |
| IO-bound (HTTP, RPC, subprocess) | `async` feature (this recipe) |
Both can be on at once if your evaluation does *both* substantial
CPU work *and* IO. The two features are independent.
[`Problem`]: https://docs.rs/heuropt/latest/heuropt/core/problem/trait.Problem.html
+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
+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,
);
}
+1 -1
View File
@@ -66,7 +66,7 @@ dependencies = [
[[package]]
name = "heuropt"
version = "0.3.0"
version = "0.8.0"
dependencies = [
"rand",
"rand_distr",
+9 -2
View File
@@ -77,10 +77,17 @@ fuzz_target!(|input: Input| {
);
let after = y.clone();
proj.repair(&mut y);
// The simplex projection's `τ` computation operates on values
// up to `simplex_total · 1e6` (per the filter above), so its FP
// precision floor is ~1e-4 of the input scale. Outputs near the
// `max(x_i τ, 0)` clamp boundary can flip between 0 and a
// small positive value across re-applications. The fuzzer is
// checking for *gross* non-idempotence (all-zeros vs valid),
// not ULP-level slop.
let scale = input.simplex_total.max(max_abs).max(1.0);
for (a, b) in after.iter().zip(y.iter()) {
let scale = a.abs().max(b.abs()).max(1.0);
assert!(
(a - b).abs() < 1e-9 * scale,
(a - b).abs() < 1e-4 * scale,
"project not idempotent: {a} vs {b}",
);
}
+74
View File
@@ -155,6 +155,80 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> AgeMoea<I, V> {
/// 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.
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size > 0,
"AgeMoea population_size must be > 0"
);
let n = self.config.population_size;
let objectives = problem.objectives();
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n, &mut rng);
let mut population: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = population.len();
for _ in 0..self.config.generations {
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
while offspring_decisions.len() < n {
let p1 = rng.random_range(0..population.len());
let p2 = rng.random_range(0..population.len());
let parents = vec![
population[p1].decision.clone(),
population[p2].decision.clone(),
];
let children = self.variation.vary(&parents, &mut rng);
assert!(
!children.is_empty(),
"AgeMoea variation returned no children"
);
for child in children {
if offspring_decisions.len() >= n {
break;
}
offspring_decisions.push(child);
}
}
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
evaluations += offspring.len();
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
combined.extend(population);
combined.extend(offspring);
population = environmental_selection(combined, &objectives, n);
}
let front = pareto_front(&population, &objectives);
let best = best_candidate(&population, &objectives);
OptimizationResult::new(
Population::new(population),
front,
best,
evaluations,
self.config.generations,
)
}
}
fn environmental_selection<D: Clone>(
combined: Vec<Candidate<D>>,
objectives: &ObjectiveSpace,
+113
View File
@@ -241,6 +241,119 @@ where
}
}
#[cfg(feature = "async")]
impl AntColonyTsp {
/// 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 generation.
pub async fn run_async<P>(
&mut self,
problem: &P,
concurrency: usize,
) -> OptimizationResult<Vec<usize>>
where
P: crate::core::async_problem::AsyncProblem<Decision = Vec<usize>>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(self.config.ants >= 1, "AntColonyTsp ants must be >= 1");
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"AntColonyTsp requires exactly one objective",
);
let direction = objectives.objectives[0].direction;
let n = self.distances.len();
let mut rng = rng_from_seed(self.config.seed);
let eta: Vec<Vec<f64>> = self
.distances
.iter()
.map(|row| {
row.iter()
.map(|&d| if d > 0.0 { 1.0 / d } else { 0.0 })
.collect()
})
.collect();
let mut pheromone: Vec<Vec<f64>> = vec![vec![self.config.initial_pheromone; n]; n];
let mut best_decision: Option<Vec<usize>> = None;
let mut best_eval: Option<crate::core::evaluation::Evaluation> = None;
let mut evaluations = 0usize;
for _ in 0..self.config.generations {
let mut tours: Vec<Vec<usize>> = Vec::with_capacity(self.config.ants);
for _ in 0..self.config.ants {
let start = rng.random_range(0..n);
let tour = build_tour(
n,
start,
&pheromone,
&eta,
self.config.alpha,
self.config.beta,
&mut rng,
);
tours.push(tour);
}
let cands = evaluate_batch_async(problem, tours.clone(), concurrency).await;
evaluations += cands.len();
let tour_evals: Vec<crate::core::evaluation::Evaluation> =
cands.into_iter().map(|c| c.evaluation).collect();
for (tour, eval) in tours.iter().zip(tour_evals.iter()) {
let beats = match &best_eval {
None => true,
Some(b) => better_than_so(eval, b, direction),
};
if beats {
best_decision = Some(tour.clone());
best_eval = Some(eval.clone());
}
}
for row in pheromone.iter_mut() {
for v in row.iter_mut() {
*v *= 1.0 - self.config.evaporation;
}
}
for (tour, eval) in tours.iter().zip(tour_evals.iter()) {
let length = eval
.objectives
.first()
.copied()
.unwrap_or(f64::INFINITY)
.max(1e-12);
let deposit = self.config.deposit / length;
for w in tour.windows(2) {
let (i, j) = (w[0], w[1]);
pheromone[i][j] += deposit;
pheromone[j][i] += deposit;
}
let (i, j) = (*tour.last().unwrap(), tour[0]);
pheromone[i][j] += deposit;
pheromone[j][i] += deposit;
}
}
let best = Candidate::new(best_decision.unwrap(), best_eval.unwrap());
let population = Population::new(vec![best.clone()]);
let front = vec![best.clone()];
OptimizationResult::new(
population,
front,
Some(best),
evaluations,
self.config.generations,
)
}
}
fn build_tour(
n: usize,
start: usize,
+144
View File
@@ -396,6 +396,150 @@ fn erf(x: f64) -> f64 {
sign * y
}
#[cfg(feature = "async")]
impl BayesianOpt {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` bounds in-flight evaluations during the initial
/// uniform-sample design; the sequential BO loop runs one
/// evaluation per iteration regardless.
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 crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.initial_samples >= 2,
"BayesianOpt initial_samples must be >= 2",
);
assert!(
self.config.signal_variance > 0.0,
"BayesianOpt signal_variance must be > 0"
);
assert!(
self.config.noise_variance > 0.0,
"BayesianOpt noise_variance must be > 0"
);
assert!(
self.config.acquisition_samples >= 1,
"BayesianOpt acquisition_samples must be >= 1",
);
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"BayesianOpt requires exactly one objective",
);
let direction = objectives.objectives[0].direction;
let dim = self.bounds.bounds.len();
if let Some(ls) = &self.config.length_scales {
assert_eq!(
ls.len(),
dim,
"BayesianOpt length_scales.len() must equal dim"
);
}
let length_scales: Vec<f64> = self.config.length_scales.clone().unwrap_or_else(|| {
self.bounds
.bounds
.iter()
.map(|&(lo, hi)| 0.2 * (hi - lo).max(1e-9))
.collect()
});
let mut rng = rng_from_seed(self.config.seed);
// Initial random design: sample all decisions first (consuming
// RNG in the same order as the sync `run`), then evaluate
// concurrently.
let mut decisions: Vec<Vec<f64>> =
Vec::with_capacity(self.config.initial_samples + self.config.iterations);
let mut targets: Vec<f64> = Vec::with_capacity(decisions.capacity());
let mut evaluations: Vec<Evaluation> = Vec::with_capacity(decisions.capacity());
let initial_decisions: Vec<Vec<f64>> = (0..self.config.initial_samples)
.map(|_| sample_uniform_in_bounds(&self.bounds, &mut rng))
.collect();
let initial_cands = evaluate_batch_async(problem, initial_decisions, concurrency).await;
for c in initial_cands {
let t = oriented_target(&c.evaluation, direction);
decisions.push(c.decision);
targets.push(t);
evaluations.push(c.evaluation);
}
for _ in 0..self.config.iterations {
let posterior = match GpPosterior::fit(
&decisions,
&targets,
&length_scales,
self.config.signal_variance,
self.config.noise_variance,
) {
Ok(p) => p,
Err(_) => {
let x = sample_uniform_in_bounds(&self.bounds, &mut rng);
let e = problem.evaluate_async(&x).await;
targets.push(oriented_target(&e, direction));
decisions.push(x);
evaluations.push(e);
continue;
}
};
let best_target = targets.iter().cloned().fold(f64::INFINITY, f64::min);
let mut best_x = sample_uniform_in_bounds(&self.bounds, &mut rng);
let mut best_ei = -f64::INFINITY;
for _ in 0..self.config.acquisition_samples {
let cand = sample_uniform_in_bounds(&self.bounds, &mut rng);
let (mu, sigma) = posterior.predict(&cand);
let ei = expected_improvement(mu, sigma, best_target);
if ei > best_ei {
best_ei = ei;
best_x = cand;
}
}
let e = problem.evaluate_async(&best_x).await;
targets.push(oriented_target(&e, direction));
decisions.push(best_x);
evaluations.push(e);
}
let final_pop: Vec<Candidate<Vec<f64>>> = decisions
.into_iter()
.zip(evaluations)
.map(|(d, e)| Candidate::new(d, e))
.collect();
let mut best_idx = 0;
for i in 1..final_pop.len() {
if better(
&final_pop[i].evaluation,
&final_pop[best_idx].evaluation,
direction,
) {
best_idx = i;
}
}
let total_evaluations = final_pop.len();
let best = final_pop[best_idx].clone();
let front = vec![best.clone()];
OptimizationResult::new(
Population::new(final_pop),
front,
Some(best),
total_evaluations,
self.config.iterations + self.config.initial_samples,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
+231
View File
@@ -366,6 +366,237 @@ where
}
}
#[cfg(feature = "async")]
impl CmaEs {
/// 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 generation.
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 crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size >= 4,
"CmaEs population_size must be >= 4",
);
assert!(
self.config.initial_sigma > 0.0,
"CmaEs initial_sigma must be positive",
);
assert!(
self.config.eigen_decomposition_period >= 1,
"CmaEs eigen_decomposition_period must be >= 1",
);
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"CmaEs only supports single-objective problems",
);
let direction = objectives.objectives[0].direction;
let n = self.bounds.bounds.len();
let n_f = n as f64;
let lambda = self.config.population_size;
let lambda_f = lambda as f64;
let mu = lambda / 2;
assert!(mu >= 1, "CmaEs derived mu (= lambda/2) must be >= 1");
let mut rng = rng_from_seed(self.config.seed);
let raw_weights: Vec<f64> = (0..mu)
.map(|i| ((lambda_f + 1.0) / 2.0).ln() - ((i + 1) as f64).ln())
.collect();
let sum_w: f64 = raw_weights.iter().sum();
let weights: Vec<f64> = raw_weights.iter().map(|w| w / sum_w).collect();
let mu_eff = 1.0 / weights.iter().map(|w| w * w).sum::<f64>();
let c_sigma = (mu_eff + 2.0) / (n_f + mu_eff + 5.0);
let d_sigma = 1.0 + 2.0 * ((mu_eff - 1.0) / (n_f + 1.0)).sqrt().max(0.0) + c_sigma;
let c_c = (4.0 + mu_eff / n_f) / (n_f + 4.0 + 2.0 * mu_eff / n_f);
let c_1 = 2.0 / ((n_f + 1.3).powi(2) + mu_eff);
let c_mu = ((1.0 - c_1) * 2.0 * (mu_eff - 2.0 + 1.0 / mu_eff)
/ ((n_f + 2.0).powi(2) + mu_eff))
.min(1.0 - c_1);
let chi_n = n_f.sqrt() * (1.0 - 1.0 / (4.0 * n_f) + 1.0 / (21.0 * n_f * n_f));
let mut mean: Vec<f64> = if let Some(provided) = self.config.initial_mean.clone() {
assert_eq!(
provided.len(),
self.bounds.bounds.len(),
"CmaEs initial_mean.len() must equal the bounds dimension",
);
provided
.into_iter()
.zip(self.bounds.bounds.iter())
.map(|(v, &(lo, hi))| v.clamp(lo, hi))
.collect()
} else {
self.bounds
.bounds
.iter()
.map(|&(lo, hi)| 0.5 * (lo + hi))
.collect()
};
let mut sigma = self.config.initial_sigma;
let mut c_matrix: Vec<Vec<f64>> = (0..n)
.map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
.collect();
let mut b: Vec<Vec<f64>> = c_matrix.to_vec();
let mut d: Vec<f64> = vec![1.0; n];
let mut p_sigma = vec![0.0_f64; n];
let mut p_c = vec![0.0_f64; n];
let mut evaluations = 0usize;
let normal = Normal::new(0.0, 1.0).expect("Normal::new(0, 1)");
let mut best_candidate_seen: Option<Candidate<Vec<f64>>> = None;
for generation in 0..self.config.generations {
if generation % self.config.eigen_decomposition_period == 0 {
#[allow(clippy::needless_range_loop)]
for i in 0..n {
for j in (i + 1)..n {
let avg = 0.5 * (c_matrix[i][j] + c_matrix[j][i]);
c_matrix[i][j] = avg;
c_matrix[j][i] = avg;
}
}
let (eigenvalues, eigenvectors) = symmetric_eigen(&c_matrix, 1e-14, 100);
d = eigenvalues.iter().map(|&v| v.max(1e-20).sqrt()).collect();
b = (0..n)
.map(|r| (0..n).map(|c| eigenvectors[c][r]).collect())
.collect();
}
let mut z_samples: Vec<Vec<f64>> = Vec::with_capacity(lambda);
let mut x_samples: Vec<Vec<f64>> = Vec::with_capacity(lambda);
for _ in 0..lambda {
let z: Vec<f64> = (0..n).map(|_| normal.sample(&mut rng)).collect();
let bd_z: Vec<f64> = (0..n)
.map(|i| (0..n).map(|j| b[i][j] * d[j] * z[j]).sum::<f64>())
.collect();
let x: Vec<f64> = (0..n)
.map(|i| {
let v = mean[i] + sigma * bd_z[i];
let (lo, hi) = self.bounds.bounds[i];
v.clamp(lo, hi)
})
.collect();
z_samples.push(z);
x_samples.push(x);
}
let evaluated = evaluate_batch_async(problem, x_samples.clone(), concurrency).await;
evaluations += evaluated.len();
for c in &evaluated {
let beats_best = match &best_candidate_seen {
None => true,
Some(b) => better_than_so(&c.evaluation, &b.evaluation, direction),
};
if beats_best {
best_candidate_seen = Some(c.clone());
}
}
let mut order: Vec<usize> = (0..lambda).collect();
order.sort_by(|&a, &b_| {
compare_so(
&evaluated[a].evaluation,
&evaluated[b_].evaluation,
direction,
)
});
let old_mean = mean.clone();
let mut new_mean = vec![0.0_f64; n];
for k in 0..mu {
let xk = &x_samples[order[k]];
let wk = weights[k];
for i in 0..n {
new_mean[i] += wk * xk[i];
}
}
mean = new_mean;
let mut z_weighted = vec![0.0_f64; n];
for k in 0..mu {
let zk = &z_samples[order[k]];
let wk = weights[k];
for i in 0..n {
z_weighted[i] += wk * zk[i];
}
}
let factor_p_sigma = (c_sigma * (2.0 - c_sigma) * mu_eff).sqrt();
let bz: Vec<f64> = (0..n)
.map(|i| (0..n).map(|j| b[i][j] * z_weighted[j]).sum::<f64>())
.collect();
for i in 0..n {
p_sigma[i] = (1.0 - c_sigma) * p_sigma[i] + factor_p_sigma * bz[i];
}
let p_sigma_norm = p_sigma.iter().map(|x| x * x).sum::<f64>().sqrt();
sigma *= ((c_sigma / d_sigma) * (p_sigma_norm / chi_n - 1.0)).exp();
let h_sigma = if p_sigma_norm
/ (1.0 - (1.0 - c_sigma).powi(2 * (generation as i32 + 1))).sqrt()
< (1.4 + 2.0 / (n_f + 1.0)) * chi_n
{
1.0
} else {
0.0
};
let factor_p_c = h_sigma * (c_c * (2.0 - c_c) * mu_eff).sqrt();
for i in 0..n {
p_c[i] = (1.0 - c_c) * p_c[i] + factor_p_c * (mean[i] - old_mean[i]) / sigma;
}
let delta_h = (1.0 - h_sigma) * c_c * (2.0 - c_c);
#[allow(clippy::needless_range_loop)]
for i in 0..n {
for j in 0..n {
let mut update = (1.0 - c_1 - c_mu) * c_matrix[i][j]
+ c_1 * (p_c[i] * p_c[j] + delta_h * c_matrix[i][j]);
let mut rank_mu_term = 0.0;
for k in 0..mu {
let xk = &x_samples[order[k]];
let yi = (xk[i] - old_mean[i]) / sigma;
let yj = (xk[j] - old_mean[j]) / sigma;
rank_mu_term += weights[k] * yi * yj;
}
update += c_mu * rank_mu_term;
c_matrix[i][j] = update;
}
}
for (i, m) in mean.iter_mut().enumerate() {
let (lo, hi) = self.bounds.bounds[i];
*m = m.clamp(lo, hi);
}
}
let best = best_candidate_seen.expect("at least one generation evaluated");
let final_pop = vec![best.clone()];
let front = vec![best.clone()];
let best_opt = best_candidate(&final_pop, &objectives);
OptimizationResult::new(
Population::new(final_pop),
front,
best_opt,
evaluations,
self.config.generations,
)
}
}
fn compare_so(
a: &crate::core::evaluation::Evaluation,
b: &crate::core::evaluation::Evaluation,
+100
View File
@@ -184,6 +184,106 @@ where
}
}
#[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::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 {
Direction::Minimize => trial_obj <= target_obj,
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 front = pareto_front(&current_pop, &objectives);
let best = best_candidate(&current_pop, &objectives);
OptimizationResult::new(
Population::new(current_pop),
front,
best,
evaluations,
self.config.generations,
)
}
}
fn pick_three_distinct(
n: usize,
exclude: usize,
+92
View File
@@ -190,6 +190,98 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> EpsilonMoea<I, V> {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` bounds in-flight evaluations of the initial
/// population. Per-step evaluations are sequential because the
/// algorithm is steady-state (one offspring per step).
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size > 0,
"EpsilonMoea population_size must be > 0"
);
let n = self.config.population_size;
let objectives = problem.objectives();
assert_eq!(
self.config.epsilon.len(),
objectives.len(),
"EpsilonMoea epsilon.len() must equal number of objectives",
);
for (i, &e) in self.config.epsilon.iter().enumerate() {
assert!(e > 0.0, "EpsilonMoea epsilon[{i}] must be > 0.0");
}
let epsilon = self.config.epsilon.clone();
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n, &mut rng);
let mut population: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = population.len();
let mut archive: Vec<Candidate<P::Decision>> = Vec::new();
for c in &population {
insert_into_epsilon_archive(&mut archive, c.clone(), &objectives, &epsilon);
}
let total_evals = self.config.evaluations.max(evaluations);
while evaluations < total_evals {
let p1_idx = rng.random_range(0..population.len());
let parent_a = population[p1_idx].decision.clone();
let parent_b = if !archive.is_empty() {
let j = rng.random_range(0..archive.len());
archive[j].decision.clone()
} else {
let j = rng.random_range(0..population.len());
population[j].decision.clone()
};
let parents = vec![parent_a, parent_b];
let children = self.variation.vary(&parents, &mut rng);
assert!(
!children.is_empty(),
"EpsilonMoea variation returned no children"
);
let child_decision = children.into_iter().next().unwrap();
let child_eval = problem.evaluate_async(&child_decision).await;
evaluations += 1;
let child = Candidate::new(child_decision, child_eval);
update_population(&mut population, &child, &objectives, &mut rng);
insert_into_epsilon_archive(&mut archive, child, &objectives, &epsilon);
}
let final_pop: Vec<Candidate<P::Decision>> = if !archive.is_empty() {
archive.clone()
} else {
population
};
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.evaluations,
)
}
}
/// Standard ε-MOEA population update: if the child is dominated by some
/// member, drop it; if it dominates a member, replace that member; if
/// non-dominated wrt all, replace a random member.
+88
View File
@@ -181,6 +181,94 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> GeneticAlgorithm<I, V> {
/// 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 offspring).
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size >= 2,
"GeneticAlgorithm population_size must be >= 2",
);
assert!(
self.config.tournament_size >= 1,
"GeneticAlgorithm tournament_size must be >= 1",
);
assert!(
self.config.elitism < self.config.population_size,
"GeneticAlgorithm elitism must be < population_size",
);
let n = self.config.population_size;
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"GeneticAlgorithm requires exactly one objective",
);
let direction = objectives.objectives[0].direction;
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n, &mut rng);
let mut population: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = population.len();
for _ in 0..self.config.generations {
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
while offspring_decisions.len() < n {
let parents_decisions = tournament_select_single_objective(
&population,
&objectives,
self.config.tournament_size,
2,
&mut rng,
);
let children = self.variation.vary(&parents_decisions, &mut rng);
assert!(
!children.is_empty(),
"GeneticAlgorithm variation returned no children"
);
for child in children {
if offspring_decisions.len() >= n {
break;
}
offspring_decisions.push(child);
}
}
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
evaluations += offspring.len();
population =
survival_selection(&population, offspring, direction, n, self.config.elitism);
}
let best = best_candidate(&population, &objectives);
let front: Vec<Candidate<P::Decision>> = best.iter().cloned().collect();
OptimizationResult::new(
Population::new(population),
front,
best,
evaluations,
self.config.generations,
)
}
}
fn survival_selection<D: Clone>(
parents: &[Candidate<D>],
offspring: Vec<Candidate<D>>,
+76
View File
@@ -160,6 +160,82 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> Grea<I, V> {
/// 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.
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size > 0,
"Grea population_size must be > 0"
);
assert!(
self.config.grid_divisions >= 1,
"Grea grid_divisions must be >= 1"
);
let n = self.config.population_size;
let objectives = problem.objectives();
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n, &mut rng);
let mut population: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = population.len();
for _ in 0..self.config.generations {
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
while offspring_decisions.len() < n {
let p1 = rng.random_range(0..population.len());
let p2 = rng.random_range(0..population.len());
let parents = vec![
population[p1].decision.clone(),
population[p2].decision.clone(),
];
let children = self.variation.vary(&parents, &mut rng);
assert!(!children.is_empty(), "Grea variation returned no children");
for child in children {
if offspring_decisions.len() >= n {
break;
}
offspring_decisions.push(child);
}
}
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
evaluations += offspring.len();
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
combined.extend(population);
combined.extend(offspring);
population =
environmental_selection(combined, &objectives, n, self.config.grid_divisions);
}
let front = pareto_front(&population, &objectives);
let best = best_candidate(&population, &objectives);
OptimizationResult::new(
Population::new(population),
front,
best,
evaluations,
self.config.generations,
)
}
}
fn environmental_selection<D: Clone>(
combined: Vec<Candidate<D>>,
objectives: &ObjectiveSpace,
+78
View File
@@ -146,6 +146,84 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> HillClimber<I, V> {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` is mostly inert here because HillClimber evaluates
/// one child per iteration; it's accepted for API parity with other
/// algorithms.
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>,
V: Variation<P::Decision>,
{
let _ = concurrency;
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"HillClimber requires exactly one objective",
);
let direction = objectives.objectives[0].direction;
let mut rng = rng_from_seed(self.config.seed);
let mut initial = self.initializer.initialize(1, &mut rng);
assert!(
!initial.is_empty(),
"HillClimber initializer returned no decisions"
);
let mut current_decision = initial.remove(0);
let mut current_eval = problem.evaluate_async(&current_decision).await;
let mut evaluations = 1usize;
for _ in 0..self.config.iterations {
let parents = vec![current_decision.clone()];
let children = self.variation.vary(&parents, &mut rng);
assert!(
!children.is_empty(),
"HillClimber variation returned no children"
);
let child_decision = children.into_iter().next().unwrap();
let child_eval = problem.evaluate_async(&child_decision).await;
evaluations += 1;
let child_better = match (child_eval.is_feasible(), current_eval.is_feasible()) {
(true, false) => true,
(false, true) => false,
(false, false) => {
child_eval.constraint_violation < current_eval.constraint_violation
}
(true, true) => match direction {
Direction::Minimize => child_eval.objectives[0] < current_eval.objectives[0],
Direction::Maximize => child_eval.objectives[0] > current_eval.objectives[0],
},
};
if child_better {
current_decision = child_decision;
current_eval = child_eval;
}
}
let best = Candidate::new(current_decision, current_eval);
let population = Population::new(vec![best.clone()]);
let front = vec![best.clone()];
OptimizationResult::new(
population,
front,
Some(best),
evaluations,
self.config.iterations,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
+125
View File
@@ -226,6 +226,131 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> Hype<I, V> {
/// 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.
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size > 0,
"Hype population_size must be > 0"
);
assert!(self.config.mc_samples > 0, "Hype mc_samples must be > 0");
let n = self.config.population_size;
let objectives = problem.objectives();
assert_eq!(
self.config.reference_point.len(),
objectives.len(),
"Hype reference_point.len() must equal number of objectives",
);
let reference = self.config.reference_point.clone();
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n, &mut rng);
let mut population: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = population.len();
for _ in 0..self.config.generations {
let fitness = hype_fitness(
&population,
&objectives,
&reference,
self.config.mc_samples,
&mut rng,
);
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
while offspring_decisions.len() < n {
let p1 = binary_tournament(&fitness, &mut rng);
let p2 = binary_tournament(&fitness, &mut rng);
let parents = vec![
population[p1].decision.clone(),
population[p2].decision.clone(),
];
let children = self.variation.vary(&parents, &mut rng);
assert!(!children.is_empty(), "Hype variation returned no children");
for child in children {
if offspring_decisions.len() >= n {
break;
}
offspring_decisions.push(child);
}
}
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
evaluations += offspring.len();
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
combined.extend(population);
combined.extend(offspring);
let fronts = non_dominated_sort(&combined, &objectives);
let mut keep_indices: Vec<usize> = Vec::with_capacity(n);
let mut splitting: &[usize] = &[];
for f in &fronts {
if keep_indices.len() + f.len() <= n {
keep_indices.extend(f.iter().copied());
} else {
splitting = f;
break;
}
if keep_indices.len() == n {
break;
}
}
if keep_indices.len() < n {
let pool: Vec<&Candidate<P::Decision>> =
splitting.iter().map(|&i| &combined[i]).collect();
let contributions = estimate_contributions(
&pool,
&objectives,
&reference,
self.config.mc_samples,
&mut rng,
);
let mut order: Vec<usize> = (0..splitting.len()).collect();
order.sort_by(|&a, &b| {
contributions[b]
.partial_cmp(&contributions[a])
.unwrap_or(std::cmp::Ordering::Equal)
});
for k in order.into_iter().take(n - keep_indices.len()) {
keep_indices.push(splitting[k]);
}
}
population = keep_indices
.into_iter()
.map(|i| combined[i].clone())
.collect();
}
let front = pareto_front(&population, &objectives);
let best = best_candidate(&population, &objectives);
OptimizationResult::new(
Population::new(population),
front,
best,
evaluations,
self.config.generations,
)
}
}
fn hype_fitness<D>(
pool: &[Candidate<D>],
objectives: &ObjectiveSpace,
+100
View File
@@ -206,6 +206,106 @@ where
}
}
#[cfg(feature = "async")]
impl<I, D> Hyperband<I, D>
where
D: Clone,
I: Initializer<D>,
{
/// Async version of [`Hyperband::run`] — evaluates each
/// Successive-Halving rung's configurations concurrently through the
/// caller's async runtime. Available only with the `async` feature.
///
/// `concurrency` bounds in-flight evaluations per rung.
pub async fn run_async<P>(&mut self, problem: &P, concurrency: usize) -> OptimizationResult<D>
where
P: crate::core::async_problem::AsyncPartialProblem<Decision = D>,
D: Send + Sync,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_at_budget_async;
assert!(
self.config.max_budget > 0.0,
"Hyperband max_budget must be > 0"
);
assert!(self.config.eta > 1.0, "Hyperband eta must be > 1");
assert!(
self.config.max_brackets >= 1,
"Hyperband max_brackets must be >= 1"
);
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"Hyperband requires exactly one objective",
);
let direction = objectives.objectives[0].direction;
let mut rng = rng_from_seed(self.config.seed);
let s_max = (self.config.max_budget.ln() / self.config.eta.ln()).floor() as i64;
let s_max = (s_max as usize).min(self.config.max_brackets);
let mut total_evaluations = 0usize;
let mut total_iterations = 0usize;
let mut best_seen: Option<Candidate<D>> = None;
for s in (0..=s_max).rev() {
let s_f = s as f64;
let n =
((s_max as f64 + 1.0) / (s_f + 1.0) * self.config.eta.powf(s_f)).ceil() as usize;
let r = self.config.max_budget / self.config.eta.powf(s_f);
let mut configs: Vec<D> = self.initializer.initialize(n, &mut rng);
for i in 0..=s {
let n_i = (n as f64 / self.config.eta.powi(i as i32)).floor() as usize;
let r_i = r * self.config.eta.powi(i as i32);
if configs.is_empty() {
break;
}
let evals: Vec<Evaluation> =
evaluate_batch_at_budget_async(problem, &configs, r_i, concurrency).await;
total_evaluations += configs.len();
for (cfg, e) in configs.iter().zip(evals.iter()) {
let beats = match &best_seen {
None => true,
Some(b) => better(e, &b.evaluation, direction),
};
if beats {
best_seen = Some(Candidate::new(cfg.clone(), e.clone()));
}
}
total_iterations += 1;
let next_size = (n_i / self.config.eta as usize).max(1);
if next_size >= configs.len() {
continue;
}
let mut order: Vec<usize> = (0..configs.len()).collect();
order.sort_by(|&a, &b| compare(&evals[a], &evals[b], direction));
let keep: std::collections::HashSet<usize> =
order.into_iter().take(next_size).collect();
let new_configs: Vec<D> = configs
.into_iter()
.enumerate()
.filter_map(|(idx, c)| if keep.contains(&idx) { Some(c) } else { None })
.collect();
configs = new_configs;
}
}
let best = best_seen.expect("at least one bracket ran");
let population = Population::new(vec![best.clone()]);
let front = vec![best.clone()];
OptimizationResult::new(
population,
front,
Some(best),
total_evaluations,
total_iterations,
)
}
}
fn compare(a: &Evaluation, b: &Evaluation, direction: Direction) -> std::cmp::Ordering {
match (a.is_feasible(), b.is_feasible()) {
(true, false) => std::cmp::Ordering::Less,
+74
View File
@@ -159,6 +159,80 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> Ibea<I, V> {
/// 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.
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size > 0,
"Ibea population_size must be > 0"
);
assert!(self.config.kappa > 0.0, "Ibea kappa must be > 0");
let n = self.config.population_size;
let objectives = problem.objectives();
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n, &mut rng);
let mut population: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = population.len();
for _ in 0..self.config.generations {
let fitness = compute_fitness(&population, &objectives, self.config.kappa);
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
while offspring_decisions.len() < n {
let p1 = binary_tournament(&fitness, &mut rng);
let p2 = binary_tournament(&fitness, &mut rng);
let parents = vec![
population[p1].decision.clone(),
population[p2].decision.clone(),
];
let children = self.variation.vary(&parents, &mut rng);
assert!(!children.is_empty(), "Ibea variation returned no children");
for child in children {
if offspring_decisions.len() >= n {
break;
}
offspring_decisions.push(child);
}
}
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
evaluations += offspring.len();
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
combined.extend(population);
combined.extend(offspring);
population = environmental_selection(combined, &objectives, n, self.config.kappa);
}
let front = pareto_front(&population, &objectives);
let best = best_candidate(&population, &objectives);
OptimizationResult::new(
Population::new(population),
front,
best,
evaluations,
self.config.generations,
)
}
}
/// Iteratively remove the worst-fitness member from `pool` until `n` remain.
///
/// IBEA's standard "subtract the dropped member's contribution from every
+88
View File
@@ -187,6 +187,94 @@ where
}
}
#[cfg(feature = "async")]
impl IpopCmaEs {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` bounds in-flight evaluations within each restart's
/// CMA-ES generation.
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>>,
{
assert!(
self.config.initial_population_size >= 4,
"IpopCmaEs initial_population_size must be >= 4",
);
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"IpopCmaEs requires exactly one objective",
);
let direction = objectives.objectives[0].direction;
let mut rng = rng_from_seed(self.config.seed);
let mut remaining_gens = self.config.total_generations;
let mut pop_size = self.config.initial_population_size;
let mut total_evaluations = 0usize;
let mut total_iterations = 0usize;
let mut best_seen: Option<Candidate<Vec<f64>>> = None;
let _ = self.config.stall_generations;
let mut restart_counter = 0u64;
while remaining_gens > 0 {
let this_gens = (remaining_gens / 2).max(20).min(remaining_gens);
let inner_seed = self
.config
.seed
.wrapping_add(restart_counter.wrapping_mul(0x9E37_79B9_7F4A_7C15));
let restart_mean: Vec<f64> = self
.bounds
.bounds
.iter()
.map(|&(lo, hi)| lo + (hi - lo) * rng.random::<f64>())
.collect();
let cfg = CmaEsConfig {
population_size: pop_size,
generations: this_gens,
initial_sigma: self.config.initial_sigma,
eigen_decomposition_period: self.config.eigen_decomposition_period,
initial_mean: Some(restart_mean),
seed: inner_seed,
};
let mut inner = CmaEs::new(cfg, RealBounds::new(self.bounds.bounds.clone()));
let result = inner.run_async(problem, concurrency).await;
total_evaluations += result.evaluations;
total_iterations += result.generations;
if let Some(b) = result.best.clone() {
let beats = match &best_seen {
None => true,
Some(prev) => better(&b.evaluation, &prev.evaluation, direction),
};
if beats {
best_seen = Some(b);
}
}
remaining_gens = remaining_gens.saturating_sub(this_gens);
pop_size = pop_size.saturating_mul(2);
restart_counter = restart_counter.wrapping_add(1);
}
let best = best_seen.expect("at least one restart ran");
let population = Population::new(vec![best.clone()]);
let front = vec![best.clone()];
OptimizationResult::new(
population,
front,
Some(best),
total_evaluations,
total_iterations,
)
}
}
fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
match (a.is_feasible(), b.is_feasible()) {
(true, false) => true,
+71
View File
@@ -149,6 +149,77 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> Knea<I, V> {
/// 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.
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size > 0,
"Knea population_size must be > 0"
);
let n = self.config.population_size;
let objectives = problem.objectives();
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n, &mut rng);
let mut population: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = population.len();
for _ in 0..self.config.generations {
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
while offspring_decisions.len() < n {
let p1 = rng.random_range(0..population.len());
let p2 = rng.random_range(0..population.len());
let parents = vec![
population[p1].decision.clone(),
population[p2].decision.clone(),
];
let children = self.variation.vary(&parents, &mut rng);
assert!(!children.is_empty(), "Knea variation returned no children");
for child in children {
if offspring_decisions.len() >= n {
break;
}
offspring_decisions.push(child);
}
}
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
evaluations += offspring.len();
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
combined.extend(population);
combined.extend(offspring);
population = environmental_selection(combined, &objectives, n);
}
let front = pareto_front(&population, &objectives);
let best = best_candidate(&population, &objectives);
OptimizationResult::new(
Population::new(population),
front,
best,
evaluations,
self.config.generations,
)
}
}
fn environmental_selection<D: Clone>(
combined: Vec<Candidate<D>>,
objectives: &ObjectiveSpace,
+2
View File
@@ -22,6 +22,8 @@ pub mod nsga3;
pub mod one_plus_one_es;
pub mod paes;
pub(crate) mod parallel_eval;
#[cfg(feature = "async")]
pub(crate) mod parallel_eval_async;
pub mod particle_swarm;
pub mod pesa2;
pub mod random_search;
+120
View File
@@ -219,6 +219,126 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> Moead<I, V> {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` bounds in-flight evaluations of the initial
/// population. Per-generation evaluations are sequential because
/// each child's outcome feeds back into the same generation's
/// neighborhood updates.
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
let objectives = problem.objectives();
let m = objectives.len();
let weights = das_dennis(m, self.config.reference_divisions);
assert!(
!weights.is_empty(),
"Moead weight set is empty — increase reference_divisions",
);
let n = weights.len();
let t = self.config.neighborhood_size.min(n);
assert!(t >= 2, "Moead neighborhood_size must be >= 2");
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n, &mut rng);
assert_eq!(
initial_decisions.len(),
n,
"MOEA/D initializer must return exactly {n} decisions",
);
let mut population: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = population.len();
let mut ideal = vec![f64::INFINITY; m];
for c in &population {
let oriented = objectives.as_minimization(&c.evaluation.objectives);
for (k, v) in oriented.iter().enumerate() {
if *v < ideal[k] {
ideal[k] = *v;
}
}
}
let neighborhoods: Vec<Vec<usize>> = (0..n)
.map(|i| {
let mut idx: Vec<usize> = (0..n).collect();
idx.sort_by(|&a, &b| {
let da = weight_distance(&weights[i], &weights[a]);
let db = weight_distance(&weights[i], &weights[b]);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
});
idx.into_iter().take(t).collect()
})
.collect();
for _ in 0..self.config.generations {
#[allow(clippy::needless_range_loop)]
for i in 0..n {
let nbh = &neighborhoods[i];
let p1 = *nbh.choose(&mut rng).unwrap();
let mut p2 = *nbh.choose(&mut rng).unwrap();
while p2 == p1 && nbh.len() > 1 {
p2 = *nbh.choose(&mut rng).unwrap();
}
let parents = vec![
population[p1].decision.clone(),
population[p2].decision.clone(),
];
let children = self.variation.vary(&parents, &mut rng);
assert!(
!children.is_empty(),
"MOEA/D variation returned no children"
);
let child_decision = children.into_iter().next().unwrap();
let child_eval = problem.evaluate_async(&child_decision).await;
evaluations += 1;
let oriented_child = objectives.as_minimization(&child_eval.objectives);
for (k, v) in oriented_child.iter().enumerate() {
if *v < ideal[k] {
ideal[k] = *v;
}
}
for &j in nbh {
let cur_oriented =
objectives.as_minimization(&population[j].evaluation.objectives);
let g_cur = tchebycheff(&cur_oriented, &weights[j], &ideal);
let g_new = tchebycheff(&oriented_child, &weights[j], &ideal);
if g_new <= g_cur {
population[j] = Candidate::new(child_decision.clone(), child_eval.clone());
}
}
}
}
let front = pareto_front(&population, &objectives);
let best = best_candidate(&population, &objectives);
OptimizationResult::new(
Population::new(population),
front,
best,
evaluations,
self.config.generations,
)
}
}
/// Tchebycheff scalarization: `max_k w_k * |f_k - z*_k|`.
///
/// `weight` components that are zero are floored to `1e-6` so every axis
+118
View File
@@ -212,6 +212,124 @@ where
}
}
#[cfg(feature = "async")]
impl Mopso {
/// 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.
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 crate::algorithms::parallel_eval_async::evaluate_batch_async;
use crate::traits::Initializer as _;
assert!(self.config.swarm_size >= 1, "Mopso swarm_size must be >= 1");
assert!(
self.config.archive_size >= 1,
"Mopso archive_size must be >= 1"
);
let objectives = problem.objectives();
assert!(
objectives.is_multi_objective(),
"Mopso requires multi-objective problems (use ParticleSwarm for single-objective)",
);
let dim = self.bounds.bounds.len();
let n = self.config.swarm_size;
let mut rng = rng_from_seed(self.config.seed);
let mut positions: Vec<Vec<f64>> = self.bounds.initialize(n, &mut rng);
let mut velocities: Vec<Vec<f64>> = (0..n)
.map(|_| {
self.bounds
.bounds
.iter()
.map(|&(lo, hi)| 0.1 * (hi - lo) * (rng.random::<f64>() * 2.0 - 1.0))
.collect()
})
.collect();
let v_max: Vec<f64> = self.bounds.bounds.iter().map(|&(lo, hi)| hi - lo).collect();
let initial_pop = evaluate_batch_async(problem, positions.clone(), concurrency).await;
let mut evaluations = initial_pop.len();
let mut pbest_decisions: Vec<Vec<f64>> = positions.clone();
let mut pbest_evals: Vec<crate::core::evaluation::Evaluation> =
initial_pop.iter().map(|c| c.evaluation.clone()).collect();
let mut archive = ParetoArchive::new(objectives.clone());
for c in initial_pop {
archive.insert(c);
}
archive.truncate(self.config.archive_size);
for _ in 0..self.config.generations {
for i in 0..n {
let leader = archive
.members()
.choose(&mut rng)
.map(|c| c.decision.clone())
.unwrap_or_else(|| positions[i].clone());
#[allow(clippy::needless_range_loop)]
for j in 0..dim {
let r1: f64 = rng.random();
let r2: f64 = rng.random();
let cognitive_term =
self.config.cognitive * r1 * (pbest_decisions[i][j] - positions[i][j]);
let social_term = self.config.social * r2 * (leader[j] - positions[i][j]);
let mut v =
self.config.inertia * velocities[i][j] + cognitive_term + social_term;
if v > v_max[j] {
v = v_max[j];
} else if v < -v_max[j] {
v = -v_max[j];
}
velocities[i][j] = v;
let (lo, hi) = self.bounds.bounds[j];
positions[i][j] = (positions[i][j] + v).clamp(lo, hi);
}
}
let evaluated = evaluate_batch_async(problem, positions.clone(), concurrency).await;
evaluations += evaluated.len();
for (i, cand) in evaluated.iter().enumerate() {
let dominance = pareto_compare(&cand.evaluation, &pbest_evals[i], &objectives);
let replace = match dominance {
Dominance::Dominates => true,
Dominance::DominatedBy => false,
Dominance::Equal | Dominance::NonDominated => rng.random_bool(0.5),
};
if replace {
pbest_decisions[i] = cand.decision.clone();
pbest_evals[i] = cand.evaluation.clone();
}
}
for c in evaluated {
archive.insert(c);
}
archive.truncate(self.config.archive_size);
}
let members = archive.into_vec();
let front = pareto_front(&members, &objectives);
let best = best_candidate(&members, &objectives);
OptimizationResult::new(
Population::new(members),
front,
best,
evaluations,
self.config.generations,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
+155
View File
@@ -295,6 +295,161 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
compare(a, b, direction) == std::cmp::Ordering::Less
}
#[cfg(feature = "async")]
impl NelderMead {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` is largely inert here because Nelder-Mead
/// evaluates one or two new vertices per iteration sequentially
/// (the next decision depends on the previous evaluation); it's
/// accepted for API parity with other algorithms.
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>>,
{
let _ = concurrency;
assert!(
self.config.reflection > 0.0,
"NelderMead reflection must be > 0"
);
assert!(
self.config.expansion > 1.0,
"NelderMead expansion must be > 1",
);
assert!(
self.config.contraction > 0.0 && self.config.contraction < 1.0,
"NelderMead contraction must be in (0, 1)",
);
assert!(
self.config.shrinkage > 0.0 && self.config.shrinkage < 1.0,
"NelderMead shrinkage must be in (0, 1)",
);
assert!(
self.config.initial_step > 0.0,
"NelderMead initial_step must be > 0",
);
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"NelderMead requires exactly one objective",
);
let direction = objectives.objectives[0].direction;
let n = self.bounds.bounds.len();
let mut vertices: Vec<Vec<f64>> = Vec::with_capacity(n + 1);
let start: Vec<f64> = self
.bounds
.bounds
.iter()
.map(|&(lo, hi)| 0.5 * (lo + hi))
.collect();
vertices.push(start.clone());
for j in 0..n {
let mut v = start.clone();
let (lo, hi) = self.bounds.bounds[j];
let step = self.config.initial_step.min(0.5 * (hi - lo));
v[j] = (v[j] + step).clamp(lo, hi);
vertices.push(v);
}
let mut evals: Vec<Evaluation> = Vec::with_capacity(vertices.len());
for v in &vertices {
evals.push(problem.evaluate_async(v).await);
}
let mut evaluations = evals.len();
for _ in 0..self.config.iterations {
let mut order: Vec<usize> = (0..vertices.len()).collect();
order.sort_by(|&a, &b| compare(&evals[a], &evals[b], direction));
let best_idx = order[0];
let worst_idx = order[order.len() - 1];
let second_worst_idx = order[order.len() - 2];
let mut centroid = vec![0.0_f64; n];
for &idx in &order[..order.len() - 1] {
for j in 0..n {
centroid[j] += vertices[idx][j];
}
}
for c in centroid.iter_mut() {
*c /= (order.len() - 1) as f64;
}
let reflected = self.reflect(&centroid, &vertices[worst_idx], self.config.reflection);
let r_eval = problem.evaluate_async(&reflected).await;
evaluations += 1;
if better(&r_eval, &evals[best_idx], direction) {
let expanded = self.reflect(&centroid, &vertices[worst_idx], self.config.expansion);
let e_eval = problem.evaluate_async(&expanded).await;
evaluations += 1;
if better(&e_eval, &r_eval, direction) {
vertices[worst_idx] = expanded;
evals[worst_idx] = e_eval;
} else {
vertices[worst_idx] = reflected;
evals[worst_idx] = r_eval;
}
} else if better(&r_eval, &evals[second_worst_idx], direction) {
vertices[worst_idx] = reflected;
evals[worst_idx] = r_eval;
} else {
let contraction_target = if better(&r_eval, &evals[worst_idx], direction) {
self.contract(&centroid, &reflected, self.config.contraction)
} else {
self.contract(&centroid, &vertices[worst_idx], self.config.contraction)
};
let c_eval = problem.evaluate_async(&contraction_target).await;
evaluations += 1;
if better(&c_eval, &evals[worst_idx], direction) {
vertices[worst_idx] = contraction_target;
evals[worst_idx] = c_eval;
} else {
let best_pt = vertices[best_idx].clone();
for &idx in &order {
if idx == best_idx {
continue;
}
#[allow(clippy::needless_range_loop)]
for j in 0..n {
vertices[idx][j] = best_pt[j]
+ self.config.shrinkage * (vertices[idx][j] - best_pt[j]);
}
for (j, x) in vertices[idx].iter_mut().enumerate() {
let (lo, hi) = self.bounds.bounds[j];
*x = x.clamp(lo, hi);
}
evals[idx] = problem.evaluate_async(&vertices[idx]).await;
evaluations += 1;
}
}
}
}
let mut best_idx = 0;
for i in 1..vertices.len() {
if better(&evals[i], &evals[best_idx], direction) {
best_idx = i;
}
}
let best = Candidate::new(vertices[best_idx].clone(), evals[best_idx].clone());
let population = Population::new(vec![best.clone()]);
let front = vec![best.clone()];
OptimizationResult::new(
population,
front,
Some(best),
evaluations,
self.config.iterations,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
+111
View File
@@ -232,6 +232,117 @@ fn annotate<D: Clone>(
.collect()
}
#[cfg(feature = "async")]
impl<I, V> Nsga2<I, V> {
/// 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 offspring).
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size > 0,
"Nsga2 population_size must be greater than 0",
);
let n = self.config.population_size;
let objectives = problem.objectives();
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n, &mut rng);
assert_eq!(
initial_decisions.len(),
n,
"NSGA-II initializer must return exactly population_size decisions",
);
let population: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = population.len();
let mut annotated = annotate(population, &objectives);
for _ in 0..self.config.generations {
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
while offspring_decisions.len() < n {
let p1 = binary_tournament(&annotated, &mut rng);
let p2 = binary_tournament(&annotated, &mut rng);
let parents = vec![
annotated[p1].candidate.decision.clone(),
annotated[p2].candidate.decision.clone(),
];
let children = self.variation.vary(&parents, &mut rng);
assert!(
!children.is_empty(),
"NSGA-II variation returned no children",
);
for child_decision in children {
if offspring_decisions.len() >= n {
break;
}
offspring_decisions.push(child_decision);
}
}
let offspring: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, offspring_decisions, concurrency).await;
evaluations += offspring.len();
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
combined.extend(annotated.into_iter().map(|e| e.candidate));
combined.extend(offspring);
let fronts = non_dominated_sort(&combined, &objectives);
let mut next: Vec<Candidate<P::Decision>> = Vec::with_capacity(n);
for front in &fronts {
if next.len() + front.len() <= n {
for &idx in front {
next.push(combined[idx].clone());
}
} else {
let dist = crowding_distance(&combined, front, &objectives);
let mut order: Vec<usize> = (0..front.len()).collect();
order.sort_by(|&a, &b| {
dist[b]
.partial_cmp(&dist[a])
.unwrap_or(std::cmp::Ordering::Equal)
});
let needed = n - next.len();
for &k in order.iter().take(needed) {
next.push(combined[front[k]].clone());
}
break;
}
if next.len() == n {
break;
}
}
annotated = annotate(next, &objectives);
}
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,
)
}
}
fn binary_tournament<D>(entries: &[Nsga2Entry<D>], rng: &mut Rng) -> usize {
let n = entries.len();
let a = rng.random_range(0..n);
+86
View File
@@ -180,6 +180,92 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> Nsga3<I, V> {
/// 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.
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size > 0,
"Nsga3 population_size must be greater than 0",
);
let n = self.config.population_size;
let objectives = problem.objectives();
let m = objectives.len();
let reference_points = das_dennis(m, self.config.reference_divisions);
assert!(
!reference_points.is_empty(),
"Nsga3 reference set is empty — check reference_divisions",
);
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n, &mut rng);
assert_eq!(
initial_decisions.len(),
n,
"NSGA-III initializer must return exactly population_size decisions",
);
let mut population: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = population.len();
for _ in 0..self.config.generations {
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
while offspring_decisions.len() < n {
let p1 = rng.random_range(0..population.len());
let p2 = rng.random_range(0..population.len());
let parents = vec![
population[p1].decision.clone(),
population[p2].decision.clone(),
];
let children = self.variation.vary(&parents, &mut rng);
assert!(
!children.is_empty(),
"NSGA-III variation returned no children",
);
for child_decision in children {
if offspring_decisions.len() >= n {
break;
}
offspring_decisions.push(child_decision);
}
}
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
evaluations += offspring.len();
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
combined.extend(population);
combined.extend(offspring);
population =
environmental_selection(&combined, &objectives, &reference_points, n, &mut rng);
}
let front = pareto_front(&population, &objectives);
let best = best_candidate(&population, &objectives);
OptimizationResult::new(
Population::new(population),
front,
best,
evaluations,
self.config.generations,
)
}
}
/// NSGA-III environmental selection: front-by-front + reference-point niching
/// on the splitting front.
fn environmental_selection<D: Clone>(
+94
View File
@@ -191,6 +191,100 @@ fn worse_than(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
}
}
#[cfg(feature = "async")]
impl OnePlusOneEs {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` is mostly inert here because (1+1)-ES evaluates
/// one child per iteration; it's accepted for API parity with
/// other algorithms.
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>>,
{
let _ = concurrency;
assert!(
self.config.initial_sigma > 0.0,
"OnePlusOneEs initial_sigma must be > 0"
);
assert!(
self.config.step_increase > 1.0,
"OnePlusOneEs step_increase must be > 1",
);
assert!(
self.config.adaptation_period >= 1,
"OnePlusOneEs adaptation_period must be >= 1",
);
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"OnePlusOneEs requires exactly one objective",
);
let direction = objectives.objectives[0].direction;
let mut rng = rng_from_seed(self.config.seed);
let mut parent: Vec<f64> = self
.bounds
.bounds
.iter()
.map(|&(lo, hi)| 0.5 * (lo + hi))
.collect();
let mut parent_eval = problem.evaluate_async(&parent).await;
let mut evaluations = 1usize;
let mut sigma = self.config.initial_sigma;
let mut window = std::collections::VecDeque::with_capacity(self.config.adaptation_period);
for _ in 0..self.config.iterations {
let normal = Normal::new(0.0, sigma).expect("Normal::new(0, sigma)");
let mut child = parent.clone();
for (j, x) in child.iter_mut().enumerate() {
let (lo, hi) = self.bounds.bounds[j];
*x = (*x + normal.sample(&mut rng)).clamp(lo, hi);
}
let child_eval = problem.evaluate_async(&child).await;
evaluations += 1;
let accepted = !worse_than(&child_eval, &parent_eval, direction);
if accepted {
parent = child;
parent_eval = child_eval;
}
window.push_back(if accepted { 1u8 } else { 0u8 });
if window.len() > self.config.adaptation_period {
window.pop_front();
}
if window.len() == self.config.adaptation_period {
let success_count: usize = window.iter().map(|&b| b as usize).sum();
let rate = success_count as f64 / window.len() as f64;
if rate > 0.2 {
sigma *= self.config.step_increase;
} else if rate < 0.2 {
sigma /= self.config.step_increase;
}
}
}
let best = Candidate::new(parent, parent_eval);
let population = Population::new(vec![best.clone()]);
let front = vec![best.clone()];
OptimizationResult::new(
population,
front,
Some(best),
evaluations,
self.config.iterations,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
+86
View File
@@ -156,6 +156,92 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> Paes<I, V> {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` is mostly inert here because PAES evaluates one
/// child per iteration; it's accepted for API parity with other
/// algorithms.
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>,
V: Variation<P::Decision>,
{
let _ = concurrency;
assert!(
self.config.archive_size > 0,
"PAES archive_size must be greater than 0",
);
let objectives = problem.objectives();
let mut rng = rng_from_seed(self.config.seed);
let mut initial = self.initializer.initialize(1, &mut rng);
assert!(
!initial.is_empty(),
"PAES initializer returned no decisions",
);
let mut current_decision = initial.remove(0);
let mut current_eval = problem.evaluate_async(&current_decision).await;
let mut evaluations = 1usize;
let mut archive = ParetoArchive::new(objectives.clone());
archive.insert(Candidate::new(
current_decision.clone(),
current_eval.clone(),
));
for _ in 0..self.config.iterations {
let parents = vec![current_decision.clone()];
let children = self.variation.vary(&parents, &mut rng);
assert!(!children.is_empty(), "PAES variation returned no children",);
let child_decision = children.into_iter().next().unwrap();
let child_eval = problem.evaluate_async(&child_decision).await;
evaluations += 1;
match pareto_compare(&child_eval, &current_eval, &objectives) {
Dominance::Dominates => {
current_decision = child_decision.clone();
current_eval = child_eval.clone();
}
Dominance::DominatedBy => {
// Stay at current.
}
Dominance::NonDominated | Dominance::Equal => {
current_decision = child_decision.clone();
current_eval = child_eval.clone();
}
}
archive.insert(Candidate::new(child_decision, child_eval));
archive.insert(Candidate::new(
current_decision.clone(),
current_eval.clone(),
));
archive.truncate(self.config.archive_size);
}
let members = archive.into_vec();
let front = pareto_front(&members, &objectives);
let best = best_candidate(&members, &objectives);
OptimizationResult::new(
Population::new(members),
front,
best,
evaluations,
self.config.iterations,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
+91
View File
@@ -0,0 +1,91 @@
//! 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::{AsyncPartialProblem, AsyncProblem};
use crate::core::candidate::Candidate;
use crate::core::evaluation::Evaluation;
/// 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
}
/// Evaluate every decision at the given `budget` concurrently against a
/// multi-fidelity `problem`, preserving input order. Hyperband's async
/// path uses this for each Successive-Halving rung.
pub async fn evaluate_batch_at_budget_async<P>(
problem: &P,
decisions: &[P::Decision],
budget: f64,
concurrency: usize,
) -> Vec<Evaluation>
where
P: AsyncPartialProblem,
{
assert!(
concurrency >= 1,
"evaluate_batch_at_budget_async concurrency must be >= 1"
);
let mut out: Vec<Evaluation> = Vec::with_capacity(decisions.len());
let mut idx = 0usize;
while idx < decisions.len() {
let mut futs = FuturesOrdered::new();
let end = (idx + concurrency).min(decisions.len());
for d in &decisions[idx..end] {
futs.push_back(async move { problem.evaluate_at_budget_async(d, budget).await });
}
while let Some(e) = futs.next().await {
out.push(e);
}
idx = end;
}
out
}
+123
View File
@@ -220,6 +220,129 @@ where
}
}
#[cfg(feature = "async")]
impl ParticleSwarm {
/// 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
/// swarm, per-generation positions, and the final evaluation pass).
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 crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.swarm_size >= 1,
"ParticleSwarm swarm_size must be >= 1",
);
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"ParticleSwarm requires exactly one objective",
);
let direction = objectives.objectives[0].direction;
let dim = self.bounds.bounds.len();
let n = self.config.swarm_size;
let mut rng = rng_from_seed(self.config.seed);
let mut positions: Vec<Vec<f64>> = {
use crate::traits::Initializer as _;
self.bounds.initialize(n, &mut rng)
};
let mut velocities: Vec<Vec<f64>> = (0..n)
.map(|_| {
self.bounds
.bounds
.iter()
.map(|&(lo, hi)| 0.1 * (hi - lo) * (rng.random::<f64>() * 2.0 - 1.0))
.collect()
})
.collect();
let v_max: Vec<f64> = self.bounds.bounds.iter().map(|&(lo, hi)| hi - lo).collect();
let initial_pop = evaluate_batch_async(problem, positions.clone(), concurrency).await;
let mut evaluations = initial_pop.len();
let mut pbest_decisions: Vec<Vec<f64>> = positions.clone();
let mut pbest_evals: Vec<f64> = initial_pop
.iter()
.map(|c| c.evaluation.objectives[0])
.collect();
let mut gbest_idx = best_index(&pbest_evals, direction);
let mut gbest_decision = pbest_decisions[gbest_idx].clone();
let mut gbest_eval = pbest_evals[gbest_idx];
for _ in 0..self.config.generations {
for i in 0..n {
#[allow(clippy::needless_range_loop)]
for j in 0..dim {
let r1: f64 = rng.random();
let r2: f64 = rng.random();
let cognitive_term =
self.config.cognitive * r1 * (pbest_decisions[i][j] - positions[i][j]);
let social_term =
self.config.social * r2 * (gbest_decision[j] - positions[i][j]);
let mut v =
self.config.inertia * velocities[i][j] + cognitive_term + social_term;
if v > v_max[j] {
v = v_max[j];
} else if v < -v_max[j] {
v = -v_max[j];
}
velocities[i][j] = v;
let (lo, hi) = self.bounds.bounds[j];
positions[i][j] = (positions[i][j] + v).clamp(lo, hi);
}
}
let evaluated = evaluate_batch_async(problem, positions.clone(), concurrency).await;
evaluations += evaluated.len();
for (i, cand) in evaluated.iter().enumerate() {
let f = cand.evaluation.objectives[0];
let improves = match direction {
Direction::Minimize => f < pbest_evals[i],
Direction::Maximize => f > pbest_evals[i],
};
if improves {
pbest_decisions[i] = positions[i].clone();
pbest_evals[i] = f;
gbest_idx = i;
let beats_global = match direction {
Direction::Minimize => f < gbest_eval,
Direction::Maximize => f > gbest_eval,
};
if beats_global {
gbest_decision = pbest_decisions[i].clone();
gbest_eval = f;
}
}
}
}
let _ = gbest_idx;
let final_pop = evaluate_batch_async(problem, positions, concurrency).await;
evaluations += final_pop.len();
let best = best_candidate(&final_pop, &objectives);
let front: Vec<Candidate<Vec<f64>>> = best.iter().cloned().collect();
OptimizationResult::new(
Population::new(final_pop),
front,
best,
evaluations,
self.config.generations,
)
}
}
fn best_index(values: &[f64], direction: Direction) -> usize {
let mut idx = 0;
for i in 1..values.len() {
+103
View File
@@ -204,6 +204,109 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> PesaII<I, V> {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` bounds in-flight evaluations of the initial
/// population. Per-step evaluations are sequential to preserve the
/// algorithm's exact RNG sequencing.
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size > 0,
"PesaII population_size must be > 0"
);
assert!(
self.config.archive_size > 0,
"PesaII archive_size must be > 0"
);
assert!(
self.config.grid_divisions >= 1,
"PesaII grid_divisions must be >= 1"
);
let n = self.config.population_size;
let objectives = problem.objectives();
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n, &mut rng);
let mut internal: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = internal.len();
let mut archive = ParetoArchive::new(objectives.clone());
for c in &internal {
archive.insert(c.clone());
}
truncate_by_grid(
&mut archive,
self.config.archive_size,
self.config.grid_divisions,
);
for _ in 0..self.config.generations {
let (boxes, counts) = build_grid(&archive, &objectives, self.config.grid_divisions);
let mut offspring: Vec<Candidate<P::Decision>> = Vec::with_capacity(n);
while offspring.len() < n {
let p1 = region_tournament(&archive, &boxes, &counts, &mut rng);
let p2 = region_tournament(&archive, &boxes, &counts, &mut rng);
let parents = vec![
archive.members()[p1].decision.clone(),
archive.members()[p2].decision.clone(),
];
let children = self.variation.vary(&parents, &mut rng);
assert!(
!children.is_empty(),
"PesaII variation returned no children"
);
for child in children {
if offspring.len() >= n {
break;
}
let eval = problem.evaluate_async(&child).await;
evaluations += 1;
offspring.push(Candidate::new(child, eval));
}
}
for c in &offspring {
archive.insert(c.clone());
}
truncate_by_grid(
&mut archive,
self.config.archive_size,
self.config.grid_divisions,
);
internal = offspring;
}
let _ = internal;
let members = archive.into_vec();
let front = pareto_front(&members, &objectives);
let best = best_candidate(&members, &objectives);
OptimizationResult::new(
Population::new(members),
front,
best,
evaluations,
self.config.generations,
)
}
}
/// Compute per-member box index (M-tuple of grid coordinates) and the
/// population count of each occupied box.
fn build_grid<D: Clone>(
+45
View File
@@ -113,6 +113,51 @@ where
}
}
#[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 best = best_candidate(&all, &objectives);
OptimizationResult::new(
Population::new(all),
front,
best,
evaluations,
self.config.iterations,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
+157
View File
@@ -261,6 +261,163 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> Rvea<I, V> {
/// 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.
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size > 0,
"Rvea population_size must be > 0"
);
let n = self.config.population_size;
let objectives = problem.objectives();
let m = objectives.len();
let raw_refs = das_dennis(m, self.config.reference_divisions);
let references: Vec<Vec<f64>> = raw_refs.into_iter().map(unit_normalize).collect();
assert!(
!references.is_empty(),
"Rvea: no reference vectors generated"
);
let theta_max = smallest_neighbor_angle(&references);
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n, &mut rng);
let mut population: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = population.len();
for gen_idx in 0..self.config.generations {
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
while offspring_decisions.len() < n {
let p1 = rng.random_range(0..population.len());
let p2 = rng.random_range(0..population.len());
let parents = vec![
population[p1].decision.clone(),
population[p2].decision.clone(),
];
let children = self.variation.vary(&parents, &mut rng);
assert!(!children.is_empty(), "Rvea variation returned no children");
for child in children {
if offspring_decisions.len() >= n {
break;
}
offspring_decisions.push(child);
}
}
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
evaluations += offspring.len();
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
combined.extend(population);
combined.extend(offspring);
let m_dim = m;
let mut ideal = vec![f64::INFINITY; m_dim];
for c in &combined {
let oriented = objectives.as_minimization(&c.evaluation.objectives);
for (k, v) in oriented.iter().enumerate() {
if *v < ideal[k] {
ideal[k] = *v;
}
}
}
let translated: Vec<Vec<f64>> = combined
.iter()
.map(|c| {
let oriented = objectives.as_minimization(&c.evaluation.objectives);
oriented
.iter()
.enumerate()
.map(|(k, v)| v - ideal[k])
.collect()
})
.collect();
let mut assoc: Vec<usize> = vec![0; combined.len()];
let mut angles: Vec<f64> = vec![0.0; combined.len()];
for (i, t) in translated.iter().enumerate() {
let (best_ref, best_angle) = closest_reference(t, &references);
assoc[i] = best_ref;
angles[i] = best_angle;
}
let alpha_t = (gen_idx as f64 / (self.config.generations as f64).max(1.0))
.powf(self.config.alpha);
let mut keep: Vec<Option<(usize, f64)>> = vec![None; references.len()];
for i in 0..combined.len() {
let r = assoc[i];
let length: f64 = translated[i].iter().map(|v| v * v).sum::<f64>().sqrt();
let theta_max_safe = theta_max.max(1e-12);
let penalty = 1.0 + (m_dim as f64) * alpha_t * (angles[i] / theta_max_safe);
let apd = penalty * length;
match keep[r] {
None => keep[r] = Some((i, apd)),
Some((_, current)) if apd < current => keep[r] = Some((i, apd)),
_ => {}
}
}
let mut next: Vec<Candidate<P::Decision>> = keep
.into_iter()
.flatten()
.map(|(i, _)| combined[i].clone())
.collect();
if next.len() < n {
let mut all_apds: Vec<(usize, f64)> = (0..combined.len())
.map(|i| {
let length: f64 = translated[i].iter().map(|v| v * v).sum::<f64>().sqrt();
let theta_max_safe = theta_max.max(1e-12);
let penalty = 1.0 + (m_dim as f64) * alpha_t * (angles[i] / theta_max_safe);
(i, penalty * length)
})
.collect();
all_apds.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
for (i, _) in all_apds {
if next.len() >= n {
break;
}
if !next
.iter()
.any(|c| std::ptr::eq(c as *const _, &combined[i] as *const _))
{
next.push(combined[i].clone());
}
}
}
if next.len() > n {
next.truncate(n);
}
population = next;
}
let front = pareto_front(&population, &objectives);
let best = best_candidate(&population, &objectives);
OptimizationResult::new(
Population::new(population),
front,
best,
evaluations,
self.config.generations,
)
}
}
fn unit_normalize(mut v: Vec<f64>) -> Vec<f64> {
let n: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
if n > 1e-12 {
+118
View File
@@ -215,6 +215,124 @@ fn better_than(
}
}
#[cfg(feature = "async")]
impl<I, V> SimulatedAnnealing<I, V> {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` is mostly inert here because SA evaluates one
/// child per iteration; it's accepted for API parity with other
/// algorithms.
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>,
V: Variation<P::Decision>,
{
let _ = concurrency;
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"SimulatedAnnealing requires exactly one objective",
);
assert!(
self.config.initial_temperature > 0.0,
"SimulatedAnnealing initial_temperature must be positive",
);
assert!(
self.config.final_temperature > 0.0,
"SimulatedAnnealing final_temperature must be positive",
);
assert!(
self.config.final_temperature <= self.config.initial_temperature,
"SimulatedAnnealing final_temperature must be <= initial_temperature",
);
let direction = objectives.objectives[0].direction;
let mut rng = rng_from_seed(self.config.seed);
let mut initial = self.initializer.initialize(1, &mut rng);
assert!(
!initial.is_empty(),
"SimulatedAnnealing initializer returned no decisions",
);
let mut current_decision = initial.remove(0);
let mut current_eval = problem.evaluate_async(&current_decision).await;
let mut best_decision = current_decision.clone();
let mut best_eval = current_eval.clone();
let mut evaluations = 1usize;
let cooling = if self.config.iterations <= 1 {
1.0
} else {
(self.config.final_temperature / self.config.initial_temperature)
.powf(1.0 / (self.config.iterations as f64 - 1.0))
};
let mut temperature = self.config.initial_temperature;
for _ in 0..self.config.iterations {
let parents = vec![current_decision.clone()];
let children = self.variation.vary(&parents, &mut rng);
assert!(
!children.is_empty(),
"SimulatedAnnealing variation returned no children"
);
let child_decision = children.into_iter().next().unwrap();
let child_eval = problem.evaluate_async(&child_decision).await;
evaluations += 1;
let accept = match (child_eval.is_feasible(), current_eval.is_feasible()) {
(true, false) => true,
(false, true) => false,
(false, false) => {
child_eval.constraint_violation <= current_eval.constraint_violation
}
(true, true) => {
let delta = match direction {
Direction::Minimize => {
child_eval.objectives[0] - current_eval.objectives[0]
}
Direction::Maximize => {
current_eval.objectives[0] - child_eval.objectives[0]
}
};
if delta <= 0.0 {
true
} else {
let prob = (-delta / temperature).exp();
rng.random::<f64>() < prob
}
}
};
if accept {
current_decision = child_decision;
current_eval = child_eval;
if better_than(&current_eval, &best_eval, direction) {
best_decision = current_decision.clone();
best_eval = current_eval.clone();
}
}
temperature *= cooling;
}
let best = Candidate::new(best_decision, best_eval);
let population = Population::new(vec![best.clone()]);
let front = vec![best.clone()];
OptimizationResult::new(
population,
front,
Some(best),
evaluations,
self.config.iterations,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
+74
View File
@@ -173,6 +173,80 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> SmsEmoa<I, V> {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` bounds in-flight evaluations of the initial
/// population. Per-generation evaluations are sequential because
/// SMS-EMOA is a steady-state algorithm (one child per generation).
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size > 0,
"SmsEmoa population_size must be > 0"
);
let n = self.config.population_size;
let objectives = problem.objectives();
assert_eq!(
self.config.reference_point.len(),
objectives.len(),
"SmsEmoa reference_point.len() must equal number of objectives",
);
let reference = self.config.reference_point.clone();
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n, &mut rng);
let mut population: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = population.len();
for _ in 0..self.config.generations {
let p1 = rng.random_range(0..population.len());
let p2 = rng.random_range(0..population.len());
let parents = vec![
population[p1].decision.clone(),
population[p2].decision.clone(),
];
let children = self.variation.vary(&parents, &mut rng);
assert!(
!children.is_empty(),
"SmsEmoa variation returned no children"
);
let child_decision = children.into_iter().next().unwrap();
let child_eval = problem.evaluate_async(&child_decision).await;
evaluations += 1;
let child = Candidate::new(child_decision, child_eval);
population.push(child);
let drop_idx = pick_drop_index(&population, &objectives, &reference);
population.swap_remove(drop_idx);
}
let front = pareto_front(&population, &objectives);
let best = best_candidate(&population, &objectives);
OptimizationResult::new(
Population::new(population),
front,
best,
evaluations,
self.config.generations,
)
}
}
/// Choose the index in `pool` whose removal is preferred per SMS-EMOA's
/// rules: drop from the worst non-dominated front; within that front,
/// drop the member whose removal increases hypervolume the most (= the
+131
View File
@@ -222,6 +222,137 @@ where
}
}
#[cfg(feature = "async")]
impl SeparableNes {
/// 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 generation.
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 crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size >= 2,
"SeparableNes population_size must be >= 2",
);
assert!(
self.config.initial_sigma > 0.0,
"SeparableNes initial_sigma must be > 0"
);
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"SeparableNes requires exactly one objective",
);
let direction = objectives.objectives[0].direction;
let n = self.bounds.bounds.len();
let lambda = self.config.population_size;
let mut rng = rng_from_seed(self.config.seed);
let mut mean: Vec<f64> = self
.bounds
.bounds
.iter()
.map(|&(lo, hi)| 0.5 * (lo + hi))
.collect();
let mut sigma = vec![self.config.initial_sigma; n];
let eta_sigma = self
.config
.sigma_learning_rate
.unwrap_or_else(|| (3.0 + (n as f64).ln()) / (5.0 * (n as f64).sqrt()));
let eta_mean = self.config.mean_learning_rate;
let utilities = nes_utilities(lambda);
let mut best_seen: Option<Candidate<Vec<f64>>> = None;
let mut total_evaluations = 0usize;
for _ in 0..self.config.generations {
// Sample λ offspring; matches the sync RNG draw order so seeded
// runs reproduce exactly.
let mut z_samples: Vec<Vec<f64>> = Vec::with_capacity(lambda);
let mut x_samples: Vec<Vec<f64>> = Vec::with_capacity(lambda);
for _ in 0..lambda {
let z: Vec<f64> = (0..n)
.map(|_| Normal::new(0.0, 1.0).unwrap().sample(&mut rng))
.collect();
let x: Vec<f64> = (0..n)
.map(|j| {
let v = mean[j] + sigma[j] * z[j];
let (lo, hi) = self.bounds.bounds[j];
v.clamp(lo, hi)
})
.collect();
z_samples.push(z);
x_samples.push(x);
}
let cands = evaluate_batch_async(problem, x_samples.clone(), concurrency).await;
total_evaluations += cands.len();
let evals: Vec<Evaluation> = cands.iter().map(|c| c.evaluation.clone()).collect();
for c in &cands {
let beats_best = match &best_seen {
None => true,
Some(b) => better(&c.evaluation, &b.evaluation, direction),
};
if beats_best {
best_seen = Some(c.clone());
}
}
let mut order: Vec<usize> = (0..lambda).collect();
order.sort_by(|&a, &b| compare(&evals[a], &evals[b], direction));
let mut grad_mean = vec![0.0_f64; n];
for k in 0..lambda {
let u = utilities[k];
let z = &z_samples[order[k]];
for j in 0..n {
grad_mean[j] += u * z[j];
}
}
for j in 0..n {
mean[j] += eta_mean * sigma[j] * grad_mean[j];
let (lo, hi) = self.bounds.bounds[j];
mean[j] = mean[j].clamp(lo, hi);
}
for j in 0..n {
let mut grad_sigma_j = 0.0;
for k in 0..lambda {
let u = utilities[k];
let z = &z_samples[order[k]];
grad_sigma_j += u * (z[j] * z[j] - 1.0);
}
sigma[j] *= (0.5 * eta_sigma * grad_sigma_j).exp();
if !sigma[j].is_finite() || sigma[j] < 1e-30 {
sigma[j] = 1e-30;
}
}
}
let best = best_seen.expect("at least one generation evaluated");
let population = Population::new(vec![best.clone()]);
let front = vec![best.clone()];
OptimizationResult::new(
population,
front,
Some(best),
total_evaluations,
self.config.generations,
)
}
}
fn nes_utilities(lambda: usize) -> Vec<f64> {
let half = lambda as f64 / 2.0 + 1.0;
let raw: Vec<f64> = (0..lambda)
+85
View File
@@ -169,6 +169,91 @@ where
}
}
#[cfg(feature = "async")]
impl<I, V> Spea2<I, V> {
/// 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.
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>,
V: Variation<P::Decision>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size > 0,
"Spea2 population_size must be greater than 0",
);
assert!(
self.config.archive_size > 0,
"Spea2 archive_size must be greater than 0",
);
let n_pop = self.config.population_size;
let n_arc = self.config.archive_size;
let objectives = problem.objectives();
let mut rng = rng_from_seed(self.config.seed);
let initial_decisions = self.initializer.initialize(n_pop, &mut rng);
assert_eq!(
initial_decisions.len(),
n_pop,
"SPEA2 initializer must return exactly population_size decisions",
);
let mut population: Vec<Candidate<P::Decision>> =
evaluate_batch_async(problem, initial_decisions, concurrency).await;
let mut evaluations = population.len();
let mut archive: Vec<Candidate<P::Decision>> = Vec::new();
for _ in 0..self.config.generations {
let mut pool: Vec<Candidate<P::Decision>> =
Vec::with_capacity(population.len() + archive.len());
pool.append(&mut population);
pool.append(&mut archive);
let fitness = compute_fitness(&pool, &objectives);
archive = build_archive(&pool, &fitness, &objectives, n_arc);
let archive_fitness = compute_fitness(&archive, &objectives);
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n_pop);
while offspring_decisions.len() < n_pop {
let p1 = binary_tournament(&archive_fitness, &mut rng);
let p2 = binary_tournament(&archive_fitness, &mut rng);
let parents = vec![archive[p1].decision.clone(), archive[p2].decision.clone()];
let children = self.variation.vary(&parents, &mut rng);
assert!(!children.is_empty(), "SPEA2 variation returned no children");
for child_decision in children {
if offspring_decisions.len() >= n_pop {
break;
}
offspring_decisions.push(child_decision);
}
}
let new_population =
evaluate_batch_async(problem, offspring_decisions, concurrency).await;
evaluations += new_population.len();
population = new_population;
}
let front = pareto_front(&archive, &objectives);
let best = best_candidate(&archive, &objectives);
OptimizationResult::new(
Population::new(archive),
front,
best,
evaluations,
self.config.generations,
)
}
}
/// SPEA2 fitness: `R(i) + D(i)`, where lower is better.
///
/// `R(i)` is the sum of `S(j)` over all `j` that dominate `i`. `S(j)` is the
+122
View File
@@ -209,6 +209,128 @@ fn better_than(
}
}
#[cfg(feature = "async")]
impl<D, I, N> TabuSearch<D, I, N>
where
D: Clone + Hash + Eq,
I: Initializer<D>,
N: FnMut(&D, &mut Rng) -> Vec<D>,
{
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// Each iteration evaluates the K neighbors of the current
/// incumbent concurrently (bounded by `concurrency`), then picks
/// the best non-tabu (or aspiration-passing) move.
pub async fn run_async<P>(&mut self, problem: &P, concurrency: usize) -> OptimizationResult<D>
where
P: crate::core::async_problem::AsyncProblem<Decision = D>,
D: Send + Sync,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"TabuSearch requires exactly one objective",
);
assert!(
self.config.tabu_tenure >= 1,
"TabuSearch tabu_tenure must be >= 1",
);
let direction = objectives.objectives[0].direction;
let mut rng = rng_from_seed(self.config.seed);
let mut initial = self.initializer.initialize(1, &mut rng);
assert!(
!initial.is_empty(),
"TabuSearch initializer returned no decisions"
);
let mut current_decision = initial.remove(0);
let mut current_eval = problem.evaluate_async(&current_decision).await;
let mut best_decision = current_decision.clone();
let mut best_eval = current_eval.clone();
let mut evaluations = 1usize;
let mut tabu_queue: VecDeque<D> = VecDeque::with_capacity(self.config.tabu_tenure);
let mut tabu_set: HashSet<D> = HashSet::new();
for _ in 0..self.config.iterations {
let candidates = (self.neighbors)(&current_decision, &mut rng);
if candidates.is_empty() {
break;
}
let cand_results = evaluate_batch_async(problem, candidates.clone(), concurrency).await;
let mut cand_evals: Vec<crate::core::evaluation::Evaluation> =
cand_results.into_iter().map(|c| c.evaluation).collect();
evaluations += candidates.len();
let mut best_idx: Option<usize> = None;
let mut best_cand_eval: Option<crate::core::evaluation::Evaluation> = None;
for (i, c) in candidates.iter().enumerate() {
let is_tabu = tabu_set.contains(c);
let aspires = is_tabu && better_than(&cand_evals[i], &best_eval, direction);
if is_tabu && !aspires {
continue;
}
let eligible = match &best_cand_eval {
None => true,
Some(b) => better_than(&cand_evals[i], b, direction),
};
if eligible {
best_idx = Some(i);
best_cand_eval = Some(cand_evals[i].clone());
}
}
if best_idx.is_none() {
for (i, _) in candidates.iter().enumerate() {
let eligible = match &best_cand_eval {
None => true,
Some(b) => better_than(&cand_evals[i], b, direction),
};
if eligible {
best_idx = Some(i);
best_cand_eval = Some(cand_evals[i].clone());
}
}
}
let chosen_idx = best_idx.expect("non-empty candidate list");
let chosen_decision = candidates[chosen_idx].clone();
current_eval = cand_evals.remove(chosen_idx);
current_decision = chosen_decision.clone();
if better_than(&current_eval, &best_eval, direction) {
best_decision = current_decision.clone();
best_eval = current_eval.clone();
}
tabu_queue.push_back(chosen_decision.clone());
tabu_set.insert(chosen_decision);
if tabu_queue.len() > self.config.tabu_tenure {
if let Some(old) = tabu_queue.pop_front() {
tabu_set.remove(&old);
}
}
}
let best = Candidate::new(best_decision, best_eval);
let population = Population::new(vec![best.clone()]);
let front = vec![best.clone()];
OptimizationResult::new(
population,
front,
Some(best),
evaluations,
self.config.iterations,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
+116
View File
@@ -187,6 +187,122 @@ where
}
}
#[cfg(feature = "async")]
impl Tlbo {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` bounds in-flight evaluations within batched phases
/// (only the initial population uses a batch; the teacher and learner
/// phases evaluate sequentially because each accept/reject step
/// depends on the previous one).
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 crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size >= 2,
"Tlbo population_size must be >= 2"
);
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"Tlbo requires exactly one objective",
);
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>> = {
use crate::traits::Initializer as _;
self.bounds.initialize(n, &mut rng)
};
let initial = evaluate_batch_async(problem, decisions.clone(), concurrency).await;
let mut evals: Vec<Evaluation> = initial.iter().map(|c| c.evaluation.clone()).collect();
let mut evaluations = initial.len();
for _ in 0..self.config.generations {
let teacher_idx = best_index(&evals, direction);
let teacher = decisions[teacher_idx].clone();
let mut mean = vec![0.0_f64; dim];
for d in &decisions {
for j in 0..dim {
mean[j] += d[j];
}
}
for v in mean.iter_mut() {
*v /= n as f64;
}
let tf = if rng.random_bool(0.5) { 1.0 } else { 2.0 };
for i in 0..n {
let mut candidate = decisions[i].clone();
for j in 0..dim {
let r: f64 = rng.random();
candidate[j] += r * (teacher[j] - tf * mean[j]);
let (lo, hi) = self.bounds.bounds[j];
candidate[j] = candidate[j].clamp(lo, hi);
}
let cand_eval = problem.evaluate_async(&candidate).await;
evaluations += 1;
if better(&cand_eval, &evals[i], direction) {
decisions[i] = candidate;
evals[i] = cand_eval;
}
}
for i in 0..n {
let mut k = rng.random_range(0..n);
while k == i && n > 1 {
k = rng.random_range(0..n);
}
let partner_better = better(&evals[k], &evals[i], direction);
let mut candidate = decisions[i].clone();
for j in 0..dim {
let r: f64 = rng.random();
let delta = if partner_better {
r * (decisions[k][j] - decisions[i][j])
} else {
r * (decisions[i][j] - decisions[k][j])
};
candidate[j] += delta;
let (lo, hi) = self.bounds.bounds[j];
candidate[j] = candidate[j].clamp(lo, hi);
}
let cand_eval = problem.evaluate_async(&candidate).await;
evaluations += 1;
if better(&cand_eval, &evals[i], direction) {
decisions[i] = candidate;
evals[i] = cand_eval;
}
}
}
let final_pop: Vec<Candidate<Vec<f64>>> = decisions
.into_iter()
.zip(evals)
.map(|(d, e)| Candidate::new(d, e))
.collect();
let best = best_candidate(&final_pop, &objectives);
let front: Vec<Candidate<Vec<f64>>> = best.iter().cloned().collect();
OptimizationResult::new(
Population::new(final_pop),
front,
best,
evaluations,
self.config.generations,
)
}
}
fn best_index(evals: &[Evaluation], direction: Direction) -> usize {
let mut idx = 0;
for i in 1..evals.len() {
+123
View File
@@ -356,6 +356,129 @@ fn scott_bandwidths(decisions: &[Vec<f64>], support: &[usize], factor: f64) -> V
.collect()
}
#[cfg(feature = "async")]
impl Tpe {
/// Async version of [`Optimizer::run`] — drives evaluations through
/// the user-chosen async runtime. Available only with the `async`
/// feature.
///
/// `concurrency` bounds in-flight evaluations during the initial
/// uniform-sample design; the sequential TPE loop runs one
/// evaluation per iteration regardless.
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 crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.initial_samples >= 2,
"Tpe initial_samples must be >= 2"
);
assert!(
self.config.good_fraction > 0.0 && self.config.good_fraction < 1.0,
"Tpe good_fraction must be in (0, 1)",
);
assert!(
self.config.candidate_samples >= 1,
"Tpe candidate_samples must be >= 1",
);
assert!(
self.config.bandwidth_factor > 0.0,
"Tpe bandwidth_factor must be > 0"
);
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"Tpe requires exactly one objective",
);
let direction = objectives.objectives[0].direction;
let dim = self.bounds.bounds.len();
let mut rng = rng_from_seed(self.config.seed);
let mut decisions: Vec<Vec<f64>> = Vec::new();
let mut targets: Vec<f64> = Vec::new();
let mut evals: Vec<Evaluation> = Vec::new();
let initial_decisions: Vec<Vec<f64>> = (0..self.config.initial_samples)
.map(|_| sample_uniform_in_bounds(&self.bounds, &mut rng))
.collect();
let initial_cands = evaluate_batch_async(problem, initial_decisions, concurrency).await;
for c in initial_cands {
targets.push(oriented_target(&c.evaluation, direction));
decisions.push(c.decision);
evals.push(c.evaluation);
}
for _ in 0..self.config.iterations {
let (good_idx, bad_idx) = split_good_bad(&targets, self.config.good_fraction);
let mut best_x: Option<Vec<f64>> = None;
let mut best_ratio = f64::NEG_INFINITY;
for _ in 0..self.config.candidate_samples {
let cand = sample_from_kde(
&decisions,
&good_idx,
&self.bounds,
self.config.bandwidth_factor,
&mut rng,
);
let l = log_kde_density(
&cand,
&decisions,
&good_idx,
&self.bounds,
self.config.bandwidth_factor,
);
let g = log_kde_density(
&cand,
&decisions,
&bad_idx,
&self.bounds,
self.config.bandwidth_factor,
);
let ratio = l - g;
if ratio > best_ratio {
best_ratio = ratio;
best_x = Some(cand);
}
}
let x = best_x.expect("at least one candidate sampled");
let _ = dim;
let e = problem.evaluate_async(&x).await;
targets.push(oriented_target(&e, direction));
decisions.push(x);
evals.push(e);
}
let mut best_idx = 0;
for i in 1..evals.len() {
if better(&evals[i], &evals[best_idx], direction) {
best_idx = i;
}
}
let total_evals = evals.len();
let final_pop: Vec<Candidate<Vec<f64>>> = decisions
.into_iter()
.zip(evals)
.map(|(d, e)| Candidate::new(d, e))
.collect();
let best = final_pop[best_idx].clone();
let front = vec![best.clone()];
OptimizationResult::new(
Population::new(final_pop),
front,
Some(best),
total_evals,
self.config.iterations + self.config.initial_samples,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
+119
View File
@@ -202,6 +202,125 @@ where
}
}
#[cfg(feature = "async")]
impl Umda {
/// 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 samples).
pub async fn run_async<P>(
&mut self,
problem: &P,
concurrency: usize,
) -> OptimizationResult<Vec<bool>>
where
P: crate::core::async_problem::AsyncProblem<Decision = Vec<bool>>,
{
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
assert!(
self.config.population_size >= 2,
"Umda population_size must be >= 2"
);
assert!(
self.config.selected_size >= 1,
"Umda selected_size must be >= 1",
);
assert!(
self.config.selected_size <= self.config.population_size,
"Umda selected_size must be <= population_size",
);
assert!(self.config.bits >= 1, "Umda bits must be >= 1");
let objectives = problem.objectives();
assert!(
objectives.is_single_objective(),
"Umda requires exactly one objective",
);
let direction = objectives.objectives[0].direction;
let n = self.config.population_size;
let bits = self.config.bits;
let mu = self.config.selected_size;
let mut rng = rng_from_seed(self.config.seed);
let mut decisions: Vec<Vec<bool>> = (0..n)
.map(|_| (0..bits).map(|_| rng.random_bool(0.5)).collect())
.collect();
let mut population = evaluate_batch_async(problem, decisions.clone(), concurrency).await;
let mut evaluations = population.len();
let smoothing = 1.0 / (2.0 * mu as f64);
let prob_min = smoothing;
let prob_max = 1.0 - smoothing;
let mut best_seen: Option<Candidate<Vec<bool>>> = None;
for c in &population {
let beats = match &best_seen {
None => true,
Some(b) => better_than_so(&c.evaluation, &b.evaluation, direction),
};
if beats {
best_seen = Some(c.clone());
}
}
for _ in 0..self.config.generations {
let mut order: Vec<usize> = (0..population.len()).collect();
order.sort_by(|&a, &b| {
compare_so(
&population[a].evaluation,
&population[b].evaluation,
direction,
)
});
let selected: Vec<&Candidate<Vec<bool>>> =
order.iter().take(mu).map(|&i| &population[i]).collect();
let mut probs = vec![0.0_f64; bits];
for c in &selected {
for (i, b) in c.decision.iter().enumerate() {
if *b {
probs[i] += 1.0;
}
}
}
for p in probs.iter_mut() {
*p = (*p / mu as f64).clamp(prob_min, prob_max);
}
decisions = (0..n)
.map(|_| probs.iter().map(|&p| rng.random_bool(p)).collect())
.collect();
population = evaluate_batch_async(problem, decisions.clone(), concurrency).await;
evaluations += population.len();
for c in &population {
let beats = match &best_seen {
None => true,
Some(b) => better_than_so(&c.evaluation, &b.evaluation, direction),
};
if beats {
best_seen = Some(c.clone());
}
}
}
let best = best_seen.expect("at least one generation evaluated");
let final_pop = vec![best.clone()];
let front = vec![best.clone()];
let best_opt = best_candidate(&final_pop, &objectives);
OptimizationResult::new(
Population::new(final_pop),
front,
best_opt,
evaluations,
self.config.generations,
)
}
}
fn compare_so(
a: &crate::core::evaluation::Evaluation,
b: &crate::core::evaluation::Evaluation,
+81
View File
@@ -0,0 +1,81 @@
//! 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. Every algorithm in heuropt exposes
//! a `run_async` method that drives evaluations through a user-chosen
//! async runtime (typically tokio). Hyperband uses
//! [`AsyncPartialProblem`] instead, which mirrors
//! [`PartialProblem`](crate::core::partial_problem::PartialProblem) for
//! multi-fidelity workloads.
//!
//! 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;
}
/// Async equivalent of [`PartialProblem`](crate::core::partial_problem::PartialProblem)
/// for multi-fidelity workloads — used by Hyperband's `run_async`.
///
/// Like [`AsyncProblem`], `evaluate_at_budget_async` returns a future
/// so callers can fan out budgeted evaluations across an async runtime.
pub trait AsyncPartialProblem: Sync {
/// The thing the optimizer changes. Same constraints as
/// [`PartialProblem::Decision`](crate::core::partial_problem::PartialProblem::Decision).
type Decision: Clone + Send + Sync;
/// Return the objectives for this problem.
fn objectives(&self) -> ObjectiveSpace;
/// Evaluate `decision` at the given fidelity `budget` asynchronously.
///
/// Same monotonicity contract as
/// [`PartialProblem::evaluate_at_budget`](crate::core::partial_problem::PartialProblem::evaluate_at_budget):
/// higher budget should give a more accurate estimate.
fn evaluate_at_budget_async(
&self,
decision: &Self::Decision,
budget: f64,
) -> 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.
#[cfg(feature = "async")]
pub mod async_problem;
pub mod candidate;
pub mod evaluation;
pub mod objective;
@@ -9,6 +11,8 @@ pub mod problem;
pub mod result;
pub mod rng;
#[cfg(feature = "async")]
pub use async_problem::AsyncProblem;
pub use candidate::*;
pub use evaluation::*;
pub use objective::*;
+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
//!
+38
View File
@@ -14,6 +14,26 @@ use crate::core::objective::ObjectiveSpace;
///
/// # Panics
/// If `objectives` does not have exactly two objectives.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
/// use heuropt::metrics::hypervolume_2d;
///
/// let space = ObjectiveSpace::new(vec![
/// Objective::minimize("f1"),
/// Objective::minimize("f2"),
/// ]);
/// // Reference (4, 4); front at (1,3), (2,2), (3,1) → dominated area = 6.
/// let front = [
/// Candidate::new((), Evaluation::new(vec![1.0, 3.0])),
/// Candidate::new((), Evaluation::new(vec![2.0, 2.0])),
/// Candidate::new((), Evaluation::new(vec![3.0, 1.0])),
/// ];
/// let hv = hypervolume_2d(&front, &space, [4.0, 4.0]);
/// assert!((hv - 6.0).abs() < 1e-12);
/// ```
pub fn hypervolume_2d<D>(
front: &[Candidate<D>],
objectives: &ObjectiveSpace,
@@ -147,6 +167,24 @@ mod tests {
///
/// # Panics
/// If `objectives.len() != reference_point.len()`, or if either is zero.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
/// use heuropt::metrics::hypervolume_nd;
///
/// let space = ObjectiveSpace::new(vec![
/// Objective::minimize("f1"),
/// Objective::minimize("f2"),
/// Objective::minimize("f3"),
/// ]);
/// // Single corner point at the origin against a unit-cube reference:
/// // dominated volume = 1.
/// let front = [Candidate::new((), Evaluation::new(vec![0.0, 0.0, 0.0]))];
/// let hv = hypervolume_nd(&front, &space, &[1.0, 1.0, 1.0]);
/// assert!((hv - 1.0).abs() < 1e-12);
/// ```
pub fn hypervolume_nd<D>(
front: &[Candidate<D>],
objectives: &ObjectiveSpace,
+20
View File
@@ -11,6 +11,26 @@ use crate::core::objective::ObjectiveSpace;
/// uniform front has spacing 0.
///
/// Returns `0.0` for empty or single-point fronts (spec §14.1).
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
/// use heuropt::metrics::spacing;
///
/// let space = ObjectiveSpace::new(vec![
/// Objective::minimize("f1"),
/// Objective::minimize("f2"),
/// ]);
/// // Five points evenly spaced on a line — spacing should be 0.
/// let front: Vec<Candidate<()>> = (0..5)
/// .map(|i| {
/// let t = i as f64;
/// Candidate::new((), Evaluation::new(vec![t, 4.0 - t]))
/// })
/// .collect();
/// assert!(spacing(&front, &space) < 1e-12);
/// ```
pub fn spacing<D>(front: &[Candidate<D>], objectives: &ObjectiveSpace) -> f64 {
let n = front.len();
if n < 2 {
+13
View File
@@ -9,6 +9,19 @@ use crate::traits::Variation;
///
/// Always returns exactly one child (spec §11.3). Panics if `probability` is
/// outside `[0.0, 1.0]` or if no parents are provided.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let mut rng = rng_from_seed(42);
/// let mut m = BitFlipMutation { probability: 0.5 };
/// let parent = vec![true, false, true, false];
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
/// assert_eq!(children.len(), 1);
/// assert_eq!(children[0].len(), parent.len());
/// ```
#[derive(Debug, Clone)]
pub struct BitFlipMutation {
/// Per-bit flip probability. Must lie in `[0.0, 1.0]`.
+16
View File
@@ -8,6 +8,22 @@ use crate::traits::Variation;
/// Swap two distinct random indices in the first parent (spec §11.4).
///
/// If the parent has length `< 2` the child is returned unchanged.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let mut rng = rng_from_seed(42);
/// let mut m = SwapMutation;
/// let parent: Vec<usize> = (0..6).collect();
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
/// assert_eq!(children.len(), 1);
/// // Still a permutation of [0, 1, 2, 3, 4, 5]:
/// let mut sorted = children[0].clone();
/// sorted.sort();
/// assert_eq!(sorted, vec![0, 1, 2, 3, 4, 5]);
/// ```
#[derive(Debug, Clone, Copy, Default)]
pub struct SwapMutation;
+101
View File
@@ -10,6 +10,23 @@ use crate::traits::{Initializer, Variation};
///
/// Bounds are inclusive `(lo, hi)` ranges per dimension. Panics if any bound
/// has `lo > hi` (spec §11.1).
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let mut rng = rng_from_seed(42);
/// let mut init = RealBounds::new(vec![(-1.0, 1.0); 3]);
/// let decisions = init.initialize(5, &mut rng);
/// assert_eq!(decisions.len(), 5);
/// for d in &decisions {
/// assert_eq!(d.len(), 3);
/// for &v in d {
/// assert!(v >= -1.0 && v <= 1.0);
/// }
/// }
/// ```
#[derive(Debug, Clone)]
pub struct RealBounds {
/// Per-variable inclusive bounds in decision order.
@@ -54,6 +71,19 @@ impl Initializer<Vec<f64>> for RealBounds {
/// Add `Normal(0, sigma)` noise to every variable of the first parent.
///
/// Always returns exactly one child. Does not enforce bounds in v1 (spec §11.2).
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let mut rng = rng_from_seed(42);
/// let mut m = GaussianMutation { sigma: 0.1 };
/// let parent = vec![0.0; 4];
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
/// assert_eq!(children.len(), 1);
/// assert_eq!(children[0].len(), parent.len());
/// ```
#[derive(Debug, Clone)]
pub struct GaussianMutation {
/// Standard deviation of the Gaussian noise. Must be positive.
@@ -88,6 +118,26 @@ impl Variation<Vec<f64>> for GaussianMutation {
///
/// Panics on construction if any bound has `lo > hi`, or at run time if
/// `parents.len() < 2` or any parent length differs from `bounds.len()`.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let bounds = vec![(-1.0, 1.0); 3];
/// let mut sbx = SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5);
/// let mut rng = rng_from_seed(42);
/// let parents = [vec![-0.5, 0.0, 0.5], vec![0.5, 0.5, -0.5]];
/// let children = sbx.vary(&parents, &mut rng);
/// assert_eq!(children.len(), 2);
/// // Children stay in bounds.
/// for c in &children {
/// for (j, &v) in c.iter().enumerate() {
/// let (lo, hi) = bounds[j];
/// assert!(v >= lo && v <= hi);
/// }
/// }
/// ```
#[derive(Debug, Clone)]
pub struct SimulatedBinaryCrossover {
/// Per-variable inclusive bounds. Length must match the parent decisions.
@@ -180,6 +230,23 @@ impl Variation<Vec<f64>> for SimulatedBinaryCrossover {
///
/// This is the simple bound-rescale form; the bound-aware `δ_q` variant from
/// the full paper is left as a future refinement.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let bounds = vec![(-1.0, 1.0); 3];
/// let mut pm = PolynomialMutation::new(bounds.clone(), 20.0, 1.0 / 3.0);
/// let mut rng = rng_from_seed(42);
/// let parent = vec![0.0, 0.5, -0.5];
/// let children = pm.vary(std::slice::from_ref(&parent), &mut rng);
/// assert_eq!(children.len(), 1);
/// for (j, &v) in children[0].iter().enumerate() {
/// let (lo, hi) = bounds[j];
/// assert!(v >= lo && v <= hi);
/// }
/// ```
#[derive(Debug, Clone)]
pub struct PolynomialMutation {
/// Per-variable inclusive bounds. Length must match the parent decision.
@@ -254,6 +321,23 @@ impl Variation<Vec<f64>> for PolynomialMutation {
/// Always returns exactly one child. Use this when you want feasibility
/// maintained across generations without leaning on
/// clamp-inside-`Problem::evaluate`.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let bounds = vec![(-1.0, 1.0); 3];
/// let mut m = BoundedGaussianMutation::new(0.3, bounds.clone());
/// let mut rng = rng_from_seed(42);
/// let parent = vec![0.0; 3];
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
/// assert_eq!(children.len(), 1);
/// for (j, &v) in children[0].iter().enumerate() {
/// let (lo, hi) = bounds[j];
/// assert!(v >= lo && v <= hi);
/// }
/// ```
#[derive(Debug, Clone)]
pub struct BoundedGaussianMutation {
/// Standard deviation of the Gaussian noise. Must be positive.
@@ -315,6 +399,23 @@ impl Variation<Vec<f64>> for BoundedGaussianMutation {
/// produce a Lévy(α) sample. `alpha` is the tail exponent in `(0, 2]`;
/// typical value is `1.5`. `1.0` gives the Cauchy distribution (very
/// heavy); `2.0` collapses to the Normal.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let bounds = vec![(-1.0, 1.0); 3];
/// let mut m = LevyMutation::new(1.5, 0.1, bounds.clone());
/// let mut rng = rng_from_seed(42);
/// let parent = vec![0.0; 3];
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
/// assert_eq!(children.len(), 1);
/// for (j, &v) in children[0].iter().enumerate() {
/// let (lo, hi) = bounds[j];
/// assert!(v >= lo && v <= hi);
/// }
/// ```
#[derive(Debug, Clone)]
pub struct LevyMutation {
/// Tail exponent `α ∈ (0, 2]`. Smaller = heavier tail.
+24
View File
@@ -8,6 +8,17 @@ use crate::traits::Repair;
/// The simplest possible repair — pair with `GaussianMutation` (which
/// doesn't enforce bounds in v1) to produce a bounds-respecting variant
/// without writing a custom Variation impl.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let mut r = ClampToBounds::new(vec![(-1.0, 1.0); 3]);
/// let mut x = vec![-2.0, 0.5, 5.0];
/// r.repair(&mut x);
/// assert_eq!(x, vec![-1.0, 0.5, 1.0]);
/// ```
#[derive(Debug, Clone)]
pub struct ClampToBounds {
/// Per-variable inclusive bounds.
@@ -46,6 +57,19 @@ impl Repair<Vec<f64>> for ClampToBounds {
/// Perpiñán 2013. Useful for portfolio-style problems where the
/// decision must sum to a budget, and for normalizing reference
/// directions onto the unit simplex.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let mut r = ProjectToSimplex::new(1.0);
/// let mut x = vec![0.6, 0.5, -0.1, 0.3];
/// r.repair(&mut x);
/// let sum: f64 = x.iter().sum();
/// assert!((sum - 1.0).abs() < 1e-12);
/// assert!(x.iter().all(|&v| v >= 0.0));
/// ```
#[derive(Debug, Clone)]
pub struct ProjectToSimplex {
/// Target sum (the simplex's "size"). Standard probability simplex
+17
View File
@@ -9,6 +9,23 @@ use crate::core::objective::ObjectiveSpace;
/// archive insert/extend operations maintain the non-domination property among
/// members; `truncate` enforces a maximum size by simple tail-truncation in
/// v1.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let s = ObjectiveSpace::new(vec![
/// Objective::minimize("f1"),
/// Objective::minimize("f2"),
/// ]);
/// let mut a: ParetoArchive<u32> = ParetoArchive::new(s);
/// a.insert(Candidate::new(1, Evaluation::new(vec![1.0, 4.0])));
/// a.insert(Candidate::new(2, Evaluation::new(vec![3.0, 2.0])));
/// // Dominated by both — should be discarded:
/// a.insert(Candidate::new(3, Evaluation::new(vec![5.0, 5.0])));
/// assert_eq!(a.members().len(), 2);
/// ```
#[derive(Debug, Clone)]
pub struct ParetoArchive<D> {
/// The current approximate non-dominated set.
+22
View File
@@ -11,6 +11,28 @@ use crate::core::objective::ObjectiveSpace;
/// `f64::INFINITY`. If the front has 0 entries an empty vector is returned;
/// 1 or 2 entries return all `f64::INFINITY`. All comparisons happen on
/// minimization-oriented objective values (spec §9.6).
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let s = ObjectiveSpace::new(vec![
/// Objective::minimize("f1"),
/// Objective::minimize("f2"),
/// ]);
/// // Three points along a Pareto-like trade-off; the interior point gets
/// // a finite crowding distance, the boundaries get +∞.
/// let pop = [
/// Candidate::new((), Evaluation::new(vec![0.0, 4.0])),
/// Candidate::new((), Evaluation::new(vec![2.0, 2.0])),
/// Candidate::new((), Evaluation::new(vec![4.0, 0.0])),
/// ];
/// let d = crowding_distance(&pop, &[0, 1, 2], &s);
/// assert!(d[0].is_infinite());
/// assert!(d[1].is_finite() && d[1] > 0.0);
/// assert!(d[2].is_infinite());
/// ```
pub fn crowding_distance<D>(
population: &[Candidate<D>],
front: &[usize],
+15
View File
@@ -29,6 +29,21 @@ pub enum Dominance {
/// `constraint_violation` dominates.
/// 3. Otherwise compare objective values after converting both to
/// minimization orientation via [`ObjectiveSpace::as_minimization`].
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let s = ObjectiveSpace::new(vec![
/// Objective::minimize("f1"),
/// Objective::minimize("f2"),
/// ]);
/// let a = Evaluation::new(vec![1.0, 1.0]);
/// let b = Evaluation::new(vec![2.0, 2.0]);
/// assert_eq!(pareto_compare(&a, &b, &s), Dominance::Dominates);
/// assert_eq!(pareto_compare(&b, &a, &s), Dominance::DominatedBy);
/// ```
pub fn pareto_compare(a: &Evaluation, b: &Evaluation, objectives: &ObjectiveSpace) -> Dominance {
let a_feasible = a.is_feasible();
let b_feasible = b.is_feasible();
+34
View File
@@ -8,6 +8,25 @@ use crate::pareto::dominance::{Dominance, pareto_compare};
///
/// O(N²·M) in v1 (spec §9.3). Input order is preserved among returned
/// candidates.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let s = ObjectiveSpace::new(vec![
/// Objective::minimize("f1"),
/// Objective::minimize("f2"),
/// ]);
/// let pop = [
/// Candidate::new(1u32, Evaluation::new(vec![1.0, 4.0])), // non-dominated
/// Candidate::new(2u32, Evaluation::new(vec![3.0, 2.0])), // non-dominated
/// Candidate::new(3u32, Evaluation::new(vec![5.0, 5.0])), // dominated
/// ];
/// let front = pareto_front(&pop, &s);
/// let kept: Vec<u32> = front.iter().map(|c| c.decision).collect();
/// assert_eq!(kept, vec![1, 2]);
/// ```
pub fn pareto_front<D: Clone>(
population: &[Candidate<D>],
objectives: &ObjectiveSpace,
@@ -34,6 +53,21 @@ pub fn pareto_front<D: Clone>(
///
/// Returns `None` if there is not exactly one objective, if the population is
/// empty, or if every candidate is infeasible (spec §9.4).
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let s = ObjectiveSpace::new(vec![Objective::minimize("f")]);
/// let pop = [
/// Candidate::new(1u32, Evaluation::new(vec![3.0])),
/// Candidate::new(2u32, Evaluation::new(vec![1.0])),
/// Candidate::new(3u32, Evaluation::new(vec![2.0])),
/// ];
/// let best = best_candidate(&pop, &s).unwrap();
/// assert_eq!(best.decision, 2);
/// ```
pub fn best_candidate<D: Clone>(
population: &[Candidate<D>],
objectives: &ObjectiveSpace,
+15
View File
@@ -10,6 +10,21 @@
///
/// # Panics
/// If `num_objectives == 0`.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// // 3 objectives, 4 divisions → binomial(6, 2) = 15 points.
/// let pts = das_dennis(3, 4);
/// assert_eq!(pts.len(), 15);
/// for w in &pts {
/// assert_eq!(w.len(), 3);
/// let sum: f64 = w.iter().sum();
/// assert!((sum - 1.0).abs() < 1e-12);
/// }
/// ```
pub fn das_dennis(num_objectives: usize, divisions: usize) -> Vec<Vec<f64>> {
assert!(
num_objectives > 0,
+20
View File
@@ -9,6 +9,26 @@ use crate::core::objective::ObjectiveSpace;
/// non-dominated after removing `fronts[0]`, and so on. Each entry is an index
/// into the input population. Equal-objective candidates land on the same
/// front. O(N²·M) is acceptable for v1 (spec §9.5).
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
///
/// let s = ObjectiveSpace::new(vec![
/// Objective::minimize("f1"),
/// Objective::minimize("f2"),
/// ]);
/// let pop = [
/// Candidate::new((), Evaluation::new(vec![1.0, 5.0])), // front 0
/// Candidate::new((), Evaluation::new(vec![2.0, 3.0])), // front 0
/// Candidate::new((), Evaluation::new(vec![4.0, 1.0])), // front 0
/// Candidate::new((), Evaluation::new(vec![3.0, 4.0])), // front 1
/// Candidate::new((), Evaluation::new(vec![5.0, 6.0])), // front 2
/// ];
/// let fronts = non_dominated_sort(&pop, &s);
/// assert_eq!(fronts.len(), 3);
/// ```
pub fn non_dominated_sort<D>(
population: &[Candidate<D>],
objectives: &ObjectiveSpace,
+2
View File
@@ -4,6 +4,8 @@
//! use heuropt::prelude::*;
//! ```
#[cfg(feature = "async")]
pub use crate::core::async_problem::AsyncProblem;
pub use crate::core::{
Candidate, Direction, Evaluation, Objective, ObjectiveSpace, OptimizationResult,
PartialProblem, Population, Problem, Rng, rng_from_seed,