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

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

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

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

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

Bumps Cargo.toml to 0.6.0; CHANGELOG entry consolidates the above.
Existing 247 unit + 38 doctest + 32 algorithm-property + property /
metric / numerical-stability tests all pass; bit-identical compare
output verified post-DE refactor.
2026-05-05 15:07:05 -06:00
107 changed files with 4102 additions and 19408 deletions
+2 -42
View File
@@ -5,52 +5,12 @@
# cargo mutants # full sweep (slow)
# cargo mutants --in-diff HEAD~1 # only mutate recently-changed lines
#
# IMPORTANT: pass `--all-features` (or at least `--features async,serde`).
# Without them the async `run_async` paths and the `explorer` module are
# not compiled, so their mutants come back as unviable/missed noise rather
# than being exercised by the test suite.
#
# Note: `--test-tool nextest` does not accept the libtest-style
# `--test-threads=1` set below; for a nextest run pass `--no-config` (and
# re-add `--all-features` / the `--file` filters you need on the CLI).
#
# A *surviving* mutation = the test suite passed despite a code change,
# which usually means a missing test or a missing invariant.
#
# This isn't gated CI; it's an advisory tool. The property tests in
# tests/properties.rs and the per-algorithm exact-output snapshot tests
# in tests/algorithm_properties.rs are the natural places to land new
# invariants discovered via mutation runs.
#
# Mutation-coverage notes (2026-05 campaign — catch rate ~74% -> ~85%):
# - tests/algorithm_properties.rs pins an exact final-population
# snapshot for every algorithm at a fixed seed. Those snapshots use
# deliberately *hard* fixtures (3-D Rosenbrock, an 8-city scattered
# TSP, a budget-sensitive multi-fidelity problem): on convex /
# trivially-solved problems the optimizers converge to the same
# answer regardless of arithmetic mutations, which hides them.
# - The residual MISSED mutants are dominated by (a) equivalent
# mutants — e.g. `<` vs `<=` at a boundary the inputs never hit —
# and (b) arithmetic the optimizers are mathematically robust to.
# - TIMEOUT mutants here are loop-bound mutations that make an
# offspring-collection loop non-terminating; cargo-mutants reports
# those *as detected*, in their own category separate from MISSED.
#
# Performance notes (2026-05 profiling campaign — compare_profile
# whole-program callgrind Ir 357.06B -> 165.31B, -53.7%):
# - `benches/compare_profile.rs` profiles the whole `compare` example
# workload under callgrind via gungraun; it drove the seven perf
# commits of this campaign. (It's in `exclude_globs` below — a
# bench harness, not behavior to mutate.)
# - Every perf commit was bit-identical: all the tests/algorithm_-
# properties.rs snapshots stayed green. But the round-4
# `pareto::front::pareto_front` change adds a `dominated` bitset
# that is *pure* skip-bookkeeping — the `dominated[j] = true` write
# and the `if dominated[i]` early `continue` are optimization-only.
# Deleting either leaves the returned front bit-identical (just
# slower), so a mutation run will (correctly) report those as
# MISSED. They are genuine equivalent mutants, not test gaps —
# don't try to pin them with new tests.
# tests/properties.rs are the natural place to land new invariants
# discovered via mutation runs.
# Files to skip mutating. We skip:
# - examples (illustrative, not core algorithm correctness)
+1
View File
@@ -0,0 +1 @@
{"sessionId":"ac44d107-52ca-4cd4-9586-ae2fe91bc9f7","pid":2366937,"procStart":"77336928","acquiredAt":1778002505967}
-7
View File
@@ -4,8 +4,6 @@ on:
push:
branches: [main]
tags: ["v*.*.*"]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
@@ -13,8 +11,6 @@ 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
@@ -42,9 +38,6 @@ 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:
-7
View File
@@ -1,9 +1,2 @@
/target
/Cargo.lock
# Generated by `cargo run --example pick_a_car`
/pick_a_car.json
# Generated by `cargo mutants`
/mutants.out
/mutants.out.old
+84 -293
View File
@@ -7,319 +7,110 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.11.0] — 2026-05-14
## [0.7.0] — 2026-05-05
Theme: a full permutation-operator toolkit, plus two sweeping
performance passes. The first is a micro-benchmark-guided pass over
the combinatorial operators and the Pareto/metrics machinery; the
second is a whole-program profiling campaign that roughly halved the
instruction count of the `compare` example workload. Every
performance change is bit-identical — verified against per-algorithm
exact-output snapshot tests — so results are unchanged, only faster.
No public-API breaks. The release is purely additive: new permutation
operators, plus internal-only performance work.
### Added
- A full permutation crossover/mutation toolkit in
`heuropt::operators`, all re-exported from the prelude:
`OrderCrossover` (OX), `PartiallyMappedCrossover` (PMX),
`CycleCrossover` (CX), and `EdgeRecombinationCrossover` (ERX)
crossovers, and `InversionMutation`, `InsertionMutation`, and
`ScrambleMutation` mutations — joining the pre-existing
`SwapMutation`. The mutations preserve both strict permutations and
multisets.
- Combinatorial problems in the `compare` example: a bi-objective
ring TSP, a 3-objective FT06 job-shop schedule, and a bi-objective
knapsack — plus standalone Ulysses16 TSP and FT06 JSS benchmark
examples and a bi-objective TSP crossover-comparison demo.
- Many-objective problems in the `compare` example: DTLZ at 4, 8, and
10 objectives.
- `benches/compare_profile.rs` — a gungraun/callgrind benchmark that
profiles the entire `compare` workload as one unit; the harness
behind this release's profiling campaign.
- A permutation-toolkit and multi-objective-combinatorial cookbook
chapter in the mdbook.
### Performance
All changes below are bit-identical — outputs are byte-for-byte
unchanged, verified by the per-algorithm snapshot tests.
- **Whole-program profiling campaign.** Profiling the full `compare`
workload under callgrind cut its instruction count from 357.06B to
165.31B (53.7%):
- `pareto_compare` is now allocation-free — it no longer
materializes two minimization-oriented `Vec<f64>`s per call. This
alone was 38%, the single biggest win.
- `pareto_front` precomputes its oriented buffers once and skips
candidates already known to be dominated.
- `ibea` pre-exponentiates its indicator matrix, turning the
survival loop's `exp` sweep into plain additions.
- `hype` reuses its per-Monte-Carlo-sample scratch buffer instead
of reallocating it thousands of times per call.
- `age_moea` scores only the splitting front rather than the whole
combined population.
- **Combinatorial-operator pass.** `CycleCrossover`,
`PartiallyMappedCrossover`, and `OrderCrossover` are now O(n) via
position-index tables; `EdgeRecombinationCrossover` removes edges
in O(degree) per step.
- **Pareto / metrics pass.** `non_dominated_sort` halves its
dominance comparisons and reads from a flattened objective buffer;
`crowding_distance` sorts without `Vec<Vec<f64>>` indirection;
`hypervolume_nd` no longer re-sorts prefixes per slice.
- **Algorithm hot paths.** `ant_colony_tsp` hoists `powf` out of its
tour-building loop; `tpe` computes KDE bandwidths once per
iteration instead of once per call; `bayesian_opt` reuses scratch
buffers in the expected-improvement acquisition loop.
### Changed
- Documentation now recommends MOEA/D as the default multi- and
many-objective algorithm, with disconnected-front and sequencing
guidance corrected against fresh `compare` results.
- The `compare` example's result tables are realigned and sorted,
and its workload now lives in a reusable module shared with the
profiling benchmark.
### Internal
- A large mutation-testing-driven test-hardening pass: per-algorithm
exact-output snapshots and pinned helper-function tests across the
whole algorithm catalog and operator set, raising the cargo-mutants
catch rate from ~74% to ~85%. See `.cargo/mutants.toml` for the
campaign notes and the residual equivalent-mutant categories.
[0.11.0]: https://github.com/swaits/heuropt/releases/tag/v0.11.0
## [0.10.0] — 2026-05-06
Theme: every algorithm now returns its **canonical name** as it
appears in the literature, with an academic long form available
alongside, and the docs use those names everywhere. Plus the
explorer JSON export now carries both forms so display tools can
show the short name with a hover tooltip for the long one.
No public-API breaks beyond the value of `AlgorithmInfo::name()`,
which previously returned the Rust type name and now returns the
literature short name (`"NSGA-II"` vs `"Nsga2"`). If your code
matched on those strings you'll need to update — but the trait
shape itself is unchanged and `algorithm.name()` continues to be
the way to read it.
### Added
- `AlgorithmInfo::full_name(&self) -> &'static str` — academic
long form, e.g. `"Non-dominated Sorting Genetic Algorithm II"`.
Defaults to `name()` for algorithms whose short and long forms
coincide (Random Search, Hill Climber, Tabu Search).
- Every built-in algorithm overrides `full_name()` with its
expanded literature name. Mapping table is in the cookbook
recipe at `docs/book/src/cookbook/explorer.md`.
- `ExplorerExport`'s `RunMeta` gained an optional
`algorithm_full_name: Option<String>` field. The
`with_algorithm_info()` builder populates both that and
`algorithm` from the same `AlgorithmInfo` source. Schema
version stays at **1** — the new field is `#[serde(default)]`,
so older readers tolerate it and older writers' output still
loads cleanly.
### Changed
- `AlgorithmInfo::name()` return values for every built-in
algorithm. Examples: `"Nsga2"``"NSGA-II"`, `"Cmaes"`
`"CMA-ES"`, `"Mopso"``"MOPSO"`, `"Moead"``"MOEA/D"`,
`"EpsilonMoea"``"ε-MOEA"`. Full table in the cookbook recipe.
- README, mdbook chapters, decision tree, choosing-an-algorithm
guide, comparison page, getting-started, defining-problems,
cookbook recipes, and migration notes now all use the canonical
algorithm names in body prose. Code blocks (which reference the
Rust types like `Nsga2::new(...)` or `Nsga2Config { … }`)
unchanged — those are still the API.
- Default `cargo run --release --example pick_a_car` output now
reads `"algorithm": "NSGA-III", "algorithm_full_name":
"Non-dominated Sorting Genetic Algorithm III"` in the JSON
envelope instead of `"Nsga3"`.
### Migration
If you display `optimizer.name()` in your own UI, you'll suddenly
get the proper short name for free — usually a strict improvement.
The only break: code that pattern-matched on the Rust-type-shaped
strings (e.g. `if name == "Nsga3"`) needs updating to the new
canonical strings. The names are stable now (they match the
literature), so this is a one-time fix.
[0.10.0]: https://github.com/swaits/heuropt/releases/tag/v0.10.0
## [0.9.0] — 2026-05-06
Theme: explorer JSON export. Real Pareto fronts have 50200+
candidates spanning 27+ objectives — too many to read as numbers
in a terminal. 0.9.0 adds a tiny additive surface that turns any
`OptimizationResult` into a self-describing JSON file you can drop
into [heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
to filter, brush, pin, and rank candidates interactively.
No public-API breaks. The new surface lives behind the existing
`serde` feature and the new methods on `Problem` / the new
`AlgorithmInfo` trait have working defaults so existing impls
compile untouched.
### Added
#### Explorer export (the headline feature)
- New `heuropt::explorer` module (gated on the `serde` feature).
Defines `ExplorerExport`, `ExplorerCandidate`, `RunMeta`, the
`ToDecisionValues` adapter trait, and free functions
`to_json` / `to_writer` / `to_file`.
- Schema is versioned (`SCHEMA_VERSION = 1`); the explorer webapp
refuses to load files with an unknown version.
- `front_rank` is computed once via `non_dominated_sort` at export
time and attached to every candidate so downstream tools don't
have to re-derive it.
- `ToDecisionValues` is implemented for `Vec<f64>`, `Vec<bool>`,
`Vec<usize>`, and `Vec<i64>` out of the box; users with custom
decision types implement it themselves (one method).
#### Problem-side metadata (single source of truth, no duplication)
- `Objective` gained optional `label: Option<String>` and
`unit: Option<String>` fields plus fluent builders
`.with_label("Price")` / `.with_unit("$k")`. Existing
`Objective::minimize("name")` / `Objective::maximize("name")`
unchanged. Backwards-compatible at source level and at the JSON
level (the new fields use `#[serde(default,
skip_serializing_if = "Option::is_none")]`).
- `Problem` trait gained an optional `fn decision_schema(&self)
-> Vec<DecisionVariable>` with default empty impl. Override it
to provide pretty names / labels / units / bounds for the
explorer; the default produces fallback `x[0]`, `x[1]`, … names.
- New `DecisionVariable` type at `heuropt::core::DecisionVariable`,
re-exported via the prelude. Builder methods: `with_label`,
`with_unit`, `with_bounds`.
#### Algorithm metadata for the export header
- New `heuropt::traits::AlgorithmInfo` trait with `name() ->
&'static str` (required) and `seed() -> Option<u64>` (default
`None`). Every built-in algorithm — all 33 — implements it.
Separate from `Optimizer<P>` so multi-fidelity algorithms
(Hyperband, which uses `PartialProblem`) implement it uniformly.
- `ExplorerExport::with_algorithm_info(&optimizer)` pulls the
algorithm name and seed from this trait into the export's `run`
metadata.
#### Worked example
- New `examples/pick_a_car.rs` (gated on `serde`). Implements the
README's `PickACar` multi-objective problem with a fully
enriched `decision_schema` and labelled / unit-tagged objectives,
runs NSGA-III, and writes `pick_a_car.json` ready to drop into
the explorer.
#### Documentation
- New cookbook recipe at `docs/book/src/cookbook/explorer.md`
covering Problem enrichment, the export call, the JSON schema,
and custom decision-type handling.
### Notes
- The explorer webapp itself lives in a separate repo
(`heuropt-explorer`) on its own release cadence. The schema in
`heuropt::explorer` is the contract between them; bumping
`SCHEMA_VERSION` is reserved for breaking changes.
- Phase 1 is additive only. No existing test breaks; the lib test
count went from 229 to 242 (10 new explorer tests + 3 from the
new `Objective` / `DecisionVariable` builders).
[0.9.0]: https://github.com/swaits/heuropt/releases/tag/v0.9.0
## [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.
Theme: async evaluation. heuropt now supports problems where each
evaluation is a `.await`-able operation — HTTP services, RPC clients,
spawned subprocesses. This is the differentiating capability vs.
pymoo / hyperopt / MOEA Framework, none of which ship first-class
async support.
No public-API breaks for synchronous users. The new surface is
gated behind a new `async` feature flag.
### Added
#### 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`.
`RandomSearch` and `DifferentialEvolution` — drives evaluations
through whichever async runtime the caller is using (typically
tokio). `concurrency` bounds in-flight evaluations.
- Internal `algorithms::parallel_eval_async::evaluate_batch_async`
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.
helper — uses `futures::stream::FuturesOrdered` with concurrency-
bounded chunks, preserves input order so seeded determinism is
preserved when evaluations are themselves deterministic.
- `examples/async_eval.rs` — worked example with a simulated 20 ms
remote service. At concurrency = 1 it's serial; at concurrency = 4
it's 2× faster; demonstrates `DifferentialEvolution` under tokio.
it's 2× faster; demonstrates DifferentialEvolution under tokio.
#### Documentation
[0.7.0]: https://github.com/swaits/heuropt/releases/tag/v0.7.0
- 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).
## [0.6.0] — 2026-05-05
#### CI / build
Theme: production lifecycle. heuropt becomes deployable for long-
running, real-world optimization workloads — callbacks, stop
conditions, tracing, and two new performance indicators.
- `.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.
No breaking changes to the public API. Existing `Optimizer<P>` impls
keep compiling — `run_with` is added as a default-impl method that
falls back to `run` plus a single final notification.
[0.8.0]: https://github.com/swaits/heuropt/releases/tag/v0.8.0
### Added
#### Observer + stop-conditions API
A new module `heuropt::observer` introduces:
- `Snapshot<'a, D>` — per-generation observation payload with
`iteration`, `evaluations`, `elapsed`, `population`,
`pareto_front`, `best`, and `objectives`.
- `Observer<D>` trait — single method `observe(&Snapshot) ->
ControlFlow<()>`. Closures of the right shape implement it
automatically. `()` is the no-op observer.
- `Optimizer::run_with(problem, observer)` — new method on the
`Optimizer` trait with a default impl that falls back to `run`.
Algorithms that override `run_with` (so far: `Nsga2`,
`RandomSearch`, `DifferentialEvolution`) call the observer once
per generation; others call it once at the end. Returning
`ControlFlow::Break` halts the optimizer and returns the partial
result.
#### Built-in observers (`observer::builtin`)
- `MaxTime(Duration)` — wall-clock cap.
- `MaxIterations(usize)` — generation cap.
- `TargetFitness(f64)` — direction-aware single-objective target.
- `Stagnation { window, tolerance }` — halt when the best fitness
hasn't improved by `tolerance` over `window` generations.
- `Periodic::new(every, |snap| { … })` — call a user closure every
`every` generations.
- `AnyOf` / `AllOf` plus `Observer::or` / `Observer::and` for
composition.
- `TracingObserver` (behind the new `tracing` feature) — emits
structured `debug!` events per generation.
#### Tracing feature
New optional feature `tracing`, gated on the
[`tracing`](https://crates.io/crates/tracing) crate. Adds
`TracingObserver` to the prelude when enabled.
#### Performance indicators
- `metrics::igd::igd` — Inverted Generational Distance against a
reference set (typically the true Pareto front).
- `metrics::igd::igd_plus` — Pareto-compliant IGD+ variant; adding
a dominated point never improves the score.
- `metrics::r2::r2` — R2 indicator using the weighted Tchebycheff
utility. Pair with `pareto::das_dennis` for the canonical weight
set.
#### Constrained example
`examples/constrained.rs` — solves the BNH constrained 2-objective
problem (Binh & Korn 1996) with NSGA-II + the new observer API,
demonstrating `Periodic` progress logging and `MaxTime` /
composition.
### Changed
- `Population::as_slice()` — new convenience accessor.
[0.6.0]: https://github.com/swaits/heuropt/releases/tag/v0.6.0
## [0.5.0] — 2026-05-05
@@ -783,5 +574,5 @@ Initial release.
`RandomSearch`, `Nsga2`, and `DifferentialEvolution`. Seeded runs stay
bit-identical to serial mode.
[Unreleased]: https://github.com/swaits/heuropt/compare/v0.10.0...HEAD
[Unreleased]: https://github.com/swaits/heuropt/compare/v0.7.0...HEAD
[0.1.0]: https://github.com/swaits/heuropt/releases/tag/v0.1.0
+4 -11
View File
@@ -1,6 +1,6 @@
[package]
name = "heuropt"
version = "0.11.0"
version = "0.7.0"
edition = "2024"
rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"]
@@ -15,8 +15,9 @@ categories = ["algorithms", "science", "mathematics", "simulation"]
[features]
default = []
serde = ["dep:serde", "dep:serde_json"]
serde = ["dep:serde"]
parallel = ["dep:rayon"]
tracing = ["dep:tracing"]
async = ["dep:futures"]
[dependencies]
@@ -25,7 +26,7 @@ rand = "0.9"
rand_distr = "0.5"
rayon = { version = "1", optional = true }
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
tracing = { version = "0.1", optional = true, default-features = false, features = ["std", "attributes"] }
[dev-dependencies]
gungraun = "0.18"
@@ -36,18 +37,10 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
name = "hot_paths"
harness = false
[[bench]]
name = "compare_profile"
harness = false
[[example]]
name = "async_eval"
required-features = ["async"]
[[example]]
name = "pick_a_car"
required-features = ["serde"]
# 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.
+170 -312
View File
@@ -7,210 +7,85 @@
[![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. 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.
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.
If you can write a `Problem` impl and read Random Search, you can write your
If you can write a `Problem` impl and read `RandomSearch`, you can write your
own optimizer. That's the whole pitch.
Docs: [user guide](https://swaits.github.io/heuropt/) · [API reference](https://docs.rs/heuropt).
- 📖 **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.
## Installation
```toml
[dependencies]
heuropt = "0.11"
heuropt = "0.5"
# 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.
# - "async": AsyncProblem / AsyncPartialProblem traits and a
# run_async(&problem, concurrency).await method on
# every algorithm — for IO-bound evaluations.
# heuropt = { version = "0.11", features = ["serde", "parallel", "async"] }
# heuropt = { version = "0.5", features = ["serde", "parallel"] }
```
## 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.
## Define a problem
```rust
use heuropt::prelude::*;
struct PickACar;
struct SchafferN1;
impl Problem for PickACar {
type Decision = Vec<f64>; // [engine_liters, weight_kg, drag_cd]
impl Problem for SchafferN1 {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("price_thousand_dollars"),
Objective::minimize("seconds_to_60mph"),
Objective::minimize("fuel_gallons_per_100mi"),
Objective::minimize("noise_db_at_idle"),
Objective::minimize("f1"),
Objective::minimize("f2"),
])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
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]);
let v = x[0];
Evaluation::new(vec![v * v, (v - 2.0).powi(2)])
}
}
```
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:
## Run NSGA-II
```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
```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());
```
### 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.
### Explore it interactively
Six hand-picked rows out of a hundred is a sample, not a search.
With the `serde` feature enabled, the same result becomes one JSON
file you can drop into the [heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
webapp to browse interactively — parallel coordinates, scatter,
range filters, weighted ranking:
```rust,ignore
heuropt::explorer::ExplorerExport::from_result(&PickACar, &result)
.with_algorithm_info(&optimizer)
.with_problem_name("Pick a car")
.to_file("results.json")?;
```
The full worked example (which produces this output verbatim) is at
`examples/pick_a_car.rs`:
```text
cargo run --release --example pick_a_car --features serde
```
See the [Explore your results](https://swaits.github.io/heuropt/cookbook/explorer.html)
cookbook recipe for the export schema and how to enrich your `Problem`
with display labels and units.
See `examples/toy_nsga2.rs` for the full version.
## Implement a custom optimizer
@@ -230,7 +105,13 @@ where
// Evaluate them with `problem.evaluate(...)`.
// Keep the best, or maintain a Pareto archive.
// Return an OptimizationResult.
todo!()
# OptimizationResult::new(
# Population::new(Vec::new()),
# Vec::new(),
# None,
# 0,
# 0,
# )
}
}
```
@@ -314,12 +195,12 @@ you need a **sample-efficient** or **multi-fidelity** approach:
- **Cheap (1k+ evals affordable):** any of the population-based
algorithms — DE, GA, CMA-ES, NSGA-II, etc.
- **Expensive (50500 evals):** Bayesian Optimization (Gaussian-process
surrogate + Expected Improvement) or TPE (Parzen-density
- **Expensive (50500 evals):** `BayesianOpt` (Gaussian-process
surrogate + Expected Improvement) or `Tpe` (Parzen-density
surrogate, cheaper per step, more robust without hyperparameter
tuning).
- **Multi-fidelity (each eval has a tunable budget — epochs, sim
steps, MC samples):** Hyperband. Implement the `PartialProblem`
steps, MC samples):** `Hyperband`. Implement the `PartialProblem`
trait on your problem and Hyperband allocates compute aggressively
across promising configs.
@@ -365,12 +246,12 @@ START
│ │
│ ├─ Yes → sample-efficient regime
│ │ ├─ Standard expensive black-box, single-objective
│ │ │ → Bayesian Optimization (GP + Expected Improvement; gold
│ │ │ → BayesianOpt (GP + Expected Improvement; gold
│ │ │ standard *with* per-problem kernel
│ │ │ tuning. The default RBF kernel at
│ │ │ 60 evals is honestly bad — give it
│ │ │ more evals or tune the kernel.)
│ │ │ → TPE (KDE-based; cheaper per-step,
│ │ │ → Tpe (KDE-based; cheaper per-step,
│ │ │ more robust without tuning)
│ │ │
│ │ └─ Each eval has a tunable fidelity (epochs, sim steps, …)
@@ -385,126 +266,103 @@ START
│ │
│ ├─ Decision is Vec<f64> (continuous)
│ │ ├─ Smooth landscape (well-conditioned)
│ │ │ → CMA-ES (full-cov adaptive Gaussian)
│ │ │ → sNES (cheaper diag-cov; high-dim)
│ │ │ → Nelder-Mead (low-dim, deterministic, simple)
│ │ │ → CmaEs (full-cov adaptive Gaussian)
│ │ │ → SeparableNes (cheaper diag-cov; high-dim)
│ │ │ → NelderMead (low-dim, deterministic, simple)
│ │ ├─ Multimodal landscape
│ │ │ → IPOP-CMA-ES (CMA-ES with restart;
│ │ │ → IpopCmaEs (CMA-ES with restart;
│ │ │ fixes vanilla CMA-ES's
│ │ │ multimodal failure)
│ │ │ → Differential Evolution (rarely beaten on cheap
│ │ │ → DifferentialEvolution (rarely beaten on cheap
│ │ │ multimodal continuous)
│ │ │ → Simulated Annealing (cheap & generic)
│ │ │ → SimulatedAnnealing (cheap & generic)
│ │ ├─ Want parameter-free (no F, CR, w, σ to tune)
│ │ │ → TLBO
│ │ │ → Tlbo
│ │ ├─ Want minimum self-adapting baseline
│ │ │ → (1+1)-ES (one-fifth rule,
│ │ │ → OnePlusOneEs (one-fifth rule,
│ │ │ smallest possible ES)
│ │ ├─ Just want a strong default for cheap continuous
│ │ │ → Differential Evolution
│ │ │ → DifferentialEvolution
│ │ └─ Just want a baseline
│ │ → Random Search
│ │ → RandomSearch
│ │
│ ├─ Decision is Vec<bool> (binary)
│ │ ├─ Independent bits, smooth fitness
│ │ │ → UMDA (per-bit marginal EDA)
│ │ │ → Umda (per-bit marginal EDA)
│ │ └─ Bit interactions matter
│ │ → GA with BitFlipMutation +
│ │ → GeneticAlgorithm with BitFlipMutation +
│ │ a bit-string crossover
│ │
│ ├─ Decision is Vec<usize> (permutation: TSP, JSS, …)
│ │ → Ant Colony (TSP, with a distance matrix)
│ │ → Simulated Annealing / Tabu Search (strong on
│ │ sequencing — they win the harness TSP and JSS
│ │ tables — you supply the neighbour move)
│ │ → GA + permutation toolkit (ERX for TSP-shaped
│ │ instances)
│ ├─ Decision is Vec<usize> (permutation, e.g., TSP)
│ │ → AntColonyTsp (with a distance matrix)
│ │ → TabuSearch (with your own neighbor function)
│ │ → SimulatedAnnealing with SwapMutation
│ │
│ └─ Custom decision type (a struct, a tree, …)
│ → Simulated Annealing or Hill Climber
│ → SimulatedAnnealing or HillClimber
│ with your own Variation impl
├─ 2 or 3 (multi-objective)
│ │
│ ├─ Strong default — top-3 on every multi- and
│ │ many-objective table on the harness, fastest or
│ │ near-fastest every time
│ │ → MOEA/D (decomposition into scalar sub-problems;
│ │ robust across convex / disconnected /
│ │ spherical / linear fronts and 210
│ │ objectives. Caveat: weight-vector spread
│ │ can leave gaps on highly irregular or
│ │ degenerate fronts)
│ │ → NSGA-II (canonical Pareto EA; well-understood and
│ │ the established choice for combinatorial
│ │ encodings — but edged out by MOEA/D on
│ │ every MO table here, and fades past
│ │ ~4 objectives)
│ ├─ Strong default, fast, well-understood
│ │ → Nsga2
│ │
│ ├─ Real-valued, smooth front, want best convergence
│ │ → MOPSO (multi-objective PSO; on the benches
│ │ → Mopso (multi-objective PSO; on the benches
│ │ here it wins ZDT1 on both HV and
│ │ convergence by 100× over the
│ │ dominance-based methods)
│ │
│ ├─ Want better front quality than the default
│ │ → IBEA (indicator-based; consistently the best
│ ├─ Want better front quality than NSGA-II
│ │ → Ibea (indicator-based; consistently the best
│ │ of the dominance-based methods on these
│ │ benches — wins ZDT3 HV and DTLZ2 mean
│ │ dist by 24×)
│ │ → SPEA2 (strength + density)
│ │ → SMS-EMOA (hypervolume-contribution selection;
│ │ → Spea2 (strength + density)
│ │ → SmsEmoa (hypervolume-contribution selection;
│ │ elegant in theory but underperforms
│ │ NSGA-II on these benches at our budgets —
│ │ only worth its higher per-step cost on
│ │ fronts where exact HV-contribution is
│ │ the right discriminator)
│ │
│ ├─ Disconnected front (separate arcs, e.g. ZDT3)
│ │ → IBEA (wins ZDT3 hypervolume on the harness;
│ │ MOEA/D and NSGA-II follow. Geometry-aware
│ │ methods trail when the front is in pieces)
│ ├─ Want decomposition / weight-vector style
│ │ → Moead (very fast per generation, scales well)
│ │
│ ├─ Non-convex but *contiguous* front
│ │ → AGE-MOEA (estimates front geometry adaptively)
│ │ → KnEA (favors knee points)
│ ├─ Disconnected or non-convex front
│ │ → AgeMoea (estimates front geometry adaptively)
│ │ → Knea (favors knee points)
│ │ → Ibea
│ │
│ ├─ Want region-based diversity
│ │ → PESA-II (grid hyperboxes drive selection)
│ │ → ε-MOEA (ε-grid archive,
│ │ archive size auto-limits)
│ │ → PesaII (grid hyperboxes drive selection)
│ │ → EpsilonMoea (ε-grid archive,
│ │ archive size auto-limits)
│ │
│ └─ Just one starting decision (no population budget)
│ → PAES (1+1 ES with a Pareto archive)
│ → Paes (1+1 ES with a Pareto archive)
└─ 4+ (many-objective)
├─ Strong default — #2 on every many-objective table on
│ the harness (DTLZ2 at 4 and 10 objectives, DTLZ1 at 8);
│ decomposition sidesteps the dominance collapse that
│ wrecks Pareto-based EAs at high objective count
│ → MOEA/D
│ (NSGA-II is the cautionary tale: on DTLZ2 at 10
│ objectives it finishes last — behind random search)
├─ Linear / simplex-shaped front (e.g., DTLZ1)
│ → GrEA (grid coords drive ranking; on DTLZ1
│ → Grea (grid coords drive ranking; on DTLZ1
│ here it beats NSGA-III by 3× and
│ AGE-MOEA by 2.5×, and wins the
8-objective DTLZ1 table outright)
→ MOEA/D (also #2 on both DTLZ1 tables)
│ AGE-MOEA by 2.5×)
→ Moead (decomposition shines on linear fronts;
second on DTLZ1, also among the
│ fastest per generation)
├─ Curved / unknown front geometry
│ → NSGA-III (reference-point niching; canonical by
reputation, but MOEA/D outperforms it
on every harness table)
│ → AGE-MOEA (estimates L_p geometry per generation)
│ → RVEA (reference vectors with adaptive penalty)
│ → Nsga3 (reference-point niching, canonical;
a strong default when the front
isn't simplex-shaped)
│ → AgeMoea (estimates L_p geometry per generation)
│ → Rvea (reference vectors with adaptive penalty)
├─ Want indicator-based selection
│ → IBEA (additive ε-indicator; doesn't degrade
│ → Ibea (additive ε-indicator; doesn't degrade
│ at high obj count)
│ → HypE (Monte Carlo HV estimation; scales
│ → Hype (Monte Carlo HV estimation; scales
│ to arbitrary M)
```
@@ -514,54 +372,54 @@ START
| Algorithm | Objectives | Decision | Strengths |
|---|---|---|---|
| **Bayesian Optimization** | 1 | `Vec<f64>` | GP surrogate + EI; gold standard *with* per-problem kernel tuning (default RBF at 60 evals is honestly bad) |
| **TPE** | 1 | `Vec<f64>` | KDE surrogate; robust without hyperparameter tuning |
| **Hyperband** | 1 | any | multi-fidelity; needs `PartialProblem` |
| `BayesianOpt` | 1 | `Vec<f64>` | GP surrogate + EI; gold standard *with* per-problem kernel tuning (default RBF at 60 evals is honestly bad) |
| `Tpe` | 1 | `Vec<f64>` | KDE surrogate; robust without hyperparameter tuning |
| `Hyperband` | 1 | any | multi-fidelity; needs `PartialProblem` |
**Single-objective continuous (`Vec<f64>`):**
| Algorithm | Strengths |
|---|---|
| **Random Search** | sanity baseline |
| **Hill Climber** | simplest greedy local search |
| **(1+1)-ES** | one-fifth-rule self-adapting baseline |
| **Simulated Annealing** | escapes local optima |
| **GA** | classic SO GA with elitism |
| **PSO** | simple swarm baseline |
| **Differential Evolution** | strong default for cheap continuous |
| **TLBO** | parameter-free (no F, CR, w, σ) |
| **CMA-ES** | smooth landscapes; full covariance |
| **IPOP-CMA-ES** | CMA-ES + restart for multimodal |
| **sNES** | diagonal-cov NES; cheap per-step |
| **Nelder-Mead** | classical simplex; deterministic |
| `RandomSearch` | sanity baseline |
| `HillClimber` | simplest greedy local search |
| `OnePlusOneEs` | one-fifth-rule self-adapting baseline |
| `SimulatedAnnealing` | escapes local optima |
| `GeneticAlgorithm` | classic SO GA with elitism |
| `ParticleSwarm` | simple swarm baseline |
| `DifferentialEvolution` | strong default for cheap continuous |
| `Tlbo` | parameter-free (no F, CR, w, σ) |
| `CmaEs` | smooth landscapes; full covariance |
| `IpopCmaEs` | CMA-ES + restart for multimodal |
| `SeparableNes` | diagonal-cov NES; cheap per-step |
| `NelderMead` | classical simplex; deterministic |
**Single-objective other decision types:**
| Algorithm | Decision | Strengths |
|---|---|---|
| **UMDA** | `Vec<bool>` | independent-bit EDA |
| **Tabu Search** | any | discrete, you supply neighbors |
| **Ant Colony** | `Vec<usize>` | TSP / permutation |
| `Umda` | `Vec<bool>` | independent-bit EDA |
| `TabuSearch` | any | discrete, you supply neighbors |
| `AntColonyTsp` | `Vec<usize>` | TSP / permutation |
**Multi-objective (23) and many-objective (4+):**
| Algorithm | Objectives | Strengths |
|---|---|---|
| **MOEA/D** | 2+ | decomposition; the most consistent all-rounder — top-3 on every MO/many-objective table here, fastest or near-fastest |
| **NSGA-II** | 23 | canonical Pareto-based EA; well-understood, the go-to for combinatorial encodings — but fades past ~4 objectives |
| **MOPSO** | 23 | multi-objective PSO; best convergence on smooth real-valued 2-obj fronts |
| **IBEA** | 2+ | indicator-based; consistently best of the dominance-based methods; wins disconnected fronts |
| **SPEA2** | 23 | strength + density |
| **SMS-EMOA** | 2+ | exact HV-contribution selection; high per-step cost, modest gain |
| **HypE** | 2+ | Monte Carlo HV estimation; strong on spherical many-objective fronts |
| **ε-MOEA** | 2+ | ε-grid archive; auto-sized |
| **PESA-II** | 2+ | grid-based region selection |
| **AGE-MOEA** | 2+ | adaptive front-geometry estimation |
| **KnEA** | 2+ | knee-point favored survival |
| **PAES** | 23 | 1+1 ES with Pareto archive |
| **NSGA-III** | 4+ | reference-point niching; strong on curved fronts |
| **RVEA** | 4+ | reference vectors with penalty |
| **GrEA** | 4+ | grid coords drive selection; wins linear/simplex fronts at any objective count |
| `Paes` | 23 | 1+1 ES with Pareto archive |
| `Nsga2` | 23 | canonical Pareto-based EA |
| `Spea2` | 23 | strength + density |
| `Mopso` | 23 | multi-objective PSO; best convergence on smooth real-valued 2-obj fronts |
| `Ibea` | 2+ | indicator-based; consistently best of the dominance-based methods |
| `SmsEmoa` | 2+ | exact HV-contribution selection; high per-step cost, modest gain |
| `Hype` | 2+ | Monte Carlo HV estimation |
| `EpsilonMoea` | 2+ | ε-grid archive; auto-sized |
| `PesaII` | 2+ | grid-based region selection |
| `AgeMoea` | 2+ | adaptive front-geometry estimation |
| `Knea` | 2+ | knee-point favored survival |
| `Moead` | 2+ | decomposition; fast per-gen |
| `Nsga3` | 4+ | reference-point niching; strong on curved fronts |
| `Rvea` | 4+ | reference vectors with penalty |
| `Grea` | 4+ | grid coords drive selection; particularly strong on linear/simplex fronts |
## Current algorithms
@@ -569,48 +427,48 @@ The full list with one-line descriptions:
**Sample-efficient / multi-fidelity:**
- **Bayesian Optimization** — Gaussian-process surrogate + Expected Improvement.
- **TPE** — Bergstra et al. 2011 Tree-structured Parzen Estimator.
- **Hyperband** — Li et al. 2017 multi-fidelity (uses `PartialProblem`).
- `BayesianOpt` — Gaussian-process surrogate + Expected Improvement.
- `Tpe` — Bergstra et al. 2011 Tree-structured Parzen Estimator.
- `Hyperband` — Li et al. 2017 multi-fidelity (uses `PartialProblem`).
**Single-objective:**
- **Random Search** — sample-evaluate-keep baseline.
- **Hill Climber** — greedy single-step local search.
- **(1+1)-ES** — Rechenberg 1973 (1+1)-ES with one-fifth rule.
- **Simulated Annealing** — Kirkpatrick et al. 1983, generic over decision type.
- **Tabu Search** — Glover 1986, with a user-supplied neighbor generator.
- **GA** — generational GA with tournament selection + elitism.
- **PSO** — Eberhart & Kennedy 1995 PSO for `Vec<f64>`.
- **Differential Evolution** — Storn & Price DE/rand/1/bin for `Vec<f64>`.
- **TLBO** — Rao 2011 Teaching-Learning-Based Optimization (parameter-free).
- **CMA-ES** — Hansen & Ostermeier 2001 covariance-matrix adaptation.
- **IPOP-CMA-ES** — Auger & Hansen 2005 CMA-ES with restart, for multimodal.
- **sNES** — Wierstra et al. 2008/2014 diagonal-cov NES.
- **Nelder-Mead** — Nelder & Mead 1965 simplex direct search.
- **UMDA** — Mühlenbein 1997 univariate marginal-distribution EDA for `Vec<bool>`.
- **Ant Colony** — Dorigo Ant System for permutation problems.
- `RandomSearch` — sample-evaluate-keep baseline.
- `HillClimber` — greedy single-step local search.
- `OnePlusOneEs` — Rechenberg 1973 (1+1)-ES with one-fifth rule.
- `SimulatedAnnealing` — Kirkpatrick et al. 1983, generic over decision type.
- `TabuSearch` — Glover 1986, with a user-supplied neighbor generator.
- `GeneticAlgorithm` — generational GA with tournament selection + elitism.
- `ParticleSwarm` — Eberhart & Kennedy 1995 PSO for `Vec<f64>`.
- `DifferentialEvolution` — Storn & Price DE/rand/1/bin for `Vec<f64>`.
- `Tlbo` — Rao 2011 Teaching-Learning-Based Optimization (parameter-free).
- `CmaEs` — Hansen & Ostermeier 2001 covariance-matrix adaptation.
- `IpopCmaEs` — Auger & Hansen 2005 CMA-ES with restart, for multimodal.
- `SeparableNes` — Wierstra et al. 2008/2014 diagonal-cov NES.
- `NelderMead` — Nelder & Mead 1965 simplex direct search.
- `Umda` — Mühlenbein 1997 univariate marginal-distribution EDA for `Vec<bool>`.
- `AntColonyTsp` — Dorigo Ant System for permutation problems.
**Multi-objective:**
- **PAES** — Knowles & Corne 1999 Pareto Archived Evolution Strategy.
- **NSGA-II** — Deb et al. 2002, the canonical Pareto-based EA.
- **SPEA2** — Zitzler, Laumanns & Thiele 2001 strength-Pareto EA.
- **MOEA/D** — Zhang & Li 2007 decomposition-based MOEA with Tchebycheff scalarization.
- **MOPSO** — Coello, Pulido & Lechuga 2004 multi-objective PSO.
- **IBEA** — Zitzler & Künzli 2004 indicator-based EA.
- **SMS-EMOA** — Beume, Naujoks & Emmerich 2007 hypervolume-selection EMOA.
- **HypE** — Bader & Zitzler 2011 Hypervolume Estimation Algorithm.
- **ε-MOEA** — Deb, Mohan & Mishra 2003 ε-dominance MOEA.
- **PESA-II** — Corne et al. 2001 Pareto Envelope Selection II.
- **AGE-MOEA** — Panichella 2019 Adaptive Geometry Estimation MOEA.
- **KnEA** — Zhang, Tian & Jin 2015 Knee point-driven EA.
- `Paes` — Knowles & Corne 1999 Pareto Archived Evolution Strategy.
- `Nsga2` — Deb et al. 2002, the canonical Pareto-based EA.
- `Spea2` — Zitzler, Laumanns & Thiele 2001 strength-Pareto EA.
- `Moead` — Zhang & Li 2007 decomposition-based MOEA with Tchebycheff scalarization.
- `Mopso` — Coello, Pulido & Lechuga 2004 multi-objective PSO.
- `Ibea` — Zitzler & Künzli 2004 indicator-based EA.
- `SmsEmoa` — Beume, Naujoks & Emmerich 2007 hypervolume-selection EMOA.
- `Hype` — Bader & Zitzler 2011 Hypervolume Estimation Algorithm.
- `EpsilonMoea` — Deb, Mohan & Mishra 2003 ε-dominance MOEA.
- `PesaII` — Corne et al. 2001 Pareto Envelope Selection II.
- `AgeMoea` — Panichella 2019 Adaptive Geometry Estimation MOEA.
- `Knea` — Zhang, Tian & Jin 2015 Knee point-driven EA.
**Many-objective (4+):**
- **NSGA-III** — Deb & Jain 2014 reference-point NSGA-III.
- **RVEA** — Cheng et al. 2016 Reference Vector-guided EA.
- **GrEA** — Yang et al. 2013 Grid-based EA.
- `Nsga3` — Deb & Jain 2014 reference-point NSGA-III.
- `Rvea` — Cheng et al. 2016 Reference Vector-guided EA.
- `Grea` — Yang et al. 2013 Grid-based EA.
**Reusable utilities:** `pareto_compare`, `pareto_front`, `best_candidate`,
`non_dominated_sort`, `crowding_distance`, `ParetoArchive`, `das_dennis`,
@@ -625,7 +483,7 @@ and the metrics `spacing` and `hypervolume_2d`.
user-facing APIs, no generic-RNG plumbing — `Rng` is a single concrete type
alias.
- **Readable algorithms.** Built-ins are written for clarity, not maximum
abstraction reuse. Random Search is the recommended file to read before
abstraction reuse. `RandomSearch` is the recommended file to read before
writing your own optimizer.
- **One crate first.** No premature splitting into `-core`/`-algorithms`/
`-operators`. Split later if the crate grows.
+2 -2
View File
@@ -8,8 +8,8 @@ needed.
| Version | Supported |
|---------|--------------------|
| 0.10.x | ✅ |
| ≤ 0.9.x | ❌ (please upgrade) |
| 0.5.x | ✅ |
| ≤ 0.4.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
-47
View File
@@ -1,47 +0,0 @@
//! Whole-program callgrind profile of the `compare` example workload.
//!
//! Runs every algorithm runner once (seed 0) under callgrind via gungraun —
//! the same workload `examples/compare.rs` runs, minus the multi-seed
//! averaging and table printing. gungraun reports the total instruction
//! count and diffs it against the previous run; the saved `callgrind.out`
//! (`target/gungraun/compare_profile/compare_group/full_compare_workload/`)
//! carries the per-function breakdown — `callgrind_annotate` it to rank
//! functions by self-instruction cost.
//!
//! ```bash
//! cargo bench --bench compare_profile
//! ```
// The shared `compare_workload` module also carries the example's
// presentation layer (`run_all`, the `run_*_comparison` printers,
// `print_table`, …), which this profiling benchmark deliberately does not
// use — it drives only the runner functions via `profile_workload`. The
// runner functions themselves are *not* allow-listed, so a runner that
// `profile_workload` forgets to call still warns.
#![allow(dead_code)]
use std::hint::black_box;
use gungraun::Callgrind;
use gungraun::prelude::*;
#[path = "../examples/_shared/compare_workload.rs"]
mod workload;
#[library_benchmark]
fn full_compare_workload() -> u64 {
black_box(workload::profile_workload())
}
library_benchmark_group!(
name = compare_group;
benchmarks = full_compare_workload
);
// `--cache-sim=no`: the campaign ranks functions on instruction count
// (`Ir`) only, so callgrind's cache simulation is pure overhead here —
// disabling it roughly halves each profiling run.
main!(
config = LibraryBenchmarkConfig::default().tool(Callgrind::with_args(["--cache-sim=no"])),
library_benchmark_groups = compare_group
);
+2 -663
View File
@@ -10,14 +10,11 @@
use std::hint::black_box;
use gungraun::prelude::*;
use rand::Rng as _;
use heuropt::core::candidate::Candidate;
use heuropt::core::evaluation::Evaluation;
use heuropt::core::objective::{Objective, ObjectiveSpace};
use heuropt::core::partial_problem::PartialProblem;
use heuropt::core::problem::Problem;
use heuropt::core::rng::{Rng, rng_from_seed};
use heuropt::metrics::hypervolume::{hypervolume_2d, hypervolume_nd};
use heuropt::pareto::crowding::crowding_distance;
use heuropt::pareto::sort::non_dominated_sort;
@@ -401,74 +398,6 @@ fn ipop_cma_es_short() -> usize {
black_box(o.run(black_box(&Sphere1D)).evaluations)
}
/// 1-D integer parabola: minimize `(x - 5)^2`. `Vec<i32>` decision so it
/// satisfies `TabuSearch`'s `Hash + Eq` decision bound (`f64` is neither).
struct IntParabola;
impl Problem for IntParabola {
type Decision = Vec<i32>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f")])
}
fn evaluate(&self, x: &Vec<i32>) -> Evaluation {
let v = (x[0] - 5) as f64;
Evaluation::new(vec![v * v])
}
}
/// Start every 1-D integer decision at 0.
struct IntStartAtZero;
impl Initializer<Vec<i32>> for IntStartAtZero {
fn initialize(&mut self, size: usize, _rng: &mut Rng) -> Vec<Vec<i32>> {
(0..size).map(|_| vec![0]).collect()
}
}
#[library_benchmark]
fn tabu_search_short() -> usize {
let neighbors = |x: &Vec<i32>, _rng: &mut Rng| {
vec![
vec![x[0] - 2],
vec![x[0] - 1],
vec![x[0] + 1],
vec![x[0] + 2],
]
};
let mut o = TabuSearch::new(
TabuSearchConfig {
iterations: 50,
tabu_tenure: 8,
seed: 0,
},
IntStartAtZero,
neighbors,
);
black_box(o.run(black_box(&IntParabola)).evaluations)
}
/// OneMax over 16 bits: maximize the count of `true` bits.
struct OneMax16;
impl Problem for OneMax16 {
type Decision = Vec<bool>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::maximize("ones")])
}
fn evaluate(&self, x: &Vec<bool>) -> Evaluation {
Evaluation::new(vec![x.iter().filter(|b| **b).count() as f64])
}
}
#[library_benchmark]
fn umda_short() -> usize {
let mut o = Umda::new(UmdaConfig {
population_size: 20,
selected_size: 8,
generations: 5,
bits: 16,
seed: 0,
});
black_box(o.run(black_box(&OneMax16)).evaluations)
}
library_benchmark_group!(
name = single_objective_group;
benchmarks =
@@ -476,8 +405,7 @@ library_benchmark_group!(
simulated_annealing_short, genetic_algorithm_short,
particle_swarm_short, differential_evolution_short, tlbo_short,
separable_nes_short, nelder_mead_short,
bayesian_opt_short, tpe_short, ipop_cma_es_short,
tabu_search_short, umda_short
bayesian_opt_short, tpe_short, ipop_cma_es_short
);
// -----------------------------------------------------------------------------
@@ -715,598 +643,9 @@ library_benchmark_group!(
age_moea_short, grea_short, knea_short, rvea_short, paes_short
);
// -----------------------------------------------------------------------------
// Permutation operator micro-benchmarks
// -----------------------------------------------------------------------------
fn perm_parent(n: usize) -> Vec<usize> {
(0..n).collect()
}
/// Reversed `[0..n)`: same value multiset as `perm_parent`, shares no oriented
/// edges with it — a stress input for the edge-based crossovers.
fn perm_parent_rev(n: usize) -> Vec<usize> {
(0..n).rev().collect()
}
#[library_benchmark]
#[bench::n_30(30)]
#[bench::n_100(100)]
fn shuffled_permutation_init(n: usize) -> Vec<Vec<usize>> {
let mut rng = rng_from_seed(0);
let mut init = ShuffledPermutation { n };
black_box(init.initialize(black_box(16), black_box(&mut rng)))
}
#[library_benchmark]
#[bench::n_30(30)]
#[bench::n_100(100)]
fn shuffled_multiset_permutation_init(n: usize) -> Vec<Vec<usize>> {
let mut rng = rng_from_seed(0);
let mut init = ShuffledMultisetPermutation::new(vec![5; n]);
black_box(init.initialize(black_box(16), black_box(&mut rng)))
}
#[library_benchmark]
#[bench::n_30(30)]
#[bench::n_100(100)]
fn swap_mutation_vary(n: usize) -> Vec<Vec<usize>> {
let parent = perm_parent(n);
let mut rng = rng_from_seed(1);
let mut op = SwapMutation;
black_box(op.vary(
black_box(std::slice::from_ref(&parent)),
black_box(&mut rng),
))
}
#[library_benchmark]
#[bench::n_30(30)]
#[bench::n_100(100)]
fn inversion_mutation_vary(n: usize) -> Vec<Vec<usize>> {
let parent = perm_parent(n);
let mut rng = rng_from_seed(1);
let mut op = InversionMutation;
black_box(op.vary(
black_box(std::slice::from_ref(&parent)),
black_box(&mut rng),
))
}
#[library_benchmark]
#[bench::n_30(30)]
#[bench::n_100(100)]
fn insertion_mutation_vary(n: usize) -> Vec<Vec<usize>> {
let parent = perm_parent(n);
let mut rng = rng_from_seed(1);
let mut op = InsertionMutation;
black_box(op.vary(
black_box(std::slice::from_ref(&parent)),
black_box(&mut rng),
))
}
#[library_benchmark]
#[bench::n_30(30)]
#[bench::n_100(100)]
fn scramble_mutation_vary(n: usize) -> Vec<Vec<usize>> {
let parent = perm_parent(n);
let mut rng = rng_from_seed(1);
let mut op = ScrambleMutation;
black_box(op.vary(
black_box(std::slice::from_ref(&parent)),
black_box(&mut rng),
))
}
#[library_benchmark]
#[bench::n_30(30)]
#[bench::n_100(100)]
fn order_crossover_vary(n: usize) -> Vec<Vec<usize>> {
let parents = [perm_parent(n), perm_parent_rev(n)];
let mut rng = rng_from_seed(2);
let mut op = OrderCrossover;
black_box(op.vary(black_box(&parents), black_box(&mut rng)))
}
#[library_benchmark]
#[bench::n_30(30)]
#[bench::n_100(100)]
fn pmx_crossover_vary(n: usize) -> Vec<Vec<usize>> {
let parents = [perm_parent(n), perm_parent_rev(n)];
let mut rng = rng_from_seed(2);
let mut op = PartiallyMappedCrossover;
black_box(op.vary(black_box(&parents), black_box(&mut rng)))
}
#[library_benchmark]
#[bench::n_30(30)]
#[bench::n_100(100)]
fn cycle_crossover_vary(n: usize) -> Vec<Vec<usize>> {
let parents = [perm_parent(n), perm_parent_rev(n)];
let mut rng = rng_from_seed(2);
let mut op = CycleCrossover;
black_box(op.vary(black_box(&parents), black_box(&mut rng)))
}
#[library_benchmark]
#[bench::n_30(30)]
#[bench::n_100(100)]
fn edge_recombination_crossover_vary(n: usize) -> Vec<Vec<usize>> {
let parents = [perm_parent(n), perm_parent_rev(n)];
let mut rng = rng_from_seed(2);
let mut op = EdgeRecombinationCrossover;
black_box(op.vary(black_box(&parents), black_box(&mut rng)))
}
library_benchmark_group!(
name = permutation_ops_group;
benchmarks =
shuffled_permutation_init, shuffled_multiset_permutation_init,
swap_mutation_vary, inversion_mutation_vary, insertion_mutation_vary,
scramble_mutation_vary, order_crossover_vary, pmx_crossover_vary,
cycle_crossover_vary, edge_recombination_crossover_vary
);
// -----------------------------------------------------------------------------
// Un-benchmarked operators from the binary / real / repair families
// -----------------------------------------------------------------------------
#[library_benchmark]
fn bit_flip_mutation_vary() -> Vec<Vec<bool>> {
let parent: Vec<bool> = (0..64).map(|i| i % 2 == 0).collect();
let mut rng = rng_from_seed(3);
let mut op = BitFlipMutation {
probability: 1.0 / 64.0,
};
black_box(op.vary(
black_box(std::slice::from_ref(&parent)),
black_box(&mut rng),
))
}
#[library_benchmark]
fn levy_mutation_vary() -> Vec<Vec<f64>> {
let parent = vec![0.0_f64; 16];
let mut rng = rng_from_seed(3);
let mut op = LevyMutation::new(1.5, 0.1, vec![(-5.0, 5.0); 16]);
black_box(op.vary(
black_box(std::slice::from_ref(&parent)),
black_box(&mut rng),
))
}
#[library_benchmark]
fn bounded_gaussian_mutation_vary() -> Vec<Vec<f64>> {
let parent = vec![0.0_f64; 16];
let mut rng = rng_from_seed(3);
let mut op = BoundedGaussianMutation::new(0.3, vec![(-1.0, 1.0); 16]);
black_box(op.vary(
black_box(std::slice::from_ref(&parent)),
black_box(&mut rng),
))
}
#[library_benchmark]
fn clamp_to_bounds_repair() -> Vec<f64> {
let mut x: Vec<f64> = (0..32).map(|i| (i as f64) - 16.0).collect();
let mut op = ClampToBounds::new(vec![(-1.0, 1.0); 32]);
op.repair(black_box(&mut x));
black_box(x)
}
#[library_benchmark]
fn project_to_simplex_repair() -> Vec<f64> {
// 32-dim mixed-sign vector; exercises the sort-based projection path.
let mut x: Vec<f64> = (0..32).map(|i| ((i * 7 % 13) as f64) - 6.0).collect();
let mut op = ProjectToSimplex::new(1.0);
op.repair(black_box(&mut x));
black_box(x)
}
library_benchmark_group!(
name = variation_ops_group;
benchmarks =
bit_flip_mutation_vary, levy_mutation_vary, bounded_gaussian_mutation_vary,
clamp_to_bounds_repair, project_to_simplex_repair
);
// -----------------------------------------------------------------------------
// Combinatorial / sequencing end-to-end benches
// -----------------------------------------------------------------------------
const TSP_N: usize = 15;
/// Deterministic pseudo-scattered city coordinates. The bench only needs a
/// stable distance matrix, not a known optimum.
fn tsp_coords() -> Vec<(f64, f64)> {
(0..TSP_N)
.map(|i| {
let x = ((i * 37) % 100) as f64;
let y = ((i * 53 + 11) % 100) as f64;
(x, y)
})
.collect()
}
fn tsp_distance_matrix() -> Vec<Vec<f64>> {
let c = tsp_coords();
let n = c.len();
let mut d = vec![vec![0.0_f64; n]; n];
for i in 0..n {
for j in 0..n {
if i != j {
let dx = c[i].0 - c[j].0;
let dy = c[i].1 - c[j].1;
d[i][j] = (dx * dx + dy * dy).sqrt();
}
}
}
d
}
/// Single-objective TSP over a precomputed distance matrix.
struct TspProblem {
distances: Vec<Vec<f64>>,
}
impl Problem for TspProblem {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("length")])
}
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
let n = tour.len();
let mut len = 0.0;
for i in 0..n {
len += self.distances[tour[i]][tour[(i + 1) % n]];
}
Evaluation::new(vec![len])
}
}
/// Bi-objective TSP: two distance matrices over the same city set.
struct BiTspProblem {
dist_a: Vec<Vec<f64>>,
dist_b: Vec<Vec<f64>>,
}
impl Problem for BiTspProblem {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("length_a"),
Objective::minimize("length_b"),
])
}
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
let n = tour.len();
let (mut la, mut lb) = (0.0, 0.0);
for i in 0..n {
let (u, v) = (tour[i], tour[(i + 1) % n]);
la += self.dist_a[u][v];
lb += self.dist_b[u][v];
}
Evaluation::new(vec![la, lb])
}
}
#[library_benchmark]
fn tsp_nsga2_short() -> usize {
let dist_a = tsp_distance_matrix();
// Second objective: a distinct symmetric matrix with a zero diagonal.
let dist_b: Vec<Vec<f64>> = dist_a
.iter()
.enumerate()
.map(|(i, row)| {
row.iter()
.enumerate()
.map(|(j, &d)| if i == j { 0.0 } else { d * 0.5 + 3.0 })
.collect()
})
.collect();
let problem = BiTspProblem { dist_a, dist_b };
let mut o = Nsga2::new(
Nsga2Config {
population_size: 20,
generations: 3,
seed: 0,
},
ShuffledPermutation { n: TSP_N },
CompositeVariation {
crossover: OrderCrossover,
mutation: InversionMutation,
},
);
black_box(o.run(black_box(&problem)).evaluations)
}
#[library_benchmark]
fn ant_colony_tsp_short() -> usize {
let distances = tsp_distance_matrix();
let problem = TspProblem {
distances: distances.clone(),
};
let mut o = AntColonyTsp::new(
AntColonyTspConfig {
ants: 8,
generations: 3,
alpha: 1.0,
beta: 2.0,
evaporation: 0.5,
deposit: 1.0,
initial_pheromone: 1.0,
seed: 0,
},
distances,
);
black_box(o.run(black_box(&problem)).evaluations)
}
const JSS_JOBS: usize = 6;
const JSS_MACHINES: usize = 6;
/// FT06 (Fisher & Thompson 1963) routing — machine id of the k-th operation
/// of job j.
const FT06_MACHINE: [[usize; JSS_MACHINES]; JSS_JOBS] = [
[2, 0, 1, 3, 5, 4],
[1, 2, 4, 5, 0, 3],
[2, 3, 5, 0, 1, 4],
[1, 0, 2, 3, 4, 5],
[2, 1, 4, 5, 0, 3],
[1, 3, 5, 0, 4, 2],
];
/// FT06 processing times — duration of the k-th operation of job j.
const FT06_TIME: [[f64; JSS_MACHINES]; JSS_JOBS] = [
[1.0, 3.0, 6.0, 7.0, 3.0, 6.0],
[8.0, 5.0, 10.0, 10.0, 10.0, 4.0],
[5.0, 4.0, 8.0, 9.0, 1.0, 7.0],
[5.0, 5.0, 5.0, 3.0, 8.0, 9.0],
[9.0, 3.0, 5.0, 4.0, 3.0, 1.0],
[3.0, 3.0, 9.0, 10.0, 4.0, 1.0],
];
/// Bi-objective FT06 job-shop scheduling: f1 = makespan, f2 = total flow time.
struct Ft06Problem;
impl Problem for Ft06Problem {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("makespan"),
Objective::minimize("total_flow_time"),
])
}
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
let mut job_next = [0_usize; JSS_JOBS];
let mut job_clock = [0.0_f64; JSS_JOBS];
let mut machine_clock = [0.0_f64; JSS_MACHINES];
for &job in schedule {
let k = job_next[job];
let m = FT06_MACHINE[job][k];
let t = FT06_TIME[job][k];
let start = job_clock[job].max(machine_clock[m]);
let end = start + t;
job_clock[job] = end;
machine_clock[m] = end;
job_next[job] = k + 1;
}
let makespan = machine_clock.iter().cloned().fold(0.0_f64, f64::max);
let flow_time: f64 = job_clock.iter().sum();
Evaluation::new(vec![makespan, flow_time])
}
}
/// Precedence-Order Crossover — multiset-preserving crossover for the
/// operation-string JSS encoding. Trimmed from `examples/mo_jss_la01.rs`;
/// the strict-permutation crossovers cannot be used on multiset encodings.
#[derive(Debug, Clone, Copy, Default)]
struct PrecedenceOrderCrossover;
impl Variation<Vec<usize>> for PrecedenceOrderCrossover {
fn vary(&mut self, parents: &[Vec<usize>], rng: &mut Rng) -> Vec<Vec<usize>> {
assert!(parents.len() >= 2, "POX requires 2 parents");
let (p1, p2) = (&parents[0], &parents[1]);
let mut in_j1 = [false; JSS_JOBS];
loop {
for slot in &mut in_j1 {
*slot = rng.random_bool(0.5);
}
let c = in_j1.iter().filter(|&&b| b).count();
if c > 0 && c < JSS_JOBS {
break;
}
}
vec![pox_child(p1, p2, &in_j1), pox_child(p2, p1, &in_j1)]
}
}
fn pox_child(donor: &[usize], filler: &[usize], in_donor_set: &[bool]) -> Vec<usize> {
let n = donor.len();
let mut child = vec![usize::MAX; n];
for k in 0..n {
if in_donor_set[donor[k]] {
child[k] = donor[k];
}
}
let mut fill_idx = 0;
for &v in filler {
if !in_donor_set[v] {
while fill_idx < n && child[fill_idx] != usize::MAX {
fill_idx += 1;
}
child[fill_idx] = v;
fill_idx += 1;
}
}
child
}
#[library_benchmark]
fn jss_nsga2_short() -> usize {
let mut o = Nsga2::new(
Nsga2Config {
population_size: 20,
generations: 3,
seed: 0,
},
ShuffledMultisetPermutation::new(vec![JSS_MACHINES; JSS_JOBS]),
CompositeVariation {
crossover: PrecedenceOrderCrossover,
mutation: InsertionMutation,
},
);
black_box(o.run(black_box(&Ft06Problem)).evaluations)
}
const KNAPSACK_N: usize = 20;
const KP_PROFIT_A: [f64; KNAPSACK_N] = [
61.0, 17.0, 92.0, 49.0, 73.0, 28.0, 84.0, 36.0, 55.0, 78.0, 23.0, 91.0, 12.0, 67.0, 45.0, 58.0,
33.0, 71.0, 14.0, 26.0,
];
const KP_PROFIT_B: [f64; KNAPSACK_N] = [
24.0, 81.0, 16.0, 67.0, 29.0, 73.0, 41.0, 60.0, 52.0, 19.0, 77.0, 34.0, 95.0, 22.0, 71.0, 88.0,
56.0, 27.0, 64.0, 90.0,
];
const KP_WEIGHT: [f64; KNAPSACK_N] = [
35.0, 58.0, 22.0, 71.0, 14.0, 86.0, 31.0, 53.0, 78.0, 19.0, 44.0, 16.0, 67.0, 88.0, 25.0, 51.0,
33.0, 74.0, 12.0, 47.0,
];
/// Bi-objective 0/1 knapsack with a penalty-based capacity constraint.
struct KnapsackProblem {
capacity: f64,
}
impl Problem for KnapsackProblem {
type Decision = Vec<bool>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::maximize("profit_a"),
Objective::maximize("profit_b"),
])
}
fn evaluate(&self, take: &Vec<bool>) -> Evaluation {
let (mut pa, mut pb, mut w) = (0.0, 0.0, 0.0);
for (i, &t) in take.iter().enumerate() {
if t {
pa += KP_PROFIT_A[i];
pb += KP_PROFIT_B[i];
w += KP_WEIGHT[i];
}
}
let penalty = 1000.0 * (w - self.capacity).max(0.0);
Evaluation::new(vec![pa - penalty, pb - penalty])
}
}
/// Random binary initializer — each bit 50/50 independently.
#[derive(Debug, Clone, Copy)]
struct RandomBinary {
n: usize,
}
impl Initializer<Vec<bool>> for RandomBinary {
fn initialize(&mut self, size: usize, rng: &mut Rng) -> Vec<Vec<bool>> {
(0..size)
.map(|_| (0..self.n).map(|_| rng.random_bool(0.5)).collect())
.collect()
}
}
/// One-point crossover for binary chromosomes. Trimmed from
/// `examples/mo_knapsack.rs`.
#[derive(Debug, Clone, Copy, Default)]
struct OnePointCrossoverBool;
impl Variation<Vec<bool>> for OnePointCrossoverBool {
fn vary(&mut self, parents: &[Vec<bool>], rng: &mut Rng) -> Vec<Vec<bool>> {
assert!(
parents.len() >= 2,
"OnePointCrossoverBool requires 2 parents"
);
let (p1, p2) = (&parents[0], &parents[1]);
let n = p1.len();
if n < 2 {
return vec![p1.clone(), p2.clone()];
}
let cut = rng.random_range(1..n);
let mut c1 = Vec::with_capacity(n);
let mut c2 = Vec::with_capacity(n);
c1.extend_from_slice(&p1[..cut]);
c1.extend_from_slice(&p2[cut..]);
c2.extend_from_slice(&p2[..cut]);
c2.extend_from_slice(&p1[cut..]);
vec![c1, c2]
}
}
#[library_benchmark]
fn knapsack_nsga2_short() -> usize {
let capacity = 0.5 * KP_WEIGHT.iter().sum::<f64>();
let problem = KnapsackProblem { capacity };
let mut o = Nsga2::new(
Nsga2Config {
population_size: 20,
generations: 3,
seed: 0,
},
RandomBinary { n: KNAPSACK_N },
CompositeVariation {
crossover: OnePointCrossoverBool,
mutation: BitFlipMutation {
probability: 1.0 / KNAPSACK_N as f64,
},
},
);
black_box(o.run(black_box(&problem)).evaluations)
}
library_benchmark_group!(
name = combinatorial_group;
benchmarks =
tsp_nsga2_short, ant_colony_tsp_short, jss_nsga2_short, knapsack_nsga2_short
);
// -----------------------------------------------------------------------------
// Multi-fidelity (Hyperband)
// -----------------------------------------------------------------------------
/// Multi-fidelity 2-D sphere: higher budget shrinks an additive residual, so
/// the loss is budget-monotone the way Hyperband expects. Deterministic.
struct MultiFidelitySphere;
impl PartialProblem for MultiFidelitySphere {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("loss")])
}
fn evaluate_at_budget(&self, x: &Vec<f64>, budget: f64) -> Evaluation {
let true_f: f64 = x.iter().map(|v| v * v).sum();
let residual = 1.0 / (budget + 1.0);
Evaluation::new(vec![true_f + residual])
}
}
#[library_benchmark]
fn hyperband_short() -> usize {
let mut o = Hyperband::new(
HyperbandConfig {
max_budget: 27.0,
eta: 3.0,
max_brackets: 3,
seed: 0,
},
RealBounds::new(vec![(-5.0, 5.0); 2]),
);
black_box(o.run(black_box(&MultiFidelitySphere)).evaluations)
}
library_benchmark_group!(
name = multi_fidelity_group;
benchmarks = hyperband_short
);
main!(
library_benchmark_groups = pareto_group,
algorithm_group,
single_objective_group,
multi_objective_group,
permutation_ops_group,
variation_ops_group,
combinatorial_group,
multi_fidelity_group
multi_objective_group
);
+1 -1
View File
@@ -31,4 +31,4 @@ use-boolean-and = true
enable = true
[rust]
edition = "2021"
edition = "2024"
-3
View File
@@ -12,14 +12,11 @@
- [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)
- [Multi-objective combinatorial problems](./cookbook/multi-objective-combinatorial.md)
- [Constrain your search with `Repair`](./cookbook/constraints.md)
- [Pick one answer off a Pareto front](./cookbook/pick-one.md)
- [Explore your results in a webapp](./cookbook/explorer.md)
- [Write your own algorithm](./cookbook/custom-optimizer.md)
# Reference
+100 -163
View File
@@ -17,9 +17,9 @@ after it.
For the cheap-eval branch, you have the run of the catalog. For the
expensive branch, classical evolutionary methods waste your evaluation
budget — go to [Bayesian Optimization][BayesianOpt] or [TPE]. For the *very* expensive
budget — go to [`BayesianOpt`] or [`Tpe`]. For the *very* expensive
branch where each eval has a tunable budget (epochs, MC samples, sim
steps), [Hyperband] over the [`PartialProblem`] trait is the move.
steps), [`Hyperband`] over the [`PartialProblem`] trait is the move.
## Step 1: How many objectives?
@@ -51,48 +51,48 @@ These all take `Vec<f64>` decisions.
### Smooth, low-to-moderate dimension
[CMA-ES][CmaEs] is the strong default. It adapts the search distribution's
[`CmaEs`] is the strong default. It adapts the search distribution's
covariance to the local landscape. On the comparison harness it
hits machine epsilon on Rosenbrock at 30 000 evaluations.
For very low-dimensional smooth problems (≤ 5 dim), [Nelder-Mead][NelderMead] is
For very low-dimensional smooth problems (≤ 5 dim), [`NelderMead`] is
deterministic and converges to f = 0 exactly on Rosenbrock.
### High dimension, smooth
[sNES][SeparableNes] uses a diagonal covariance — cheaper per step than
CMA-ES at the cost of being unable to model rotated landscapes. Worth
trying when CMA-ES's `O(d²)` per-step cost hurts.
[`SeparableNes`] uses a diagonal covariance — cheaper per step than
CmaEs at the cost of being unable to model rotated landscapes. Worth
trying when CmaEs's `O(d²)` per-step cost hurts.
### Multimodal landscapes
Multimodal = many local minima that aren't the global one. Rastrigin
and Ackley are classic traps.
[IPOP-CMA-ES][IpopCmaEs] is CMA-ES with an increasing-population restart strategy
specifically designed for this. On the harness it drops vanilla CMA-ES's
[`IpopCmaEs`] is CmaEs with an increasing-population restart strategy
specifically designed for this. On the harness it drops vanilla CmaEs's
Rastrigin score from f = 2.35 to f = 0.13.
[Differential Evolution][DifferentialEvolution] is rarely beaten on cheap multimodal
[`DifferentialEvolution`] is rarely beaten on cheap multimodal
continuous problems. On Rastrigin it ties with `(1+1)-ES` at f = 0.
[Simulated Annealing][SimulatedAnnealing] is a cheap, generic baseline that escapes local
[`SimulatedAnnealing`] is a cheap, generic baseline that escapes local
optima via temperature decay.
### Want parameter-free
[TLBO][Tlbo] (Teaching-Learning-Based Optimization) has no `F`, `CR`, `w`,
[`Tlbo`] (Teaching-Learning-Based Optimization) has no `F`, `CR`, `w`,
or `σ` to tune. Often a respectable middle-of-the-pack performer.
### Smallest possible self-adapting baseline
[(1+1)-ES][OnePlusOneEs] — Rechenberg's 1973 `(1+1)`-ES with the one-fifth
[`OnePlusOneEs`] — Rechenberg's 1973 `(1+1)`-ES with the one-fifth
success rule. On the harness it hits f = 0 on Rastrigin in 50 000
evaluations.
### Just want a baseline
[Random Search][RandomSearch]. Useful as a sanity check: if your fancy optimizer
[`RandomSearch`]. Useful as a sanity check: if your fancy optimizer
can't beat random search, something is wrong (with the fancy
optimizer or with the problem).
@@ -100,140 +100,96 @@ optimizer or with the problem).
| Decision type | Algorithm | Notes |
|---|---|---|
| `Vec<bool>` | [UMDA][Umda] | Per-bit marginal EDA. Independent-bit assumption. |
| `Vec<bool>` | [GA][GeneticAlgorithm] + [`BitFlipMutation`] | When bit interactions matter. |
| `Vec<usize>` (permutation) | [Ant Colony][AntColonyTsp] | TSP-style with a distance matrix. |
| `Vec<usize>` (permutation) | [GA][GeneticAlgorithm] + [`ShuffledPermutation`] + [`OrderCrossover`] + [`InversionMutation`] | Generic permutation GA; use [`EdgeRecombinationCrossover`] for TSP-shaped instances. |
| `Vec<usize>` (JSS multiset) | [Simulated Annealing][SimulatedAnnealing] / [Tabu Search][TabuSearch] with [`InsertionMutation`], or [GA][GeneticAlgorithm] + [`ShuffledMultisetPermutation`] + local POX | Operation-string encoding. On the FT06 harness the local-search pair edges out the GA — see [Optimize a permutation](./cookbook/permutation.md). |
| `Vec<usize>` (permutation) | [Simulated Annealing][SimulatedAnnealing] + [`InversionMutation`] | Strong on sequencing, not just a baseline — wins the harness's FT06 job-shop table and ties for the TSP optimum. |
| `Vec<usize>` or custom | [Tabu Search][TabuSearch] | You supply the neighbor function; consistently near the top on the TSP and JSS tables. |
| Custom struct | [Simulated Annealing][SimulatedAnnealing] / [Hill Climber][HillClimber] | With your own `Variation` impl. |
heuropt's permutation operator toolkit covers four crossovers
([`OrderCrossover`], [`PartiallyMappedCrossover`], [`CycleCrossover`],
[`EdgeRecombinationCrossover`]) and four mutations ([`SwapMutation`],
[`InversionMutation`], [`InsertionMutation`], [`ScrambleMutation`]),
plus two initializers for strict and multiset permutations. See
[Optimize a permutation](./cookbook/permutation.md) for the full
picker.
| `Vec<bool>` | [`Umda`] | Per-bit marginal EDA. Independent-bit assumption. |
| `Vec<bool>` | [`GeneticAlgorithm`] + [`BitFlipMutation`] | When bit interactions matter. |
| `Vec<usize>` (permutation) | [`AntColonyTsp`] | TSP-style with a distance matrix. |
| `Vec<usize>` (permutation) | [`SimulatedAnnealing`] + [`SwapMutation`] | Generic discrete baseline. |
| `Vec<usize>` or custom | [`TabuSearch`] | You supply the neighbor function. |
| Custom struct | [`SimulatedAnnealing`] / [`HillClimber`] | With your own `Variation` impl. |
## Step 2 — multi-objective (2 or 3)
### Strong default
[MOEA/D][Moead] is the most consistent performer on the harness. It
decomposes the problem into many scalar sub-problems (Tchebycheff or
weighted sum) and solves them in parallel — fast per generation, and
robust: it finishes **top-3 on every multi- and many-objective table**
(convex, disconnected, spherical and linear fronts; 2 through 10
objectives) and is consistently the fastest or near-fastest. It rarely
*wins* a table outright — a specialist usually does — but it never lands
badly. One caveat from the literature: MOEA/D's spread depends on the
weight-vector distribution and the scalarizing function, so it can leave
gaps on highly irregular or degenerate fronts; the DTLZ/ZDT suite here
doesn't stress that.
[NSGA-II][Nsga2] is the other safe default — the canonical Pareto-based
EA: fast, well-understood, diversity-preserving via crowding distance.
On the harness it's edged out by MOEA/D on every multi-objective table
and degrades past ~4 objectives (see the many-objective section), but it
stays a solid 23-objective pick and is the established choice for
*combinatorial* encodings: drop in [`ShuffledPermutation`] + a
permutation crossover and it solves bi-objective TSP; drop in a binary
initializer and [`BitFlipMutation`] and it solves bi-objective knapsack.
See [Multi-objective combinatorial problems](./cookbook/multi-objective-combinatorial.md).
[`Nsga2`] is the canonical Pareto-based EA. Fast, well-understood,
maintains diversity via crowding distance. On the harness it lands
on the Pareto front of every test problem.
### Real-valued, smooth front, want best convergence
[MOPSO][Mopso] (multi-objective PSO with archive). On ZDT1 it wins
[`Mopso`] (multi-objective PSO with archive). On ZDT1 it wins
hypervolume outright and converges 100× tighter than the
dominance-based methods.
### Better front quality than the default
### Better front quality than NSGA-II
[IBEA][Ibea] (indicator-based) is consistently the best of the
[`Ibea`] (indicator-based) is consistently the best of the
dominance-based methods on the harness — wins ZDT3 hypervolume and
DTLZ2 mean distance by 24×. It uses an additive ε-indicator for
selection rather than dominance + crowding.
[SPEA2][Spea2] (strength + density) — solid alternative; explicit external
[`Spea2`] (strength + density) — solid alternative; explicit external
archive separate from the population.
[SMS-EMOA][SmsEmoa] uses exact hypervolume contribution for selection. Elegant
[`SmsEmoa`] uses exact hypervolume contribution for selection. Elegant
in theory; in practice on the harness budgets here it underperforms
NSGA-II. Worth the higher per-step cost only when exact HV
contribution is the right discriminator.
### Decomposition / weight-vector style
[`Moead`] decomposes the multi-objective problem into many scalar
sub-problems (Tchebycheff or weighted sum) and solves them in
parallel. Very fast per generation; scales naturally to many
objectives.
### Disconnected or non-convex front
A *disconnected* front (separate arcs, like ZDT3) and a *non-convex but
contiguous* front are different problems — don't conflate them.
For a **disconnected** front, [IBEA][Ibea] is the clear pick: on the
harness it wins ZDT3 — the disconnected-front benchmark — outright on
hypervolume, with [MOEA/D][Moead] and [NSGA-II][Nsga2] close behind.
Counter-intuitively the geometry-aware methods below *trail* here:
estimating a single front geometry or chasing knee points doesn't help
when the front is in pieces (on ZDT3, AGE-MOEA and KnEA finish last).
For a **non-convex but contiguous** front:
[AGE-MOEA][AgeMoea] estimates the front geometry adaptively (the L_p
[`AgeMoea`] estimates the front geometry adaptively (the L_p
parameter `p` is fit from data each generation).
[KnEA][Knea] favors knee points — the regions of the front where small
[`Knea`] favors knee points — the regions of the front where small
gains in one objective cost large losses in another.
[`Ibea`] also handles disconnected fronts well.
### Region-based diversity
[PESA-II][PesaII] uses grid hyperboxes to drive selection — divide the
[`PesaII`] uses grid hyperboxes to drive selection — divide the
objective space into a grid, pick from the least-crowded boxes.
[ε-MOEA][EpsilonMoea] uses an ε-grid archive that auto-limits its size.
[`EpsilonMoea`] uses an ε-grid archive that auto-limits its size.
### Just one starting decision (no population budget)
[PAES][Paes] — `(1+1)`-ES with a Pareto archive. Cheap, simple, useful
[`Paes`] — `(1+1)`-ES with a Pareto archive. Cheap, simple, useful
when your evaluations are expensive enough that you can't afford a
population.
## Step 2 — many-objective (4+)
### Strong default
[MOEA/D][Moead] again. Decomposition sidesteps the *dominance resistance*
that breaks Pareto-based methods at high objective count — each scalar
sub-problem still has a clear best, even when almost every pair of
solutions is mutually non-dominated. On the harness it is **#2 on every
many-objective table** (DTLZ2 at 4 and 10 objectives, DTLZ1 at 8), and
fast every time. [NSGA-II][Nsga2] is the cautionary tale: on DTLZ2 at 10
objectives it finishes *last — behind random search* — because its
crowding distance has no dominance signal left to refine.
### Linear / simplex-shaped front (e.g., DTLZ1)
[GrEA][Grea] — grid coords drive ranking. On 3-objective DTLZ1 it beats
NSGA-III by 3× and AGE-MOEA by 2.5×, and it wins the 8-objective DTLZ1
table outright.
[`Grea`] — grid coords drive ranking. On DTLZ1 it beats NSGA-III by
3× and AGE-MOEA by 2.5×.
[MOEA/D][Moead] — also #2 on both DTLZ1 tables.
[`Moead`] — decomposition shines on linear fronts; second on DTLZ1
and among the fastest per generation.
### Curved / unknown front geometry
[NSGA-III][Nsga3] — reference-point niching; the canonical many-objective
method by reputation, though on the harness MOEA/D outperforms it on
every table. Reach for it when you specifically want reference-point
niching.
[`Nsga3`] — reference-point niching; canonical many-objective method;
strong default when the front isn't simplex-shaped.
[AGE-MOEA][AgeMoea] — estimates L_p geometry per generation.
[`AgeMoea`] — estimates L_p geometry per generation.
[RVEA][Rvea] — reference vectors with adaptive penalty.
[`Rvea`] — reference vectors with adaptive penalty.
### Indicator-based selection
[IBEA][Ibea] — additive ε-indicator; doesn't degrade at high obj count.
[`Ibea`] — additive ε-indicator; doesn't degrade at high obj count.
[HypE][Hype] — Monte Carlo hypervolume estimation; scales to arbitrary
[`HypE`] — Monte Carlo hypervolume estimation; scales to arbitrary
objective count where exact HV is too expensive.
## Step 3: Are there hard constraints?
@@ -260,87 +216,68 @@ for worked examples.
## Step 4: Should you parallelize?
Enable the `parallel` feature flag if your `evaluate` takes more
than ~50 µs. Population-based algorithms ([Random Search][RandomSearch], [NSGA-II][Nsga2],
[Differential Evolution][DifferentialEvolution], [SPEA2][Spea2], [IBEA][Ibea], [MOPSO][Mopso], …) batch-
than ~50 µs. Population-based algorithms ([`RandomSearch`], [`Nsga2`],
[`DifferentialEvolution`], [`Spea2`], [`Ibea`], [`Mopso`], …) batch-
evaluate via rayon when the feature is on. **Seeded runs stay
bit-identical** to serial mode.
```toml
heuropt = { version = "0.10", features = ["parallel"] }
heuropt = { version = "0.5", 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 |
|---|---|
| Smooth single-objective continuous | [CMA-ES][CmaEs] |
| Multimodal single-objective continuous | [IPOP-CMA-ES][IpopCmaEs] or [Differential Evolution][DifferentialEvolution] |
| Expensive single-objective | [Bayesian Optimization][BayesianOpt] or [TPE] |
| Multi-fidelity single-objective | [Hyperband] |
| 2- or 3-objective default | [MOEA/D][Moead] (or [NSGA-II][Nsga2]) |
| Many-objective default | [MOEA/D][Moead] |
| 2-objective real-valued smooth front | [MOPSO][Mopso] |
| Disconnected front | [IBEA][Ibea] |
| Many-objective, curved front | [NSGA-III][Nsga3] |
| Many-objective, linear / simplex front | [GrEA][Grea] |
| Permutation problem (TSP with distance matrix) | [Ant Colony][AntColonyTsp] |
| Generic permutation problem | [GA][GeneticAlgorithm] + permutation toolkit |
| Bi-objective combinatorial (TSP / scheduling / knapsack) | [NSGA-II][Nsga2] + matching encoding operators |
| 3-objective combinatorial | [NSGA-III][Nsga3] + matching encoding operators |
| Binary problem | [UMDA][Umda] |
| Custom decision type | [Simulated Annealing][SimulatedAnnealing] + your `Variation` |
| Sanity baseline | [Random Search][RandomSearch] |
| Smooth single-objective continuous | [`CmaEs`] |
| Multimodal single-objective continuous | [`IpopCmaEs`] or [`DifferentialEvolution`] |
| Expensive single-objective | [`BayesianOpt`] or [`Tpe`] |
| Multi-fidelity single-objective | [`Hyperband`] |
| 2- or 3-objective default | [`Nsga2`] |
| 2-objective real-valued smooth front | [`Mopso`] |
| Disconnected / non-convex front | [`Ibea`] |
| Many-objective default (curved front) | [`Nsga3`] |
| Many-objective linear / simplex front | [`Grea`] |
| Permutation problem | [`AntColonyTsp`] |
| Binary problem | [`Umda`] |
| Custom decision type | [`SimulatedAnnealing`] + your `Variation` |
| Sanity baseline | [`RandomSearch`] |
[CmaEs]: https://docs.rs/heuropt/latest/heuropt/algorithms/cma_es/struct.CmaEs.html
[IpopCmaEs]: https://docs.rs/heuropt/latest/heuropt/algorithms/ipop_cma_es/struct.IpopCmaEs.html
[SeparableNes]: https://docs.rs/heuropt/latest/heuropt/algorithms/snes/struct.SeparableNes.html
[NelderMead]: https://docs.rs/heuropt/latest/heuropt/algorithms/nelder_mead/struct.NelderMead.html
[DifferentialEvolution]: https://docs.rs/heuropt/latest/heuropt/algorithms/differential_evolution/struct.DifferentialEvolution.html
[SimulatedAnnealing]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
[Tlbo]: https://docs.rs/heuropt/latest/heuropt/algorithms/tlbo/struct.Tlbo.html
[OnePlusOneEs]: https://docs.rs/heuropt/latest/heuropt/algorithms/one_plus_one_es/struct.OnePlusOneEs.html
[RandomSearch]: https://docs.rs/heuropt/latest/heuropt/algorithms/random_search/struct.RandomSearch.html
[HillClimber]: https://docs.rs/heuropt/latest/heuropt/algorithms/hill_climber/struct.HillClimber.html
[BayesianOpt]: https://docs.rs/heuropt/latest/heuropt/algorithms/bayesian_opt/struct.BayesianOpt.html
[TPE]: https://docs.rs/heuropt/latest/heuropt/algorithms/tpe/struct.Tpe.html
[Hyperband]: https://docs.rs/heuropt/latest/heuropt/algorithms/hyperband/struct.Hyperband.html
[`CmaEs`]: https://docs.rs/heuropt/latest/heuropt/algorithms/cma_es/struct.CmaEs.html
[`IpopCmaEs`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ipop_cma_es/struct.IpopCmaEs.html
[`SeparableNes`]: https://docs.rs/heuropt/latest/heuropt/algorithms/snes/struct.SeparableNes.html
[`NelderMead`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nelder_mead/struct.NelderMead.html
[`DifferentialEvolution`]: https://docs.rs/heuropt/latest/heuropt/algorithms/differential_evolution/struct.DifferentialEvolution.html
[`SimulatedAnnealing`]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
[`Tlbo`]: https://docs.rs/heuropt/latest/heuropt/algorithms/tlbo/struct.Tlbo.html
[`OnePlusOneEs`]: https://docs.rs/heuropt/latest/heuropt/algorithms/one_plus_one_es/struct.OnePlusOneEs.html
[`RandomSearch`]: https://docs.rs/heuropt/latest/heuropt/algorithms/random_search/struct.RandomSearch.html
[`HillClimber`]: https://docs.rs/heuropt/latest/heuropt/algorithms/hill_climber/struct.HillClimber.html
[`BayesianOpt`]: https://docs.rs/heuropt/latest/heuropt/algorithms/bayesian_opt/struct.BayesianOpt.html
[`Tpe`]: https://docs.rs/heuropt/latest/heuropt/algorithms/tpe/struct.Tpe.html
[`Hyperband`]: https://docs.rs/heuropt/latest/heuropt/algorithms/hyperband/struct.Hyperband.html
[`PartialProblem`]: https://docs.rs/heuropt/latest/heuropt/core/partial_problem/trait.PartialProblem.html
[Umda]: https://docs.rs/heuropt/latest/heuropt/algorithms/umda/struct.Umda.html
[GeneticAlgorithm]: https://docs.rs/heuropt/latest/heuropt/algorithms/genetic_algorithm/struct.GeneticAlgorithm.html
[`Umda`]: https://docs.rs/heuropt/latest/heuropt/algorithms/umda/struct.Umda.html
[`GeneticAlgorithm`]: https://docs.rs/heuropt/latest/heuropt/algorithms/genetic_algorithm/struct.GeneticAlgorithm.html
[`BitFlipMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.BitFlipMutation.html
[AntColonyTsp]: https://docs.rs/heuropt/latest/heuropt/algorithms/ant_colony_tsp/struct.AntColonyTsp.html
[`AntColonyTsp`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ant_colony_tsp/struct.AntColonyTsp.html
[`SwapMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.SwapMutation.html
[`InversionMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.InversionMutation.html
[`InsertionMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.InsertionMutation.html
[`ScrambleMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ScrambleMutation.html
[`OrderCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.OrderCrossover.html
[`PartiallyMappedCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.PartiallyMappedCrossover.html
[`CycleCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.CycleCrossover.html
[`EdgeRecombinationCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.EdgeRecombinationCrossover.html
[`ShuffledPermutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ShuffledPermutation.html
[`ShuffledMultisetPermutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ShuffledMultisetPermutation.html
[TabuSearch]: https://docs.rs/heuropt/latest/heuropt/algorithms/tabu_search/struct.TabuSearch.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
[Mopso]: https://docs.rs/heuropt/latest/heuropt/algorithms/mopso/struct.Mopso.html
[Ibea]: https://docs.rs/heuropt/latest/heuropt/algorithms/ibea/struct.Ibea.html
[Spea2]: https://docs.rs/heuropt/latest/heuropt/algorithms/spea2/struct.Spea2.html
[SmsEmoa]: https://docs.rs/heuropt/latest/heuropt/algorithms/sms_emoa/struct.SmsEmoa.html
[Moead]: https://docs.rs/heuropt/latest/heuropt/algorithms/moead/struct.Moead.html
[AgeMoea]: https://docs.rs/heuropt/latest/heuropt/algorithms/age_moea/struct.AgeMoea.html
[Knea]: https://docs.rs/heuropt/latest/heuropt/algorithms/knea/struct.Knea.html
[PesaII]: https://docs.rs/heuropt/latest/heuropt/algorithms/pesa2/struct.PesaII.html
[EpsilonMoea]: https://docs.rs/heuropt/latest/heuropt/algorithms/epsilon_moea/struct.EpsilonMoea.html
[Paes]: https://docs.rs/heuropt/latest/heuropt/algorithms/paes/struct.Paes.html
[Grea]: https://docs.rs/heuropt/latest/heuropt/algorithms/grea/struct.Grea.html
[Rvea]: https://docs.rs/heuropt/latest/heuropt/algorithms/rvea/struct.Rvea.html
[Hype]: https://docs.rs/heuropt/latest/heuropt/algorithms/hype/struct.Hype.html
[`TabuSearch`]: https://docs.rs/heuropt/latest/heuropt/algorithms/tabu_search/struct.TabuSearch.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
[`Mopso`]: https://docs.rs/heuropt/latest/heuropt/algorithms/mopso/struct.Mopso.html
[`Ibea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ibea/struct.Ibea.html
[`Spea2`]: https://docs.rs/heuropt/latest/heuropt/algorithms/spea2/struct.Spea2.html
[`SmsEmoa`]: https://docs.rs/heuropt/latest/heuropt/algorithms/sms_emoa/struct.SmsEmoa.html
[`Moead`]: https://docs.rs/heuropt/latest/heuropt/algorithms/moead/struct.Moead.html
[`AgeMoea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/age_moea/struct.AgeMoea.html
[`Knea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/knea/struct.Knea.html
[`PesaII`]: https://docs.rs/heuropt/latest/heuropt/algorithms/pesa2/struct.PesaII.html
[`EpsilonMoea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/epsilon_moea/struct.EpsilonMoea.html
[`Paes`]: https://docs.rs/heuropt/latest/heuropt/algorithms/paes/struct.Paes.html
[`Grea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/grea/struct.Grea.html
[`Rvea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/rvea/struct.Rvea.html
[`HypE`]: https://docs.rs/heuropt/latest/heuropt/algorithms/hype/struct.Hype.html
[`Repair<D>`]: https://docs.rs/heuropt/latest/heuropt/traits/trait.Repair.html
[`ClampToBounds`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ClampToBounds.html
[`ProjectToSimplex`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ProjectToSimplex.html
+9 -10
View File
@@ -15,11 +15,11 @@ The columns:
| Library | Lang | Algorithms | Multi-obj | Surrogates | Determinism | Async |
|---|---|---|---|---|---|---|
| **heuropt 0.10** | 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 |
| **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 |
| 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 | ✅ | partial (study-level, not eval-level) |
| optuna | Python | TPE / CMA-ES / NSGA-II | ✅ | ✅ TPE, BoTorch via plug-in | ✅ | |
| MOEA Framework | Java | ~40 | ✅ very extensive | ❌ | ✅ | ❌ |
| metaheuristics-rs | Rust | ~10 | partial | ❌ | ✅ | ❌ |
| argmin | Rust | line-search / quasi-Newton | ❌ | ❌ | ✅ | ❌ |
@@ -36,15 +36,14 @@ The columns:
otherwise.
- You want a **small, readable codebase** — every algorithm is
written for clarity, no trait-object plumbing, no GATs in user-
facing APIs. Reading Random Search should be enough to write a
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`
@@ -64,12 +63,12 @@ heuropt covers the same major Pareto MOEAs as pymoo and MOEA Framework:
NSGA-II/III, SPEA2, IBEA, MOEA/D, MOPSO, SMS-EMOA, HypE, AGE-MOEA,
GrEA, KnEA, RVEA, PESA-II, ε-MOEA, PAES.
The expensive-evaluation regime: Bayesian Optimization + TPE + Hyperband. This
The expensive-evaluation regime: BayesianOpt + TPE + Hyperband. This
is comparable to optuna's coverage but in pure Rust.
The single-objective continuous catalog (CMA-ES, IPOP-CMA-ES, sNES,
DE, PSO, GA, TLBO, (1+1)-ES, Nelder-Mead, Random Search, Hill Climber,
Simulated Annealing) covers the canonical baselines and several modern
DE, PSO, GA, TLBO, (1+1)-ES, NelderMead, RandomSearch, HillClimber,
SimulatedAnnealing) covers the canonical baselines and several modern
variants.
What heuropt does **not** ship that some libraries do:
+4 -16
View File
@@ -7,32 +7,20 @@ project.
## Recipes
- [Parallelize evaluation with rayon](./cookbook/parallel.md) — when
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.
your `evaluate` is non-trivial, the `parallel` feature pays for
itself almost immediately.
- [Tune a model with expensive evaluations](./cookbook/expensive-evaluations.md)
— Bayesian Optimization, TPE, and Hyperband for the 50500-eval
`BayesianOpt`, `Tpe`, and `Hyperband` for the 50500-eval
regime.
- [Compare two algorithms on your problem](./cookbook/compare.md) —
multi-seed harness pattern straight from `examples/compare.rs`.
- [Optimize a permutation (TSP-style)](./cookbook/permutation.md) —
the permutation operator toolkit (OX / PMX / CX / ERX + Inversion /
Insertion / Scramble), plus Ant Colony for distance-matrix TSP.
- [Multi-objective combinatorial problems](./cookbook/multi-objective-combinatorial.md)
— bi-objective TSP, bi-objective knapsack (`Vec<bool>`), and
3-objective JSS via NSGA-II / NSGA-III.
`AntColonyTsp` with a distance matrix.
- [Constrain your search with `Repair`](./cookbook/constraints.md) —
bounds, simplex projection, custom repair.
- [Pick one answer off a Pareto front](./cookbook/pick-one.md) — the
a-posteriori weighted-decision pattern from the `jiggly_tuning`
example.
- [Explore your results in a webapp](./cookbook/explorer.md) — export
an `OptimizationResult` to JSON and browse it interactively at
[heuropt-explorer](https://swaits.github.io/heuropt-explorer/) —
parallel coordinates, scatter, range filters, weighted ranking.
- [Write your own algorithm](./cookbook/custom-optimizer.md) —
implement `Optimizer<P>` from scratch, à la the
`examples/custom_optimizer.rs` walkthrough.
-165
View File
@@ -1,165 +0,0 @@
# 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.10", 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 Random Search (200 evaluations × 20 ms
each) at `concurrency = 1, 4, 16` and Differential Evolution 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
+2 -5
View File
@@ -134,11 +134,8 @@ parallel.
result.
- **No error type.** Invalid configuration panics with a clear
message; this matches the style of the built-in algorithms.
- **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).
- **No async.** `evaluate` is synchronous; for async work, drive it
on a tokio runtime around the optimizer loop yourself.
The smallness is the point: you should be able to read a built-in
algorithm and write your own in an afternoon. See
@@ -7,9 +7,9 @@ algorithms aimed at this regime.
| Algorithm | Surrogate | Best for |
|---|---|---|
| [Bayesian Optimization][BayesianOpt] | Gaussian process + Expected Improvement | The textbook choice; needs kernel tuning to shine |
| [TPE] | Kernel-density estimate of good vs bad points | Cheaper per step; more robust without tuning |
| [Hyperband] | (none — it's a multi-fidelity scheduler) | When each eval has a tunable budget (epochs, MC samples) |
| [`BayesianOpt`] | Gaussian process + Expected Improvement | The textbook choice; needs kernel tuning to shine |
| [`Tpe`] | Kernel-density estimate of good vs bad points | Cheaper per step; more robust without tuning |
| [`Hyperband`] | (none — it's a multi-fidelity scheduler) | When each eval has a tunable budget (epochs, MC samples) |
## When each is right
@@ -101,7 +101,7 @@ canonical Bergstra value.
## Hyperband
[Hyperband] needs your problem to implement [`PartialProblem`] —
[`Hyperband`] needs your problem to implement [`PartialProblem`] —
that is, you can evaluate at a tunable fidelity (e.g. number of
training epochs). The algorithm schedules many cheap-fidelity runs
and promotes only the survivors to higher fidelity.
@@ -156,9 +156,9 @@ The state of the art (BOHB) combines BO with Hyperband: TPE picks the
configurations Hyperband then evaluates at increasing fidelity.
heuropt doesn't ship a unified BOHB but the building blocks are
there — wrap your `PartialProblem` with a TPE-driven sampler and
feed the picks into Hyperband. PRs welcome.
feed the picks into `Hyperband`. PRs welcome.
[BayesianOpt]: https://docs.rs/heuropt/latest/heuropt/algorithms/bayesian_opt/struct.BayesianOpt.html
[TPE]: https://docs.rs/heuropt/latest/heuropt/algorithms/tpe/struct.Tpe.html
[Hyperband]: https://docs.rs/heuropt/latest/heuropt/algorithms/hyperband/struct.Hyperband.html
[`BayesianOpt`]: https://docs.rs/heuropt/latest/heuropt/algorithms/bayesian_opt/struct.BayesianOpt.html
[`Tpe`]: https://docs.rs/heuropt/latest/heuropt/algorithms/tpe/struct.Tpe.html
[`Hyperband`]: https://docs.rs/heuropt/latest/heuropt/algorithms/hyperband/struct.Hyperband.html
[`PartialProblem`]: https://docs.rs/heuropt/latest/heuropt/core/partial_problem/trait.PartialProblem.html
-191
View File
@@ -1,191 +0,0 @@
# Explore your results in a webapp
Real Pareto fronts have 50200+ candidates spanning 27+ objectives.
Reading them as a wall of numbers in a terminal scales badly. Drop
the result into [heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
to filter, brush, pin, and rank candidates interactively in the
browser — parallel coordinates, scatter plots, sortable table, range
filters, weighted ranking, knee-point detection.
This recipe shows the export side. The webapp is a static page; no
install needed beyond a browser.
## Enable the `serde` feature
```toml
[dependencies]
heuropt = { version = "0.10", features = ["serde"] }
```
The export uses `serde_json` under the hood, so the explorer module
is gated on the existing `serde` feature.
## Enrich your `Problem` (optional but worth it)
Two places to add display metadata that flows through to the
explorer's axis labels and tooltips:
```rust
use heuropt::prelude::*;
struct PickACar;
impl Problem for PickACar {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
// `name` is the canonical short ID; `label` and `unit`
// are display-only. The explorer renders axes as
// `Price ($k)` instead of just `price`.
Objective::minimize("price").with_label("Price").with_unit("$k"),
Objective::minimize("zero_to_sixty").with_label("0-60 mph").with_unit("s"),
Objective::minimize("fuel").with_label("Fuel").with_unit("gal/100mi"),
Objective::minimize("noise").with_label("Idle noise").with_unit("dB"),
])
}
fn decision_schema(&self) -> Vec<DecisionVariable> {
// Optional: provide name/label/unit/bounds per decision-variable
// slot. If you skip this, the exporter falls back to `x[0]`,
// `x[1]`, … with no units or bounds.
vec![
DecisionVariable::new("displacement")
.with_label("Engine size").with_unit("L").with_bounds(1.0, 6.0),
DecisionVariable::new("weight")
.with_label("Curb weight").with_unit("kg").with_bounds(1100.0, 2200.0),
DecisionVariable::new("drag")
.with_label("Drag coefficient").with_unit("Cd").with_bounds(0.20, 0.40),
]
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
// ... compute objectives ...
# Evaluation::new(vec![0.0, 0.0, 0.0, 0.0])
}
}
```
Both `Objective::with_label` / `with_unit` and `Problem::decision_schema`
are entirely optional — the rest of heuropt doesn't read them. They
exist so the exported JSON describes itself well enough for a
display tool to render readable axes.
## Run the optimizer and write the JSON
The simplest call (no algorithm metadata in the export):
```rust,ignore
use heuropt::prelude::*;
let result = optimizer.run(&problem);
heuropt::explorer::ExplorerExport::from_result(&problem, &result)
.to_file("results.json")
.unwrap();
```
The richer call — pulls algorithm name + seed automatically from
the `AlgorithmInfo` trait that every built-in algorithm implements:
```rust,ignore
use heuropt::prelude::*;
let started = std::time::Instant::now();
let result = optimizer.run(&problem);
let export = heuropt::explorer::ExplorerExport::from_result(&problem, &result)
.with_algorithm_info(&optimizer)
.with_problem_name("Pick a car")
.with_wall_clock(started.elapsed().as_secs_f64());
export.to_file("results.json").unwrap();
```
There's also a one-liner if you don't need to set extra metadata:
```rust,ignore
heuropt::explorer::to_file("results.json", &problem, &optimizer, &result).unwrap();
```
## Open it in the explorer
Visit <https://swaits.github.io/heuropt-explorer/> and drag the JSON
file onto the page. The explorer reads the units and labels you
attached and renders parallel-coordinates / scatter / table views
that respect them. Brushing on any axis filters the others; pinned
candidates stay highlighted; the weight sliders let you rank the
front by your priorities.
## What's in the file
The full schema is documented in
[`heuropt::explorer::ExplorerExport`](https://docs.rs/heuropt/latest/heuropt/explorer/struct.ExplorerExport.html).
The shape:
```json
{
"schema_version": 1,
"run": {
"problem_name": "Pick a car",
"algorithm": "Nsga3",
"seed": 42,
"wall_clock_seconds": 0.097,
"evaluations": 20100,
"generations": 200
},
"objectives": [
{ "name": "price", "direction": "Minimize", "label": "Price", "unit": "$k" },
...
],
"decision_variables": [
{ "name": "displacement", "label": "Engine size", "unit": "L", "min": 1.0, "max": 6.0 },
...
],
"candidates": [
{
"decision": [1.0, 1505.0, 0.35],
"objectives": [13.0, 7.0, 3.17, 63.0],
"constraint_violation": 0.0,
"feasible": true,
"front_rank": 0,
"in_pareto_front": true
},
...
]
}
```
`front_rank` is computed by `non_dominated_sort` once at export
time — `0` means on the Pareto front, higher numbers indicate
deeper layers.
## Custom decision types
Out of the box, `Vec<f64>`, `Vec<bool>`, `Vec<usize>`, and `Vec<i64>`
work as decisions. For a custom decision type, implement
`heuropt::explorer::ToDecisionValues`:
```rust,ignore
struct MyDecision { color: String, count: u32 }
impl heuropt::explorer::ToDecisionValues for MyDecision {
fn to_decision_values(&self) -> Vec<serde_json::Value> {
vec![
serde_json::Value::String(self.color.clone()),
serde_json::Value::Number(self.count.into()),
]
}
}
```
The explorer renders strings as categorical axes and numbers as
continuous.
## Worked example
`examples/pick_a_car.rs` ships with the crate. It implements the
problem above, runs NSGA-III for 200 generations, and writes
`pick_a_car.json` ready to load:
```text
cargo run --release --example pick_a_car --features serde
```
@@ -1,381 +0,0 @@
# Multi-objective combinatorial problems
Real combinatorial problems usually have more than one cost. A TSP
where every edge has both *distance* and *time*; a job-shop where you
care about *makespan*, *flow time*, *and* *tardiness*; a knapsack
with two profit metrics and a single weight budget. The decision
type is still combinatorial — a permutation, a bitstring — but the
objective is a vector, and the answer is a Pareto front rather than
a single best.
heuropt's NSGA-II and NSGA-III are fully generic over the decision
type. You don't need a separate "combinatorial NSGA" — just plug in
the right initializer and variation operators for your encoding.
This recipe walks through three patterns:
- **Bi-objective TSP** with NSGA-II (Pareto front of two distance
matrices over the same cities)
- **Bi-objective 0/1 knapsack** with NSGA-II (binary encoding)
- **3-objective JSS** with NSGA-III (the many-objective successor)
For the single-objective permutation toolkit it builds on, see
[Optimize a permutation](./permutation.md).
## Bi-objective TSP
This is the canonical multi-objective combinatorial benchmark
(LustTeghem 2010). Two TSP instances on the **same** city set define
two distance matrices A and B; the search trades off length under A
versus length under B.
```rust,no_run
use heuropt::prelude::*;
use heuropt::metrics::hypervolume_2d;
struct BiObjectiveTsp {
dist_a: Vec<Vec<f64>>,
dist_b: Vec<Vec<f64>>,
}
impl BiObjectiveTsp {
fn tour_length(d: &[Vec<f64>], tour: &[usize]) -> f64 {
let n = tour.len();
let mut total = 0.0;
for i in 0..n {
total += d[tour[i]][tour[(i + 1) % n]];
}
total
}
}
impl Problem for BiObjectiveTsp {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("length_A"),
Objective::minimize("length_B"),
])
}
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
Evaluation::new(vec![
Self::tour_length(&self.dist_a, tour),
Self::tour_length(&self.dist_b, tour),
])
}
}
fn main() {
let n: usize = 25;
let dist_a = vec![vec![0.0_f64; n]; n]; // your matrix A
let dist_b = vec![vec![0.0_f64; n]; n]; // your matrix B
let problem = BiObjectiveTsp { dist_a, dist_b };
let mut optimizer = Nsga2::new(
Nsga2Config {
population_size: 200,
generations: 600,
seed: 11,
},
ShuffledPermutation { n },
CompositeVariation {
crossover: EdgeRecombinationCrossover,
mutation: InversionMutation,
},
);
let result = optimizer.run(&problem);
println!("Pareto-front size: {}", result.pareto_front.len());
// Hypervolume against a generous reference point (larger than any
// length you'd reasonably see). Use this as the single-number
// quality metric for the run.
let ref_point = [40_000.0, 40_000.0];
let hv = hypervolume_2d(&result.pareto_front, &problem.objectives(), ref_point);
println!("Hypervolume vs. {:?}: {:.0}", ref_point, hv);
}
```
[`EdgeRecombinationCrossover`] (ERX) is the standout crossover for
TSP. On a 25-city bi-objective instance it produces about twice the
front diversity of OX, PMX, or CX — see
`examples/tsp_operators_compare.rs` for a head-to-head benchmark.
## Bi-objective 0/1 knapsack — `Vec<bool>` decisions
NSGA-II works over `Vec<bool>` the same way. The ZitzlerThiele
bi-objective knapsack is the textbook benchmark: each item has two
profit values and a single weight; you maximize both profits under
one capacity constraint.
```rust,no_run
use heuropt::prelude::*;
use rand::Rng as _;
const N_ITEMS: usize = 30;
struct BiKnapsack {
profits_a: Vec<f64>,
profits_b: Vec<f64>,
weights: Vec<f64>,
capacity: f64,
}
impl Problem for BiKnapsack {
type Decision = Vec<bool>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::maximize("profit_A"),
Objective::maximize("profit_B"),
])
}
fn evaluate(&self, take: &Vec<bool>) -> Evaluation {
let (pa, pb, w) = take.iter().enumerate().fold(
(0.0_f64, 0.0_f64, 0.0_f64),
|(pa, pb, w), (i, &t)| {
if t {
(pa + self.profits_a[i], pb + self.profits_b[i], w + self.weights[i])
} else {
(pa, pb, w)
}
},
);
// Standard heuristic-MO constraint handling: penalize weight
// overruns heavily so the recovered front is feasible.
let penalty = 1000.0 * (w - self.capacity).max(0.0);
Evaluation::new(vec![pa - penalty, pb - penalty])
}
}
/// Each bit 50/50 independently.
#[derive(Clone, Copy)]
struct RandomBinary { n: usize }
impl Initializer<Vec<bool>> for RandomBinary {
fn initialize(&mut self, size: usize, rng: &mut Rng) -> Vec<Vec<bool>> {
(0..size).map(|_| (0..self.n).map(|_| rng.random_bool(0.5)).collect()).collect()
}
}
/// One-point crossover for binary chromosomes.
#[derive(Default)]
struct OnePointCrossoverBool;
impl Variation<Vec<bool>> for OnePointCrossoverBool {
fn vary(&mut self, parents: &[Vec<bool>], rng: &mut Rng) -> Vec<Vec<bool>> {
let (p1, p2) = (&parents[0], &parents[1]);
let n = p1.len();
let cut = rng.random_range(1..n);
let mut c1 = Vec::with_capacity(n);
let mut c2 = Vec::with_capacity(n);
c1.extend_from_slice(&p1[..cut]); c1.extend_from_slice(&p2[cut..]);
c2.extend_from_slice(&p2[..cut]); c2.extend_from_slice(&p1[cut..]);
vec![c1, c2]
}
}
fn main() {
# let profits_a = vec![0.0; N_ITEMS];
# let profits_b = vec![0.0; N_ITEMS];
# let weights = vec![0.0; N_ITEMS];
let problem = BiKnapsack {
profits_a, profits_b, weights,
capacity: 750.0, // ~half the total weight
};
let mut optimizer = Nsga2::new(
Nsga2Config {
population_size: 120,
generations: 400,
seed: 19,
},
RandomBinary { n: N_ITEMS },
CompositeVariation {
crossover: OnePointCrossoverBool,
mutation: BitFlipMutation { probability: 1.0 / N_ITEMS as f64 },
},
);
let result = optimizer.run(&problem);
println!("Pareto-front size: {}", result.pareto_front.len());
}
```
Two things worth noting:
- **`OnePointCrossoverBool` and `RandomBinary` are defined locally.**
They're tiny and common — a future PR could lift them into the
library, but for now you write them inline.
- **Constraint handling is a penalty.** The factor `1000.0` is chosen
so that even a 1-unit overrun beats any feasible solution by more
than the entire profit range; the recovered front is entirely
feasible. This is the standard heuristic-MO pattern (Deb 2001) and
cheaper than a hard repair operator.
## Three-objective JSS with NSGA-III
NSGA-III is designed for ≥ 3 objectives. NSGA-II's crowding distance
degrades when most of the population is mutually non-dominated, which
is the rule rather than the exception in higher dimensions; NSGA-III
uses reference-point niching instead.
The example below adds *tardiness* to the standard (makespan, flow
time) JSS pair. Tardiness needs due dates; the common heuristic is
`dⱼ = 1.3 × sum_of_processing_times(j)`.
```rust,no_run
use heuropt::prelude::*;
use rand::Rng as _;
const N_JOBS: usize = 10;
const N_MACHINES: usize = 5;
struct La01ThreeObjective {
routing: [[usize; N_MACHINES]; N_JOBS],
times: [[f64; N_MACHINES]; N_JOBS],
due: [f64; N_JOBS],
}
impl Problem for La01ThreeObjective {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("makespan"),
Objective::minimize("total_flow_time"),
Objective::minimize("total_tardiness"),
])
}
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
let mut job_next = [0_usize; N_JOBS];
let mut job_clock = [0.0_f64; N_JOBS];
let mut machine_clock = [0.0_f64; N_MACHINES];
for &job in schedule {
let k = job_next[job];
let m = self.routing[job][k];
let t = self.times[job][k];
let start = job_clock[job].max(machine_clock[m]);
let end = start + t;
job_clock[job] = end;
machine_clock[m] = end;
job_next[job] = k + 1;
}
let makespan = machine_clock.iter().cloned().fold(0.0_f64, f64::max);
let flow_time: f64 = job_clock.iter().sum();
let tardiness: f64 = job_clock.iter().zip(self.due.iter())
.map(|(&c, &d)| (c - d).max(0.0))
.sum();
Evaluation::new(vec![makespan, flow_time, tardiness])
}
}
/// Mix Insertion and Scramble per call — both preserve the multiset,
/// giving the search access to two complementary neighborhood moves.
#[derive(Default)]
struct InsertionOrScramble;
impl Variation<Vec<usize>> for InsertionOrScramble {
fn vary(&mut self, parents: &[Vec<usize>], rng: &mut Rng) -> Vec<Vec<usize>> {
if rng.random_bool(0.5) {
InsertionMutation.vary(parents, rng)
} else {
ScrambleMutation.vary(parents, rng)
}
}
}
fn main() {
# let routing = [[0; N_MACHINES]; N_JOBS];
# let times = [[0.0; N_MACHINES]; N_JOBS];
let due = std::array::from_fn::<f64, N_JOBS, _>(
|j| 1.3 * times[j].iter().sum::<f64>(),
);
let problem = La01ThreeObjective { routing, times, due };
let mut optimizer = Nsga3::new(
Nsga3Config {
population_size: 120,
generations: 600,
reference_divisions: 12, // 91 Das-Dennis points in 3-D
seed: 9,
},
ShuffledMultisetPermutation::new(vec![N_MACHINES; N_JOBS]),
// Drop in a local PrecedenceOrderCrossover (POX) here for the
// crossover slot if you want stronger mixing; see the
// permutation recipe for the implementation.
InsertionOrScramble,
);
let result = optimizer.run(&problem);
println!("Pareto-front size: {}", result.pareto_front.len());
}
```
A few NSGA-III tips:
- **`reference_divisions` controls how many reference points the
algorithm spreads across the front.** For M objectives, the DasDennis
construction produces `C(divisions + M - 1, M - 1)` reference points.
For M = 3 and divisions = 12 that's 91 points; pick a population size
≥ that.
- **`PrecedenceOrderCrossover` (POX)** belongs in the crossover slot
for JSS. The strict-permutation crossovers (OX, PMX, CX, ERX) break
the operation-string multiset. See
[Optimize a permutation](./permutation.md#job-shop-scheduling-multiset-encodings)
for the local POX definition.
## Comparing operators by hypervolume
For Pareto-front problems, single-objective fitness is the wrong
comparison metric. Use **hypervolume** instead — the dominated area
under the front, against a fixed reference point.
```rust,ignore
use heuropt::metrics::hypervolume_2d;
let ref_point = [40_000.0, 40_000.0]; // worse than anything you expect
for (name, crossover) in &[
("OX", Box::new(OrderCrossover) as Box<dyn Variation<Vec<usize>>>),
("PMX", Box::new(PartiallyMappedCrossover) as _),
("CX", Box::new(CycleCrossover) as _),
("ERX", Box::new(EdgeRecombinationCrossover) as _),
] {
let result = run_nsga2_with_crossover(crossover);
let hv = hypervolume_2d(&result.pareto_front, &problem.objectives(), ref_point);
println!("{name:>3}: hv = {hv:.0}");
}
```
This is the pattern in `examples/tsp_operators_compare.rs`. On the
KroAB-25 instance it ranks ERX > OX > PMX > CX by hypervolume.
For ≥ 3 objectives, hypervolume in N dimensions is exponentially
expensive; use [`hypervolume_2d`] when you can collapse to two
objectives for the metric, or sample-based hypervolume from [`HypE`]
otherwise.
## Pareto-front tips
| Problem | Algorithm | Notes |
|---|---|---|
| 2 objectives, permutation | [Nsga2][Nsga2] | Strong default |
| 2 objectives, binary | [Nsga2][Nsga2] | Same machinery, different encoding |
| 3 objectives | [Nsga3][Nsga3] | NSGA-II's crowding distance starts to degrade |
| 4+ objectives | [Nsga3][Nsga3] or [HypE][HypE] | NSGA-III if front is curved; HypE for indicator-based at scale |
| Many-objective with grid structure | [GrEA][Grea] | Wins linear / simplex fronts |
| Question | Use |
|---|---|
| Single-number quality metric for a run | `hypervolume_2d` against a fixed reference |
| "Is run A's front better than B's?" | Same reference point, compare hypervolume |
| "Pick one solution from the front" | See [Pick one answer off a Pareto front](./pick-one.md) |
| Interactive exploration / visualization | See [Explore your results in a webapp](./explorer.md) |
[Nsga2]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga2/struct.Nsga2.html
[Nsga3]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga3/struct.Nsga3.html
[HypE]: https://docs.rs/heuropt/latest/heuropt/algorithms/hype/struct.Hype.html
[Grea]: https://docs.rs/heuropt/latest/heuropt/algorithms/grea/struct.Grea.html
[`EdgeRecombinationCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.EdgeRecombinationCrossover.html
[`hypervolume_2d`]: https://docs.rs/heuropt/latest/heuropt/metrics/fn.hypervolume_2d.html
+29 -39
View File
@@ -9,7 +9,7 @@ population, and rayon parallelizes that batch.
```toml
[dependencies]
heuropt = { version = "0.10", features = ["parallel"] }
heuropt = { version = "0.5", features = ["parallel"] }
```
There's nothing else to opt into in your code. The
@@ -29,14 +29,14 @@ pass.
Algorithms with a per-generation `evaluate_batch`:
- [Random Search][RandomSearch], [NSGA-II][Nsga2], [NSGA-III][Nsga3], [SPEA2][Spea2], [MOEA/D][Moead],
[MOPSO][Mopso], [IBEA][Ibea], [SMS-EMOA][SmsEmoa], [HypE][Hype], [PESA-II][PesaII],
[ε-MOEA][EpsilonMoea], [AGE-MOEA][AgeMoea], [KnEA][Knea], [GrEA][Grea], [RVEA][Rvea].
- [Differential Evolution][DifferentialEvolution] and [GA][GeneticAlgorithm] benefit on the
- [`RandomSearch`], [`Nsga2`], [`Nsga3`], [`Spea2`], [`Moead`],
[`Mopso`], [`Ibea`], [`SmsEmoa`], [`HypE`], [`PesaII`],
[`EpsilonMoea`], [`AgeMoea`], [`Knea`], [`Grea`], [`Rvea`].
- [`DifferentialEvolution`] and [`GeneticAlgorithm`] benefit on the
initial population and offspring batches.
Steady-state algorithms ([PAES][Paes], [Simulated Annealing][SimulatedAnnealing],
[Hill Climber][HillClimber], [(1+1)-ES][OnePlusOneEs]) only evaluate one or a few
Steady-state algorithms ([`Paes`], [`SimulatedAnnealing`],
[`HillClimber`], [`OnePlusOneEs`]) only evaluate one or a few
candidates per iteration, so the parallel feature gives them
nothing — leave it off if those are your primary optimizers.
@@ -102,36 +102,26 @@ to scope it.
- You're already running multiple seeds in parallel at the harness
level (see [Compare two algorithms](./compare.md)). Stacking
parallelism rarely helps.
- The algorithm is steady-state (PAES, SA, hill climber).
- 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
[Spea2]: https://docs.rs/heuropt/latest/heuropt/algorithms/spea2/struct.Spea2.html
[Moead]: https://docs.rs/heuropt/latest/heuropt/algorithms/moead/struct.Moead.html
[Mopso]: https://docs.rs/heuropt/latest/heuropt/algorithms/mopso/struct.Mopso.html
[Ibea]: https://docs.rs/heuropt/latest/heuropt/algorithms/ibea/struct.Ibea.html
[SmsEmoa]: https://docs.rs/heuropt/latest/heuropt/algorithms/sms_emoa/struct.SmsEmoa.html
[Hype]: https://docs.rs/heuropt/latest/heuropt/algorithms/hype/struct.Hype.html
[PesaII]: https://docs.rs/heuropt/latest/heuropt/algorithms/pesa2/struct.PesaII.html
[EpsilonMoea]: https://docs.rs/heuropt/latest/heuropt/algorithms/epsilon_moea/struct.EpsilonMoea.html
[AgeMoea]: https://docs.rs/heuropt/latest/heuropt/algorithms/age_moea/struct.AgeMoea.html
[Knea]: https://docs.rs/heuropt/latest/heuropt/algorithms/knea/struct.Knea.html
[Grea]: https://docs.rs/heuropt/latest/heuropt/algorithms/grea/struct.Grea.html
[Rvea]: https://docs.rs/heuropt/latest/heuropt/algorithms/rvea/struct.Rvea.html
[DifferentialEvolution]: https://docs.rs/heuropt/latest/heuropt/algorithms/differential_evolution/struct.DifferentialEvolution.html
[GeneticAlgorithm]: https://docs.rs/heuropt/latest/heuropt/algorithms/genetic_algorithm/struct.GeneticAlgorithm.html
[Paes]: https://docs.rs/heuropt/latest/heuropt/algorithms/paes/struct.Paes.html
[SimulatedAnnealing]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
[HillClimber]: https://docs.rs/heuropt/latest/heuropt/algorithms/hill_climber/struct.HillClimber.html
[OnePlusOneEs]: https://docs.rs/heuropt/latest/heuropt/algorithms/one_plus_one_es/struct.OnePlusOneEs.html
[`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
[`Spea2`]: https://docs.rs/heuropt/latest/heuropt/algorithms/spea2/struct.Spea2.html
[`Moead`]: https://docs.rs/heuropt/latest/heuropt/algorithms/moead/struct.Moead.html
[`Mopso`]: https://docs.rs/heuropt/latest/heuropt/algorithms/mopso/struct.Mopso.html
[`Ibea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ibea/struct.Ibea.html
[`SmsEmoa`]: https://docs.rs/heuropt/latest/heuropt/algorithms/sms_emoa/struct.SmsEmoa.html
[`HypE`]: https://docs.rs/heuropt/latest/heuropt/algorithms/hype/struct.Hype.html
[`PesaII`]: https://docs.rs/heuropt/latest/heuropt/algorithms/pesa2/struct.PesaII.html
[`EpsilonMoea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/epsilon_moea/struct.EpsilonMoea.html
[`AgeMoea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/age_moea/struct.AgeMoea.html
[`Knea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/knea/struct.Knea.html
[`Grea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/grea/struct.Grea.html
[`Rvea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/rvea/struct.Rvea.html
[`DifferentialEvolution`]: https://docs.rs/heuropt/latest/heuropt/algorithms/differential_evolution/struct.DifferentialEvolution.html
[`GeneticAlgorithm`]: https://docs.rs/heuropt/latest/heuropt/algorithms/genetic_algorithm/struct.GeneticAlgorithm.html
[`Paes`]: https://docs.rs/heuropt/latest/heuropt/algorithms/paes/struct.Paes.html
[`SimulatedAnnealing`]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
[`HillClimber`]: https://docs.rs/heuropt/latest/heuropt/algorithms/hill_climber/struct.HillClimber.html
[`OnePlusOneEs`]: https://docs.rs/heuropt/latest/heuropt/algorithms/one_plus_one_es/struct.OnePlusOneEs.html
+52 -332
View File
@@ -1,287 +1,12 @@
# Optimize a permutation (TSP-style)
When your decision is "an ordering" — visiting cities, scheduling
jobs, routing — the natural representation is `Vec<usize>`. heuropt
ships three reasonable starting points:
jobs, routing — the natural representation is `Vec<usize>` and the
specialized algorithm is [`AntColonyTsp`]. Generic alternatives are
[`SimulatedAnnealing`] + [`SwapMutation`] for any permutation, and
[`TabuSearch`] when you have a custom neighbor function.
- **A purpose-built algorithm** — [Ant Colony][AntColonyTsp] for TSP-shaped
problems with a distance matrix.
- **A genetic algorithm** with the permutation operator toolkit —
the most general option, and the right choice when you want to
bring your own evaluator without a pheromone metaphor.
- **A trajectory method** — [Simulated Annealing][SimulatedAnnealing] +
[`SwapMutation`] for a tiny baseline, or [Tabu Search][TabuSearch] when
you have a custom neighbor function.
This recipe walks through all three, with the bulk of the page on
the GA toolkit, since it's the most flexible. For the multi-objective
versions (bi-objective TSP, bi-objective JSS, Pareto fronts) see
[Multi-objective combinatorial problems](./multi-objective-combinatorial.md).
## The permutation operator toolkit
heuropt ships a complete set of permutation operators in the prelude.
You compose them with [`CompositeVariation`] into a crossover-plus-mutation
pipeline and feed them to any GA-shaped algorithm.
### Initializers
| Operator | What it produces | Use for |
|---|---|---|
| [`ShuffledPermutation`] | Random shuffles of `[0..n)` | TSP, QAP, single-machine scheduling — strict permutations |
| [`ShuffledMultisetPermutation`] | Random shuffles of `[0]*r₀ ++ [1]*r₁ ++ …` | Job-shop scheduling operation strings (each job id repeated `n_machines` times) |
### Crossovers
All four take two parents and return two children. They assume *strict*
permutations — applying them to multiset encodings (like JSS) will
break the multiset.
| Operator | One-liner | Best at |
|---|---|---|
| [`OrderCrossover`] (OX) | Copy a random segment from A, fill the rest in B's order | General-purpose, fast |
| [`PartiallyMappedCrossover`] (PMX) | Slide A's segment into B via positional swaps | Classic; preserves more position info than OX |
| [`CycleCrossover`] (CX) | Partition positions into cycles, alternate parents | Preserves the most positional information |
| [`EdgeRecombinationCrossover`] (ERX) | Greedy walk through the union of both parents' edges | The gold standard for TSP — preserves adjacency, not position |
For TSP specifically, ERX usually wins on Pareto-front quality at the
cost of being ~70% slower per crossover. See
[Multi-objective combinatorial problems](./multi-objective-combinatorial.md)
for a head-to-head comparison.
### Mutations
All five take one parent and return one child. All four below preserve
both strict permutations *and* multiset encodings, so they're safe for
JSS too.
| Operator | What it does | Notes |
|---|---|---|
| [`SwapMutation`] | Swap two random positions | Smallest perturbation; canonical default |
| [`InversionMutation`] | Reverse a random sub-slice | The textbook 2-opt-style move for TSP |
| [`InsertionMutation`] | Remove an element, re-insert elsewhere | Strong for sequencing / scheduling |
| [`ScrambleMutation`] | Randomly permute a random sub-slice | Stronger diversification |
### Quick "what should I use?" guide
| Your problem | Initializer | Crossover | Mutation |
|---|---|---|---|
| TSP / routing | `ShuffledPermutation` | `EdgeRecombinationCrossover` | `InversionMutation` |
| Single-machine scheduling | `ShuffledPermutation` | `OrderCrossover` | `InsertionMutation` |
| Generic strict permutation | `ShuffledPermutation` | `OrderCrossover` | `InversionMutation` |
| Job-shop scheduling (multiset) | `ShuffledMultisetPermutation` | *example-local POX* (see below) | `InversionMutation` or `SwapMutation` |
## Single-objective TSP with a Genetic Algorithm
This is the toolkit's headline pattern. It mirrors the
`examples/tsp_ulysses16.rs` benchmark, which converges to the known
TSPLIB optimum for the 16-city Ulysses instance.
```rust,no_run
use heuropt::prelude::*;
struct Tsp {
distances: Vec<Vec<f64>>,
}
impl Problem for Tsp {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("tour_length")])
}
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
let n = tour.len();
let mut len = 0.0;
for i in 0..n {
len += self.distances[tour[i]][tour[(i + 1) % n]];
}
Evaluation::new(vec![len])
}
}
fn main() {
// Replace with your actual distance matrix.
let n: usize = 16;
let distances = vec![vec![0.0_f64; n]; n];
let problem = Tsp { distances };
let mut optimizer = GeneticAlgorithm::new(
GeneticAlgorithmConfig {
population_size: 150,
generations: 1500,
tournament_size: 3,
elitism: 4,
seed: 42,
},
ShuffledPermutation { n },
CompositeVariation {
crossover: OrderCrossover,
mutation: InversionMutation,
},
);
let r = optimizer.run(&problem);
let best = r.best.unwrap();
println!("best tour length: {:.0}", best.evaluation.objectives[0]);
println!("tour: {:?}", best.decision);
}
```
A few things to notice:
- **Decision type is `Vec<usize>`.** Every operator in the toolkit is
generic over the decision type via `Variation<Vec<usize>>`, so the
whole pipeline composes naturally.
- **`CompositeVariation` is the wiring.** It runs the crossover first,
then runs the mutation on each child. For a single-parent operator
pair (e.g., two mutations stacked), it still works — the "crossover"
slot just becomes a first-stage mutation.
- **Tournament size 3 and elitism 4** are slightly stronger than the
defaults; small permutation GAs benefit from a touch more selection
pressure.
## Job-shop scheduling — multiset encodings
JSS problems use a different encoding: a string of length
`n_jobs × n_machines` where each job id appears `n_machines` times. The
k-th occurrence of job `j` represents the k-th operation of job `j`.
This is a *multiset permutation*, not a strict permutation, and the
crossovers above (OX, PMX, CX, ERX) will break it because they assume
each value appears exactly once.
Use `ShuffledMultisetPermutation` for the initializer:
```rust,ignore
use heuropt::prelude::*;
// 6 jobs × 6 machines (FT06 layout): each job id 0..6 appears 6 times.
let initializer = ShuffledMultisetPermutation::new(vec![6; 6]);
```
For variation you have two options:
1. **Mutation only.** The four mutations above all preserve the
multiset, so you can drive a GA with just `InversionMutation` or
`SwapMutation` and skip crossover. This works on small JSS
instances; on larger ones search becomes slow.
2. **Add a JSS-aware crossover.** The standard choice is **POX**
(Precedence-preserving Order-based Crossover, Bierwirth 1996).
It's not in the library because every JSS instance specifies its
own number of distinct ids and POX needs that constant; defining
it locally per example keeps the type clean:
```rust,no_run
use heuropt::prelude::*;
use rand::Rng as _;
const N_JOBS: usize = 6;
/// POX — partition job ids into two sets J1/J2; child takes positions
/// of J1-jobs from parent A and fills the remaining positions with
/// J2-jobs from parent B in B's order. Preserves the multiset.
#[derive(Default)]
struct PrecedenceOrderCrossover;
impl Variation<Vec<usize>> for PrecedenceOrderCrossover {
fn vary(&mut self, parents: &[Vec<usize>], rng: &mut Rng) -> Vec<Vec<usize>> {
let p1 = &parents[0];
let p2 = &parents[1];
let mut in_j1 = [false; N_JOBS];
loop {
for slot in &mut in_j1 {
*slot = rng.random_bool(0.5);
}
let count = in_j1.iter().filter(|&&b| b).count();
if count > 0 && count < N_JOBS { break; }
}
vec![pox_child(p1, p2, &in_j1), pox_child(p2, p1, &in_j1)]
}
}
fn pox_child(donor: &[usize], filler: &[usize], in_donor_set: &[bool]) -> Vec<usize> {
let n = donor.len();
let mut child = vec![usize::MAX; n];
for k in 0..n {
if in_donor_set[donor[k]] {
child[k] = donor[k];
}
}
let mut idx = 0;
for &v in filler {
if !in_donor_set[v] {
while idx < n && child[idx] != usize::MAX { idx += 1; }
child[idx] = v;
idx += 1;
}
}
child
}
```
See `examples/jss_ft06_bi.rs` and `examples/mo_jss_la01.rs` for the
complete worked examples.
A full JSS evaluator walks the schedule string left-to-right, tracking
per-job operation counters and per-machine clocks:
```rust,ignore
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
let mut job_next = [0_usize; N_JOBS];
let mut job_clock = [0.0_f64; N_JOBS];
let mut machine_clock = [0.0_f64; N_MACHINES];
for &job in schedule {
let k = job_next[job];
let m = ROUTING[job][k];
let t = PROCESSING_TIME[job][k];
let start = job_clock[job].max(machine_clock[m]);
let end = start + t;
job_clock[job] = end;
machine_clock[m] = end;
job_next[job] = k + 1;
}
let makespan = machine_clock.iter().cloned().fold(0.0_f64, f64::max);
Evaluation::new(vec![makespan])
}
```
## Comparing crossover operators
Tuning the right operator combo matters more than tuning population
size. The pattern is: hold everything constant, swap the operator,
record the metric:
```rust,ignore
use heuropt::prelude::*;
use heuropt::metrics::hypervolume_2d;
fn run_with<C: Variation<Vec<usize>>>(crossover: C) -> f64 {
let mut opt = GeneticAlgorithm::new(
GeneticAlgorithmConfig { /* identical config */ ..Default::default() },
ShuffledPermutation { n: 25 },
CompositeVariation { crossover, mutation: InversionMutation },
);
opt.run(&problem).best.unwrap().evaluation.objectives[0]
}
println!("OX: {:.0}", run_with(OrderCrossover));
println!("PMX: {:.0}", run_with(PartiallyMappedCrossover));
println!("CX: {:.0}", run_with(CycleCrossover));
println!("ERX: {:.0}", run_with(EdgeRecombinationCrossover));
```
For Pareto-front problems use hypervolume, not single-objective
fitness, as the comparison metric — see
[`examples/tsp_operators_compare.rs`][CompareExample] for the bi-objective
version.
## TSP with Ant Colony
When your problem is genuinely TSP-shaped — symmetric distance matrix,
visit-every-node — Ant Colony is purpose-built and worth a look. It
doesn't use crossover or mutation; instead it deposits pheromone trails
that bias future ants toward good edges.
## TSP with `AntColonyTsp`
```rust,no_run
use heuropt::prelude::*;
@@ -308,7 +33,14 @@ impl Problem for Tsp {
}
fn main() {
let cities = vec![(0.0, 0.0), (1.0, 5.0), (5.0, 2.0), (6.0, 6.0), (8.0, 3.0)];
// 5-city Euclidean instance
let cities = vec![
(0.0, 0.0),
(1.0, 5.0),
(5.0, 2.0),
(6.0, 6.0),
(8.0, 3.0),
];
let n = cities.len();
let mut distances = vec![vec![0.0; n]; n];
for i in 0..n {
@@ -334,18 +66,19 @@ fn main() {
let r = opt.run(&problem);
let best = r.best.unwrap();
println!("best tour length: {:.3}", best.evaluation.objectives[0]);
println!("tour: {:?}", best.decision);
}
```
`alpha` weights pheromone influence and `beta` weights the heuristic
(1 / distance). `evaporation` is the per-iteration pheromone decay.
The classic Dorigo paper uses `alpha = 1`, `beta = 2..5`,
`evaporation = 0.1..0.5`.
`alpha` weights pheromone influence and `beta` weights the
heuristic (1 / distance). `evaporation` is the per-iteration decay
of pheromone trails. The classic Dorigo paper uses `alpha = 1`,
`beta = 2..5`, `evaporation = 0.1..0.5`.
## Tiny baseline: SA + SwapMutation
## Generic permutation: SA + SwapMutation
The smallest possible permutation optimizer — one starting decision,
no population, one mutation operator. Good as a sanity-check baseline.
Use this when your problem isn't TSP-shaped (no distance matrix
makes sense) but you still want to optimize an ordering.
```rust,no_run
use heuropt::prelude::*;
@@ -356,9 +89,10 @@ struct JobShop {
impl Problem for JobShop {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("weighted_completion")])
ObjectiveSpace::new(vec![Objective::minimize("makespan")])
}
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
// Pretend cumulative weighted-completion-time. Replace with your real cost.
let cost: f64 = schedule.iter().enumerate()
.map(|(i, &job)| (i as f64 + 1.0) * self.process_times[job])
.sum();
@@ -366,18 +100,22 @@ impl Problem for JobShop {
}
}
let times = vec![3.0, 1.5, 4.2, 2.7, 5.1];
let n = times.len();
let problem = JobShop { process_times: times };
fn make_initial_perm(n: usize, seed: u64) -> Vec<usize> {
use rand::seq::SliceRandom;
let mut rng = rng_from_seed(seed);
let mut perm: Vec<usize> = (0..n).collect();
perm.shuffle(&mut rng);
perm
}
// SimulatedAnnealing expects exactly one initial decision.
struct OneShuffle { n: usize }
impl Initializer<Vec<usize>> for OneShuffle {
fn initialize(&mut self, _size: usize, rng: &mut Rng) -> Vec<Vec<usize>> {
use rand::seq::SliceRandom;
let mut p: Vec<usize> = (0..self.n).collect();
p.shuffle(rng);
vec![p]
let times = vec![3.0, 1.5, 4.2, 2.7, 5.1];
let problem = JobShop { process_times: times.clone() };
// SimulatedAnnealing needs a starting decision; pass a custom Initializer.
struct OnePerm(Vec<usize>);
impl Initializer<Vec<usize>> for OnePerm {
fn initialize(&mut self, _size: usize, _rng: &mut Rng) -> Vec<Vec<usize>> {
vec![self.0.clone()]
}
}
@@ -388,24 +126,28 @@ let mut opt = SimulatedAnnealing::new(
final_temperature: 1e-3,
seed: 7,
},
OneShuffle { n },
OnePerm(make_initial_perm(times.len(), 7)),
SwapMutation,
);
let r = opt.run(&problem);
let best = r.best.unwrap();
println!("best cost: {:.3}", best.evaluation.objectives[0]);
println!("best makespan: {:.3}", best.evaluation.objectives[0]);
println!("schedule: {:?}", best.decision);
```
## Custom neighborhoods: Tabu Search
`SwapMutation` swaps two random indices in the permutation —
preserves the "every element appears once" invariant for free.
When you want full control of the move set (e.g., systematic 2-opt for
TSP, or insert-and-shift for scheduling), [Tabu Search][TabuSearch] takes
your own neighbor function.
## Custom neighborhoods: `TabuSearch`
When swap isn't the right move set (e.g., 2-opt for TSP, insert /
shift for scheduling), use [`TabuSearch`] with your own neighbor
function.
```rust,ignore
use heuropt::prelude::*;
let neighbors = |x: &Vec<usize>, _rng: &mut Rng| -> Vec<Vec<usize>> {
// All 2-opt neighbors of x.
// Generate all 2-opt neighbors of x.
let mut out = Vec::new();
for i in 0..x.len() {
for j in (i + 2)..x.len() {
@@ -419,29 +161,7 @@ let neighbors = |x: &Vec<usize>, _rng: &mut Rng| -> Vec<Vec<usize>> {
// Pass `neighbors` to TabuSearch::new(...).
```
## When to use which approach
| Situation | Use |
|---|---|
| TSP-shaped with a distance matrix | [Ant Colony][AntColonyTsp] |
| Generic permutation, multi-seed budget | GA + `ShuffledPermutation` + OX + Inversion |
| Job-shop scheduling | GA + `ShuffledMultisetPermutation` + local POX + Inversion |
| Single-decision baseline | [SimulatedAnnealing][SimulatedAnnealing] + `SwapMutation` |
| Hand-crafted neighborhood (e.g. systematic 2-opt) | [Tabu Search][TabuSearch] |
| Bi-objective / many-objective permutation problem | See [Multi-objective combinatorial](./multi-objective-combinatorial.md) |
[AntColonyTsp]: https://docs.rs/heuropt/latest/heuropt/algorithms/ant_colony_tsp/struct.AntColonyTsp.html
[SimulatedAnnealing]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
[TabuSearch]: https://docs.rs/heuropt/latest/heuropt/algorithms/tabu_search/struct.TabuSearch.html
[`ShuffledPermutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ShuffledPermutation.html
[`ShuffledMultisetPermutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ShuffledMultisetPermutation.html
[`OrderCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.OrderCrossover.html
[`PartiallyMappedCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.PartiallyMappedCrossover.html
[`CycleCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.CycleCrossover.html
[`EdgeRecombinationCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.EdgeRecombinationCrossover.html
[`AntColonyTsp`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ant_colony_tsp/struct.AntColonyTsp.html
[`SimulatedAnnealing`]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
[`SwapMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.SwapMutation.html
[`InversionMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.InversionMutation.html
[`InsertionMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.InsertionMutation.html
[`ScrambleMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ScrambleMutation.html
[`CompositeVariation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.CompositeVariation.html
[CompareExample]: https://github.com/swaits/heuropt/blob/main/examples/tsp_operators_compare.rs
[`TabuSearch`]: https://docs.rs/heuropt/latest/heuropt/algorithms/tabu_search/struct.TabuSearch.html
+15 -15
View File
@@ -87,8 +87,8 @@ impl Problem for Zdt1 {
```
For multi-objective problems, pick a Pareto-aware optimizer:
[NSGA-II][Nsga2] is the canonical default; [MOPSO][Mopso] often wins on
smooth-front 2-objective problems; [IBEA][Ibea] often wins on
[`Nsga2`] is the canonical default; [`Mopso`] often wins on
smooth-front 2-objective problems; [`Ibea`] often wins on
disconnected fronts. See [choosing-an-algorithm](./choosing-an-algorithm.md).
## Maximizing instead of minimizing
@@ -166,8 +166,8 @@ impl Problem for OneMax {
}
```
For `Vec<bool>` problems, [UMDA][Umda] is a parameter-free EDA;
[GA][GeneticAlgorithm] with [`BitFlipMutation`] is the GA route.
For `Vec<bool>` problems, [`Umda`] is a parameter-free EDA;
[`GeneticAlgorithm`] with [`BitFlipMutation`] is the GA route.
### Permutations (`Vec<usize>`)
@@ -191,9 +191,9 @@ impl Problem for Tsp {
}
```
For permutations, [Ant Colony][AntColonyTsp] specializes on TSP-style problems;
[Tabu Search][TabuSearch] takes a user-supplied neighbor function for arbitrary
discrete neighborhoods; [Simulated Annealing][SimulatedAnnealing] with [`SwapMutation`]
For permutations, [`AntColonyTsp`] specializes on TSP-style problems;
[`TabuSearch`] takes a user-supplied neighbor function for arbitrary
discrete neighborhoods; [`SimulatedAnnealing`] with [`SwapMutation`]
is the simplest baseline.
### Custom decision types
@@ -232,13 +232,13 @@ through the decision tree.
[`Evaluation`]: https://docs.rs/heuropt/latest/heuropt/core/evaluation/struct.Evaluation.html
[`Evaluation::new`]: https://docs.rs/heuropt/latest/heuropt/core/evaluation/struct.Evaluation.html#method.new
[`Evaluation::constrained`]: https://docs.rs/heuropt/latest/heuropt/core/evaluation/struct.Evaluation.html#method.constrained
[Nsga2]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga2/struct.Nsga2.html
[Mopso]: https://docs.rs/heuropt/latest/heuropt/algorithms/mopso/struct.Mopso.html
[Ibea]: https://docs.rs/heuropt/latest/heuropt/algorithms/ibea/struct.Ibea.html
[Umda]: https://docs.rs/heuropt/latest/heuropt/algorithms/umda/struct.Umda.html
[GeneticAlgorithm]: https://docs.rs/heuropt/latest/heuropt/algorithms/genetic_algorithm/struct.GeneticAlgorithm.html
[`Nsga2`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga2/struct.Nsga2.html
[`Mopso`]: https://docs.rs/heuropt/latest/heuropt/algorithms/mopso/struct.Mopso.html
[`Ibea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ibea/struct.Ibea.html
[`Umda`]: https://docs.rs/heuropt/latest/heuropt/algorithms/umda/struct.Umda.html
[`GeneticAlgorithm`]: https://docs.rs/heuropt/latest/heuropt/algorithms/genetic_algorithm/struct.GeneticAlgorithm.html
[`BitFlipMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.BitFlipMutation.html
[AntColonyTsp]: https://docs.rs/heuropt/latest/heuropt/algorithms/ant_colony_tsp/struct.AntColonyTsp.html
[TabuSearch]: https://docs.rs/heuropt/latest/heuropt/algorithms/tabu_search/struct.TabuSearch.html
[SimulatedAnnealing]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
[`AntColonyTsp`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ant_colony_tsp/struct.AntColonyTsp.html
[`TabuSearch`]: https://docs.rs/heuropt/latest/heuropt/algorithms/tabu_search/struct.TabuSearch.html
[`SimulatedAnnealing`]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
[`SwapMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.SwapMutation.html
+51 -93
View File
@@ -6,141 +6,99 @@ The shortest path from a fresh project to a working optimizer.
```toml
[dependencies]
heuropt = "0.10"
heuropt = "0.5"
```
The default feature set is small. Optional features:
- `parallel` — rayon-backed parallel population evaluation.
- `serde``Serialize` / `Deserialize` derives on the core data
types, plus the `heuropt::explorer` JSON export module for the
[heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
webapp.
- `async``AsyncProblem` trait + per-algorithm `run_async` for
IO-bound evaluations.
types.
```toml
heuropt = { version = "0.10", features = ["parallel"] }
heuropt = { version = "0.5", features = ["parallel"] }
```
## 2. Define a problem and run an optimizer
## 2. Define a problem
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, [CMA-ES][CmaEs] is a strong
default.
```rust,no_run
use heuropt::prelude::*;
struct LineFit {
points: Vec<(f64, f64)>,
}
struct Sphere;
impl Problem for LineFit {
type Decision = Vec<f64>; // [slope, intercept]
impl Problem for Sphere {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("sum_squared_error")])
ObjectiveSpace::new(vec![Objective::minimize("f")])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
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])
let f: f64 = x.iter().map(|v| v * v).sum();
Evaluation::new(vec![f])
}
}
```
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)],
};
The Sphere function is a single-objective continuous problem: minimize
`f(x) = Σ xᵢ²`. The optimum is `x = 0`, `f = 0`.
// Search box: slope and intercept each in [-10, 10].
let bounds = RealBounds::new(vec![(-10.0, 10.0); 2]);
## 3. Pick an algorithm and run it
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,
);
For a smooth single-objective continuous problem, [`CmaEs`] is a
strong default. Configure it, build it, run it.
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,
);
```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
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,
);
}
}
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. The actual output:
to debug builds. Expect output like:
```text
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
best f = 1.4e-29 at x = [-1.6e-15, 4.5e-16, ...]
```
### 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.
CMA-ES drops to machine epsilon on the Sphere in well under 80
generations.
## 4. What just happened
- [`Problem`] is the **what** you're optimizing.
- [CMA-ES][CmaEs] (or any other optimizer) is the **how**.
- [`CmaEs`] (or any other optimizer) is the **how**.
- [`CmaEsConfig`] is a plain public-field struct: there are no
builders, no chained setters, just public fields you set
directly.
@@ -165,5 +123,5 @@ problems this clean in well under that budget.
[`Problem`]: https://docs.rs/heuropt/latest/heuropt/core/problem/trait.Problem.html
[`Optimizer::run`]: https://docs.rs/heuropt/latest/heuropt/traits/trait.Optimizer.html
[`OptimizationResult`]: https://docs.rs/heuropt/latest/heuropt/core/result/struct.OptimizationResult.html
[CmaEs]: https://docs.rs/heuropt/latest/heuropt/algorithms/cma_es/struct.CmaEs.html
[`CmaEs`]: https://docs.rs/heuropt/latest/heuropt/algorithms/cma_es/struct.CmaEs.html
[`CmaEsConfig`]: https://docs.rs/heuropt/latest/heuropt/algorithms/cma_es/struct.CmaEsConfig.html
+14 -21
View File
@@ -28,7 +28,7 @@ hyperopt, optuna, DEAP). heuropt's design priorities:
1. **Approachable code.** No trait objects in the public API. No
GATs, HRTBs, generic-RNG plumbing. A junior Rust engineer should
be able to read Random Search and write a new optimizer by
be able to read `RandomSearch` and write a new optimizer by
implementing only the `Optimizer<P>` trait.
2. **One concrete RNG type.** Seeded determinism is a property tested
across the crate; identical inputs always produce identical
@@ -44,18 +44,20 @@ hyperopt, optuna, DEAP). heuropt's design priorities:
## What's in the box
heuropt v0.10 ships **33 algorithms** spanning:
heuropt v0.5 ships **35 algorithms** spanning:
- Single-objective continuous: Random Search, Hill Climber,
(1+1)-ES, Simulated Annealing, GA, PSO, Differential Evolution,
TLBO, CMA-ES, IPOP-CMA-ES, sNES, Nelder-Mead.
- Single-objective other types: UMDA (binary), Tabu Search (any),
Ant Colony (permutation).
- Multi-objective (23): PAES, NSGA-II, SPEA2, MOPSO, IBEA,
SMS-EMOA, HypE, ε-MOEA, PESA-II, AGE-MOEA, KnEA, MOEA/D.
- Many-objective (4+): NSGA-III, RVEA, GrEA.
- Sample-efficient / multi-fidelity: Bayesian Optimization, TPE,
Hyperband.
- Single-objective continuous: `RandomSearch`, `HillClimber`,
`OnePlusOneEs`, `SimulatedAnnealing`, `GeneticAlgorithm`,
`ParticleSwarm`, `DifferentialEvolution`, `Tlbo`, `CmaEs`,
`IpopCmaEs`, `SeparableNes`, `NelderMead`.
- Single-objective other types: `Umda` (binary), `TabuSearch`
(any), `AntColonyTsp` (permutation).
- Multi-objective (23): `Paes`, `Nsga2`, `Spea2`, `Mopso`, `Ibea`,
`SmsEmoa`, `HypE`, `EpsilonMoea`, `PesaII`, `AgeMoea`, `Knea`,
`Moead`.
- Many-objective (4+): `Nsga3`, `Rvea`, `Grea`.
- Sample-efficient / multi-fidelity: `BayesianOpt`, `Tpe`,
`Hyperband`.
Plus the operators (SBX, PolynomialMutation, BoundedGaussianMutation,
LevyMutation, BitFlipMutation, SwapMutation, ClampToBounds,
@@ -63,15 +65,6 @@ 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:
+5 -127
View File
@@ -3,128 +3,6 @@
Per-release notes for upgrading between heuropt versions. Skip the
sections that don't apply to your starting version.
## To 0.10
### From 0.9.x
**Almost additive.** Bumping `heuropt = "0.10"` recompiles
without touching most code. The one breaking change is the value
returned by `AlgorithmInfo::name()`:
| Before (`0.9`) | After (`0.10`) |
|---|---|
| `"Nsga2"` | `"NSGA-II"` |
| `"Nsga3"` | `"NSGA-III"` |
| `"Cmaes"` | `"CMA-ES"` |
| `"Mopso"` | `"MOPSO"` |
| `"Moead"` | `"MOEA/D"` |
| `"EpsilonMoea"` | `"ε-MOEA"` |
| (and 27 more) | … |
If you pattern-matched on those strings (e.g. for branching
display logic), update to the new canonical strings. They now
match the literature and will be stable going forward.
What's new and additive:
- `AlgorithmInfo::full_name(&self) -> &'static str` — academic
long form (`"Non-dominated Sorting Genetic Algorithm II"`).
Defaults to `name()` for algorithms whose long and short
forms coincide.
- `ExplorerExport`'s `RunMeta` gained `algorithm_full_name:
Option<String>`. Schema version stays at **1** (the new field
is `#[serde(default)]`); display tools can use the long form
as a hover tooltip on the short name.
## To 0.9
### From 0.8.x
**Additive only.** Bumping `heuropt = "0.9"` works for all 0.8.x
code untouched. The new surfaces ship behind the existing `serde`
feature.
What's new:
- `heuropt::explorer` module (gated on `serde`) — turns an
`OptimizationResult` into a self-describing JSON file that the
[heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
webapp can load. See the
[Explore your results](./cookbook/explorer.md) recipe.
- `Objective` gained optional `label` and `unit` fields with
fluent builders `.with_label("…")` / `.with_unit("…")`. Existing
`Objective::minimize("…")` / `Objective::maximize("…")` are
unchanged. The serde representation is forward- and backward-
compatible (new fields are `#[serde(default)]`).
- `Problem` trait gained a default-empty
`fn decision_schema(&self) -> Vec<DecisionVariable>` method.
Existing impls compile untouched; override it to provide pretty
names / labels / units / bounds for the explorer.
- `heuropt::traits::AlgorithmInfo` — every built-in algorithm
exposes its short canonical name (`"Nsga3"`, …) and its seed.
Used by the explorer JSON export.
If you don't want any of this, no migration needed — just bump
the 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
@@ -165,9 +43,9 @@ from v0.3 are still numerically accurate but will run faster.
### From 0.2.x
**Additive only.** New algorithms (Bayesian Optimization, TPE,
(1+1)-ES, IPOP-CMA-ES, sNES, Nelder-Mead,
Hyperband), new operators (`LevyMutation`, `ClampToBounds`,
**Additive only.** New algorithms (`BayesianOpt`, `Tpe`,
`OnePlusOneEs`, `IpopCmaEs`, `SeparableNes`, `NelderMead`,
`Hyperband`), new operators (`LevyMutation`, `ClampToBounds`,
`ProjectToSimplex`), new traits (`PartialProblem`, `Repair<D>`).
`CmaEsConfig` gained an `initial_mean: Option<Vec<f64>>` field;
@@ -178,8 +56,8 @@ existing call sites need a `.. CmaEsConfig { initial_mean: None,
### From 0.1.x
**Additive.** New algorithms across the catalog (Hill Climber, SA,
GA, PSO, CMA-ES, Tabu Search, Ant Colony, UMDA, TLBO, MOPSO, IBEA,
**Additive.** New algorithms across the catalog (HillClimber, SA,
GA, PSO, CMA-ES, TabuSearch, AntColonyTsp, Umda, TLBO, MOPSO, IBEA,
SMS-EMOA, HypE, RVEA, PESA-II, ε-MOEA, AGE-MOEA, GrEA, KnEA), new
operators (`SimulatedBinaryCrossover`, `PolynomialMutation`,
`CompositeVariation`, `BoundedGaussianMutation`), and the
+13 -12
View File
@@ -18,10 +18,10 @@ versions — use them at your own risk.
While we are pre-1.0:
- **Minor bumps (`0.10 → 0.11`) may break the public API.** The
- **Minor bumps (`0.5 → 0.6`) 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.10.0 → 0.10.1`) only contain bug fixes,
- **Patch bumps (`0.5.0 → 0.5.1`) only contain bug fixes,
performance improvements, and additive non-breaking features.**
No deprecations, no removals.
@@ -29,20 +29,24 @@ While we are pre-1.0:
In rough order of likelihood:
1. **Algorithm config structs may gain fields.** All current configs
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
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]`.
2. **Some operators may move between `operators` and `pareto`** as
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
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.
@@ -56,12 +60,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.8, the entire history of perf
optimizations has been bit-identical against the v0.3.0 reference.
when it happens. As of v0.5, 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.10. This is tested in CI against
heuropt's MSRV is **1.85** as of v0.5. This is tested in CI against
every PR.
MSRV bumps are treated as patch-bump-eligible (they don't break the
@@ -75,9 +79,6 @@ 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
File diff suppressed because it is too large Load Diff
-225
View File
@@ -1,225 +0,0 @@
//! Bi-objective TSP using NSGA-II on the **Kroak/Krobk** instance family
//! (Lust & Teghem, 2010).
//!
//! Two TSP instances over the **same** set of cities define two distance
//! matrices A and B; the search trades off tour length under A versus tour
//! length under B. This is the canonical multi-objective combinatorial
//! benchmark, and it gives a rich Pareto front because the geographies
//! disagree.
//!
//! The instance embedded here is **KroAB-25**: the first 25 cities of
//! TSPLIB KroA100 and KroB100 (both EUC_2D). Same city *indices*, two
//! coordinate listings.
//!
//! - **Algorithm**: [`Nsga2`].
//! - **Variation**: [`EdgeRecombinationCrossover`] (the gold-standard TSP
//! crossover) piped into [`InversionMutation`] via [`CompositeVariation`].
//! - **Initializer**: [`ShuffledPermutation`].
//! - **Encoding**: strict permutation of `[0..25)`.
//!
//! Sources:
//! - TSPLIB95 KroA100 / KroB100 (Reinelt, 1991).
//! - Lust & Teghem (2010), "The Multiobjective Traveling Salesman Problem:
//! A Survey and a New Approach."
//!
//! Run with:
//!
//! ```bash
//! cargo run --release --example btsp_kroab
//! ```
use heuropt::metrics::hypervolume_2d;
use heuropt::prelude::*;
/// First 25 cities of TSPLIB KroA100 (EUC_2D).
const KROA_25: [(f64, f64); 25] = [
(1380.0, 939.0),
(2848.0, 96.0),
(3510.0, 1671.0),
(457.0, 334.0),
(3888.0, 666.0),
(984.0, 965.0),
(2721.0, 1482.0),
(1286.0, 525.0),
(2716.0, 1432.0),
(738.0, 1325.0),
(1251.0, 1832.0),
(2728.0, 1698.0),
(3815.0, 169.0),
(3683.0, 1533.0),
(1247.0, 1945.0),
(123.0, 862.0),
(1234.0, 1946.0),
(252.0, 1240.0),
(611.0, 673.0),
(2576.0, 1676.0),
(928.0, 1700.0),
(53.0, 857.0),
(1807.0, 1711.0),
(274.0, 1420.0),
(2574.0, 946.0),
];
/// First 25 cities of TSPLIB KroB100 (EUC_2D).
const KROB_25: [(f64, f64); 25] = [
(3140.0, 1401.0),
(556.0, 1056.0),
(3675.0, 1522.0),
(1182.0, 1853.0),
(3595.0, 1340.0),
(1936.0, 953.0),
(2722.0, 1311.0),
(2839.0, 2055.0),
(2253.0, 1242.0),
(3142.0, 1591.0),
(627.0, 1336.0),
(936.0, 211.0),
(4014.0, 471.0),
(1376.0, 1452.0),
(3289.0, 593.0),
(1453.0, 67.0),
(1014.0, 1944.0),
(2811.0, 1080.0),
(3010.0, 1290.0),
(1817.0, 1517.0),
(510.0, 458.0),
(1717.0, 1693.0),
(1252.0, 1633.0),
(1693.0, 1374.0),
(539.0, 1378.0),
];
const N_CITIES: usize = 25;
/// TSPLIB EUC_2D distance: rounded Euclidean.
fn euc2d_matrix(coords: &[(f64, f64)]) -> Vec<Vec<f64>> {
let n = coords.len();
let mut d = vec![vec![0.0_f64; n]; n];
for i in 0..n {
for j in (i + 1)..n {
let dx = coords[i].0 - coords[j].0;
let dy = coords[i].1 - coords[j].1;
let dij = (dx * dx + dy * dy).sqrt().round();
d[i][j] = dij;
d[j][i] = dij;
}
}
d
}
struct BTspKroAB {
dist_a: Vec<Vec<f64>>,
dist_b: Vec<Vec<f64>>,
}
impl BTspKroAB {
fn new() -> Self {
Self {
dist_a: euc2d_matrix(&KROA_25),
dist_b: euc2d_matrix(&KROB_25),
}
}
fn tour_length(d: &[Vec<f64>], tour: &[usize]) -> f64 {
let n = tour.len();
let mut total = 0.0;
for i in 0..n {
total += d[tour[i]][tour[(i + 1) % n]];
}
total
}
}
impl Problem for BTspKroAB {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("length_A"),
Objective::minimize("length_B"),
])
}
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
Evaluation::new(vec![
Self::tour_length(&self.dist_a, tour),
Self::tour_length(&self.dist_b, tour),
])
}
fn decision_schema(&self) -> Vec<DecisionVariable> {
(0..N_CITIES)
.map(|k| DecisionVariable::new(format!("tour_position_{k}")))
.collect()
}
}
fn main() {
let problem = BTspKroAB::new();
let mut optimizer = Nsga2::new(
Nsga2Config {
population_size: 200,
generations: 600,
seed: 11,
},
ShuffledPermutation { n: N_CITIES },
CompositeVariation {
crossover: EdgeRecombinationCrossover,
mutation: InversionMutation,
},
);
let result = optimizer.run(&problem);
println!("bTSP KroAB-25 — bi-objective TSP via NSGA-II");
println!("Source: TSPLIB95 KroA100/KroB100 (first 25 cities), Lust & Teghem bTSP family");
println!();
println!("Total evaluations: {}", result.evaluations);
println!("Pareto-front size: {}", result.pareto_front.len());
println!();
let mut front: Vec<&Candidate<Vec<usize>>> = result.pareto_front.iter().collect();
front.sort_by(|a, b| {
a.evaluation.objectives[0]
.partial_cmp(&b.evaluation.objectives[0])
.unwrap_or(std::cmp::Ordering::Equal)
});
// Print a spread sample of the front (no more than 12 rows).
let stride = (front.len() / 12).max(1);
println!(" length_A length_B");
let mut printed = 0_usize;
for (i, c) in front.iter().enumerate() {
if i % stride == 0 || i + 1 == front.len() {
let o = &c.evaluation.objectives;
println!(" {:>8.0} {:>8.0}", o[0], o[1]);
printed += 1;
if printed >= 12 {
break;
}
}
}
println!();
if let (Some(corner_a), Some(corner_b)) = (front.first(), front.last()) {
println!(
"A-corner: A={:.0}, B={:.0}",
corner_a.evaluation.objectives[0], corner_a.evaluation.objectives[1]
);
println!(
"B-corner: A={:.0}, B={:.0}",
corner_b.evaluation.objectives[0], corner_b.evaluation.objectives[1]
);
}
// Hypervolume vs. a generous reference point. Pick a reference well past
// the worst values likely to appear so different runs can be compared.
let ref_point = [40_000.0, 40_000.0];
let owned: Vec<Candidate<Vec<usize>>> = result.pareto_front.to_vec();
let hv = hypervolume_2d(&owned, &problem.objectives(), ref_point);
println!();
println!(
"Hypervolume vs. reference ({}, {}): {:.0}",
ref_point[0], ref_point[1], hv
);
}
+85 -276
View File
@@ -1,333 +1,142 @@
# `compare` example — reference output
Snapshot from `cargo run --release --example compare`, refreshed 2026-05-14
for heuropt v0.10.0. 10 seeds per algorithm per problem.
Snapshot from `cargo run --release --example compare` after the v0.4.0
perf pass landed (2026-05-05). 10 seeds per algorithm per problem.
Each table is **sorted best-first** by its primary quality metric. The
live terminal output uses ASCII `+/-` for the mean ± std cells (so column
alignment can't be broken by a terminal that renders `±` at an odd
width); this doc uses `±` since markdown renders it fine.
The **quality metrics** (hypervolume / spacing / mean L2 / mean dist /
front size) are bit-identical to the v0.3.0 snapshot — the v0.4.0
optimization work was strictly CPU-time, never algorithmic. The **ms
columns** reflect the v0.4.0 numbers; total compare-harness wall-clock
dropped from ~18.6 s to ~5.7 s (3.27× faster).
The **continuous-problem quality metrics** are bit-identical to the
v0.3.0v0.4.0 snapshots — every optimization pass so far (including the
Phase B CPU work) has been verified bit-identical by the `run()` snapshot
tests. The **ms columns** are the post-Phase-B numbers; SMS-EMOA on DTLZ2
in particular fell ~2.7× from the `hypervolume_nd` rework.
This refresh also adds three **combinatorial / sequencing** problems —
TSP, job-shop scheduling, and a bi-objective knapsack — which exercise the
permutation and bitstring operators and a different algorithm roster (the
real-vector methods can't run them) — and three **many-objective**
problems (DTLZ at 4, 10, and 8 objectives) that push past where Pareto
dominance still discriminates.
Wall-clock numbers are from the development machine and will vary; the
*relative* numbers across algorithms are the interesting part.
Wall-clock numbers are from the development machine and will vary;
the *relative* numbers across algorithms are the interesting part.
---
## ZDT1 (dim=30, 25000 evals/run × 10 seeds)
Zitzler-Deb-Thiele 2-objective benchmark: 30 real variables, one smooth
convex Pareto front `f₂ = 1 √f₁`. Hard because 29 of 30 variables must
collapse to 0 before the front is even reachable, and only then can the
population spread along it. Optimum: mean L2 → 0 (the front is known
exactly). Sorted by hypervolume (reference `[11, 11]`).
Two-objective benchmark with a smooth Pareto front along
`f₂ = 1 √f₁`. Hypervolume reference point: `[11, 11]`.
| algorithm | hypervolume ↑ | spacing ↓ | mean L2 ↓ | front | ms |
| algorithm | hypervolume ↑ | spacing ↓ | mean L2 ↓ | front | ms |
|---|---|---|---|---|---|
| MOPSO | **120.6149 ± 0.0529** | 0.0125 ± 0.0025 | **0.0005 ± 0.0001** | 100 | 80 |
| IBEA | 120.0167 ± 0.3112 | 0.0130 ± 0.0027 | 0.0448 ± 0.0168 | 73 | 130 |
| MOEA/D | 119.9450 ± 0.4953 | 0.0118 ± 0.0013 | 0.0065 ± 0.0020 | 96 | 27 |
| PESA-II | 119.3670 ± 0.3261 | **0.0095 ± 0.0011** | 0.0802 ± 0.0354 | 100 | 67 |
| eps-MOEA | 118.8742 ± 0.6835 | 0.0167 ± 0.0058 | 0.0493 ± 0.0227 | 45 | 46 |
| NSGA-II | 118.3336 ± 0.7750 | 0.0112 ± 0.0022 | 0.1891 ± 0.0599 | 96 | 40 |
| SPEA2 | 118.0823 ± 0.5973 | 0.0111 ± 0.0023 | 0.2408 ± 0.0509 | 97 | 226 |
| NSGA-III | 115.1612 ± 0.4745 | 0.0139 ± 0.0029 | 0.4314 ± 0.0582 | 86 | 47 |
| RVEA | 111.7151 ± 1.8195 | 0.0308 ± 0.0099 | 0.8399 ± 0.1569 | 47 | 62 |
| HypE | 105.6489 ± 0.9789 | 0.0266 ± 0.0053 | 1.4820 ± 0.1003 | 72 | 30 |
| PAES | 104.1887 ± 0.8953 | 0.0351 ± 0.0067 | 1.3195 ± 0.0558 | 33 | 27 |
| SMS-EMOA | 102.8871 ± 1.0543 | 0.0263 ± 0.0039 | 1.4937 ± 0.1192 | 40 | 54 |
| RandomSearch | 99.5691 ± 0.9383 | 0.0937 ± 0.0347 | 2.3621 ± 0.1428 | 28 | 88 |
| RandomSearch | 99.5691 ± 0.94 | 0.0937 ± 0.03 | 2.3621 ± 0.14 | 28 | 94 |
| PAES | 104.1887 ± 0.90 | 0.0351 ± 0.01 | 1.3195 ± 0.06 | 33 | 30 |
| MOPSO | **120.6149 ± 0.05** | 0.0125 ± 0.00 | **0.0005 ± 0.00** | 100 | 89 |
| SPEA2 | 118.0823 ± 0.60 | 0.0111 ± 0.00 | 0.2408 ± 0.05 | 97 | 234 |
| PESA-II | 119.3670 ± 0.33 | **0.0095 ± 0.00** | 0.0802 ± 0.04 | 100 | 73 |
| ε-MOEA | 118.8742 ± 0.68 | 0.0167 ± 0.01 | 0.0493 ± 0.02 | 45 | 50 |
| IBEA | 120.0167 ± 0.31 | 0.0130 ± 0.00 | 0.0448 ± 0.02 | 73 | 138 |
| HypE | 105.6489 ± 0.98 | 0.0266 ± 0.01 | 1.4820 ± 0.10 | 72 | 38 |
| SMS-EMOA | 102.8871 ± 1.05 | 0.0263 ± 0.00 | 1.4937 ± 0.12 | 40 | 67 |
| RVEA | 111.7151 ± 1.82 | 0.0308 ± 0.01 | 0.8399 ± 0.16 | 47 | 65 |
| NSGA-II | 118.3336 ± 0.78 | 0.0112 ± 0.00 | 0.1891 ± 0.06 | 96 | 67 |
| NSGA-III | 115.1612 ± 0.47 | 0.0139 ± 0.00 | 0.4314 ± 0.06 | 86 | 70 |
| MOEA/D | 119.9450 ± 0.50 | 0.0118 ± 0.00 | 0.0065 ± 0.00 | 96 | 28 |
**MOPSO and MOEA/D dominate** convergence (mean L2 to true front ≤ 0.01).
PESA-II edges spacing.
## ZDT3 (dim=30, 25000 evals × 10 seeds)
Zitzler-Deb-Thiele 2-objective with a **disconnected** front: five
separate arcs rather than one curve. Hard because an algorithm has to
discover and populate every arc while not stranding solutions in the
dominated gaps between them.
Disconnected Pareto front; tests an algorithm's ability to maintain
spread across gaps.
| algorithm | hypervolume ↑ | spacing ↓ | front | ms |
| algorithm | hypervolume ↑ | spacing ↓ | front | ms |
|---|---|---|---|---|
| **IBEA** | **126.2072 ± 1.2280** | 0.0164 ± 0.0036 | 48 | 126 |
| MOEA/D | 125.2413 ± 2.1647 | 0.0198 ± 0.0043 | 92 | 26 |
| NSGA-II | 123.1826 ± 1.5829 | **0.0092 ± 0.0020** | 98 | 39 |
| AGE-MOEA | 119.5132 ± 1.2732 | 0.0136 ± 0.0023 | 90 | 170 |
| KnEA | 117.2180 ± 0.7027 | 0.0147 ± 0.0049 | 79 | 32 |
| NSGA-II | 123.1826 ± 1.58 | 0.0092 ± 0.00 | 98 | 68 |
| MOEA/D | 125.2413 ± 2.16 | 0.0198 ± 0.00 | 92 | 28 |
| **IBEA** | **126.2072 ± 1.23** | 0.0164 ± 0.00 | 48 | 135 |
| AGE-MOEA | 119.5132 ± 1.27 | 0.0136 ± 0.00 | 90 | 199 |
The **geometry-aware methods finish last** on the disconnected front:
AGE-MOEA and KnEA both trail the dominance- and decomposition-based
methods. Estimating a single front geometry — or chasing knee points —
doesn't help when the front is in pieces; IBEA's indicator-based
selection wins here.
## DTLZ2 (3-obj, dim=12, 30000 evals × 10 seeds)
## DTLZ2 (3-obj, dim=12, 30000 evals/run × 10 seeds)
Spherical Pareto front. Mean dist = `|‖f‖ 1|`.
Deb-Thiele-Laumanns-Zitzler 3-objective; the Pareto front is the
unit-sphere octant (`Σf² = 1, all f ≥ 0`) — a curved 2-D surface embedded
in 3-D objective space. `mean dist = |‖f‖ 1|`, so 0 means perfectly on
the sphere (the known optimum).
| algorithm | mean dist ↓ | spacing ↓ | front | ms |
| algorithm | mean dist ↓ | spacing ↓ | front | ms |
|---|---|---|---|---|
| **IBEA** | **0.0014 ± 0.0002** | 0.0607 ± 0.0047 | 87 | 148 |
| MOEA/D | 0.0037 ± 0.0003 | 0.0886 ± 0.0024 | 78 | 23 |
| HypE | 0.0113 ± 0.0033 | **0.0269 ± 0.0172** | 80 | 41 |
| NSGA-III | 0.0197 ± 0.0015 | 0.0735 ± 0.0052 | 92 | 91 |
| eps-MOEA | 0.0325 ± 0.0104 | 0.0572 ± 0.0170 | 136 | 88 |
| NSGA-II | 0.0332 ± 0.0068 | 0.0577 ± 0.0109 | 92 | 60 |
| SPEA2 | 0.0368 ± 0.0021 | 0.0288 ± 0.0038 | 92 | 530 |
| PESA-II | 0.0395 ± 0.0033 | 0.0616 ± 0.0051 | 100 | 372 |
| SMS-EMOA | 0.0484 ± 0.0134 | 0.0764 ± 0.0081 | 40 | 483 |
| RVEA | 0.0510 ± 0.0044 | 0.0631 ± 0.0024 | 68 | 66 |
| MOPSO | 0.0566 ± 0.0048 | 0.0687 ± 0.0084 | 100 | 66 |
| RandomSearch | 0.3949 ± 0.0152 | 0.0797 ± 0.0083 | 239 | 530 |
| RandomSearch | 0.3949 ± 0.02 | 0.0797 ± 0.01 | 239 | 520 |
| MOPSO | 0.0566 ± 0.00 | 0.0687 ± 0.01 | 100 | 71 |
| NSGA-II | 0.0332 ± 0.01 | 0.0577 ± 0.01 | 92 | 104 |
| SPEA2 | 0.0368 ± 0.00 | **0.0288 ± 0.00** | 92 | 534 |
| PESA-II | 0.0395 ± 0.00 | 0.0616 ± 0.01 | 100 | 396 |
| ε-MOEA | 0.0325 ± 0.01 | 0.0572 ± 0.02 | 136 | 89 |
| **IBEA** | **0.0014 ± 0.00** | 0.0607 ± 0.00 | 87 | 156 |
| HypE | 0.0113 ± 0.00 | 0.0269 ± 0.02 | 80 | 53 |
| SMS-EMOA | 0.0484 ± 0.01 | 0.0764 ± 0.01 | 40 | 1218 |
| RVEA | 0.0510 ± 0.00 | 0.0631 ± 0.00 | 68 | 73 |
| NSGA-III | 0.0197 ± 0.00 | 0.0735 ± 0.01 | 92 | 137 |
| MOEA/D | 0.0037 ± 0.00 | 0.0886 ± 0.00 | 78 | 24 |
**IBEA wins decisively** (14× closer to the true front than NSGA-III).
SMS-EMOA's wall-clock fell ~2.7× from the v0.4.0 snapshot — the
`hypervolume_nd` rework.
**IBEA wins decisively** (15× closer to the true front than NSGA-III).
## DTLZ1 (3-obj, dim=7, 30000 evals × 10 seeds)
Deb-Thiele-Laumanns-Zitzler 3-objective; the Pareto front is the linear
simplex `Σf = 0.5` in the positive octant. Hard because a deceptive
multimodal `g` term riddles the approach with a huge number of local
fronts — only fully-converged runs land on the simplex.
Linear simplex Pareto front (`Σf = 0.5`).
| algorithm | mean dist ↓ | spacing ↓ | front | ms |
| algorithm | mean dist ↓ | spacing ↓ | front | ms |
|---|---|---|---|---|
| **GrEA** | **1.7725 ± 0.9897** | **0.0719 ± 0.0438** | 72 | 62 |
| MOEA/D | 2.8022 ± 1.7807 | 0.2279 ± 0.2247 | 78 | 22 |
| AGE-MOEA | 4.5395 ± 2.2114 | 0.3930 ± 0.2864 | 90 | 193 |
| NSGA-III | 5.9130 ± 2.8212 | 0.4375 ± 0.2212 | 92 | 81 |
| NSGA-III | 5.9130 ± 2.82 | 0.4375 ± 0.22 | 92 | 133 |
| MOEA/D | 2.8022 ± 1.78 | 0.2279 ± 0.22 | 78 | 21 |
| AGE-MOEA | 4.5395 ± 2.21 | 0.3930 ± 0.29 | 90 | 247 |
| **GrEA** | **1.7725 ± 0.99** | **0.0719 ± 0.04** | 72 | 104 |
**GrEA shines on linear fronts** — the grid-based niching matches the
geometry better than reference points.
## Rastrigin (dim=5, 50000 evals/run × 10 seeds)
Highly multimodal trap: `f = 10n + Σ(xᵢ² 10·cos(2π·xᵢ))`. Hard because a
near-quadratic global bowl is overlaid with ~10⁵ regularly spaced local
minima — any greedy step lands in the nearest dimple. Global optimum
`f = 0` at the origin.
Multimodal trap. Global minimum f = 0 at the origin.
| algorithm | best f | ms |
| algorithm | best f | ms |
|---|---|---|
| **(1+1)-ES** | **0.0000e0 ± 0.00e0** | 4 |
| **DE** | **0.0000e0 ± 0.00e0** | 6 |
| GA | 7.0913e-8 ± 5.50e-8 | 15 |
| NSGA-II | 4.9270e-5 ± 5.04e-5 | 60 |
| IPOP-CMA-ES | 1.3423e-1 ± 2.71e-1 | 61 |
| PSO | 7.9598e-1 ± 8.67e-1 | 5 |
| CMA-ES | 2.3453e0 ± 1.49e0 | 10 |
| SimulatedAnneal | 3.8540e0 ± 1.48e0 | 7 |
| RandomSearch | 1.1064e1 ± 2.54e0 | 14 |
| HillClimber | 1.5966e1 ± 6.25e0 | 6 |
| PAES | 1.5966e1 ± 6.25e0 | 10 |
| RandomSearch | 1.1064e1 ± 2.54 | 14 |
| HillClimber | 1.5966e1 ± 6.25 | 6 |
| **(1+1)-ES** | **0.0000e0 ± 0.00** | 4 |
| SimulatedAnneal | 3.8540e0 ± 1.48 | 7 |
| PAES | 1.5966e1 ± 6.25 | 10 |
| GA | 7.0913e-8 ± 5.50e-8 | 16 |
| PSO | 7.9598e-1 ± 8.67e-1 | 5 |
| NSGA-II | 4.9270e-5 ± 5.04e-5 | 83 |
| **DE** | **0.0000e0 ± 0.00** | 6 |
| CMA-ES | 2.3453e0 ± 1.49 | 11 |
| **IPOP-CMA-ES** | 1.3423e-1 ± 2.71e-1 | 66 |
(1+1)-ES and DE tie for `f = 0`. **IPOP-CMA-ES drops vanilla CMA-ES from
(1+1)-ES and DE tie for f = 0. **IPOP-CMA-ES drops vanilla CMA-ES from
2.35 → 0.13** — the restart logic does what it should.
## Rosenbrock (dim=5, 30000 evals × 10 seeds)
Rosenbrock's banana valley: `f = Σ(100·(xᵢ₊₁ xᵢ²)² + (1 − xᵢ)²)`. Hard
because the minimum sits in a long, bent, near-flat valley — easy to
enter, very slow to crawl along to the tip. Global optimum `f = 0` at the
all-ones point.
Smooth non-convex valley.
| algorithm | best f | ms |
| algorithm | best f | ms |
|---|---|---|
| **Nelder-Mead** | **0.0000e0 ± 0.00e0** | 1 |
| CMA-ES | 3.6207e-29 ± 2.35e-29 | 5 |
| TLBO | 1.8458e-3 ± 1.91e-3 | 1 |
| DE | 3.3345e-1 ± 3.01e-1 | 2 |
| PSO | 8.2124e-1 ± 1.58e0 | 2 |
| (1+1)-ES | 2.2115e0 ± 2.70e0 | 1 |
| BO (60 evals) | 3.1725e3 ± 2.92e3 | 39 |
| DE | 3.3345e-1 ± 3.01e-1 | 2 |
| PSO | 8.2124e-1 ± 1.58e0 | 2 |
| **CMA-ES** | **3.6207e-29 ± 2.35e-29** | 5 |
| TLBO | 1.8458e-3 ± 1.91e-3 | 1 |
| (1+1)-ES | 2.2115e0 ± 2.70e0 | 1 |
| **Nelder-Mead** | **0.0000e0 ± 0.00** | 1 |
| BO (60 evals) | 3.1725e3 ± 2.92e3 | 40 |
Nelder-Mead **= 0 exactly**, CMA-ES at machine epsilon. BO at only 60
evaluations is honestly bad on 5-D Rosenbrock (no kernel hyperparameter
tuning) — included as a reminder that BO needs more evaluations than a
smooth problem actually requires for these other methods.
evaluations is honestly bad on 5-D Rosenbrock (no kernel
hyperparameter tuning) — included as a reminder that BO needs more
evaluations than a smooth problem actually requires for these other
methods.
## Ackley (dim=5, 30000 evals × 10 seeds)
Ackley's function: a near-flat outer plateau with shallow ripples
surrounding a single deep, narrow global basin. Hard because the gradient
is almost zero far from the optimum, giving local search little to
follow. Global optimum `f = 0` at the origin.
Smoother multimodal landscape than Rastrigin.
| algorithm | best f | ms |
|---|---|---|
| **DE** | **4.4409e-16 ± 0.00e0** | 3 |
| DE | 4.4409e-16 ± 0.00 | 4 |
| PSO | 1.5099e-15 ± 1.63e-15 | 3 |
| CMA-ES | 1.5099e-15 ± 1.63e-15 | 5 |
| CMA-ES | 1.5099e-15 ± 1.63e-15 | 6 |
| TLBO | 2.2204e-15 ± 1.78e-15 | 2 |
| BO (60 evals) | 1.9622e1 ± 1.23e0 | 38 |
| BO (60 evals) | 1.9622e1 ± 1.23 | 40 |
All conventional methods reach machine precision. BO at 60 evals
struggles — same caveat as Rosenbrock.
---
## TSP ring-15 (8000 evals/run × 10 seeds)
15 equally-spaced cities on the unit circle; minimize the closed tour
length. The space is `(151)!/2` distinct tours, but cities in convex
position have no 2-opt local optima — so this instance cleanly separates
methods with good neighbourhood moves (inversion = 2-opt) from blind
recombination / sampling. Known optimum (the polygon perimeter):
**6.2374**.
| algorithm | tour length ↓ | ms |
|---|---|---|
| **HillClimber** | **6.2374 ± 0.0000** | 0 |
| **SimulatedAnneal** | **6.2374 ± 0.0000** | 0 |
| **TabuSearch** | **6.2374 ± 0.0000** | 0 |
| **AntColony** | **6.2374 ± 0.0000** | 8 |
| GA | 7.0133 ± 0.6725 | 2 |
| RandomSearch | 12.1474 ± 0.6797 | 1 |
Every local-search method (and Ant Colony) hits the exact optimum — as
theory predicts for convex-position TSP under 2-opt. The GA's order
crossover drifts off the optimum, and random sampling is hopeless.
## JSS FT06 (8000 evals/run × 10 seeds)
Fisher & Thompson 1963 6-job × 6-machine job-shop; minimize makespan.
Hard because every job has a fixed machine order, so swapping two
operations can ripple delays across the whole schedule. Known optimum:
**55**.
| algorithm | makespan ↓ | ms |
|---|---|---|
| **SimulatedAnneal** | **55.2000 ± 0.6000** | 1 |
| TabuSearch | 55.9000 ± 1.4457 | 1 |
| GA | 56.0000 ± 1.5492 | 4 |
| RandomSearch | 58.5000 ± 1.2042 | 4 |
| HillClimber | 62.5000 ± 4.3186 | 0 |
Simulated annealing gets within 0.4% of the known optimum on average;
greedy hill-climbing stalls in operation-order local optima.
## Knapsack (30 items, bi-objective, 20000 evals/run × 10 seeds)
Zitzler-Thiele style 0/1 knapsack: two profit vectors, one capacity (half
the total weight). Hard because the two profit objectives conflict and
the capacity constraint carves feasible regions out of the `2³⁰`
bitstrings. No closed-form optimum; scored by hypervolume vs reference
`[0, 0]` (higher is better).
| algorithm | hypervolume ↑ | front | ms |
|---|---|---|---|
| **NSGA-II** | **1360468.3 ± 11619.6** | 100 | 39 |
| SPEA2 | 1355615.5 ± 9266.8 | 100 | 213 |
| IBEA | 1352595.5 ± 10183.6 | 99 | 101 |
| NSGA-III | 1346446.0 ± 6922.1 | 100 | 39 |
| RandomSearch | 1118233.1 ± 34150.3 | 9 | 17 |
The three Pareto EAs land within ~1% of each other; random search finds a
front of only ~9 points and trails badly. Note IBEA — which dominates the
*continuous* multi-objective tables — is only mid-pack here: its
continuous-MO edge does not transfer to a binary combinatorial encoding.
---
## Many-objective (4+ objectives)
The curse of dimensionality for multi-objective optimizers: as objective
count climbs, almost every pair of solutions becomes mutually
non-dominated, so Pareto rank stops discriminating. NSGA-II's whole
population collapses into front 0 and only crowding distance is left to
steer. Reference-point (NSGA-III), decomposition (MOEA/D),
reference-vector (RVEA), grid (GrEA), and indicator (IBEA, HypE) methods
are built for this regime. Scored by mean distance to the true front
(lower better).
### DTLZ2 4-objective (dim=13, 40000 evals/run × 10 seeds)
DTLZ2 scaled to 4 objectives — the entry point to many-objective. Front
is still the unit-hypersphere octant (`Σf² = 1`). Already hard: with 4
objectives most random solution pairs are mutually non-dominated, so
Pareto rank alone barely discriminates.
| algorithm | mean dist ↓ | front | ms |
|---|---|---|---|
| **HypE** | **0.0005 ± 0.0004** | 56 | 292 |
| MOEA/D | 0.0019 ± 0.0004 | 46 | 33 |
| GrEA | 0.0023 ± 0.0021 | 56 | 75 |
| IBEA | 0.0043 ± 0.0008 | 56 | 135 |
| RVEA | 0.0193 ± 0.0040 | 56 | 58 |
| NSGA-III | 0.0312 ± 0.0046 | 56 | 100 |
| AGE-MOEA | 0.0457 ± 0.0113 | 56 | 239 |
| NSGA-II | 0.1149 ± 0.0249 | 56 | 74 |
| RandomSearch | 0.4720 ± 0.0122 | 887 | 1960 |
NSGA-II already trails the specialists by ~230× — and its "front" is the
whole population (56), the first sign of dominance resistance. Random
search's front balloons to ~887: nothing it sampled dominates anything
else.
### DTLZ2 10-objective (dim=19, 40000 evals/run × 10 seeds)
DTLZ2 at 10 objectives — the curse of dimensionality in full. In 10-D
objective space almost *every* pair of solutions is mutually
non-dominated.
| algorithm | mean dist ↓ | front | ms |
|---|---|---|---|
| **HypE** | **0.0007 ± 0.0005** | 55 | 555 |
| MOEA/D | 0.0029 ± 0.0022 | 48 | 57 |
| GrEA | 0.0066 ± 0.0145 | 55 | 146 |
| RVEA | 0.0094 ± 0.0066 | 41 | 74 |
| IBEA | 0.0118 ± 0.0033 | 55 | 171 |
| AGE-MOEA | 0.1812 ± 0.0523 | 55 | 529 |
| NSGA-III | 0.3064 ± 0.0327 | 55 | 220 |
| RandomSearch | 0.6326 ± 0.0044 | 4592 | 16131 |
| NSGA-II | 2.0096 ± 0.0540 | 55 | 186 |
**The headline result.** NSGA-II is *dead last — worse than random
search* (2.01 vs 0.63). Its crowding distance in 10-D doesn't just fail
to help, it actively misleads. The indicator (HypE, IBEA), decomposition
(MOEA/D) and grid (GrEA) methods barely notice the objective-count jump
from 4 to 10; AGE-MOEA and NSGA-III degrade noticeably but still beat
random.
### DTLZ1 8-objective (dim=12, 40000 evals/run × 10 seeds)
DTLZ1 at 8 objectives — the brutal one: many-objective dominance collapse
*plus* DTLZ1's deceptive multimodal `g`-term (a huge number of local
fronts). The true front is the linear simplex `Σf = 0.5`; reaching it at
all is the achievement.
| algorithm | mean dist ↓ | front | ms |
|---|---|---|---|
| **GrEA** | **1.5441 ± 0.3844** | 98 | 183 |
| MOEA/D | 2.2867 ± 2.0553 | 94 | 37 |
| RVEA | 2.4016 ± 1.3780 | 51 | 116 |
| IBEA | 7.9615 ± 3.6041 | 101 | 283 |
| NSGA-III | 26.6956 ± 7.3771 | 120 | 295 |
| HypE | 26.8702 ± 5.5660 | 120 | 375 |
| AGE-MOEA | 43.9530 ± 15.4464 | 120 | 591 |
| RandomSearch | 172.6562 ± 6.8456 | 700 | 2553 |
| NSGA-II | 281.4563 ± 11.9140 | 120 | 277 |
**GrEA wins** — consistent with the 3-objective DTLZ1 table, where it
also won: grid-based niching matches a linear/simplex front at any
objective count. The other striking result is **HypE's reversal**: #1 on
both DTLZ2 tables, but #6 here — Monte-Carlo hypervolume is a poor
discriminator on the deceptive simplex. NSGA-II again finishes last,
worse than random by ~1.6×.
+1966 -9
View File
File diff suppressed because it is too large Load Diff
+125
View File
@@ -0,0 +1,125 @@
//! Constrained multi-objective optimization (BNH problem) plus a
//! demo of the observer / stop-condition API.
//!
//! BNH (Binh & Korn 1996) is a 2-variable / 2-objective / 2-constraint
//! multi-objective problem:
//!
//! ```text
//! minimize f1 = 4·x1² + 4·x2²
//! f2 = (x1 5)² + (x2 5)²
//! subject to
//! g1: (x1 5)² + x2² ≤ 25
//! g2: (x1 8)² + (x2 + 3)² ≥ 7.7
//! 0 ≤ x1 ≤ 5, 0 ≤ x2 ≤ 3
//! ```
//!
//! Demonstrates:
//! - Constraint handling via `Evaluation::constrained` (heuropt's
//! default tournament/Pareto comparators prefer feasibles).
//! - The Observer API: a `Stagnation` observer that halts the run
//! once the front stops improving, plus a `Periodic` observer that
//! prints progress every 25 generations.
//! - Composing observers with `.or()`.
//!
//! Run with: `cargo run --release --example constrained`
use heuropt::prelude::*;
struct Bnh;
impl Problem for Bnh {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
let f1 = 4.0 * x[0] * x[0] + 4.0 * x[1] * x[1];
let f2 = (x[0] - 5.0).powi(2) + (x[1] - 5.0).powi(2);
// g1: (x1 5)² + x2² ≤ 25 → violation = max(0, lhs 25)
let g1 = ((x[0] - 5.0).powi(2) + x[1].powi(2) - 25.0).max(0.0);
// g2: (x1 8)² + (x2 + 3)² ≥ 7.7 → violation = max(0, 7.7 lhs)
let g2 = (7.7 - ((x[0] - 8.0).powi(2) + (x[1] + 3.0).powi(2))).max(0.0);
let total_violation = g1 + g2;
Evaluation::constrained(vec![f1, f2], total_violation)
}
}
fn main() {
let bounds = vec![(0.0_f64, 5.0_f64), (0.0_f64, 3.0_f64)];
// Compose stop conditions: halt after 5 s OR (via .or()) print
// periodic progress every 25 generations. The Periodic observer
// never breaks; it only logs.
let stop = MaxTime::new(std::time::Duration::from_secs(5));
let progress = Periodic::new(25, |snap: &Snapshot<'_, Vec<f64>>| {
let feasible_in_pop = snap
.population
.iter()
.filter(|c| c.evaluation.is_feasible())
.count();
let front_size = snap.pareto_front.map(|f| f.len()).unwrap_or(0);
println!(
"gen {:>4} evaluations = {:>6} feasible/pop = {}/{} front = {}",
snap.iteration,
snap.evaluations,
feasible_in_pop,
snap.population.len(),
front_size,
);
});
let mut observer = <_ as Observer<Vec<f64>>>::or(stop, progress);
let mut opt = Nsga2::new(
Nsga2Config {
population_size: 100,
generations: 250,
seed: 42,
},
RealBounds::new(bounds.clone()),
CompositeVariation {
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
mutation: PolynomialMutation::new(bounds, 20.0, 1.0 / 2.0),
},
);
let result = opt.run_with(&Bnh, &mut observer);
let total_feasible = result
.population
.iter()
.filter(|c| c.evaluation.is_feasible())
.count();
println!();
println!("Final state after {} generations:", result.generations);
println!(" total evaluations: {}", result.evaluations);
println!(
" feasible / total pop: {} / {}",
total_feasible,
result.population.len()
);
println!(" pareto front size: {}", result.pareto_front.len());
println!();
println!("Sample of the front (f1, f2):");
let mut sorted = result.pareto_front.clone();
sorted.sort_by(|a, b| {
a.evaluation.objectives[0]
.partial_cmp(&b.evaluation.objectives[0])
.unwrap_or(std::cmp::Ordering::Equal)
});
let n = sorted.len();
if n > 0 {
for k in (0..n).step_by((n / 5).max(1)) {
let c = &sorted[k];
println!(
" f1 = {:>7.3}, f2 = {:>7.3}, violation = {:.3}",
c.evaluation.objectives[0],
c.evaluation.objectives[1],
c.evaluation.constraint_violation,
);
}
}
}
-212
View File
@@ -1,212 +0,0 @@
//! Solve a bi-objective extension of the FisherThompson FT06 job-shop
//! scheduling benchmark using NSGA-II.
//!
//! - **Benchmark**: FT06 (Fisher & Thompson, 1963), 6 jobs × 6 machines, 36
//! operations total. Each operation has a fixed machine and processing
//! time; operations within a job must run in the given order.
//! - **Canonical (single-objective) optimum**: makespan **55**.
//! - **Bi-objective extension** (this example):
//! - f₁ = makespan (Cₘₐₓ)
//! - f₂ = total flow time Σⱼ Cⱼ
//!
//! Both are standard JSS objectives in the multi-objective literature.
//! - **Algorithm**: [`Nsga2`].
//! - **Encoding**: operation-based string of length 36, each job id appears
//! 6 times. The k-th occurrence of job `j` represents the k-th operation
//! of job `j`.
//! - **Variation**: a local `PrecedenceOrderCrossover` (POX) piped into
//! [`InversionMutation`] via [`CompositeVariation`]. The strict-permutation
//! crossovers shipped in the library (OX, PMX, CX, ERX) would break the
//! operation-string multiset, so this example defines a small JSS-aware
//! crossover inline. POX is the standard crossover for operation-based JSS
//! GAs (Lee & Yamakawa, 1996; Bierwirth et al., 1996).
//! - **Initializer**: [`ShuffledMultisetPermutation`].
//!
//! Sources:
//! - Fisher, H., Thompson, G. L. (1963). *Probabilistic learning combinations
//! of local job-shop scheduling rules.*
//! - OR-Library / JSPLIB FT06 instance file.
//!
//! Run with:
//!
//! ```bash
//! cargo run --release --example jss_ft06_bi
//! ```
use heuropt::prelude::*;
use rand::Rng as _;
/// Precedence-preserving Order-based Crossover for operation-string JSS
/// encodings. Partitions job ids into two sets J1 / J2; the child takes
/// positions occupied by J1 from parent A and fills the remaining positions
/// with J2's operations in parent B's order. Two children are produced by
/// reversing the parent roles.
///
/// Preserves the JSS multiset invariant (each job id appears `N_MACHINES`
/// times) because every operation in the multiset is covered exactly once:
/// J1 ops by parent A, J2 ops by parent B.
#[derive(Debug, Clone, Copy, Default)]
struct PrecedenceOrderCrossover;
impl Variation<Vec<usize>> for PrecedenceOrderCrossover {
fn vary(&mut self, parents: &[Vec<usize>], rng: &mut Rng) -> Vec<Vec<usize>> {
assert!(parents.len() >= 2, "POX requires 2 parents");
let p1 = &parents[0];
let p2 = &parents[1];
let mut in_j1 = [false; N_JOBS];
// Ensure both partitions are non-empty to avoid degenerate (child == one parent).
loop {
for slot in &mut in_j1 {
*slot = rng.random_bool(0.5);
}
let n_in_j1 = in_j1.iter().filter(|&&b| b).count();
if n_in_j1 > 0 && n_in_j1 < N_JOBS {
break;
}
}
vec![pox_child(p1, p2, &in_j1), pox_child(p2, p1, &in_j1)]
}
}
fn pox_child(donor: &[usize], filler: &[usize], in_donor_set: &[bool]) -> Vec<usize> {
let n = donor.len();
let mut child = vec![usize::MAX; n];
for k in 0..n {
if in_donor_set[donor[k]] {
child[k] = donor[k];
}
}
let mut fill_idx = 0;
for &v in filler {
if !in_donor_set[v] {
while fill_idx < n && child[fill_idx] != usize::MAX {
fill_idx += 1;
}
child[fill_idx] = v;
fill_idx += 1;
}
}
child
}
/// FT06 routing — machine id for the k-th operation of job j.
const FT06_MACHINE: [[usize; 6]; 6] = [
[2, 0, 1, 3, 5, 4],
[1, 2, 4, 5, 0, 3],
[2, 3, 5, 0, 1, 4],
[1, 0, 2, 3, 4, 5],
[2, 1, 4, 5, 0, 3],
[1, 3, 5, 0, 4, 2],
];
/// FT06 processing times — duration of the k-th operation of job j on the
/// machine given by `FT06_MACHINE[j][k]`.
const FT06_TIME: [[f64; 6]; 6] = [
[1.0, 3.0, 6.0, 7.0, 3.0, 6.0],
[8.0, 5.0, 10.0, 10.0, 10.0, 4.0],
[5.0, 4.0, 8.0, 9.0, 1.0, 7.0],
[5.0, 5.0, 5.0, 3.0, 8.0, 9.0],
[9.0, 3.0, 5.0, 4.0, 3.0, 1.0],
[3.0, 3.0, 9.0, 10.0, 4.0, 1.0],
];
const N_JOBS: usize = 6;
const N_MACHINES: usize = 6;
const KNOWN_MAKESPAN_OPTIMUM: f64 = 55.0;
struct Ft06BiObjective;
impl Problem for Ft06BiObjective {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("makespan"),
Objective::minimize("total_flow_time"),
])
}
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
let mut job_next = [0_usize; N_JOBS];
let mut job_clock = [0.0_f64; N_JOBS];
let mut machine_clock = [0.0_f64; N_MACHINES];
for &job in schedule {
let k = job_next[job];
let m = FT06_MACHINE[job][k];
let t = FT06_TIME[job][k];
let start = job_clock[job].max(machine_clock[m]);
let end = start + t;
job_clock[job] = end;
machine_clock[m] = end;
job_next[job] = k + 1;
}
let makespan = machine_clock.iter().cloned().fold(0.0_f64, f64::max);
let flow_time: f64 = job_clock.iter().sum();
Evaluation::new(vec![makespan, flow_time])
}
fn decision_schema(&self) -> Vec<DecisionVariable> {
(0..N_JOBS * N_MACHINES)
.map(|k| DecisionVariable::new(format!("op_slot_{k}")))
.collect()
}
}
fn main() {
let problem = Ft06BiObjective;
let mut optimizer = Nsga2::new(
Nsga2Config {
population_size: 200,
generations: 1500,
seed: 7,
},
ShuffledMultisetPermutation::new(vec![N_MACHINES; N_JOBS]),
CompositeVariation {
crossover: PrecedenceOrderCrossover,
mutation: SwapMutation,
},
);
let result = optimizer.run(&problem);
println!("FT06 — bi-objective JSS via NSGA-II");
println!("Source: Fisher & Thompson (1963); known single-objective optimum makespan = 55");
println!();
println!("Total evaluations: {}", result.evaluations);
println!("Pareto-front size: {}", result.pareto_front.len());
println!();
// Sort front by makespan ascending and print a sample of points.
let mut front: Vec<&Candidate<Vec<usize>>> = result.pareto_front.iter().collect();
front.sort_by(|a, b| {
a.evaluation.objectives[0]
.partial_cmp(&b.evaluation.objectives[0])
.unwrap_or(std::cmp::Ordering::Equal)
});
// Deduplicate by objective values so the output isn't a wall of identical rows.
let mut seen: Vec<(i64, i64)> = Vec::new();
println!(" makespan total flow time");
for c in &front {
let o = &c.evaluation.objectives;
let key = (o[0] as i64, o[1] as i64);
if !seen.contains(&key) {
seen.push(key);
println!(" {:>8.0} {:>15.0}", o[0], o[1]);
}
}
println!(" ({} unique objective-space points)", seen.len());
println!();
// Compare the makespan-corner against the known optimum.
if let Some(makespan_corner) = front.first() {
let best_makespan = makespan_corner.evaluation.objectives[0];
let gap_abs = best_makespan - KNOWN_MAKESPAN_OPTIMUM;
let gap_pct = 100.0 * gap_abs / KNOWN_MAKESPAN_OPTIMUM;
println!(
"Makespan corner: {:.0} vs. known optimum 55 (gap {:+.0}, {:+.2}%)",
best_makespan, gap_abs, gap_pct
);
}
}
-269
View File
@@ -1,269 +0,0 @@
//! 3-objective Job-Shop Scheduling on Lawrence's LA01 instance, solved with
//! NSGA-III (the many-objective successor to NSGA-II).
//!
//! - **Benchmark**: Lawrence LA01 (1984), 10 jobs × 5 machines, 50 operations
//! total. Each operation has a fixed machine and processing time;
//! operations within a job run in order. Data taken from the OR-Library /
//! JSPLIB la01 instance file.
//! - **Three objectives** (this example):
//! - f₁ = makespan
//! - f₂ = total flow time Σⱼ Cⱼ
//! - f₃ = total tardiness Σⱼ max(0, Cⱼ dⱼ), with synthetic due dates
//! dⱼ = 1.3 × (sum of processing times of job j)
//! - **Algorithm**: [`Nsga3`] — designed for ≥ 3 objectives (NSGA-II's
//! crowding distance degrades in higher dim).
//! - **Encoding**: operation-based string of length 50.
//! - **Variation**: a local POX (multiset-preserving) crossover piped through
//! a small randomly-chosen mutation that alternates between
//! [`InsertionMutation`] and [`ScrambleMutation`]. Strict-permutation
//! crossovers cannot be used on multiset encodings.
//! - **Initializer**: [`ShuffledMultisetPermutation`].
//!
//! Sources:
//! - Lawrence (1984), thesis benchmark instances.
//! - OR-Library / JSPLIB LA01 instance file.
//! - Deb & Jain (2014), "An evolutionary many-objective optimization
//! algorithm using reference-point based non-dominated sorting approach,
//! Part I" — NSGA-III.
//!
//! Run with:
//!
//! ```bash
//! cargo run --release --example mo_jss_la01
//! ```
use heuropt::prelude::*;
use rand::Rng as _;
const N_JOBS: usize = 10;
const N_MACHINES: usize = 5;
/// LA01 routing — machine id for the k-th operation of job j.
const LA01_MACHINE: [[usize; N_MACHINES]; N_JOBS] = [
[1, 0, 4, 3, 2],
[0, 3, 4, 2, 1],
[3, 4, 1, 2, 0],
[1, 0, 4, 2, 3],
[0, 3, 2, 1, 4],
[1, 2, 4, 0, 3],
[3, 4, 1, 2, 0],
[2, 0, 1, 3, 4],
[3, 1, 4, 0, 2],
[4, 3, 1, 2, 0],
];
/// LA01 processing times — duration of the k-th operation of job j.
const LA01_TIME: [[f64; N_MACHINES]; N_JOBS] = [
[21.0, 53.0, 95.0, 55.0, 34.0],
[21.0, 52.0, 16.0, 26.0, 71.0],
[39.0, 98.0, 42.0, 31.0, 12.0],
[77.0, 55.0, 79.0, 66.0, 77.0],
[83.0, 34.0, 64.0, 19.0, 37.0],
[54.0, 43.0, 79.0, 92.0, 62.0],
[69.0, 77.0, 87.0, 87.0, 93.0],
[38.0, 60.0, 41.0, 24.0, 66.0],
[17.0, 49.0, 25.0, 44.0, 98.0],
[77.0, 79.0, 43.0, 75.0, 96.0],
];
/// Synthetic due dates: 1.3 × total processing time of each job.
fn due_dates() -> [f64; N_JOBS] {
let mut d = [0.0_f64; N_JOBS];
for (j, row) in LA01_TIME.iter().enumerate() {
d[j] = 1.3 * row.iter().sum::<f64>();
}
d
}
struct La01ThreeObjective {
due: [f64; N_JOBS],
}
impl Problem for La01ThreeObjective {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("makespan"),
Objective::minimize("total_flow_time"),
Objective::minimize("total_tardiness"),
])
}
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
let mut job_next = [0_usize; N_JOBS];
let mut job_clock = [0.0_f64; N_JOBS];
let mut machine_clock = [0.0_f64; N_MACHINES];
for &job in schedule {
let k = job_next[job];
let m = LA01_MACHINE[job][k];
let t = LA01_TIME[job][k];
let start = job_clock[job].max(machine_clock[m]);
let end = start + t;
job_clock[job] = end;
machine_clock[m] = end;
job_next[job] = k + 1;
}
let makespan = machine_clock.iter().cloned().fold(0.0_f64, f64::max);
let flow_time: f64 = job_clock.iter().sum();
let tardiness: f64 = job_clock
.iter()
.zip(self.due.iter())
.map(|(&c, &d)| (c - d).max(0.0))
.sum();
Evaluation::new(vec![makespan, flow_time, tardiness])
}
fn decision_schema(&self) -> Vec<DecisionVariable> {
(0..N_JOBS * N_MACHINES)
.map(|k| DecisionVariable::new(format!("op_slot_{k}")))
.collect()
}
}
/// POX — multiset-preserving crossover for operation-string encodings.
/// (Identical in spirit to the one in `jss_ft06_bi.rs`; copied locally so
/// each example stays self-contained.)
#[derive(Debug, Clone, Copy, Default)]
struct PrecedenceOrderCrossover;
impl Variation<Vec<usize>> for PrecedenceOrderCrossover {
fn vary(&mut self, parents: &[Vec<usize>], rng: &mut Rng) -> Vec<Vec<usize>> {
assert!(parents.len() >= 2, "POX requires 2 parents");
let p1 = &parents[0];
let p2 = &parents[1];
let mut in_j1 = [false; N_JOBS];
loop {
for slot in &mut in_j1 {
*slot = rng.random_bool(0.5);
}
let n_in_j1 = in_j1.iter().filter(|&&b| b).count();
if n_in_j1 > 0 && n_in_j1 < N_JOBS {
break;
}
}
vec![pox_child(p1, p2, &in_j1), pox_child(p2, p1, &in_j1)]
}
}
fn pox_child(donor: &[usize], filler: &[usize], in_donor_set: &[bool]) -> Vec<usize> {
let n = donor.len();
let mut child = vec![usize::MAX; n];
for k in 0..n {
if in_donor_set[donor[k]] {
child[k] = donor[k];
}
}
let mut fill_idx = 0;
for &v in filler {
if !in_donor_set[v] {
while fill_idx < n && child[fill_idx] != usize::MAX {
fill_idx += 1;
}
child[fill_idx] = v;
fill_idx += 1;
}
}
child
}
/// Per-call random choice between Insertion and Scramble. Both preserve the
/// multiset; flipping a coin gives the schedule access to two complementary
/// neighborhood moves.
#[derive(Debug, Clone, Copy, Default)]
struct InsertionOrScramble;
impl Variation<Vec<usize>> for InsertionOrScramble {
fn vary(&mut self, parents: &[Vec<usize>], rng: &mut Rng) -> Vec<Vec<usize>> {
if rng.random_bool(0.5) {
InsertionMutation.vary(parents, rng)
} else {
ScrambleMutation.vary(parents, rng)
}
}
}
fn main() {
let problem = La01ThreeObjective { due: due_dates() };
let mut optimizer = Nsga3::new(
Nsga3Config {
population_size: 120,
generations: 600,
reference_divisions: 12,
seed: 9,
},
ShuffledMultisetPermutation::new(vec![N_MACHINES; N_JOBS]),
CompositeVariation {
crossover: PrecedenceOrderCrossover,
mutation: InsertionOrScramble,
},
);
let result = optimizer.run(&problem);
println!("LA01 — 3-objective JSS via NSGA-III");
println!("Source: Lawrence (1984), OR-Library la01 instance");
println!();
println!("Objectives: f1 = makespan, f2 = total flow time, f3 = total tardiness");
println!("Due dates: dⱼ = 1.3 × Σ(processing times of job j)");
println!();
println!("Total evaluations: {}", result.evaluations);
println!("Pareto-front size: {}", result.pareto_front.len());
println!();
// Sort by makespan and print up to 12 well-spaced rows.
let mut front: Vec<&Candidate<Vec<usize>>> = result.pareto_front.iter().collect();
front.sort_by(|a, b| {
a.evaluation.objectives[0]
.partial_cmp(&b.evaluation.objectives[0])
.unwrap_or(std::cmp::Ordering::Equal)
});
let stride = (front.len() / 12).max(1);
println!(" f1 makespan f2 flow time f3 tardiness");
let mut printed = 0_usize;
for (i, c) in front.iter().enumerate() {
if i % stride == 0 || i + 1 == front.len() {
let o = &c.evaluation.objectives;
println!(" {:>11.0} {:>12.0} {:>11.0}", o[0], o[1], o[2]);
printed += 1;
if printed >= 12 {
break;
}
}
}
println!();
if let (Some(corner_ms), Some(corner_ft), Some(corner_td)) = (
front.first(),
front.iter().min_by(|a, b| {
a.evaluation.objectives[1]
.partial_cmp(&b.evaluation.objectives[1])
.unwrap_or(std::cmp::Ordering::Equal)
}),
front.iter().min_by(|a, b| {
a.evaluation.objectives[2]
.partial_cmp(&b.evaluation.objectives[2])
.unwrap_or(std::cmp::Ordering::Equal)
}),
) {
println!(
"Makespan corner: f1={:.0}, f2={:.0}, f3={:.0}",
corner_ms.evaluation.objectives[0],
corner_ms.evaluation.objectives[1],
corner_ms.evaluation.objectives[2],
);
println!(
"Flow-time corner: f1={:.0}, f2={:.0}, f3={:.0}",
corner_ft.evaluation.objectives[0],
corner_ft.evaluation.objectives[1],
corner_ft.evaluation.objectives[2],
);
println!(
"Tardiness corner: f1={:.0}, f2={:.0}, f3={:.0}",
corner_td.evaluation.objectives[0],
corner_td.evaluation.objectives[1],
corner_td.evaluation.objectives[2],
);
}
}
-214
View File
@@ -1,214 +0,0 @@
//! Bi-objective 0/1 knapsack — Zitzler & Thiele's textbook multi-objective
//! combinatorial benchmark, solved with NSGA-II.
//!
//! - **Benchmark family**: Zitzler & Thiele (1999) bi-objective knapsack.
//! Each item has two profit values and a single weight; a single capacity
//! constraint. We use a 30-item instance with values drawn from the same
//! U(10, 100) distribution scheme as the published instances, embedded as
//! `const` tables so the example stays self-contained.
//! - **Algorithm**: [`Nsga2`].
//! - **Decision**: `Vec<bool>` of length 30 (take / leave each item).
//! - **Variation**: a local one-point crossover (binary GAs' workhorse) piped
//! into [`BitFlipMutation`] via [`CompositeVariation`]. **A future PR could
//! lift `OnePointCrossover` / `UniformCrossover` into the library proper**
//! so users don't need to roll their own.
//! - **Initializer**: a tiny local `RandomBinary` (one-liner; would be a
//! reasonable library addition too).
//! - **Constraint handling**: weight overruns are penalized in both
//! objectives by `-large * overrun`. With the penalty dominating profit
//! range, the Pareto front is composed entirely of feasible solutions
//! (standard heuristic-MO practice).
//!
//! Sources:
//! - Zitzler & Thiele (1999), "Multiobjective evolutionary algorithms: A
//! comparative case study and the Strength Pareto approach."
//! - Deb (2001), "Multi-Objective Optimization Using Evolutionary Algorithms"
//! for the standard penalty-based MO constraint handling.
//!
//! Run with:
//!
//! ```bash
//! cargo run --release --example mo_knapsack
//! ```
use heuropt::metrics::hypervolume_2d;
use heuropt::prelude::*;
use rand::Rng as _;
const N_ITEMS: usize = 30;
/// Profit vector A (one of two objectives), U(10, 100) style.
const PROFITS_A: [f64; N_ITEMS] = [
61.0, 17.0, 92.0, 49.0, 73.0, 28.0, 84.0, 36.0, 55.0, 78.0, 23.0, 91.0, 12.0, 67.0, 45.0, 58.0,
33.0, 71.0, 14.0, 26.0, 87.0, 42.0, 19.0, 65.0, 30.0, 51.0, 79.0, 22.0, 47.0, 88.0,
];
/// Profit vector B (the other objective). Intentionally anti-correlated with
/// A on many items so the Pareto front spans a wide trade-off.
const PROFITS_B: [f64; N_ITEMS] = [
24.0, 81.0, 16.0, 67.0, 29.0, 73.0, 41.0, 60.0, 52.0, 19.0, 77.0, 34.0, 95.0, 22.0, 71.0, 88.0,
56.0, 27.0, 64.0, 90.0, 18.0, 43.0, 79.0, 31.0, 85.0, 25.0, 38.0, 92.0, 70.0, 13.0,
];
/// Item weights.
const WEIGHTS: [f64; N_ITEMS] = [
35.0, 58.0, 22.0, 71.0, 14.0, 86.0, 31.0, 53.0, 78.0, 19.0, 44.0, 16.0, 67.0, 88.0, 25.0, 51.0,
33.0, 74.0, 12.0, 47.0, 63.0, 28.0, 91.0, 36.0, 55.0, 17.0, 82.0, 41.0, 24.0, 68.0,
];
/// Capacity = roughly half the total weight (standard Zitzler-Thiele convention).
fn capacity() -> f64 {
0.5 * WEIGHTS.iter().sum::<f64>()
}
struct BiKnapsack {
cap: f64,
}
impl Problem for BiKnapsack {
type Decision = Vec<bool>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::maximize("profit_A"),
Objective::maximize("profit_B"),
])
}
fn evaluate(&self, take: &Vec<bool>) -> Evaluation {
let (pa, pb, w) =
take.iter()
.enumerate()
.fold((0.0_f64, 0.0_f64, 0.0_f64), |(pa, pb, w), (i, &t)| {
if t {
(pa + PROFITS_A[i], pb + PROFITS_B[i], w + WEIGHTS[i])
} else {
(pa, pb, w)
}
});
// Penalty: large coefficient on weight overrun, applied to both objectives.
let overrun = (w - self.cap).max(0.0);
let penalty = 1000.0 * overrun;
Evaluation::new(vec![pa - penalty, pb - penalty])
}
fn decision_schema(&self) -> Vec<DecisionVariable> {
(0..N_ITEMS)
.map(|i| DecisionVariable::new(format!("item_take_{i}")))
.collect()
}
}
/// Random binary initializer — each bit is 50/50 independently.
#[derive(Debug, Clone, Copy)]
struct RandomBinary {
n: usize,
}
impl Initializer<Vec<bool>> for RandomBinary {
fn initialize(&mut self, size: usize, rng: &mut Rng) -> Vec<Vec<bool>> {
(0..size)
.map(|_| (0..self.n).map(|_| rng.random_bool(0.5)).collect())
.collect()
}
}
/// One-point crossover for binary chromosomes.
#[derive(Debug, Clone, Copy, Default)]
struct OnePointCrossoverBool;
impl Variation<Vec<bool>> for OnePointCrossoverBool {
fn vary(&mut self, parents: &[Vec<bool>], rng: &mut Rng) -> Vec<Vec<bool>> {
assert!(
parents.len() >= 2,
"OnePointCrossoverBool requires 2 parents"
);
let p1 = &parents[0];
let p2 = &parents[1];
assert_eq!(p1.len(), p2.len(), "parent lengths differ");
let n = p1.len();
if n < 2 {
return vec![p1.clone(), p2.clone()];
}
let cut = rng.random_range(1..n);
let mut c1 = Vec::with_capacity(n);
let mut c2 = Vec::with_capacity(n);
c1.extend_from_slice(&p1[..cut]);
c1.extend_from_slice(&p2[cut..]);
c2.extend_from_slice(&p2[..cut]);
c2.extend_from_slice(&p1[cut..]);
vec![c1, c2]
}
}
fn main() {
let cap = capacity();
let problem = BiKnapsack { cap };
let mut optimizer = Nsga2::new(
Nsga2Config {
population_size: 120,
generations: 400,
seed: 19,
},
RandomBinary { n: N_ITEMS },
CompositeVariation {
crossover: OnePointCrossoverBool,
mutation: BitFlipMutation {
probability: 1.0 / N_ITEMS as f64,
},
},
);
let result = optimizer.run(&problem);
println!("Bi-objective 0/1 knapsack — ZitzlerThiele style, 30 items");
println!(
"Capacity = {:.0} (≈ half of total weight {:.0})",
cap,
WEIGHTS.iter().sum::<f64>()
);
println!();
println!("Total evaluations: {}", result.evaluations);
println!("Pareto-front size: {}", result.pareto_front.len());
println!();
// Sort by profit_A descending for display, dedupe by integer-rounded objective values.
let mut front: Vec<&Candidate<Vec<bool>>> = result.pareto_front.iter().collect();
front.sort_by(|a, b| {
b.evaluation.objectives[0]
.partial_cmp(&a.evaluation.objectives[0])
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut seen: Vec<(i64, i64)> = Vec::new();
println!(" profit_A profit_B weight");
for c in &front {
let o = &c.evaluation.objectives;
let key = (o[0] as i64, o[1] as i64);
if seen.contains(&key) {
continue;
}
seen.push(key);
let w: f64 = c
.decision
.iter()
.enumerate()
.filter(|&(_, &t)| t)
.map(|(i, _)| WEIGHTS[i])
.sum();
println!(" {:>8.0} {:>8.0} {:>6.0}", o[0], o[1], w);
}
println!(" ({} unique objective-space points)", seen.len());
// Hypervolume against a reference point of (0, 0): since these are
// maximization objectives, we transform to minimization by negation in
// the metric — hypervolume_2d uses ObjectiveSpace::as_minimization() so
// it Just Works.
let ref_point = [0.0, 0.0];
let owned: Vec<Candidate<Vec<bool>>> = result.pareto_front.to_vec();
let hv = hypervolume_2d(&owned, &problem.objectives(), ref_point);
println!();
println!(
"Hypervolume vs. reference (profit_A=0, profit_B=0): {:.0}",
hv
);
}
-167
View File
@@ -1,167 +0,0 @@
//! `pick_a_car` — designing a car along four objectives at once.
//!
//! Three decision variables (engine displacement, curb weight,
//! aerodynamic drag) and four objectives (price, 0-60 acceleration,
//! fuel consumption, idle noise) coupled by non-linear cost
//! relationships, so the Pareto front is a real surface in 3D
//! decision space — not a 1D sweep that any human could enumerate.
//!
//! Run it:
//!
//! ```text
//! cargo run --release --example pick_a_car --features serde
//! ```
//!
//! It writes a `pick_a_car.json` file in the current directory that
//! you can drop into <https://swaits.github.io/heuropt-explorer/> to
//! filter, brush, pin, and rank the 100-car Pareto front
//! interactively.
use heuropt::prelude::*;
struct PickACar;
impl Problem for PickACar {
type Decision = Vec<f64>; // [engine_liters, weight_kg, drag_cd]
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("price")
.with_label("Price")
.with_unit("$k"),
Objective::minimize("zero_to_sixty")
.with_label("0-60 mph")
.with_unit("s"),
Objective::minimize("fuel")
.with_label("Fuel")
.with_unit("gal/100mi"),
Objective::minimize("noise")
.with_label("Idle noise")
.with_unit("dB"),
])
}
fn decision_schema(&self) -> Vec<DecisionVariable> {
vec![
DecisionVariable::new("displacement")
.with_label("Engine size")
.with_unit("L")
.with_bounds(1.0, 6.0),
DecisionVariable::new("weight")
.with_label("Curb weight")
.with_unit("kg")
.with_bounds(1100.0, 2200.0),
DecisionVariable::new("drag")
.with_label("Drag coefficient")
.with_unit("Cd")
.with_bounds(0.20, 0.40),
]
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
let displacement = x[0];
let weight = x[1];
let drag = x[2];
// 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 decision vars matter.
let fuel = 0.5 + 0.5 * displacement + 0.5 * weight / 1000.0 + 4.0 * drag;
// Idle noise (dB): engine dominates, mildly non-linear.
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 started = std::time::Instant::now();
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 elapsed = started.elapsed().as_secs_f64();
// Print a short summary across the front so the user can see what
// they got without leaving the terminal.
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!(
"Pareto front: {} cars (took {:.3} s)\n",
front.len(),
elapsed,
);
println!(
"{:>5} {:>5} {:>4} {:>6} {:>5} {:>5} {:>5}",
"L", "kg", "Cd", "$k", "0-60", "fuel", "dB"
);
let n = front.len();
let sample_indices = if n <= 6 {
(0..n).collect::<Vec<_>>()
} else {
// Six representative rows: first, ~20%, ~40%, ~60%, ~80%, last
vec![0, n / 5, (2 * n) / 5, (3 * n) / 5, (4 * n) / 5, n - 1]
};
for &i in &sample_indices {
let c = front[i];
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]
);
}
// Write the explorer JSON. With the metadata the Problem provides
// (objective labels + units + decision schema) plus the algorithm's
// own AlgorithmInfo, this is genuinely zero-config: one call.
let path = "pick_a_car.json";
let export = heuropt::explorer::ExplorerExport::from_result(&PickACar, &result)
.with_algorithm_info(&optimizer)
.with_problem_name("Pick a car")
.with_wall_clock(elapsed);
export.to_file(path).expect("failed to write JSON");
println!(
"\nWrote {} candidates to {} ({}/{} on the Pareto front).",
result.population.candidates.len(),
path,
result.pareto_front.len(),
result.population.candidates.len(),
);
println!("Drop it into https://swaits.github.io/heuropt-explorer/ to explore.");
}
-263
View File
@@ -1,263 +0,0 @@
//! Crossover showdown on the bi-objective TSP from `btsp_kroab.rs`.
//!
//! Runs NSGA-II four times on the same KroAB-25 instance, holding everything
//! constant except the **crossover** operator. The mutation
//! ([`InversionMutation`]), initializer, population, generations, and seed
//! are identical across runs.
//!
//! Operators compared:
//! - [`OrderCrossover`] (OX)
//! - [`PartiallyMappedCrossover`] (PMX)
//! - [`CycleCrossover`] (CX)
//! - [`EdgeRecombinationCrossover`] (ERX)
//!
//! Each run is ranked by **hypervolume** (the standard Pareto-front quality
//! metric), not by single-objective fitness — for a Pareto search, "best
//! length on A" or "best length on B" alone is a misleading scoreboard.
//!
//! Run with:
//!
//! ```bash
//! cargo run --release --example tsp_operators_compare
//! ```
use heuropt::metrics::hypervolume_2d;
use heuropt::prelude::*;
use std::time::Instant;
/// First 25 cities of TSPLIB KroA100 (EUC_2D).
const KROA_25: [(f64, f64); 25] = [
(1380.0, 939.0),
(2848.0, 96.0),
(3510.0, 1671.0),
(457.0, 334.0),
(3888.0, 666.0),
(984.0, 965.0),
(2721.0, 1482.0),
(1286.0, 525.0),
(2716.0, 1432.0),
(738.0, 1325.0),
(1251.0, 1832.0),
(2728.0, 1698.0),
(3815.0, 169.0),
(3683.0, 1533.0),
(1247.0, 1945.0),
(123.0, 862.0),
(1234.0, 1946.0),
(252.0, 1240.0),
(611.0, 673.0),
(2576.0, 1676.0),
(928.0, 1700.0),
(53.0, 857.0),
(1807.0, 1711.0),
(274.0, 1420.0),
(2574.0, 946.0),
];
/// First 25 cities of TSPLIB KroB100 (EUC_2D).
const KROB_25: [(f64, f64); 25] = [
(3140.0, 1401.0),
(556.0, 1056.0),
(3675.0, 1522.0),
(1182.0, 1853.0),
(3595.0, 1340.0),
(1936.0, 953.0),
(2722.0, 1311.0),
(2839.0, 2055.0),
(2253.0, 1242.0),
(3142.0, 1591.0),
(627.0, 1336.0),
(936.0, 211.0),
(4014.0, 471.0),
(1376.0, 1452.0),
(3289.0, 593.0),
(1453.0, 67.0),
(1014.0, 1944.0),
(2811.0, 1080.0),
(3010.0, 1290.0),
(1817.0, 1517.0),
(510.0, 458.0),
(1717.0, 1693.0),
(1252.0, 1633.0),
(1693.0, 1374.0),
(539.0, 1378.0),
];
const N_CITIES: usize = 25;
const REF_POINT: [f64; 2] = [40_000.0, 40_000.0];
fn euc2d_matrix(coords: &[(f64, f64)]) -> Vec<Vec<f64>> {
let n = coords.len();
let mut d = vec![vec![0.0_f64; n]; n];
for i in 0..n {
for j in (i + 1)..n {
let dx = coords[i].0 - coords[j].0;
let dy = coords[i].1 - coords[j].1;
let dij = (dx * dx + dy * dy).sqrt().round();
d[i][j] = dij;
d[j][i] = dij;
}
}
d
}
struct BTsp {
dist_a: Vec<Vec<f64>>,
dist_b: Vec<Vec<f64>>,
}
impl BTsp {
fn new() -> Self {
Self {
dist_a: euc2d_matrix(&KROA_25),
dist_b: euc2d_matrix(&KROB_25),
}
}
fn tour_length(d: &[Vec<f64>], tour: &[usize]) -> f64 {
let n = tour.len();
let mut total = 0.0;
for i in 0..n {
total += d[tour[i]][tour[(i + 1) % n]];
}
total
}
}
impl Problem for BTsp {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("length_A"),
Objective::minimize("length_B"),
])
}
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
Evaluation::new(vec![
Self::tour_length(&self.dist_a, tour),
Self::tour_length(&self.dist_b, tour),
])
}
}
struct RunSummary {
name: &'static str,
front_size: usize,
front_unique: usize,
corner_a: (f64, f64),
corner_b: (f64, f64),
hypervolume: f64,
seconds: f64,
}
fn run_once<C>(name: &'static str, problem: &BTsp, crossover: C) -> RunSummary
where
C: Variation<Vec<usize>>,
{
let mut optimizer = Nsga2::new(
Nsga2Config {
population_size: 200,
generations: 500,
seed: 11,
},
ShuffledPermutation { n: N_CITIES },
CompositeVariation {
crossover,
mutation: InversionMutation,
},
);
let t0 = Instant::now();
let result = optimizer.run(problem);
let seconds = t0.elapsed().as_secs_f64();
let mut front: Vec<&Candidate<Vec<usize>>> = result.pareto_front.iter().collect();
front.sort_by(|a, b| {
a.evaluation.objectives[0]
.partial_cmp(&b.evaluation.objectives[0])
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut seen: Vec<(i64, i64)> = Vec::new();
for c in &front {
let o = &c.evaluation.objectives;
let k = (o[0] as i64, o[1] as i64);
if !seen.contains(&k) {
seen.push(k);
}
}
let corner_a = front
.first()
.map(|c| (c.evaluation.objectives[0], c.evaluation.objectives[1]))
.unwrap_or((f64::NAN, f64::NAN));
let corner_b = front
.last()
.map(|c| (c.evaluation.objectives[0], c.evaluation.objectives[1]))
.unwrap_or((f64::NAN, f64::NAN));
let owned: Vec<Candidate<Vec<usize>>> = result.pareto_front.to_vec();
let hv = hypervolume_2d(&owned, &problem.objectives(), REF_POINT);
RunSummary {
name,
front_size: result.pareto_front.len(),
front_unique: seen.len(),
corner_a,
corner_b,
hypervolume: hv,
seconds,
}
}
fn main() {
let problem = BTsp::new();
println!("Bi-objective TSP (KroAB-25): NSGA-II crossover showdown");
println!("Same population, generations, seed across all runs.");
println!("Mutation held constant at InversionMutation.");
println!(
"Reference point for hypervolume: ({:.0}, {:.0})",
REF_POINT[0], REF_POINT[1]
);
println!();
let runs = vec![
run_once("Order (OX)", &problem, OrderCrossover),
run_once("PartiallyMapped (PMX)", &problem, PartiallyMappedCrossover),
run_once("Cycle (CX)", &problem, CycleCrossover),
run_once("EdgeRecomb (ERX)", &problem, EdgeRecombinationCrossover),
];
println!(
" {:<24} | {:>5} {:>5} | {:>17} | {:>17} | {:>14} | {:>6}",
"crossover", "size", "uniq", "A-corner (A, B)", "B-corner (A, B)", "hypervolume", "time"
);
println!(" {}", "-".repeat(106));
for r in &runs {
println!(
" {:<24} | {:>5} {:>5} | ({:>6.0},{:>6.0}) | ({:>6.0},{:>6.0}) | {:>14.0} | {:>5.2}s",
r.name,
r.front_size,
r.front_unique,
r.corner_a.0,
r.corner_a.1,
r.corner_b.0,
r.corner_b.1,
r.hypervolume,
r.seconds,
);
}
println!();
// Pick the winner by hypervolume (largest dominated area = best front).
let winner = runs
.iter()
.max_by(|a, b| {
a.hypervolume
.partial_cmp(&b.hypervolume)
.unwrap_or(std::cmp::Ordering::Equal)
})
.expect("non-empty runs");
println!(
"Best by hypervolume: {} ({:.0})",
winner.name, winner.hypervolume
);
}
-171
View File
@@ -1,171 +0,0 @@
//! Solve the Ulysses16 TSP benchmark from TSPLIB using a Genetic Algorithm
//! with the new permutation-toolkit operators.
//!
//! - **Benchmark**: Ulysses16 (Groetschel/Padberg "Odyssey of Ulysses"),
//! 16 cities, GEO distance metric (TSPLIB-95).
//! - **Known optimum**: tour length **6859**.
//! - **Algorithm**: [`GeneticAlgorithm`] with elitism.
//! - **Variation**: [`OrderCrossover`] (OX) → [`InversionMutation`], piped
//! via [`CompositeVariation`].
//! - **Initializer**: [`ShuffledPermutation`].
//!
//! Source: TSPLIB95
//! <http://comopt.ifi.uni-heidelberg.de/software/TSPLIB95/tsp/>
//!
//! Run with:
//!
//! ```bash
//! cargo run --release --example tsp_ulysses16
//! ```
//!
//! The GA reliably converges to within a few percent of the known optimum on
//! this instance; on most seeds it hits 6859 exactly.
use heuropt::prelude::*;
/// TSPLIB Ulysses16 coordinates as `(lat, lon)` in TSPLIB DD.MM format.
///
/// The "decimal" part is *minutes* (out of 60), not a true decimal fraction;
/// the GEO distance formula handles the conversion.
const ULYSSES16: [(f64, f64); 16] = [
(38.24, 20.42),
(39.57, 26.15),
(40.56, 25.32),
(36.26, 23.12),
(33.48, 10.54),
(37.56, 12.19),
(38.42, 13.11),
(37.52, 20.44),
(41.23, 9.10),
(41.17, 13.05),
(36.08, -5.21),
(38.47, 15.13),
(38.15, 15.35),
(37.51, 15.17),
(35.49, 14.32),
(39.36, 19.56),
];
const KNOWN_OPTIMUM: f64 = 6859.0;
/// TSPLIB-95 GEO distance metric.
///
/// Coordinates are interpreted as latitude/longitude in DD.MM (decimal-degrees
/// with the fractional part being minutes/100), converted to radians, and the
/// arc length between the two points on a sphere of radius `RRR = 6378.388`
/// is rounded to the next integer (`floor(d + 1)`).
fn geo_distance_matrix(coords: &[(f64, f64)]) -> Vec<Vec<f64>> {
const RRR: f64 = 6378.388;
let to_radians = |x: f64| {
let deg = x.trunc();
let min = x - deg;
std::f64::consts::PI * (deg + 5.0 * min / 3.0) / 180.0
};
let radians: Vec<(f64, f64)> = coords
.iter()
.map(|&(la, lo)| (to_radians(la), to_radians(lo)))
.collect();
let n = radians.len();
let mut d = vec![vec![0.0_f64; n]; n];
for i in 0..n {
for j in (i + 1)..n {
let (la_i, lo_i) = radians[i];
let (la_j, lo_j) = radians[j];
let q1 = (lo_i - lo_j).cos();
let q2 = (la_i - la_j).cos();
let q3 = (la_i + la_j).cos();
let dij = (RRR * (0.5 * ((1.0 + q1) * q2 - (1.0 - q1) * q3)).acos() + 1.0).trunc();
d[i][j] = dij;
d[j][i] = dij;
}
}
d
}
struct Ulysses16Tsp {
dist: Vec<Vec<f64>>,
}
impl Ulysses16Tsp {
fn new() -> Self {
Self {
dist: geo_distance_matrix(&ULYSSES16),
}
}
fn tour_length(&self, tour: &[usize]) -> f64 {
let n = tour.len();
let mut total = 0.0;
for i in 0..n {
let a = tour[i];
let b = tour[(i + 1) % n];
total += self.dist[a][b];
}
total
}
}
impl Problem for Ulysses16Tsp {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("tour_length")])
}
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
Evaluation::new(vec![self.tour_length(tour)])
}
fn decision_schema(&self) -> Vec<DecisionVariable> {
(0..ULYSSES16.len())
.map(|k| DecisionVariable::new(format!("tour_position_{k}")))
.collect()
}
}
fn main() {
let problem = Ulysses16Tsp::new();
let n = ULYSSES16.len();
let mut optimizer = GeneticAlgorithm::new(
GeneticAlgorithmConfig {
population_size: 150,
generations: 1500,
tournament_size: 3,
elitism: 4,
seed: 42,
},
ShuffledPermutation { n },
CompositeVariation {
crossover: OrderCrossover,
mutation: InversionMutation,
},
);
let result = optimizer.run(&problem);
let best = result.best.expect("GA always returns a best candidate");
let best_len = best.evaluation.objectives[0];
let gap_abs = best_len - KNOWN_OPTIMUM;
let gap_pct = 100.0 * gap_abs / KNOWN_OPTIMUM;
println!("TSPLIB Ulysses16 — single-objective TSP via Genetic Algorithm");
println!("Source: TSPLIB95 (Groetschel/Padberg)");
println!();
println!("Known optimum: {:>8.0}", KNOWN_OPTIMUM);
println!(
"GA best found: {:>8.0} (gap {:+.0}, {:+.2}%)",
best_len, gap_abs, gap_pct
);
println!();
println!("Total evaluations: {}", result.evaluations);
println!("Final population: {}", result.population.len());
println!();
println!("Tour (city indices, returning to start):");
for (i, c) in best.decision.iter().enumerate() {
print!("{:>3}", c);
if i + 1 < best.decision.len() {
print!("");
}
}
println!("{}", best.decision[0]);
}
+1 -1
View File
@@ -66,7 +66,7 @@ dependencies = [
[[package]]
name = "heuropt"
version = "0.8.0"
version = "0.3.0"
dependencies = [
"rand",
"rand_distr",
+2 -9
View File
@@ -77,17 +77,10 @@ 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-4 * scale,
(a - b).abs() < 1e-9 * scale,
"project not idempotent: {a} vs {b}",
);
}
+6 -331
View File
@@ -155,80 +155,6 @@ 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,
@@ -306,17 +232,12 @@ fn environmental_selection<D: Clone>(
// once per (remaining, pick) pair instead of per (remaining, all-keep).
let mut keep = selected.clone();
let mut remaining: Vec<usize> = splitting.clone();
// `prox` and `nearest` are only ever read for splitting-front members
// (the `remaining` set) — the scoring loop never touches the entries
// for `selected` or discarded members. Filling only the `remaining`
// entries skips `lp_norm` / `lp_distance` work on the rest of
// `combined`; bit-identical, since those entries were never used.
let mut prox: Vec<f64> = vec![0.0; combined.len()];
let mut nearest: Vec<f64> = vec![f64::INFINITY; combined.len()];
for &i in &remaining {
prox[i] = lp_norm(&translated[i], p);
nearest[i] = nearest_neighbor_distance(i, &translated, &keep, p);
}
let prox: Vec<f64> = (0..combined.len())
.map(|i| lp_norm(&translated[i], p))
.collect();
let mut nearest: Vec<f64> = (0..combined.len())
.map(|i| nearest_neighbor_distance(i, &translated, &keep, p))
.collect();
while keep.len() < n {
// Pick the remaining candidate with the largest score.
let mut best_idx: Option<usize> = None;
@@ -429,18 +350,6 @@ fn estimate_p(front_indices: &[usize], translated: &[Vec<f64>], m: usize) -> f64
best_p
}
impl<I, V> crate::traits::AlgorithmInfo for AgeMoea<I, V> {
fn name(&self) -> &'static str {
"AGE-MOEA"
}
fn full_name(&self) -> &'static str {
"Adaptive Geometry Estimation Multi-Objective Evolutionary Algorithm"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -496,240 +405,6 @@ mod tests {
assert_eq!(oa, ob);
}
// ---- Direct pin tests for the L_p geometry helpers --------------------
//
// lp_norm / lp_distance / nearest_neighbor_distance / estimate_p are
// file-private fns wired into AGE-MOEA's environmental_selection. The
// tests below pin their exact numerical outputs on small inputs so the
// arithmetic-flip mutants in each function fail.
#[test]
fn lp_norm_l2_of_unit_vector() {
let v = [1.0, 0.0, 0.0];
assert!((lp_norm(&v, 2.0) - 1.0).abs() < 1e-12);
}
#[test]
fn lp_norm_l1_of_three_ones() {
let v = [1.0, 1.0, 1.0];
assert!((lp_norm(&v, 1.0) - 3.0).abs() < 1e-12);
}
#[test]
fn lp_norm_l2_of_pythagorean_3_4() {
let v = [3.0, 4.0];
assert!((lp_norm(&v, 2.0) - 5.0).abs() < 1e-12);
}
#[test]
fn lp_norm_p_one_handles_signed_via_abs() {
// lp_norm uses x.abs().powf(p), so signs don't matter — pinning the
// .abs() catches `delete -` or `replace * with +` mutants in the
// norm body.
let v_pos = [1.0, 2.0, 3.0];
let v_mixed = [-1.0, 2.0, -3.0];
let n_pos = lp_norm(&v_pos, 1.0);
let n_mixed = lp_norm(&v_mixed, 1.0);
assert!((n_pos - n_mixed).abs() < 1e-12);
assert!((n_pos - 6.0).abs() < 1e-12);
}
#[test]
fn lp_distance_l2_unit_axis() {
let a = [0.0, 0.0];
let b = [3.0, 4.0];
assert!((lp_distance(&a, &b, 2.0) - 5.0).abs() < 1e-12);
}
#[test]
fn lp_distance_l1_simple() {
let a = [1.0, 2.0, 3.0];
let b = [4.0, 6.0, 8.0];
// |1-4| + |2-6| + |3-8| = 3 + 4 + 5 = 12
assert!((lp_distance(&a, &b, 1.0) - 12.0).abs() < 1e-12);
}
#[test]
fn lp_distance_symmetric() {
let a = [1.0, 2.0, -3.0];
let b = [-4.0, 5.0, 6.0];
assert!((lp_distance(&a, &b, 2.0) - lp_distance(&b, &a, 2.0)).abs() < 1e-12);
}
#[test]
fn lp_distance_zero_to_itself() {
let a = [1.0, 2.0, 3.0];
assert_eq!(lp_distance(&a, &a, 2.0), 0.0);
}
#[test]
fn nearest_neighbor_distance_empty_selected_is_infinity() {
let translated = vec![vec![0.0, 0.0]];
let selected: Vec<usize> = vec![];
let d = nearest_neighbor_distance(0, &translated, &selected, 2.0);
assert_eq!(d, f64::INFINITY);
}
#[test]
fn nearest_neighbor_distance_skips_self() {
// i == j is skipped, so a point's distance to "itself" alone is ∞.
let translated = vec![vec![1.0, 2.0]];
let selected = vec![0];
let d = nearest_neighbor_distance(0, &translated, &selected, 2.0);
assert_eq!(d, f64::INFINITY);
}
#[test]
fn nearest_neighbor_distance_picks_closest() {
// Point 0 is at the origin; 1 is far, 2 is near. Expect distance to 2.
let translated = vec![vec![0.0, 0.0], vec![10.0, 0.0], vec![1.0, 0.0]];
let d = nearest_neighbor_distance(0, &translated, &[1, 2], 2.0);
assert!((d - 1.0).abs() < 1e-12);
}
#[test]
fn estimate_p_empty_front_falls_back_to_two() {
// Documented fallback: empty front or zero objectives → p = 2.
let translated: Vec<Vec<f64>> = Vec::new();
assert_eq!(estimate_p(&[], &translated, 0), 2.0);
assert_eq!(estimate_p(&[], &translated, 3), 2.0);
assert_eq!(estimate_p(&[0], &translated, 0), 2.0);
}
#[test]
fn estimate_p_axis_aligned_extremes_pick_smallest_candidate() {
// For axis-aligned unit extremes (1,0) and (0,1), every L_p norm
// equals 1, so the CV is 0 across the full candidate sweep. The
// function returns the first candidate (0.25). Pins the iteration
// direction and the loss tie-breaking.
let translated = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let p = estimate_p(&[0, 1], &translated, 2);
assert!(
(p - 0.25).abs() < 1e-12,
"expected smallest candidate, got {p}"
);
}
#[test]
fn estimate_p_corner_vs_diagonal_prefers_large_p() {
// Extreme points (1, 0) and (1, 1) have lp_norms = 1 and 2^(1/p),
// which converge as p → ∞. The candidate sweep covers [0.25, 10],
// so the CV-minimizing p lands at the upper end.
let translated = vec![vec![1.0, 0.0], vec![1.0, 1.0]];
let p = estimate_p(&[0, 1], &translated, 2);
assert!(p > 5.0, "expected large p, got {p}");
}
/// Pin the exact pareto-front objectives produced by a 10-generation
/// AGE-MOEA run on SchafferN1 at seed 7. Any arithmetic / comparison
/// flip inside `run` or `environmental_selection` perturbs at least
/// one front objective enough to break the exact-equality assertion.
#[test]
fn pinned_pareto_front_seed_7_schaffer() {
let bounds = vec![(-5.0, 5.0)];
let initializer = RealBounds::new(bounds.clone());
let variation = CompositeVariation {
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
};
let mut opt = AgeMoea::new(
AgeMoeaConfig {
population_size: 8,
generations: 10,
seed: 7,
},
initializer,
variation,
);
let r = opt.run(&SchafferN1);
assert_eq!(r.population.len(), 8);
// Snapshot the front: this is a regression pin — if you change the
// algorithm intentionally, regenerate. If a mutation changes one
// bit of arithmetic, the value below will not match.
let mut got: Vec<Vec<f64>> = r
.pareto_front
.iter()
.map(|c| c.evaluation.objectives.clone())
.collect();
got.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap_or(std::cmp::Ordering::Equal));
assert!(
!got.is_empty(),
"pareto front empty — likely run() degenerate-mutant survived"
);
// The recovered front must have at least one point where both
// objectives are nonneg and finite — sanity check.
for o in &got {
assert!(o[0].is_finite() && o[1].is_finite());
assert!(o[0] >= 0.0 && o[1] >= 0.0);
}
}
/// `environmental_selection` reduces a 2N-sized combined population
/// down to N. Pin that exact count post-survival so any mutant that
/// skips selection rounds (e.g., a comparison flip in the while-loop
/// that breaks the truncation) gets caught.
#[test]
fn final_population_size_matches_config() {
let bounds = vec![(-5.0, 5.0)];
let initializer = RealBounds::new(bounds.clone());
let variation = CompositeVariation {
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
};
for pop in [4_usize, 12, 30] {
let mut opt = AgeMoea::new(
AgeMoeaConfig {
population_size: pop,
generations: 5,
seed: 13,
},
initializer.clone(),
variation.clone(),
);
let r = opt.run(&SchafferN1);
assert_eq!(r.population.len(), pop, "pop size mismatch at config={pop}");
}
}
/// `run()` must record at least one evaluation per individual per
/// generation. Pin the count so mutants flipping the offspring loop's
/// comparisons (e.g., `>=` ↔ `<`) are caught when they cause skipped
/// evaluations.
#[test]
fn evaluation_count_at_least_pop_times_gens_plus_init() {
let bounds = vec![(-5.0, 5.0)];
let initializer = RealBounds::new(bounds.clone());
let variation = CompositeVariation {
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
};
let pop = 6_usize;
let gens = 4_usize;
let mut opt = AgeMoea::new(
AgeMoeaConfig {
population_size: pop,
generations: gens,
seed: 13,
},
initializer,
variation,
);
let r = opt.run(&SchafferN1);
// Initial pop (6) + per-gen offspring (≤ 6 each gen).
assert!(
r.evaluations >= pop,
"evals = {} < initial pop {}",
r.evaluations,
pop,
);
assert!(
r.evaluations <= pop * (gens + 1),
"evals = {} > pop*(gens+1) = {}",
r.evaluations,
pop * (gens + 1),
);
}
#[test]
#[should_panic(expected = "population_size must be > 0")]
fn zero_pop_panics() {
+20 -273
View File
@@ -147,46 +147,41 @@ where
let n = self.distances.len();
let mut rng = rng_from_seed(self.config.seed);
// Heuristic desirability 1/distance, pre-raised to β. η is constant
// for the whole run, so β is applied exactly once here instead of
// once per ant per step inside `build_tour`.
let eta_pow: Vec<Vec<f64>> = self
// Heuristic desirability: 1 / distance (with a small floor to avoid
// division by zero for very-close cities).
let eta: Vec<Vec<f64>> = self
.distances
.iter()
.map(|row| {
row.iter()
.map(|&d| {
let e = if d > 0.0 { 1.0 / d } else { 0.0 };
e.powf(self.config.beta)
})
.map(|&d| if d > 0.0 { 1.0 / d } else { 0.0 })
.collect()
})
.collect();
// Pheromone matrix, plus a reused buffer holding τ pre-raised to α.
// Pheromone matrix.
let mut pheromone: Vec<Vec<f64>> = vec![vec![self.config.initial_pheromone; n]; n];
let mut pheromone_pow: Vec<Vec<f64>> = vec![vec![0.0_f64; 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 {
// τ is constant across the ant loop, so raise it to α once per
// generation rather than once per ant per step per candidate.
for (src, dst) in pheromone.iter().zip(pheromone_pow.iter_mut()) {
for (&t, p) in src.iter().zip(dst.iter_mut()) {
*p = t.max(0.0).powf(self.config.alpha);
}
}
let mut tours: Vec<Vec<usize>> = Vec::with_capacity(self.config.ants);
let mut tour_evals: Vec<crate::core::evaluation::Evaluation> =
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_pow, &eta_pow, &mut rng);
let tour = build_tour(
n,
start,
&pheromone,
&eta,
self.config.alpha,
self.config.beta,
&mut rng,
);
let eval = problem.evaluate(&tour);
evaluations += 1;
tours.push(tour);
@@ -246,126 +241,13 @@ 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_pow: Vec<Vec<f64>> = self
.distances
.iter()
.map(|row| {
row.iter()
.map(|&d| {
let e = if d > 0.0 { 1.0 / d } else { 0.0 };
e.powf(self.config.beta)
})
.collect()
})
.collect();
let mut pheromone: Vec<Vec<f64>> = vec![vec![self.config.initial_pheromone; n]; n];
let mut pheromone_pow: Vec<Vec<f64>> = vec![vec![0.0_f64; 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 {
for (src, dst) in pheromone.iter().zip(pheromone_pow.iter_mut()) {
for (&t, p) in src.iter().zip(dst.iter_mut()) {
*p = t.max(0.0).powf(self.config.alpha);
}
}
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_pow, &eta_pow, &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,
pheromone_pow: &[Vec<f64>],
eta_pow: &[Vec<f64>],
pheromone: &[Vec<f64>],
eta: &[Vec<f64>],
alpha: f64,
beta: f64,
rng: &mut crate::core::rng::Rng,
) -> Vec<usize> {
let mut tour = Vec::with_capacity(n);
@@ -375,13 +257,11 @@ fn build_tour(
for _ in 1..n {
let current = *tour.last().unwrap();
// Build a probability vector over the unvisited candidates. Both
// matrices are already raised to α / β by the caller, so the per-
// candidate weight is a single multiply — no `powf` in the hot loop.
// Build a probability vector over the unvisited candidates.
let probs: Vec<(usize, f64)> = (0..n)
.filter(|&j| !visited[j])
.map(|j| {
let p = pheromone_pow[current][j] * eta_pow[current][j];
let p = pheromone[current][j].max(0.0).powf(alpha) * eta[current][j].powf(beta);
(j, p)
})
.collect();
@@ -436,18 +316,6 @@ fn better_than_so(
}
}
impl crate::traits::AlgorithmInfo for AntColonyTsp {
fn name(&self) -> &'static str {
"Ant Colony"
}
fn full_name(&self) -> &'static str {
"Ant Colony System for TSP"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -572,125 +440,4 @@ mod tests {
);
let _ = opt.run(&DummyMo);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::objective::Direction;
use crate::core::rng::rng_from_seed;
/// Raise every matrix entry to `p` — mirrors the α / β pre-raising the
/// `run` loop now does before calling `build_tour`.
fn raise(m: &[Vec<f64>], p: f64) -> Vec<Vec<f64>> {
m.iter()
.map(|row| row.iter().map(|&v| v.powf(p)).collect())
.collect()
}
/// `better_than_so` follows the feasibility-first / objective-second
/// tournament rule. Pin each of the four feasibility-cross-product
/// branches so the `<` and `>` comparisons cannot flip silently.
#[test]
fn better_than_so_feasible_beats_infeasible() {
let mut a = Evaluation::new(vec![10.0]);
a.constraint_violation = 0.0; // feasible
let mut b = Evaluation::new(vec![1.0]);
b.constraint_violation = 1.0; // infeasible
assert!(better_than_so(&a, &b, Direction::Minimize));
assert!(!better_than_so(&b, &a, Direction::Minimize));
}
#[test]
fn better_than_so_two_infeasible_compares_violation() {
let mut a = Evaluation::new(vec![0.0]);
a.constraint_violation = 0.5;
let mut b = Evaluation::new(vec![0.0]);
b.constraint_violation = 1.0;
// a has smaller constraint_violation → "better".
assert!(better_than_so(&a, &b, Direction::Minimize));
assert!(!better_than_so(&b, &a, Direction::Minimize));
}
#[test]
fn better_than_so_two_feasible_compares_objective_under_min() {
let a = Evaluation::new(vec![1.0]); // feasible (default cv=0)
let b = Evaluation::new(vec![2.0]); // feasible
assert!(better_than_so(&a, &b, Direction::Minimize));
assert!(!better_than_so(&b, &a, Direction::Minimize));
}
#[test]
fn better_than_so_two_feasible_compares_objective_under_max() {
let a = Evaluation::new(vec![2.0]);
let b = Evaluation::new(vec![1.0]);
assert!(better_than_so(&a, &b, Direction::Maximize));
assert!(!better_than_so(&b, &a, Direction::Maximize));
}
#[test]
fn better_than_so_equal_objectives_neither_strictly_better() {
let a = Evaluation::new(vec![1.0]);
let b = Evaluation::new(vec![1.0]);
// Equal objectives → strict `<` is false both directions.
assert!(!better_than_so(&a, &b, Direction::Minimize));
assert!(!better_than_so(&b, &a, Direction::Minimize));
}
/// `build_tour` must produce a permutation of `[0..n)` starting at the
/// given start city. Pin both invariants across many seeds.
#[test]
fn build_tour_is_permutation_starting_at_start() {
let n = 6;
let pher = raise(&vec![vec![1.0; n]; n], 1.0);
let eta = raise(&vec![vec![1.0; n]; n], 2.0);
for seed in 0..20 {
for start in 0..n {
let mut rng = rng_from_seed(seed);
let tour = build_tour(n, start, &pher, &eta, &mut rng);
assert_eq!(tour.len(), n);
assert_eq!(tour[0], start, "tour must start at the given city");
let mut sorted = tour.clone();
sorted.sort();
let expected: Vec<usize> = (0..n).collect();
assert_eq!(sorted, expected, "tour must visit every city exactly once");
}
}
}
/// With a high `beta` and a heuristic that strongly prefers the next
/// city, `build_tour` chooses that next city with near-certainty.
/// Pins the heuristic-weighting arithmetic.
#[test]
fn build_tour_follows_strong_heuristic() {
let n = 4;
let pher = raise(&vec![vec![1.0; n]; n], 1.0);
// Heuristic strongly favors city (i+1) % n: 1000x preferred.
let mut eta = vec![vec![1.0; n]; n];
for i in 0..n {
eta[i][(i + 1) % n] = 1000.0;
}
let eta = raise(&eta, 5.0);
let mut rng = rng_from_seed(0);
let tour = build_tour(n, 0, &pher, &eta, &mut rng);
// With beta=5 and 1000× heuristic, the path 0→1→2→3 has overwhelming
// probability.
assert_eq!(tour, vec![0, 1, 2, 3]);
}
/// `build_tour` with zero alpha + zero beta degenerates to uniform
/// random over unvisited cities; the result is still a permutation.
#[test]
fn build_tour_zero_weights_still_produces_permutation() {
let n = 5;
let pher = raise(&vec![vec![1.0; n]; n], 0.0);
let eta = raise(&vec![vec![1.0; n]; n], 0.0);
let mut rng = rng_from_seed(42);
let tour = build_tour(n, 2, &pher, &eta, &mut rng);
// With alpha=beta=0, every term is 1.0 so the result is uniform but
// still a permutation.
assert_eq!(tour.len(), n);
assert_eq!(tour[0], 2);
let mut sorted = tour.clone();
sorted.sort();
assert_eq!(sorted, vec![0, 1, 2, 3, 4]);
}
}
+28 -333
View File
@@ -194,22 +194,16 @@ where
let best_target = targets.iter().cloned().fold(f64::INFINITY, f64::min);
// Maximize EI by best-of-N random sampling. `cand` and the two
// GP-prediction scratch buffers are reused across all samples
// so the inner loop allocates nothing.
// Maximize EI by best-of-N random sampling.
let mut best_x = sample_uniform_in_bounds(&self.bounds, &mut rng);
let mut best_ei = -f64::INFINITY;
let mut cand: Vec<f64> = Vec::with_capacity(dim);
let mut k_star_buf: Vec<f64> = Vec::new();
let mut v_temp_buf: Vec<f64> = Vec::new();
for _ in 0..self.config.acquisition_samples {
sample_uniform_in_bounds_into(&self.bounds, &mut rng, &mut cand);
let (mu, sigma) = posterior.predict_into(&cand, &mut k_star_buf, &mut v_temp_buf);
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.clear();
best_x.extend_from_slice(&cand);
best_x = cand;
}
}
@@ -276,23 +270,18 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
}
}
/// Sample a uniform-in-bounds point into `out` (reused across calls).
fn sample_uniform_in_bounds_into(bounds: &RealBounds, rng: &mut Rng, out: &mut Vec<f64>) {
out.clear();
for &(lo, hi) in &bounds.bounds {
let v = if lo == hi {
lo
} else {
lo + (hi - lo) * rng.random::<f64>()
};
out.push(v);
}
}
fn sample_uniform_in_bounds(bounds: &RealBounds, rng: &mut Rng) -> Vec<f64> {
let mut out = Vec::new();
sample_uniform_in_bounds_into(bounds, rng, &mut out);
out
bounds
.bounds
.iter()
.map(|&(lo, hi)| {
if lo == hi {
lo
} else {
lo + (hi - lo) * rng.random::<f64>()
}
})
.collect()
}
/// Anisotropic RBF kernel: `k(x, y) = σ² · exp(-0.5 · Σ ((x_i - y_i)/_i)²)`.
@@ -344,23 +333,26 @@ impl GpPosterior {
})
}
/// Predict `(mean, std)` at `x`, using caller-owned scratch buffers
/// (`k_star`, `v_temp`) so the hot acquisition loop allocates nothing.
fn predict_into(&self, x: &[f64], k_star: &mut Vec<f64>, v_temp: &mut Vec<f64>) -> (f64, f64) {
fn predict(&self, x: &[f64]) -> (f64, f64) {
let n = self.decisions.len();
k_star.clear();
k_star.reserve(n);
for d in &self.decisions {
k_star.push(rbf_kernel(x, d, &self.length_scales, self.signal_variance));
let mut k_star = vec![0.0_f64; n];
for (i, k_star_i) in k_star.iter_mut().enumerate() {
*k_star_i = rbf_kernel(
x,
&self.decisions[i],
&self.length_scales,
self.signal_variance,
);
}
let _ = n;
let mu: f64 = k_star
.iter()
.zip(self.alpha.iter())
.map(|(a, b)| a * b)
.sum();
// Var = k(x,x) - k_star^T · K^{-1} · k_star; the squared norm of
// `solve_lower(L, k_star)` is exactly `k_star^T · K^{-1} · k_star`.
crate::internal::cholesky::solve_lower_into(&self.chol_l, k_star, v_temp);
// Var = k(x,x) - k_star^T · K^{-1} · k_star
// Compute K^{-1}·k_star = solve_upper_transpose(L, solve_lower(L, k_star))
let v_temp = crate::internal::cholesky::solve_lower(&self.chol_l, &k_star);
let v: f64 = v_temp.iter().map(|x| x * x).sum();
let var = (self.signal_variance - v).max(0.0);
(mu, var.sqrt())
@@ -404,166 +396,6 @@ 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;
let mut cand: Vec<f64> = Vec::with_capacity(dim);
let mut k_star_buf: Vec<f64> = Vec::new();
let mut v_temp_buf: Vec<f64> = Vec::new();
for _ in 0..self.config.acquisition_samples {
sample_uniform_in_bounds_into(&self.bounds, &mut rng, &mut cand);
let (mu, sigma) = posterior.predict_into(&cand, &mut k_star_buf, &mut v_temp_buf);
let ei = expected_improvement(mu, sigma, best_target);
if ei > best_ei {
best_ei = ei;
best_x.clear();
best_x.extend_from_slice(&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,
)
}
}
impl crate::traits::AlgorithmInfo for BayesianOpt {
fn name(&self) -> &'static str {
"Bayesian Optimization"
}
fn full_name(&self) -> &'static str {
"Gaussian Process Bayesian Optimization with Expected Improvement"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -635,141 +467,4 @@ mod tests {
);
let _ = opt.run(&Sphere1D);
}
// ---- Mutation-test pinned helpers --------------------------------------
//
// BayesianOpt's GP / EI machinery has many pure helpers (rbf_kernel,
// expected_improvement, normal_pdf/cdf, erf, oriented_target, better).
// The tests below pin their exact numerical outputs.
#[test]
fn rbf_kernel_x_equals_y_is_signal_variance() {
let x = vec![0.5_f64, -1.0, 2.0];
let lengths = vec![1.0_f64; 3];
assert!((rbf_kernel(&x, &x, &lengths, 1.5) - 1.5).abs() < 1e-12);
// Different signal variance scales the result.
assert!((rbf_kernel(&x, &x, &lengths, 4.0) - 4.0).abs() < 1e-12);
}
#[test]
fn rbf_kernel_unit_distance_unit_length() {
// k = exp(-0.5 * (1)^2) = exp(-0.5) ≈ 0.6065
let got = rbf_kernel(&[0.0], &[1.0], &[1.0], 1.0);
let expected = (-0.5_f64).exp();
assert!(
(got - expected).abs() < 1e-12,
"got {got}, expected {expected}"
);
}
#[test]
fn rbf_kernel_far_points_approach_zero() {
let got = rbf_kernel(&[0.0], &[100.0], &[1.0], 1.0);
assert!((0.0..1e-12).contains(&got), "got {got}");
}
#[test]
fn rbf_kernel_length_scale_widens_kernel() {
// Same distance, larger length scale → larger kernel value.
let small_l = rbf_kernel(&[0.0], &[1.0], &[1.0], 1.0);
let large_l = rbf_kernel(&[0.0], &[1.0], &[10.0], 1.0);
assert!(large_l > small_l, "small_l={small_l} large_l={large_l}");
}
#[test]
fn normal_pdf_at_zero_is_inverse_sqrt_2pi() {
let got = normal_pdf(0.0);
let expected = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
assert!((got - expected).abs() < 1e-12);
}
#[test]
fn normal_pdf_symmetric_about_zero() {
for z in [0.5_f64, 1.0, 2.5] {
assert!((normal_pdf(z) - normal_pdf(-z)).abs() < 1e-12);
}
}
#[test]
fn normal_cdf_at_zero_is_one_half() {
assert!((normal_cdf(0.0) - 0.5).abs() < 1e-9);
}
#[test]
fn normal_cdf_sums_to_one_at_symmetric_points() {
for z in [0.5_f64, 1.0, 2.5] {
let s = normal_cdf(z) + normal_cdf(-z);
assert!((s - 1.0).abs() < 1e-9, "z={z} sum={s}");
}
}
#[test]
fn erf_zero_is_zero() {
// The Numerical-Recipes-style rational approximation has ~1e-7 accuracy.
assert!(erf(0.0).abs() < 1e-6);
}
#[test]
fn erf_odd_function() {
for x in [0.1_f64, 0.5, 1.0, 2.0] {
assert!((erf(x) + erf(-x)).abs() < 1e-9, "x={x}");
}
}
#[test]
fn expected_improvement_zero_sigma_is_zero() {
assert_eq!(expected_improvement(0.0, 0.0, 1.0), 0.0);
assert_eq!(expected_improvement(-5.0, 1e-13, 1.0), 0.0);
}
#[test]
fn expected_improvement_grows_with_sigma() {
// At μ = f_best, EI is proportional to σ.
let lo = expected_improvement(1.0, 0.1, 1.0);
let hi = expected_improvement(1.0, 1.0, 1.0);
assert!(hi > lo, "lo={lo} hi={hi}");
}
#[test]
fn expected_improvement_positive_when_mu_below_fbest() {
// μ < f_best means improvement is expected → EI > 0.
let ei = expected_improvement(0.5, 0.5, 1.0);
assert!(ei > 0.0, "ei = {ei}");
}
#[test]
fn oriented_target_flips_sign_under_maximize() {
let e = Evaluation::new(vec![3.0]);
assert!((oriented_target(&e, Direction::Minimize) - 3.0).abs() < 1e-12);
assert!((oriented_target(&e, Direction::Maximize) - (-3.0)).abs() < 1e-12);
}
#[test]
fn oriented_target_penalizes_infeasible() {
let mut e = Evaluation::new(vec![1.0]);
e.constraint_violation = 0.5;
// base 1.0 + 1e6 * 0.5 = 500001.0
let got = oriented_target(&e, Direction::Minimize);
assert!((got - 500_001.0).abs() < 1e-9);
}
#[test]
fn better_helper_feasibility_first() {
let mut a = Evaluation::new(vec![10.0]);
a.constraint_violation = 0.0;
let mut b = Evaluation::new(vec![1.0]);
b.constraint_violation = 1.0;
assert!(better(&a, &b, Direction::Minimize));
assert!(!better(&b, &a, Direction::Minimize));
}
#[test]
fn better_helper_two_feasible_under_min_and_max() {
let a = Evaluation::new(vec![1.0]);
let b = Evaluation::new(vec![2.0]);
assert!(better(&a, &b, Direction::Minimize));
assert!(!better(&b, &a, Direction::Minimize));
assert!(better(&b, &a, Direction::Maximize));
assert!(!better(&a, &b, Direction::Maximize));
}
}
-333
View File
@@ -366,237 +366,6 @@ 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,
@@ -628,18 +397,6 @@ fn better_than_so(
compare_so(a, b, direction) == std::cmp::Ordering::Less
}
impl crate::traits::AlgorithmInfo for CmaEs {
fn name(&self) -> &'static str {
"CMA-ES"
}
fn full_name(&self) -> &'static str {
"Covariance Matrix Adaptation Evolution Strategy"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -757,94 +514,4 @@ mod tests {
);
let _ = opt.run(&Sphere1D);
}
// ---- Mutation-test pinned helpers --------------------------------------
#[test]
fn compare_so_feasibility_first_under_min() {
let mut a = Evaluation::new(vec![10.0]);
a.constraint_violation = 0.0;
let mut b = Evaluation::new(vec![1.0]);
b.constraint_violation = 1.0;
assert_eq!(
compare_so(&a, &b, Direction::Minimize),
std::cmp::Ordering::Less
);
assert_eq!(
compare_so(&b, &a, Direction::Minimize),
std::cmp::Ordering::Greater
);
}
#[test]
fn compare_so_two_feasible_under_min_and_max() {
let a = Evaluation::new(vec![1.0]);
let b = Evaluation::new(vec![2.0]);
assert_eq!(
compare_so(&a, &b, Direction::Minimize),
std::cmp::Ordering::Less
);
assert_eq!(
compare_so(&b, &a, Direction::Minimize),
std::cmp::Ordering::Greater
);
// Maximize inverts.
assert_eq!(
compare_so(&a, &b, Direction::Maximize),
std::cmp::Ordering::Greater
);
assert_eq!(
compare_so(&b, &a, Direction::Maximize),
std::cmp::Ordering::Less
);
}
#[test]
fn compare_so_two_infeasible_compares_violation() {
let mut a = Evaluation::new(vec![0.0]);
a.constraint_violation = 0.5;
let mut b = Evaluation::new(vec![0.0]);
b.constraint_violation = 1.0;
assert_eq!(
compare_so(&a, &b, Direction::Minimize),
std::cmp::Ordering::Less
);
assert_eq!(
compare_so(&b, &a, Direction::Minimize),
std::cmp::Ordering::Greater
);
}
#[test]
fn better_than_so_matches_compare_so() {
let a = Evaluation::new(vec![1.0]);
let b = Evaluation::new(vec![2.0]);
assert!(better_than_so(&a, &b, Direction::Minimize));
assert!(!better_than_so(&b, &a, Direction::Minimize));
assert!(better_than_so(&b, &a, Direction::Maximize));
// Equal: not strictly better.
let c = Evaluation::new(vec![1.0]);
assert!(!better_than_so(&a, &c, Direction::Minimize));
}
/// Pin the final population size and at least one improvement step.
#[test]
fn cmaes_decreases_sphere_objective_over_generations() {
let mut opt = CmaEs::new(
CmaEsConfig {
population_size: 8,
generations: 30,
initial_sigma: 0.5,
eigen_decomposition_period: 1,
initial_mean: None,
seed: 7,
},
RealBounds::new(vec![(-3.0, 3.0); 2]),
);
let r = opt.run(&Sphere1D);
let best = r.best.unwrap().evaluation.objectives[0];
// After 30 gens × 8 pop on a 2-D sphere starting σ=0.5, best should
// be much smaller than initial random sampling (variance bound = 9).
assert!(best < 1.0, "best = {best}");
}
}
+66 -59
View File
@@ -3,7 +3,6 @@
use rand::Rng as _;
use crate::algorithms::parallel_eval::evaluate_batch;
use crate::core::candidate::Candidate;
use crate::core::objective::Direction;
use crate::core::population::Population;
use crate::core::problem::Problem;
@@ -95,6 +94,16 @@ where
P: Problem<Decision = Vec<f64>> + Sync,
{
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
self.run_with(problem, &mut ())
}
fn run_with<O>(&mut self, problem: &P, observer: &mut O) -> OptimizationResult<P::Decision>
where
O: crate::observer::Observer<P::Decision>,
{
use crate::observer::Snapshot;
use std::ops::ControlFlow;
assert!(
self.config.population_size >= 4,
"DifferentialEvolution requires population_size >= 4 (DE/rand/1 needs three distinct donors plus the target)",
@@ -110,6 +119,7 @@ where
"DifferentialEvolution only supports single-objective problems",
);
let direction = objectives.objectives[0].direction;
let started = std::time::Instant::now();
let dim = self.bounds.bounds.len();
let n = self.config.population_size;
@@ -122,12 +132,39 @@ where
};
let initial_pop = evaluate_batch(problem, decisions.clone());
let mut evaluations = initial_pop.len();
let mut evals: Vec<f64> = initial_pop
let mut current_pop = initial_pop;
let mut evals: Vec<f64> = current_pop
.iter()
.map(|c| c.evaluation.objectives[0])
.collect();
let mut completed_generations: usize = 0;
for _gen in 0..self.config.generations {
// Initial snapshot.
{
let best = best_candidate(&current_pop, &objectives);
let snap = Snapshot {
iteration: 0,
evaluations,
elapsed: started.elapsed(),
population: &current_pop,
pareto_front: None,
best: best.as_ref(),
objectives: &objectives,
};
if let ControlFlow::Break(()) = observer.observe(&snap) {
let front = pareto_front(&current_pop, &objectives);
let best = best_candidate(&current_pop, &objectives);
return OptimizationResult::new(
Population::new(current_pop),
front,
best,
evaluations,
completed_generations,
);
}
}
for generation in 1..=self.config.generations {
// Phase 1 (serial): construct one trial per target. RNG state is
// consumed in deterministic order so seeded runs reproduce
// exactly regardless of the `parallel` feature.
@@ -164,22 +201,38 @@ where
Direction::Maximize => trial_obj >= target_obj,
};
if trial_better {
decisions[i] = trial_cand.decision;
decisions[i] = trial_cand.decision.clone();
evals[i] = trial_obj;
current_pop[i] = trial_cand;
}
}
completed_generations = generation;
// Per-generation snapshot.
let best = best_candidate(&current_pop, &objectives);
let snap = Snapshot {
iteration: generation,
evaluations,
elapsed: started.elapsed(),
population: &current_pop,
pareto_front: None,
best: best.as_ref(),
objectives: &objectives,
};
if let ControlFlow::Break(()) = observer.observe(&snap) {
break;
}
}
let final_pop: Vec<Candidate<Vec<f64>>> = evaluate_batch(problem, decisions);
evaluations += final_pop.len();
let front = pareto_front(&final_pop, &objectives);
let best = best_candidate(&final_pop, &objectives);
// Re-evaluate to make sure final population is consistent (current_pop is already current).
let front = pareto_front(&current_pop, &objectives);
let best = best_candidate(&current_pop, &objectives);
OptimizationResult::new(
Population::new(final_pop),
Population::new(current_pop),
front,
best,
evaluations,
self.config.generations,
completed_generations,
)
}
}
@@ -203,6 +256,7 @@ impl DifferentialEvolution {
use rand::Rng as _;
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
use crate::core::candidate::Candidate;
use crate::traits::Initializer as _;
assert!(
@@ -261,8 +315,8 @@ impl DifferentialEvolution {
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,
crate::core::objective::Direction::Minimize => trial_obj <= target_obj,
crate::core::objective::Direction::Maximize => trial_obj >= target_obj,
};
if trial_better {
decisions[i] = trial_cand.decision.clone();
@@ -303,18 +357,6 @@ fn pick_three_distinct(
(a, b, c)
}
impl crate::traits::AlgorithmInfo for DifferentialEvolution {
fn name(&self) -> &'static str {
"DE"
}
fn full_name(&self) -> &'static str {
"Differential Evolution"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -384,39 +426,4 @@ mod tests {
);
let _ = opt.run(&Sphere1D);
}
// ---- Mutation-test pinned helpers --------------------------------------
#[test]
fn pick_three_distinct_returns_distinct_indices_not_equal_to_exclude() {
use crate::core::rng::rng_from_seed;
for seed in 0..20 {
let mut rng = rng_from_seed(seed);
let (a, b, c) = pick_three_distinct(10, 3, &mut rng);
assert_ne!(a, 3);
assert_ne!(b, 3);
assert_ne!(c, 3);
assert_ne!(a, b);
assert_ne!(a, c);
assert_ne!(b, c);
assert!(a < 10 && b < 10 && c < 10);
}
}
#[test]
fn de_decreases_sphere_objective_over_generations() {
let mut opt = DifferentialEvolution::new(
DifferentialEvolutionConfig {
population_size: 12,
generations: 40,
differential_weight: 0.5,
crossover_probability: 0.9,
seed: 11,
},
RealBounds::new(vec![(-3.0, 3.0); 2]),
);
let r = opt.run(&Sphere1D);
let best = r.best.unwrap().evaluation.objectives[0];
assert!(best < 0.5, "best = {best}");
}
}
-164
View File
@@ -190,98 +190,6 @@ 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.
@@ -396,18 +304,6 @@ fn box_dominates(a: &[i64], b: &[i64]) -> bool {
strictly_less
}
impl<I, V> crate::traits::AlgorithmInfo for EpsilonMoea<I, V> {
fn name(&self) -> &'static str {
"ε-MOEA"
}
fn full_name(&self) -> &'static str {
"ε-dominance Multi-Objective Evolutionary Algorithm"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -507,64 +403,4 @@ mod tests {
);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::evaluation::Evaluation;
use crate::core::objective::Objective;
fn space2() -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
}
#[test]
fn box_coords_floors_each_axis() {
let s = space2();
let e = Evaluation::new(vec![2.7, 5.2]);
// floor(2.7 / 1.0) = 2, floor(5.2 / 2.0) = floor(2.6) = 2
let coords = box_coords(&e, &s, &[1.0, 2.0]);
assert_eq!(coords, vec![2, 2]);
}
#[test]
fn box_coords_zero_lands_in_box_zero() {
let s = space2();
let e = Evaluation::new(vec![0.0, 0.999]);
let coords = box_coords(&e, &s, &[1.0, 1.0]);
assert_eq!(coords, vec![0, 0]);
}
#[test]
fn corner_distance_is_euclidean_to_box_corner() {
let s = space2();
// Point (2.5, 5.5), box (2, 2), epsilon (1, 2):
// corner = (2*1, 2*2) = (2, 4). delta = (0.5, 1.5).
// distance = sqrt(0.25 + 2.25) = sqrt(2.5).
let e = Evaluation::new(vec![2.5, 5.5]);
let d = corner_distance(&e, &s, &[1.0, 2.0], &[2, 2]);
assert!((d - 2.5_f64.sqrt()).abs() < 1e-12, "d = {d}");
}
#[test]
fn corner_distance_zero_at_exact_corner() {
let s = space2();
// Point exactly at the box corner → distance 0.
let e = Evaluation::new(vec![2.0, 4.0]);
let d = corner_distance(&e, &s, &[1.0, 2.0], &[2, 2]);
assert!(d.abs() < 1e-12, "d = {d}");
}
#[test]
fn box_dominates_strict_and_boundary() {
// a strictly less on both axes → dominates.
assert!(box_dominates(&[1, 1], &[2, 2]));
// reverse → does not dominate.
assert!(!box_dominates(&[2, 2], &[1, 1]));
// equal boxes → no strict improvement → no domination.
assert!(!box_dominates(&[1, 1], &[1, 1]));
// less on one axis, equal on the other → dominates.
assert!(box_dominates(&[1, 2], &[2, 2]));
// less on one, greater on the other → no domination.
assert!(!box_dominates(&[1, 3], &[2, 2]));
}
}
-177
View File
@@ -181,94 +181,6 @@ 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>>,
@@ -317,18 +229,6 @@ fn compare_for_fitness<D>(
}
}
impl<I, V> crate::traits::AlgorithmInfo for GeneticAlgorithm<I, V> {
fn name(&self) -> &'static str {
"GA"
}
fn full_name(&self) -> &'static str {
"Genetic Algorithm"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -415,81 +315,4 @@ mod tests {
);
let _ = opt.run(&Sphere1D);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::candidate::Candidate;
use crate::core::evaluation::Evaluation;
fn fc(obj: f64) -> Candidate<u32> {
Candidate::new(0, Evaluation::new(vec![obj]))
}
fn fc_cv(obj: f64, cv: f64) -> Candidate<u32> {
Candidate::new(0, Evaluation::constrained(vec![obj], cv))
}
#[test]
fn compare_for_fitness_feasibility_first() {
let feasible = fc(100.0);
let infeasible = fc_cv(0.0, 1.0);
assert_eq!(
compare_for_fitness(&feasible, &infeasible, Direction::Minimize),
std::cmp::Ordering::Less,
);
assert_eq!(
compare_for_fitness(&infeasible, &feasible, Direction::Minimize),
std::cmp::Ordering::Greater,
);
}
#[test]
fn compare_for_fitness_two_feasible_min_and_max() {
let lo = fc(1.0);
let hi = fc(2.0);
assert_eq!(
compare_for_fitness(&lo, &hi, Direction::Minimize),
std::cmp::Ordering::Less
);
assert_eq!(
compare_for_fitness(&lo, &hi, Direction::Maximize),
std::cmp::Ordering::Greater
);
}
#[test]
fn compare_for_fitness_two_infeasible_lower_violation_wins() {
let low = fc_cv(0.0, 0.3);
let high = fc_cv(0.0, 0.9);
assert_eq!(
compare_for_fitness(&low, &high, Direction::Minimize),
std::cmp::Ordering::Less
);
}
/// `survival_selection` carries `elitism` parents and `n - elitism`
/// offspring, each set sorted best-first. Pin the exact composition.
#[test]
fn survival_selection_keeps_elites_and_best_offspring() {
// Parents: objectives 5, 1, 9 → best is 1.
let parents = vec![fc(5.0), fc(1.0), fc(9.0)];
// Offspring: objectives 4, 2, 8 → best two are 2, 4.
let offspring = vec![fc(4.0), fc(2.0), fc(8.0)];
let next = survival_selection(&parents, offspring, Direction::Minimize, 3, 1);
assert_eq!(next.len(), 3);
// 1 elite (best parent = 1.0) + 2 best offspring (2.0, 4.0).
assert_eq!(next[0].evaluation.objectives[0], 1.0);
assert_eq!(next[1].evaluation.objectives[0], 2.0);
assert_eq!(next[2].evaluation.objectives[0], 4.0);
}
#[test]
fn survival_selection_zero_elitism_is_all_offspring() {
let parents = vec![fc(1.0)];
let offspring = vec![fc(9.0), fc(3.0)];
let next = survival_selection(&parents, offspring, Direction::Minimize, 2, 0);
assert_eq!(next.len(), 2);
// No elites — both slots come from offspring, best-first.
assert_eq!(next[0].evaluation.objectives[0], 3.0);
assert_eq!(next[1].evaluation.objectives[0], 9.0);
}
}
-117
View File
@@ -160,82 +160,6 @@ 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,
@@ -334,18 +258,6 @@ fn environmental_selection<D: Clone>(
selected.into_iter().map(|i| combined[i].clone()).collect()
}
impl<I, V> crate::traits::AlgorithmInfo for Grea<I, V> {
fn name(&self) -> &'static str {
"GrEA"
}
fn full_name(&self) -> &'static str {
"Grid-based Evolutionary Algorithm"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -400,33 +312,4 @@ mod tests {
.collect();
assert_eq!(oa, ob);
}
/// `environmental_selection` truncates the combined 2N pool down to
/// exactly N. Pin the final population size across several configs so
/// the grid-coordinate arithmetic / front-peeling comparisons can't
/// silently mis-count survivors.
#[test]
fn final_population_size_matches_config() {
for pop in [4_usize, 12, 20] {
let bounds = vec![(-5.0, 5.0)];
let initializer = RealBounds::new(bounds.clone());
let variation = CompositeVariation {
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
};
let mut opt = Grea::new(
GreaConfig {
population_size: pop,
generations: 5,
grid_divisions: 8,
seed: 3,
},
initializer,
variation,
);
let r = opt.run(&SchafferN1);
assert_eq!(r.population.len(), pop, "config pop = {pop}");
assert!(!r.pareto_front.is_empty());
}
}
}
-127
View File
@@ -146,96 +146,6 @@ 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,
)
}
}
impl<I, V> crate::traits::AlgorithmInfo for HillClimber<I, V> {
fn name(&self) -> &'static str {
"Hill Climber"
}
fn full_name(&self) -> &'static str {
"Hill Climbing"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -283,41 +193,4 @@ mod tests {
let mut opt = make_optimizer(0);
let _ = opt.run(&SchafferN1);
}
/// HillClimber must never *worsen* the best objective — the accept rule
/// only moves to strictly-better neighbors. Pin that the final best is
/// at least as good as the initial decision's objective.
#[test]
fn hill_climber_never_worsens_objective() {
let mut opt = HillClimber::new(
HillClimberConfig {
iterations: 200,
seed: 5,
},
RealBounds::new(vec![(-3.0, 3.0); 2]),
GaussianMutation { sigma: 0.3 },
);
let r = opt.run(&Sphere1D);
let best = r.best.unwrap().evaluation.objectives[0];
// The worst point in a [-3,3]^2 box has objective up to ~9 for the
// first coordinate squared; a hill climber from any start should be
// well below that ceiling after 200 steps.
assert!(best <= 9.0);
assert!(best.is_finite() && best >= 0.0);
}
#[test]
fn hill_climber_decreases_sphere() {
let mut opt = HillClimber::new(
HillClimberConfig {
iterations: 500,
seed: 11,
},
RealBounds::new(vec![(-3.0, 3.0)]),
GaussianMutation { sigma: 0.2 },
);
let r = opt.run(&Sphere1D);
let best = r.best.unwrap().evaluation.objectives[0];
assert!(best < 1.0, "best = {best}");
}
}
+2 -192
View File
@@ -226,131 +226,6 @@ 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,
@@ -416,9 +291,6 @@ fn estimate_contributions<D>(
let mut contrib = vec![0.0_f64; n];
let mut sample = vec![0.0_f64; m];
// Reused across samples — previously heap-allocated once per Monte
// Carlo sample (thousands of allocations per call).
let mut dominators: Vec<usize> = Vec::with_capacity(n);
for _ in 0..samples {
for k in 0..m {
let u: f64 = rng.random();
@@ -426,7 +298,7 @@ fn estimate_contributions<D>(
}
// Count and identify candidates that dominate this sample (point
// in the box).
dominators.clear();
let mut dominators: Vec<usize> = Vec::with_capacity(n);
for (i, o) in oriented.iter().enumerate() {
if o.iter().zip(sample.iter()).all(|(p, s)| *p <= *s) {
dominators.push(i);
@@ -439,7 +311,7 @@ fn estimate_contributions<D>(
// dominators. (This generalizes "exactly-one dominator" to
// arbitrary multiplicities.)
let weight = 1.0 / dominators.len() as f64;
for &i in &dominators {
for i in dominators {
contrib[i] += weight;
}
}
@@ -462,18 +334,6 @@ fn binary_tournament(fitness: &[f64], rng: &mut Rng) -> usize {
}
}
impl<I, V> crate::traits::AlgorithmInfo for Hype<I, V> {
fn name(&self) -> &'static str {
"HypE"
}
fn full_name(&self) -> &'static str {
"Hypervolume Estimation Algorithm"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -553,54 +413,4 @@ mod tests {
);
let _ = opt.run(&SchafferN1);
}
/// `binary_tournament` picks the index with the higher fitness; on a
/// tie it coin-flips. Pin the deterministic-winner case (no tie).
#[test]
fn binary_tournament_picks_higher_fitness() {
use crate::core::rng::rng_from_seed;
// fitness[1] is strictly highest; both random draws will be in
// 0..3, and whenever a != b the higher-fitness index must win.
let fitness = vec![0.1_f64, 0.9, 0.5];
for seed in 0..50 {
let mut rng = rng_from_seed(seed);
let winner = binary_tournament(&fitness, &mut rng);
// The winner's fitness must be >= the other's — i.e. it can
// never be a strictly-dominated index when the draws differ.
assert!(winner < 3);
}
// Degenerate: all-equal fitness — winner is always a valid index.
let flat = vec![1.0_f64; 4];
let mut rng = rng_from_seed(7);
assert!(binary_tournament(&flat, &mut rng) < 4);
}
/// With a two-element fitness vector where element 0 strictly beats
/// element 1, binary_tournament must return 0 whenever the two random
/// draws land on {0, 1} — verify across many seeds it never returns
/// the strictly-worse index when the draws differ.
#[test]
fn binary_tournament_never_picks_strictly_worse() {
use crate::core::rng::rng_from_seed;
let fitness = vec![10.0_f64, 1.0];
for seed in 0..100 {
let mut rng = rng_from_seed(seed);
// Re-derive the two draws is not possible without touching the
// rng; instead just assert the winner is a valid index and,
// statistically, index 0 wins far more often.
let _ = binary_tournament(&fitness, &mut rng);
}
// Statistical check: index 0 should win the clear majority.
let mut wins0 = 0;
for seed in 0..200 {
let mut rng = rng_from_seed(seed);
if binary_tournament(&fitness, &mut rng) == 0 {
wins0 += 1;
}
}
assert!(
wins0 > 130,
"index 0 won only {wins0}/200 — comparison likely flipped"
);
}
}
-162
View File
@@ -206,106 +206,6 @@ 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,
@@ -329,22 +229,6 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
compare(a, b, direction) == std::cmp::Ordering::Less
}
impl<I, D> crate::traits::AlgorithmInfo for Hyperband<I, D>
where
D: Clone,
I: Initializer<D>,
{
fn name(&self) -> &'static str {
"Hyperband"
}
fn full_name(&self) -> &'static str {
"Hyperband multi-fidelity bandit search"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -442,50 +326,4 @@ mod tests {
);
let _ = opt.run(&MultiObj);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::objective::Direction;
#[test]
fn compare_feasibility_first_and_direction() {
let feasible = Evaluation::new(vec![10.0]);
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
assert_eq!(
compare(&feasible, &infeasible, Direction::Minimize),
std::cmp::Ordering::Less
);
assert_eq!(
compare(&infeasible, &feasible, Direction::Minimize),
std::cmp::Ordering::Greater
);
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert_eq!(
compare(&lo, &hi, Direction::Minimize),
std::cmp::Ordering::Less
);
assert_eq!(
compare(&lo, &hi, Direction::Maximize),
std::cmp::Ordering::Greater
);
// two infeasible: smaller violation is "Less" (better).
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
assert_eq!(
compare(&v_lo, &v_hi, Direction::Minimize),
std::cmp::Ordering::Less
);
}
#[test]
fn better_is_compare_equals_less() {
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert!(better(&lo, &hi, Direction::Minimize));
assert!(!better(&hi, &lo, Direction::Minimize));
// equal → not strictly better.
let eq = Evaluation::new(vec![1.0]);
assert!(!better(&lo, &eq, Direction::Minimize));
}
}
+3 -153
View File
@@ -159,80 +159,6 @@ 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
@@ -280,24 +206,14 @@ fn environmental_selection<D: Clone>(
}
}
// Pre-exponentiate the indicator matrix once. Every later use of
// `indicator[j][i]` is `exp(-indicator[j][i] / scale)` — in the initial
// fitness sum and, identically, in the per-removal fitness update — so
// computing it here turns the removal loop's O((pool-n) · pool) `exp`
// calls into plain additions.
let scale = max_abs * kappa;
let exp_terms: Vec<Vec<f64>> = indicator
.into_iter()
.map(|row| row.into_iter().map(|v| (-v / scale).exp()).collect())
.collect();
// Fitness F(i) = -Σ_{j≠i} exp(-indicator[j][i] / (max_abs · kappa)).
// (Higher is better — so a candidate dominated by many is heavily negative.)
let scale = max_abs * kappa;
let mut fitness: Vec<f64> = (0..pool.len())
.map(|i| {
(0..pool.len())
.filter(|&j| j != i)
.map(|j| -exp_terms[j][i])
.map(|j| -(-indicator[j][i] / scale).exp())
.sum()
})
.collect();
@@ -320,7 +236,7 @@ fn environmental_selection<D: Clone>(
if !alive[i] || i == worst {
continue;
}
fitness[i] += exp_terms[worst][i];
fitness[i] += (-indicator[worst][i] / scale).exp();
}
alive[worst] = false;
alive_count -= 1;
@@ -395,18 +311,6 @@ fn binary_tournament(fitness: &[f64], rng: &mut Rng) -> usize {
}
}
impl<I, V> crate::traits::AlgorithmInfo for Ibea<I, V> {
fn name(&self) -> &'static str {
"IBEA"
}
fn full_name(&self) -> &'static str {
"Indicator-Based Evolutionary Algorithm"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -484,58 +388,4 @@ mod tests {
);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::candidate::Candidate;
use crate::core::evaluation::Evaluation;
use crate::core::objective::{Objective, ObjectiveSpace};
fn ibea_space() -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
}
fn ibea_cand(o: Vec<f64>) -> Candidate<u32> {
Candidate::new(0, Evaluation::new(o))
}
#[test]
fn compute_fitness_empty_pool_is_empty() {
let pool: Vec<Candidate<u32>> = Vec::new();
assert!(compute_fitness(&pool, &ibea_space(), 0.05).is_empty());
}
#[test]
fn compute_fitness_dominating_point_has_higher_fitness() {
// (1,1) dominates (2,2). IBEA fitness (sum of -exp(-I/scale)) is
// less negative — i.e. larger — for the dominating point.
let pool = vec![ibea_cand(vec![1.0, 1.0]), ibea_cand(vec![2.0, 2.0])];
let fit = compute_fitness(&pool, &ibea_space(), 0.05);
assert_eq!(fit.len(), 2);
assert!(
fit[0] > fit[1],
"dominating point should score higher: {fit:?}"
);
}
#[test]
fn compute_fitness_symmetric_tradeoff_pair_is_equal() {
// (1,3) and (3,1) are a symmetric trade-off — equal fitness.
let pool = vec![ibea_cand(vec![1.0, 3.0]), ibea_cand(vec![3.0, 1.0])];
let fit = compute_fitness(&pool, &ibea_space(), 0.05);
assert!((fit[0] - fit[1]).abs() < 1e-9, "{fit:?}");
}
#[test]
fn binary_tournament_prefers_higher_fitness() {
use crate::core::rng::rng_from_seed;
let fitness = vec![-10.0_f64, -1.0]; // index 1 is fitter
let mut wins1 = 0;
for seed in 0..200 {
let mut rng = rng_from_seed(seed);
if binary_tournament(&fitness, &mut rng) == 1 {
wins1 += 1;
}
}
assert!(wins1 > 130, "fitter index won only {wins1}/200");
}
}
-122
View File
@@ -187,94 +187,6 @@ 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,
@@ -287,18 +199,6 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
}
}
impl crate::traits::AlgorithmInfo for IpopCmaEs {
fn name(&self) -> &'static str {
"IPOP-CMA-ES"
}
fn full_name(&self) -> &'static str {
"Increasing-Population CMA-ES with Restarts"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -396,26 +296,4 @@ mod tests {
let mut opt = make_optimizer(0);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
#[test]
fn better_feasibility_first_and_direction() {
let feasible = Evaluation::new(vec![100.0]);
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
assert!(better(&feasible, &infeasible, Direction::Minimize));
assert!(!better(&infeasible, &feasible, Direction::Minimize));
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert!(better(&lo, &hi, Direction::Minimize));
assert!(better(&hi, &lo, Direction::Maximize));
// equal → not strictly better in either direction.
let eq = Evaluation::new(vec![1.0]);
assert!(!better(&lo, &eq, Direction::Minimize));
assert!(!better(&lo, &eq, Direction::Maximize));
// two infeasible: smaller violation wins.
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
assert!(better(&v_lo, &v_hi, Direction::Minimize));
}
}
-111
View File
@@ -149,77 +149,6 @@ 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,
@@ -328,18 +257,6 @@ fn perpendicular_distance(point: &[f64], extremes: &[usize], oriented: &[Vec<f64
(dot - b).abs() / norm
}
impl<I, V> crate::traits::AlgorithmInfo for Knea<I, V> {
fn name(&self) -> &'static str {
"KnEA"
}
fn full_name(&self) -> &'static str {
"Knee point-driven Evolutionary Algorithm"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -393,32 +310,4 @@ mod tests {
.collect();
assert_eq!(oa, ob);
}
// ---- Mutation-test pinned helpers --------------------------------------
#[test]
fn perpendicular_distance_to_simplex_hyperplane() {
// Two extremes (1,0) and (0,1) define the line x + y = 1.
// The point (1,1) has signed distance |2 - 1| / sqrt(2) = 1/sqrt(2).
let oriented = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]];
let d = perpendicular_distance(&oriented[2], &[0, 1], &oriented);
assert!((d - 1.0 / 2.0_f64.sqrt()).abs() < 1e-12, "d = {d}");
}
#[test]
fn perpendicular_distance_zero_on_hyperplane() {
// (0.5, 0.5) lies exactly on x + y = 1 → distance 0.
let oriented = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![0.5, 0.5]];
let d = perpendicular_distance(&oriented[2], &[0, 1], &oriented);
assert!(d.abs() < 1e-12, "d = {d}");
}
#[test]
fn perpendicular_distance_degenerate_too_few_extremes() {
// Only one extreme for a 2-D point → falls back to L2 from that
// extreme. (1,1) to (0,0) = sqrt(2).
let oriented = vec![vec![0.0, 0.0], vec![1.0, 1.0]];
let d = perpendicular_distance(&oriented[1], &[0], &oriented);
assert!((d - 2.0_f64.sqrt()).abs() < 1e-12, "d = {d}");
}
}
-166
View File
@@ -219,126 +219,6 @@ 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
@@ -363,18 +243,6 @@ fn weight_distance(a: &[f64], b: &[f64]) -> f64 {
.sqrt()
}
impl<I, V> crate::traits::AlgorithmInfo for Moead<I, V> {
fn name(&self) -> &'static str {
"MOEA/D"
}
fn full_name(&self) -> &'static str {
"Multi-Objective Evolutionary Algorithm based on Decomposition"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -452,38 +320,4 @@ mod tests {
);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
#[test]
fn tchebycheff_is_max_weighted_deviation() {
// ideal = (0, 0), weights = (1, 1): g = max(|f0|, |f1|).
let g = tchebycheff(&[3.0, 5.0], &[1.0, 1.0], &[0.0, 0.0]);
assert!((g - 5.0).abs() < 1e-12);
// weights skew which axis dominates.
let g2 = tchebycheff(&[3.0, 5.0], &[10.0, 1.0], &[0.0, 0.0]);
assert!((g2 - 30.0).abs() < 1e-12);
}
#[test]
fn tchebycheff_uses_distance_from_ideal() {
// ideal = (2, 2): deviations are |3-2|=1, |5-2|=3 → g = 3.
let g = tchebycheff(&[3.0, 5.0], &[1.0, 1.0], &[2.0, 2.0]);
assert!((g - 3.0).abs() < 1e-12);
}
#[test]
fn tchebycheff_zero_at_ideal() {
let g = tchebycheff(&[2.0, 2.0], &[1.0, 1.0], &[2.0, 2.0]);
assert!(g.abs() < 1e-12);
}
#[test]
fn weight_distance_is_euclidean() {
// (0,0) to (3,4) = 5.
assert!((weight_distance(&[0.0, 0.0], &[3.0, 4.0]) - 5.0).abs() < 1e-12);
// symmetric and zero-to-self.
assert!((weight_distance(&[3.0, 4.0], &[0.0, 0.0]) - 5.0).abs() < 1e-12);
assert_eq!(weight_distance(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]), 0.0);
}
}
-156
View File
@@ -212,136 +212,6 @@ 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,
)
}
}
impl crate::traits::AlgorithmInfo for Mopso {
fn name(&self) -> &'static str {
"MOPSO"
}
fn full_name(&self) -> &'static str {
"Multi-Objective Particle Swarm Optimization"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -394,30 +264,4 @@ mod tests {
let mut opt = make_optimizer(0);
let _ = opt.run(&Sphere1D);
}
/// MOPSO must return a population of the configured swarm size and a
/// non-empty Pareto front on a 2-objective problem. Pins the run-loop
/// bookkeeping against degenerate mutants.
#[test]
fn final_population_and_front_sized() {
let mut opt = make_optimizer(7);
let r = opt.run(&SchafferN1);
assert!(!r.pareto_front.is_empty());
// The archive should hold no more than its configured cap.
assert!(r.pareto_front.len() <= r.population.len().max(r.pareto_front.len()));
// Determinism cross-check.
let mut opt2 = make_optimizer(7);
let r2 = opt2.run(&SchafferN1);
let f1: Vec<Vec<f64>> = r
.pareto_front
.iter()
.map(|c| c.evaluation.objectives.clone())
.collect();
let f2: Vec<Vec<f64>> = r2
.pareto_front
.iter()
.map(|c| c.evaluation.objectives.clone())
.collect();
assert_eq!(f1, f2);
}
}
-204
View File
@@ -295,170 +295,6 @@ 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,
)
}
}
impl crate::traits::AlgorithmInfo for NelderMead {
fn name(&self) -> &'static str {
"Nelder-Mead"
}
fn full_name(&self) -> &'static str {
"Nelder-Mead simplex direct search"
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -551,44 +387,4 @@ mod tests {
);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::objective::Direction;
#[test]
fn compare_feasibility_first_and_direction() {
let feasible = Evaluation::new(vec![10.0]);
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
assert_eq!(
compare(&feasible, &infeasible, Direction::Minimize),
std::cmp::Ordering::Less
);
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert_eq!(
compare(&lo, &hi, Direction::Minimize),
std::cmp::Ordering::Less
);
assert_eq!(
compare(&lo, &hi, Direction::Maximize),
std::cmp::Ordering::Greater
);
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
assert_eq!(
compare(&v_lo, &v_hi, Direction::Minimize),
std::cmp::Ordering::Less
);
}
#[test]
fn better_is_strict_less() {
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert!(better(&lo, &hi, Direction::Minimize));
assert!(!better(&hi, &lo, Direction::Minimize));
let eq = Evaluation::new(vec![1.0]);
assert!(!better(&lo, &eq, Direction::Minimize));
}
}
+69 -196
View File
@@ -108,6 +108,16 @@ where
V: Variation<P::Decision>,
{
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
self.run_with(problem, &mut ())
}
fn run_with<O>(&mut self, problem: &P, observer: &mut O) -> OptimizationResult<P::Decision>
where
O: crate::observer::Observer<P::Decision>,
{
use crate::observer::Snapshot;
use std::ops::ControlFlow;
assert!(
self.config.population_size > 0,
"Nsga2 population_size must be greater than 0",
@@ -115,6 +125,7 @@ where
let n = self.config.population_size;
let objectives = problem.objectives();
let mut rng = rng_from_seed(self.config.seed);
let started = std::time::Instant::now();
// Initial population.
let initial_decisions = self.initializer.initialize(n, &mut rng);
@@ -130,7 +141,27 @@ where
// round of tournament selection has data to compare on.
let mut annotated = annotate(population, &objectives);
for _ in 0..self.config.generations {
// Observer: notify after the initial population.
let mut completed_generations: usize = 0;
let pop_view: Vec<Candidate<P::Decision>> =
annotated.iter().map(|e| e.candidate.clone()).collect();
let front_view = pareto_front(&pop_view, &objectives);
let snap = Snapshot {
iteration: 0,
evaluations,
elapsed: started.elapsed(),
population: &pop_view,
pareto_front: Some(&front_view),
best: None,
objectives: &objectives,
};
if let ControlFlow::Break(()) = observer.observe(&snap) {
return finalize_nsga2(annotated, &objectives, evaluations, completed_generations);
}
drop(pop_view);
drop(front_view);
for generation in 1..=self.config.generations {
// --- Phase 1: serial parent selection + variation ---
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
while offspring_decisions.len() < n {
@@ -189,23 +220,48 @@ where
}
}
annotated = annotate(next, &objectives);
completed_generations = generation;
// Per-generation observation.
let pop_view: Vec<Candidate<P::Decision>> =
annotated.iter().map(|e| e.candidate.clone()).collect();
let front_view = pareto_front(&pop_view, &objectives);
let snap = Snapshot {
iteration: generation,
evaluations,
elapsed: started.elapsed(),
population: &pop_view,
pareto_front: Some(&front_view),
best: None,
objectives: &objectives,
};
if let ControlFlow::Break(()) = observer.observe(&snap) {
return finalize_nsga2(annotated, &objectives, evaluations, completed_generations);
}
}
// Return final state.
let final_pop: Vec<Candidate<P::Decision>> =
annotated.into_iter().map(|e| e.candidate).collect();
let front = pareto_front(&final_pop, &objectives);
let best = best_candidate(&final_pop, &objectives);
OptimizationResult::new(
Population::new(final_pop),
front,
best,
evaluations,
self.config.generations,
)
finalize_nsga2(annotated, &objectives, evaluations, self.config.generations)
}
}
fn finalize_nsga2<D: Clone>(
annotated: Vec<Nsga2Entry<D>>,
objectives: &crate::core::objective::ObjectiveSpace,
evaluations: usize,
generations: usize,
) -> OptimizationResult<D> {
let final_pop: Vec<Candidate<D>> = annotated.into_iter().map(|e| e.candidate).collect();
let front = pareto_front(&final_pop, objectives);
let best = best_candidate(&final_pop, objectives);
OptimizationResult::new(
Population::new(final_pop),
front,
best,
evaluations,
generations,
)
}
fn annotate<D: Clone>(
population: Vec<Candidate<D>>,
objectives: &crate::core::objective::ObjectiveSpace,
@@ -232,117 +288,6 @@ 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);
@@ -364,18 +309,6 @@ fn binary_tournament<D>(entries: &[Nsga2Entry<D>], rng: &mut Rng) -> usize {
}
}
impl<I, V> crate::traits::AlgorithmInfo for Nsga2<I, V> {
fn name(&self) -> &'static str {
"NSGA-II"
}
fn full_name(&self) -> &'static str {
"Non-dominated Sorting Genetic Algorithm II"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -463,64 +396,4 @@ mod tests {
);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
#[test]
fn binary_tournament_prefers_lower_rank() {
use crate::core::candidate::Candidate;
use crate::core::evaluation::Evaluation;
use crate::core::rng::rng_from_seed;
// Entry 0: rank 0; entry 1: rank 1. Lower rank must win every time
// the two draws differ.
let entries = vec![
Nsga2Entry {
candidate: Candidate::new(0u32, Evaluation::new(vec![1.0, 1.0])),
rank: 0,
crowding_distance: 0.0,
},
Nsga2Entry {
candidate: Candidate::new(1u32, Evaluation::new(vec![2.0, 2.0])),
rank: 1,
crowding_distance: 100.0,
},
];
let mut wins0 = 0;
for seed in 0..200 {
let mut rng = rng_from_seed(seed);
if binary_tournament(&entries, &mut rng) == 0 {
wins0 += 1;
}
}
// Rank dominates crowding distance — index 0 wins the clear majority.
assert!(wins0 > 130, "lower-rank index won only {wins0}/200");
}
#[test]
fn binary_tournament_prefers_higher_crowding_at_equal_rank() {
use crate::core::candidate::Candidate;
use crate::core::evaluation::Evaluation;
use crate::core::rng::rng_from_seed;
// Both rank 0; entry 0 has higher crowding distance → preferred.
let entries = vec![
Nsga2Entry {
candidate: Candidate::new(0u32, Evaluation::new(vec![1.0, 1.0])),
rank: 0,
crowding_distance: 10.0,
},
Nsga2Entry {
candidate: Candidate::new(1u32, Evaluation::new(vec![1.0, 1.0])),
rank: 0,
crowding_distance: 1.0,
},
];
let mut wins0 = 0;
for seed in 0..200 {
let mut rng = rng_from_seed(seed);
if binary_tournament(&entries, &mut rng) == 0 {
wins0 += 1;
}
}
assert!(wins0 > 130, "higher-crowding index won only {wins0}/200");
}
}
-151
View File
@@ -180,92 +180,6 @@ 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>(
@@ -542,71 +456,6 @@ fn associate(
(assoc, dist)
}
impl<I, V> crate::traits::AlgorithmInfo for Nsga3<I, V> {
fn name(&self) -> &'static str {
"NSGA-III"
}
fn full_name(&self) -> &'static str {
"Non-dominated Sorting Genetic Algorithm III"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod helper_tests {
use super::*;
#[test]
fn solve_intercepts_axis_aligned_extremes() {
// Extremes (2, 0) and (0, 3): the plane through them on the
// canonical simplex has intercepts (2, 3).
let oriented = vec![vec![2.0, 0.0], vec![0.0, 3.0]];
let intercepts = solve_intercepts(&oriented, &[0, 1]).expect("solvable");
assert!((intercepts[0] - 2.0).abs() < 1e-9, "got {:?}", intercepts);
assert!((intercepts[1] - 3.0).abs() < 1e-9, "got {:?}", intercepts);
}
#[test]
fn solve_intercepts_singular_matrix_returns_none() {
// Two identical extremes → singular system → None.
let oriented = vec![vec![1.0, 1.0], vec![1.0, 1.0]];
assert!(solve_intercepts(&oriented, &[0, 1]).is_none());
}
#[test]
fn solve_intercepts_empty_extremes_returns_none() {
let oriented: Vec<Vec<f64>> = Vec::new();
assert!(solve_intercepts(&oriented, &[]).is_none());
}
#[test]
fn associate_picks_closest_reference_direction() {
// Two reference directions: the x-axis and the y-axis.
let refs = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
// A point near the x-axis associates with reference 0;
// a point near the y-axis associates with reference 1.
let normalized = vec![vec![1.0, 0.05], vec![0.05, 1.0]];
let (assoc, dist) = associate(&normalized, &refs, 2);
assert_eq!(assoc[0], 0);
assert_eq!(assoc[1], 1);
// Perpendicular distance from (1, 0.05) to the x-axis is 0.05.
assert!((dist[0] - 0.05).abs() < 1e-9, "dist0 = {}", dist[0]);
assert!((dist[1] - 0.05).abs() < 1e-9, "dist1 = {}", dist[1]);
}
#[test]
fn associate_point_on_reference_line_has_zero_distance() {
let refs = vec![vec![1.0, 0.0]];
// (3, 0) lies exactly on the x-axis direction → perp distance 0.
let normalized = vec![vec![3.0, 0.0]];
let (assoc, dist) = associate(&normalized, &refs, 2);
assert_eq!(assoc[0], 0);
assert!(dist[0].abs() < 1e-9, "dist = {}", dist[0]);
}
}
#[cfg(test)]
mod tests {
use super::*;
-134
View File
@@ -191,112 +191,6 @@ 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,
)
}
}
impl crate::traits::AlgorithmInfo for OnePlusOneEs {
fn name(&self) -> &'static str {
"(1+1)-ES"
}
fn full_name(&self) -> &'static str {
"(1+1) Evolution Strategy with one-fifth success rule"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -345,32 +239,4 @@ mod tests {
let mut opt = make_optimizer(0);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::evaluation::Evaluation;
use crate::core::objective::Direction;
#[test]
fn worse_than_feasibility_and_direction() {
let feasible = Evaluation::new(vec![100.0]);
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
// infeasible is worse than feasible regardless of objective.
assert!(worse_than(&infeasible, &feasible, Direction::Minimize));
assert!(!worse_than(&feasible, &infeasible, Direction::Minimize));
// two feasible, minimize: larger objective is worse.
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert!(worse_than(&hi, &lo, Direction::Minimize));
assert!(!worse_than(&lo, &hi, Direction::Minimize));
// maximize inverts.
assert!(worse_than(&lo, &hi, Direction::Maximize));
// equal → not worse.
let eq = Evaluation::new(vec![1.0]);
assert!(!worse_than(&lo, &eq, Direction::Minimize));
// two infeasible: larger violation is worse.
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
assert!(worse_than(&v_hi, &v_lo, Direction::Minimize));
}
}
-132
View File
@@ -156,104 +156,6 @@ 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,
)
}
}
impl<I, V> crate::traits::AlgorithmInfo for Paes<I, V> {
fn name(&self) -> &'static str {
"PAES"
}
fn full_name(&self) -> &'static str {
"Pareto Archived Evolution Strategy"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -305,38 +207,4 @@ mod tests {
let r = opt.run(&Sphere1D);
assert!(r.best.is_some());
}
/// PAES must return a non-empty Pareto archive on a 2-objective problem
/// and be deterministic with a fixed seed. Pins the run-loop
/// bookkeeping against degenerate / comparison mutants.
#[test]
fn produces_deterministic_nonempty_front() {
let make = || {
Paes::new(
PaesConfig {
iterations: 40,
archive_size: 10,
seed: 5,
},
RealBounds::new(vec![(-5.0, 5.0)]),
GaussianMutation { sigma: 0.3 },
)
};
let r1 = make().run(&SchafferN1);
let r2 = make().run(&SchafferN1);
assert!(!r1.pareto_front.is_empty());
let f1: Vec<Vec<f64>> = r1
.pareto_front
.iter()
.map(|c| c.evaluation.objectives.clone())
.collect();
let f2: Vec<Vec<f64>> = r2
.pareto_front
.iter()
.map(|c| c.evaluation.objectives.clone())
.collect();
assert_eq!(f1, f2);
// Archive never exceeds its configured cap.
assert!(r1.pareto_front.len() <= 10);
}
}
+1 -34
View File
@@ -5,9 +5,8 @@
use futures::stream::{FuturesOrdered, StreamExt};
use crate::core::async_problem::{AsyncPartialProblem, AsyncProblem};
use crate::core::async_problem::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
@@ -57,35 +56,3 @@ where
}
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
}
-164
View File
@@ -220,129 +220,6 @@ 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() {
@@ -357,18 +234,6 @@ fn best_index(values: &[f64], direction: Direction) -> usize {
idx
}
impl crate::traits::AlgorithmInfo for ParticleSwarm {
fn name(&self) -> &'static str {
"PSO"
}
fn full_name(&self) -> &'static str {
"Particle Swarm Optimization"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -418,33 +283,4 @@ mod tests {
let mut opt = make_optimizer(0);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::objective::Direction;
#[test]
fn best_index_minimize_picks_smallest() {
let v = [3.0, 1.0, 4.0, 1.5];
assert_eq!(best_index(&v, Direction::Minimize), 1);
}
#[test]
fn best_index_maximize_picks_largest() {
let v = [3.0, 1.0, 4.0, 1.5];
assert_eq!(best_index(&v, Direction::Maximize), 2);
}
#[test]
fn best_index_keeps_first_on_tie() {
// Strict comparison → the earliest index of a tied extreme wins.
let v = [1.0, 1.0, 1.0];
assert_eq!(best_index(&v, Direction::Minimize), 0);
assert_eq!(best_index(&v, Direction::Maximize), 0);
}
#[test]
fn best_index_single_element() {
assert_eq!(best_index(&[42.0], Direction::Minimize), 0);
}
}
-181
View File
@@ -204,109 +204,6 @@ 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>(
@@ -409,18 +306,6 @@ fn truncate_by_grid<D: Clone>(archive: &mut ParetoArchive<D>, max_size: usize, d
}
}
impl<I, V> crate::traits::AlgorithmInfo for PesaII<I, V> {
fn name(&self) -> &'static str {
"PESA-II"
}
fn full_name(&self) -> &'static str {
"Pareto Envelope-based Selection Algorithm II"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -499,70 +384,4 @@ mod tests {
);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::candidate::Candidate;
use crate::core::evaluation::Evaluation;
use crate::core::objective::{Objective, ObjectiveSpace};
fn space2() -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
}
#[test]
fn build_grid_empty_archive_is_empty() {
let archive = ParetoArchive::<u32>::new(space2());
let (boxes, counts) = build_grid(&archive, &space2(), 4);
assert!(boxes.is_empty());
assert!(counts.is_empty());
}
#[test]
fn build_grid_assigns_corner_points_to_distinct_boxes() {
let mut archive = ParetoArchive::<u32>::new(space2());
// Three non-dominated corner points span the grid extremes.
archive.insert(Candidate::new(1u32, Evaluation::new(vec![0.0, 4.0])));
archive.insert(Candidate::new(2u32, Evaluation::new(vec![2.0, 2.0])));
archive.insert(Candidate::new(3u32, Evaluation::new(vec![4.0, 0.0])));
let (boxes, counts) = build_grid(&archive, &space2(), 4);
assert_eq!(boxes.len(), 3);
// The min and max corners land in different boxes — total count
// across all boxes equals the member count.
let total: usize = counts.values().sum();
assert_eq!(total, 3);
// The two extreme points are in different boxes (grid spreads them).
assert_ne!(boxes[0], boxes[2]);
}
#[test]
fn region_tournament_prefers_less_crowded_box() {
use crate::core::rng::rng_from_seed;
// Members 0 and 1 share a crowded box (count 2); member 2 is alone.
let mut archive = ParetoArchive::<u32>::new(space2());
archive.insert(Candidate::new(1u32, Evaluation::new(vec![0.0, 4.0])));
archive.insert(Candidate::new(2u32, Evaluation::new(vec![2.0, 2.0])));
archive.insert(Candidate::new(3u32, Evaluation::new(vec![4.0, 0.0])));
// Hand-build boxes/counts where index 2 is in a singleton box and
// indices 0,1 share a crowded box.
let boxes = vec![vec![0usize, 0], vec![0usize, 0], vec![3usize, 3]];
let mut counts = std::collections::BTreeMap::new();
counts.insert(vec![0usize, 0], 2usize);
counts.insert(vec![3usize, 3], 1usize);
// Across many seeds, the less-crowded index (2) must win whenever
// the two random draws differ between the crowded/uncrowded boxes.
let mut picked_uncrowded = 0;
for seed in 0..300 {
let mut rng = rng_from_seed(seed);
if region_tournament(&archive, &boxes, &counts, &mut rng) == 2 {
picked_uncrowded += 1;
}
}
// Index 2 wins whenever it's drawn against 0 or 1, plus half its
// self-draws — clear majority.
assert!(
picked_uncrowded > 150,
"uncrowded picked {picked_uncrowded}/300"
);
}
}
+29 -40
View File
@@ -88,28 +88,49 @@ where
I: Initializer<P::Decision>,
{
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
self.run_with(problem, &mut ())
}
fn run_with<O>(&mut self, problem: &P, observer: &mut O) -> OptimizationResult<P::Decision>
where
O: crate::observer::Observer<P::Decision>,
{
use crate::observer::Snapshot;
use std::ops::ControlFlow;
let objectives = problem.objectives();
let mut rng = rng_from_seed(self.config.seed);
let mut all: Vec<Candidate<P::Decision>> = Vec::new();
let mut evaluations = 0usize;
let started = std::time::Instant::now();
let mut completed: usize = 0;
for _ in 0..self.config.iterations {
for iteration in 1..=self.config.iterations {
let decisions = self
.initializer
.initialize(self.config.batch_size, &mut rng);
evaluations += decisions.len();
all.extend(evaluate_batch(problem, decisions));
completed = iteration;
let best = best_candidate(&all, &objectives);
let snap = Snapshot {
iteration,
evaluations,
elapsed: started.elapsed(),
population: &all,
pareto_front: None,
best: best.as_ref(),
objectives: &objectives,
};
if let ControlFlow::Break(()) = observer.observe(&snap) {
break;
}
}
let front = pareto_front(&all, &objectives);
let best = best_candidate(&all, &objectives);
OptimizationResult::new(
Population::new(all),
front,
best,
evaluations,
self.config.iterations,
)
OptimizationResult::new(Population::new(all), front, best, evaluations, completed)
}
}
@@ -158,15 +179,6 @@ impl<I> RandomSearch<I> {
}
}
impl<I> crate::traits::AlgorithmInfo for RandomSearch<I> {
fn name(&self) -> &'static str {
"Random Search"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -218,27 +230,4 @@ mod tests {
let r = opt.run(&Sphere1D);
assert!(r.best.is_some());
}
/// RandomSearch's evaluation count is exactly `iterations * batch_size`,
/// and the returned best is no worse than every sampled candidate.
#[test]
fn best_is_no_worse_than_any_sample() {
let mut opt = RandomSearch::new(
RandomSearchConfig {
iterations: 50,
batch_size: 2,
seed: 9,
},
RealBounds::new(vec![(-3.0, 3.0)]),
);
let r = opt.run(&Sphere1D);
assert_eq!(r.evaluations, 100);
let best = r.best.unwrap().evaluation.objectives[0];
let pop_min = r
.population
.iter()
.map(|c| c.evaluation.objectives[0])
.fold(f64::INFINITY, f64::min);
assert!(best <= pop_min + 1e-12, "best {best} > pop min {pop_min}");
}
}
-209
View File
@@ -261,163 +261,6 @@ 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 {
@@ -466,18 +309,6 @@ fn smallest_neighbor_angle(references: &[Vec<f64>]) -> f64 {
}
}
impl<I, V> crate::traits::AlgorithmInfo for Rvea<I, V> {
fn name(&self) -> &'static str {
"RVEA"
}
fn full_name(&self) -> &'static str {
"Reference Vector-guided Evolutionary Algorithm"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -556,44 +387,4 @@ mod tests {
);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
#[test]
fn unit_normalize_produces_unit_vector() {
let v = unit_normalize(vec![3.0, 4.0]);
let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
assert!((norm - 1.0).abs() < 1e-12);
assert!((v[0] - 0.6).abs() < 1e-12);
assert!((v[1] - 0.8).abs() < 1e-12);
}
#[test]
fn unit_normalize_zero_vector_unchanged() {
// A (near-)zero vector is left as-is (no division by ~0).
let v = unit_normalize(vec![0.0, 0.0]);
assert_eq!(v, vec![0.0, 0.0]);
}
#[test]
fn closest_reference_picks_smallest_angle() {
// References along the two axes; a point near the x-axis associates
// with reference 0 at a small angle.
let refs = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let (idx, angle) = closest_reference(&[1.0, 0.0], &refs);
assert_eq!(idx, 0);
assert!(angle.abs() < 1e-9, "angle = {angle}");
let (idx2, _) = closest_reference(&[0.1, 1.0], &refs);
assert_eq!(idx2, 1);
}
#[test]
fn smallest_neighbor_angle_of_orthogonal_refs_is_pi_over_2() {
let refs = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let a = smallest_neighbor_angle(&refs);
assert!(
(a - std::f64::consts::FRAC_PI_2).abs() < 1e-9,
"angle = {a}"
);
}
}
-149
View File
@@ -215,133 +215,6 @@ 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,
)
}
}
impl<I, V> crate::traits::AlgorithmInfo for SimulatedAnnealing<I, V> {
fn name(&self) -> &'static str {
"Simulated Annealing"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -407,26 +280,4 @@ mod tests {
);
let _ = opt.run(&Sphere1D);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::evaluation::Evaluation;
use crate::core::objective::Direction;
#[test]
fn better_than_feasibility_first_and_direction() {
let feasible = Evaluation::new(vec![100.0]);
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
assert!(better_than(&feasible, &infeasible, Direction::Minimize));
assert!(!better_than(&infeasible, &feasible, Direction::Minimize));
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert!(better_than(&lo, &hi, Direction::Minimize));
assert!(better_than(&hi, &lo, Direction::Maximize));
let eq = Evaluation::new(vec![1.0]);
assert!(!better_than(&lo, &eq, Direction::Minimize));
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
assert!(better_than(&v_lo, &v_hi, Direction::Minimize));
}
}
-133
View File
@@ -173,80 +173,6 @@ 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
@@ -289,18 +215,6 @@ fn pick_drop_index<D>(
worst_front[worst_idx_in_front]
}
impl<I, V> crate::traits::AlgorithmInfo for SmsEmoa<I, V> {
fn name(&self) -> &'static str {
"SMS-EMOA"
}
fn full_name(&self) -> &'static str {
"S-Metric Selection Evolutionary Multi-Objective Algorithm"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -400,51 +314,4 @@ mod tests {
);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::candidate::Candidate;
use crate::core::evaluation::Evaluation;
use crate::core::objective::{Objective, ObjectiveSpace};
fn sms_space() -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
}
fn sms_cand(o: Vec<f64>) -> Candidate<u32> {
Candidate::new(0, Evaluation::new(o))
}
/// `pick_drop_index` drops the member of the worst front with the
/// smallest hypervolume contribution. With one clearly-dominated point
/// in the pool, that point forms a singleton worst front and is
/// returned directly.
#[test]
fn pick_drop_index_returns_singleton_worst_front() {
// (1,1) and (2,2)-trade-offs are front 0; (9,9) is dominated → the
// sole member of front 1.
let pool = vec![
sms_cand(vec![1.0, 3.0]),
sms_cand(vec![3.0, 1.0]),
sms_cand(vec![9.0, 9.0]), // dominated — worst front, singleton
];
let drop = pick_drop_index(&pool, &sms_space(), &[100.0, 100.0]);
assert_eq!(drop, 2, "should drop the dominated singleton");
}
/// When the worst front has multiple members, the one with the
/// smallest hypervolume contribution is dropped — and the scan must
/// find it even at a non-zero index. Here `(1.0, 9.0)` at index 1 is
/// "shadowed" by its near-neighbour `(1.5, 8.5)` and contributes the
/// least unique HV (≈ 0.5 vs ≈ 3.75 and ≈ 7.5).
#[test]
fn pick_drop_index_drops_least_hv_contributor() {
// All three mutually non-dominated → single (worst) front.
let pool = vec![
sms_cand(vec![1.5, 8.5]),
sms_cand(vec![1.0, 9.0]), // least HV contribution → drop target
sms_cand(vec![9.0, 1.0]),
];
let drop = pick_drop_index(&pool, &sms_space(), &[10.0, 10.0]);
assert_eq!(drop, 1, "should drop the lowest-HV-contribution member");
}
}
-194
View File
@@ -222,137 +222,6 @@ 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)
@@ -389,18 +258,6 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
compare(a, b, direction) == std::cmp::Ordering::Less
}
impl crate::traits::AlgorithmInfo for SeparableNes {
fn name(&self) -> &'static str {
"sNES"
}
fn full_name(&self) -> &'static str {
"Separable Natural Evolution Strategy"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -457,55 +314,4 @@ mod tests {
let mut opt = make_optimizer(0);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::evaluation::Evaluation;
use crate::core::objective::Direction;
#[test]
fn nes_utilities_sum_to_zero_and_are_descending() {
// The NES utility weights are a shifted log-rank scheme; they sum
// to (approximately) zero and the first (best-ranked) is largest.
let u = nes_utilities(10);
assert_eq!(u.len(), 10);
let sum: f64 = u.iter().sum();
assert!(sum.abs() < 1e-9, "utilities sum = {sum}");
// Descending: best rank gets the most weight.
for w in u.windows(2) {
assert!(w[0] >= w[1] - 1e-12, "not descending: {:?}", u);
}
// The first utility is positive (it gets above-average weight).
assert!(u[0] > 0.0);
}
#[test]
fn compare_feasibility_first_and_direction() {
let feasible = Evaluation::new(vec![100.0]);
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
assert_eq!(
compare(&feasible, &infeasible, Direction::Minimize),
std::cmp::Ordering::Less
);
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert_eq!(
compare(&lo, &hi, Direction::Minimize),
std::cmp::Ordering::Less
);
assert_eq!(
compare(&lo, &hi, Direction::Maximize),
std::cmp::Ordering::Greater
);
}
#[test]
fn better_is_strict_less() {
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert!(better(&lo, &hi, Direction::Minimize));
assert!(!better(&hi, &lo, Direction::Minimize));
let eq = Evaluation::new(vec![1.0]);
assert!(!better(&lo, &eq, Direction::Minimize));
}
}
-123
View File
@@ -169,91 +169,6 @@ 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
@@ -503,18 +418,6 @@ fn binary_tournament(fitness: &[f64], rng: &mut Rng) -> usize {
}
}
impl<I, V> crate::traits::AlgorithmInfo for Spea2<I, V> {
fn name(&self) -> &'static str {
"SPEA2"
}
fn full_name(&self) -> &'static str {
"Strength Pareto Evolutionary Algorithm 2"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -601,30 +504,4 @@ mod tests {
);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
#[test]
fn euclidean_distance_basics() {
// (0,0) to (3,4) = 5.
assert!((euclidean(&[0.0, 0.0], &[3.0, 4.0]) - 5.0).abs() < 1e-12);
// symmetric and zero-to-self.
assert!((euclidean(&[3.0, 4.0], &[0.0, 0.0]) - 5.0).abs() < 1e-12);
assert_eq!(euclidean(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]), 0.0);
}
#[test]
fn binary_tournament_prefers_lower_fitness() {
// SPEA2 fitness is "lower is better" — index 1 here is the best.
use crate::core::rng::rng_from_seed;
let fitness = vec![5.0_f64, 0.5];
let mut wins1 = 0;
for seed in 0..200 {
let mut rng = rng_from_seed(seed);
if binary_tournament(&fitness, &mut rng) == 1 {
wins1 += 1;
}
}
assert!(wins1 > 130, "lower-fitness index won only {wins1}/200");
}
}
-156
View File
@@ -209,142 +209,6 @@ 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,
)
}
}
impl<D, I, N> crate::traits::AlgorithmInfo for TabuSearch<D, I, N>
where
D: Clone + Hash + Eq,
I: Initializer<D>,
N: FnMut(&D, &mut Rng) -> Vec<D>,
{
fn name(&self) -> &'static str {
"Tabu Search"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -421,24 +285,4 @@ mod tests {
rb.best.unwrap().evaluation.objectives,
);
}
// ---- Mutation-test pinned helpers --------------------------------------
#[test]
fn better_than_feasibility_first_and_direction() {
use crate::core::objective::Direction;
let feasible = Evaluation::new(vec![100.0]);
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
assert!(better_than(&feasible, &infeasible, Direction::Minimize));
assert!(!better_than(&infeasible, &feasible, Direction::Minimize));
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert!(better_than(&lo, &hi, Direction::Minimize));
assert!(better_than(&hi, &lo, Direction::Maximize));
let eq = Evaluation::new(vec![1.0]);
assert!(!better_than(&lo, &eq, Direction::Minimize));
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
assert!(better_than(&v_lo, &v_hi, Direction::Minimize));
}
}
-164
View File
@@ -187,122 +187,6 @@ 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() {
@@ -325,18 +209,6 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
}
}
impl crate::traits::AlgorithmInfo for Tlbo {
fn name(&self) -> &'static str {
"TLBO"
}
fn full_name(&self) -> &'static str {
"Teaching-Learning-Based Optimization"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -383,40 +255,4 @@ mod tests {
let mut opt = make_optimizer(0);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::evaluation::Evaluation;
use crate::core::objective::Direction;
#[test]
fn better_feasibility_first_and_direction() {
let feasible = Evaluation::new(vec![100.0]);
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
assert!(better(&feasible, &infeasible, Direction::Minimize));
assert!(!better(&infeasible, &feasible, Direction::Minimize));
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert!(better(&lo, &hi, Direction::Minimize));
assert!(better(&hi, &lo, Direction::Maximize));
let eq = Evaluation::new(vec![1.0]);
assert!(!better(&lo, &eq, Direction::Minimize));
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
assert!(better(&v_lo, &v_hi, Direction::Minimize));
}
#[test]
fn best_index_finds_min_and_max() {
let evals = [
Evaluation::new(vec![3.0]),
Evaluation::new(vec![1.0]),
Evaluation::new(vec![4.0]),
];
assert_eq!(best_index(&evals, Direction::Minimize), 1);
assert_eq!(best_index(&evals, Direction::Maximize), 2);
// tie keeps the first.
let flat = [Evaluation::new(vec![1.0]), Evaluation::new(vec![1.0])];
assert_eq!(best_index(&flat, Direction::Minimize), 0);
}
}
+27 -190
View File
@@ -143,19 +143,31 @@ where
// Split into good vs bad observations.
let (good_idx, bad_idx) = split_good_bad(&targets, self.config.good_fraction);
// The good / bad supports are fixed for this iteration, so their
// Scott's-rule bandwidths are too — derive them once instead of
// recomputing inside every sample / density call.
let good_bw = scott_bandwidths(&decisions, &good_idx, self.config.bandwidth_factor);
let bad_bw = scott_bandwidths(&decisions, &bad_idx, self.config.bandwidth_factor);
// Sample candidates from the good KDE.
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, &good_bw, &mut rng);
let l = log_kde_density(&cand, &decisions, &good_idx, &self.bounds, &good_bw);
let g = log_kde_density(&cand, &decisions, &bad_idx, &self.bounds, &bad_bw);
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;
@@ -259,13 +271,14 @@ fn sample_from_kde(
decisions: &[Vec<f64>],
support: &[usize],
bounds: &RealBounds,
bandwidths: &[f64],
bandwidth_factor: f64,
rng: &mut Rng,
) -> Vec<f64> {
if support.is_empty() {
return sample_uniform_in_bounds(bounds, rng);
}
let dim = bounds.bounds.len();
let bandwidths = scott_bandwidths(decisions, support, bandwidth_factor);
let pick = support[rng.random_range(0..support.len())];
let center = &decisions[pick];
@@ -279,20 +292,19 @@ fn sample_from_kde(
x
}
/// Per-axis log-density at `x` of the KDE built on `support`, given the
/// precomputed per-axis `bandwidths`.
/// Per-axis log-density at `x` of the KDE built on `support`.
fn log_kde_density(
x: &[f64],
decisions: &[Vec<f64>],
support: &[usize],
bounds: &RealBounds,
bandwidths: &[f64],
bandwidth_factor: f64,
) -> f64 {
if support.is_empty() {
return f64::NEG_INFINITY;
}
let dim = bounds.bounds.len();
let sqrt_2pi = (2.0 * std::f64::consts::PI).sqrt();
let bandwidths = scott_bandwidths(decisions, support, bandwidth_factor);
// Sum of per-axis log-densities, with the kernel a product of 1-D
// Gaussians. Using log-sum-exp for numerical stability would be more
@@ -302,11 +314,10 @@ fn log_kde_density(
let mut total = 0.0;
for j in 0..dim {
let h = bandwidths[j].max(1e-12);
let norm = h * sqrt_2pi;
let mut s = 0.0;
for &i in support {
let z = (x[j] - decisions[i][j]) / h;
s += (-0.5 * z * z).exp() / norm;
s += (-0.5 * z * z).exp() / (h * (2.0 * std::f64::consts::PI).sqrt());
}
let mean_density = s / support.len() as f64;
total += mean_density.max(1e-300).ln();
@@ -345,128 +356,6 @@ 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);
// Bandwidths depend only on the (fixed-for-this-iteration)
// supports — compute once, not once per sample / density call.
let good_bw = scott_bandwidths(&decisions, &good_idx, self.config.bandwidth_factor);
let bad_bw = scott_bandwidths(&decisions, &bad_idx, self.config.bandwidth_factor);
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, &good_bw, &mut rng);
let l = log_kde_density(&cand, &decisions, &good_idx, &self.bounds, &good_bw);
let g = log_kde_density(&cand, &decisions, &bad_idx, &self.bounds, &bad_bw);
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,
)
}
}
impl crate::traits::AlgorithmInfo for Tpe {
fn name(&self) -> &'static str {
"TPE"
}
fn full_name(&self) -> &'static str {
"Tree-structured Parzen Estimator"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -528,56 +417,4 @@ mod tests {
let mut opt = make_optimizer(0);
let _ = opt.run(&SchafferN1);
}
// ---- Mutation-test pinned helpers --------------------------------------
use crate::core::evaluation::Evaluation;
use crate::core::objective::Direction;
#[test]
fn oriented_target_flips_sign_and_penalizes() {
let e = Evaluation::new(vec![3.0]);
assert!((oriented_target(&e, Direction::Minimize) - 3.0).abs() < 1e-12);
assert!((oriented_target(&e, Direction::Maximize) + 3.0).abs() < 1e-12);
let mut bad = Evaluation::new(vec![1.0]);
bad.constraint_violation = 0.5;
assert!((oriented_target(&bad, Direction::Minimize) - 500_001.0).abs() < 1e-9);
}
#[test]
fn better_feasibility_first_and_direction() {
let feasible = Evaluation::new(vec![100.0]);
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
assert!(better(&feasible, &infeasible, Direction::Minimize));
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert!(better(&lo, &hi, Direction::Minimize));
assert!(better(&hi, &lo, Direction::Maximize));
}
#[test]
fn split_good_bad_partitions_by_target_rank() {
// targets 5, 1, 3, 9, 7 → ranked 1<3<5<7<9 → indices 1,2,0,4,3.
let targets = [5.0, 1.0, 3.0, 9.0, 7.0];
let (good, bad) = split_good_bad(&targets, 0.4);
// 40% of 5 = 2 good.
assert_eq!(good.len(), 2);
assert_eq!(bad.len(), 3);
// The two smallest targets (1.0 at idx 1, 3.0 at idx 2) are "good".
assert!(good.contains(&1));
assert!(good.contains(&2));
}
#[test]
fn split_good_bad_clamps_to_at_least_one_each() {
let targets = [5.0, 1.0, 3.0];
// good_fraction 0.0 would round to 0 — must clamp to >= 1.
let (good, bad) = split_good_bad(&targets, 0.0);
assert!(!good.is_empty());
assert!(!bad.is_empty());
// good_fraction 1.0 would take everything — must leave >= 1 bad.
let (good2, bad2) = split_good_bad(&targets, 1.0);
assert!(!good2.is_empty());
assert!(!bad2.is_empty());
}
}
-169
View File
@@ -202,125 +202,6 @@ 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,
@@ -352,18 +233,6 @@ fn better_than_so(
compare_so(a, b, direction) == std::cmp::Ordering::Less
}
impl crate::traits::AlgorithmInfo for Umda {
fn name(&self) -> &'static str {
"UMDA"
}
fn full_name(&self) -> &'static str {
"Univariate Marginal Distribution Algorithm"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -449,42 +318,4 @@ mod tests {
});
let _ = opt.run(&DummyMo);
}
// ---- Mutation-test pinned helpers --------------------------------------
#[test]
fn compare_so_feasibility_first_and_direction() {
let feasible = Evaluation::new(vec![100.0]);
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
assert_eq!(
compare_so(&feasible, &infeasible, Direction::Minimize),
std::cmp::Ordering::Less
);
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert_eq!(
compare_so(&lo, &hi, Direction::Minimize),
std::cmp::Ordering::Less
);
assert_eq!(
compare_so(&lo, &hi, Direction::Maximize),
std::cmp::Ordering::Greater
);
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
assert_eq!(
compare_so(&v_lo, &v_hi, Direction::Minimize),
std::cmp::Ordering::Less
);
}
#[test]
fn better_than_so_is_strict_less() {
let lo = Evaluation::new(vec![1.0]);
let hi = Evaluation::new(vec![2.0]);
assert!(better_than_so(&lo, &hi, Direction::Minimize));
assert!(!better_than_so(&hi, &lo, Direction::Minimize));
let eq = Evaluation::new(vec![1.0]);
assert!(!better_than_so(&lo, &eq, Direction::Minimize));
}
}
+4 -31
View File
@@ -7,12 +7,10 @@
//! 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.
//! `evaluate_async` returns a future. Algorithms that support async
//! evaluation (NSGA-II, DE, RandomSearch as of v0.7.0; others land
//! incrementally) expose a `run_async` method that drives evaluations
//! through a user-chosen async runtime (typically tokio).
//!
//! Available only with the `async` feature.
@@ -54,28 +52,3 @@ pub trait AsyncProblem: Sync {
/// 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;
}
-106
View File
@@ -1,106 +0,0 @@
//! Optional schema describing a decision variable — name, label, unit,
//! and bounds. Returned by [`Problem::decision_schema`](super::Problem::decision_schema)
//! and consumed by the explorer JSON export so that the webapp can
//! render decision-variable axes with the user's preferred labels and
//! units.
//!
//! The `Problem` trait's default `decision_schema()` returns an empty
//! `Vec`, in which case the exporter generates fallback names like
//! `x[0]`, `x[1]`. Override `decision_schema()` to provide pretty
//! names, units, and bounds.
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
/// Schema for one decision variable. All fields except `name` are
/// optional; the explorer falls back to sensible defaults when
/// they're absent.
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct DecisionVariable {
/// Canonical short identifier (e.g. `"displacement"`).
pub name: String,
/// Human-readable display label (e.g. `"Engine size"`).
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub label: Option<String>,
/// Display unit (e.g. `"L"`, `"kg"`, `"Cd"`).
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub unit: Option<String>,
/// Lower bound, if known.
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub min: Option<f64>,
/// Upper bound, if known.
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub max: Option<f64>,
}
impl DecisionVariable {
/// Construct a `DecisionVariable` with just a name.
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
label: None,
unit: None,
min: None,
max: None,
}
}
/// Attach a human-readable display label. Builder-style.
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
/// Attach a display unit string. Builder-style.
pub fn with_unit(mut self, unit: impl Into<String>) -> Self {
self.unit = Some(unit.into());
self
}
/// Attach lower / upper bounds. Builder-style.
pub fn with_bounds(mut self, min: f64, max: f64) -> Self {
self.min = Some(min);
self.max = Some(max);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_starts_with_only_name() {
let v = DecisionVariable::new("displacement");
assert_eq!(v.name, "displacement");
assert!(v.label.is_none());
assert!(v.unit.is_none());
assert!(v.min.is_none());
assert!(v.max.is_none());
}
#[test]
fn builder_methods_chain() {
let v = DecisionVariable::new("displacement")
.with_label("Engine size")
.with_unit("L")
.with_bounds(1.0, 6.0);
assert_eq!(v.label.as_deref(), Some("Engine size"));
assert_eq!(v.unit.as_deref(), Some("L"));
assert_eq!(v.min, Some(1.0));
assert_eq!(v.max, Some(6.0));
}
}
-2
View File
@@ -3,7 +3,6 @@
#[cfg(feature = "async")]
pub mod async_problem;
pub mod candidate;
pub mod decision_variable;
pub mod evaluation;
pub mod objective;
pub mod partial_problem;
@@ -15,7 +14,6 @@ pub mod rng;
#[cfg(feature = "async")]
pub use async_problem::AsyncProblem;
pub use candidate::*;
pub use decision_variable::*;
pub use evaluation::*;
pub use objective::*;
pub use partial_problem::*;
+1 -55
View File
@@ -14,32 +14,13 @@ pub enum Direction {
}
/// A named objective and its optimization direction.
///
/// `name` is the canonical short identifier (used as a key). The
/// optional `label` is a human-readable display name (e.g. "Price"
/// vs the technical name `"price_thousand_dollars"`). The optional
/// `unit` is a display unit string (e.g. `"$k"`, `"s"`, `"dB"`).
/// Both flow through to the explorer JSON export so the webapp can
/// render axes with the user's preferred labels and units.
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Objective {
/// Canonical short identifier, used as a key.
/// Human-readable name of the objective.
pub name: String,
/// Whether to minimize or maximize.
pub direction: Direction,
/// Human-readable display name (defaults to `name` if not set).
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub label: Option<String>,
/// Display unit, e.g. `"$k"`, `"s"`, `"dB"`.
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub unit: Option<String>,
}
impl Objective {
@@ -48,8 +29,6 @@ impl Objective {
Self {
name: name.into(),
direction: Direction::Minimize,
label: None,
unit: None,
}
}
@@ -58,26 +37,8 @@ impl Objective {
Self {
name: name.into(),
direction: Direction::Maximize,
label: None,
unit: None,
}
}
/// Attach a human-readable display label.
///
/// Builder-style; consumes and returns `self`.
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
/// Attach a display unit string (e.g. `"$k"`, `"seconds"`, `"dB"`).
///
/// Builder-style; consumes and returns `self`.
pub fn with_unit(mut self, unit: impl Into<String>) -> Self {
self.unit = Some(unit.into());
self
}
}
/// The collection of objectives that define a problem's objective space.
@@ -153,21 +114,6 @@ mod tests {
assert_eq!(o.direction, Direction::Maximize);
}
#[test]
fn label_and_unit_default_to_none_and_round_trip_through_builders() {
let o = Objective::minimize("price");
assert!(o.label.is_none());
assert!(o.unit.is_none());
let o = Objective::minimize("price")
.with_label("Price")
.with_unit("$k");
assert_eq!(o.label.as_deref(), Some("Price"));
assert_eq!(o.unit.as_deref(), Some("$k"));
assert_eq!(o.direction, Direction::Minimize);
assert_eq!(o.name, "price");
}
#[test]
fn as_minimization_negates_maximize_only() {
let space = ObjectiveSpace::new(vec![
+5
View File
@@ -34,6 +34,11 @@ impl<D> Population<D> {
self.candidates.iter()
}
/// View the candidates as a slice.
pub fn as_slice(&self) -> &[Candidate<D>] {
&self.candidates
}
/// Unwrap into the inner `Vec<Candidate<D>>`.
pub fn into_vec(self) -> Vec<Candidate<D>> {
self.candidates
-15
View File
@@ -1,6 +1,5 @@
//! The user-implemented `Problem` trait.
use crate::core::decision_variable::DecisionVariable;
use crate::core::evaluation::Evaluation;
use crate::core::objective::ObjectiveSpace;
@@ -25,18 +24,4 @@ pub trait Problem {
/// Evaluate a decision. Must not mutate `self`.
fn evaluate(&self, decision: &Self::Decision) -> Evaluation;
/// Optional schema describing each decision variable — names,
/// labels, units, and bounds. Used by the explorer JSON export
/// to label decision-variable axes with the user's preferred
/// names and units. Default: empty (the exporter generates
/// fallback names like `x[0]`, `x[1]`).
///
/// Override this on your `Problem` impl to provide pretty
/// metadata. The returned vector should have one entry per
/// element of the decision; if its length doesn't match, the
/// exporter fills the remainder with `x[i]` defaults.
fn decision_schema(&self) -> Vec<DecisionVariable> {
Vec::new()
}
}
-909
View File
@@ -1,909 +0,0 @@
//! Explorer JSON export — serialize an `OptimizationResult` to a
//! self-describing JSON file that the
//! [heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
//! webapp can load and explore interactively.
//!
//! ## Quick start
//!
//! ```ignore
//! use heuropt::prelude::*;
//!
//! let result = optimizer.run(&problem);
//!
//! // Zero-config — pulls metadata from `problem.objectives()`,
//! // `problem.decision_schema()`, and the algorithm's `AlgorithmInfo`.
//! heuropt::explorer::to_file("results.json", &problem, &optimizer, &result)?;
//! ```
//!
//! Drop the resulting `results.json` into the explorer at
//! <https://swaits.github.io/heuropt-explorer/> to filter, brush,
//! pin, and rank candidates.
//!
//! ## What's in the export
//!
//! The output contains:
//! - `schema_version` — an integer the explorer uses to detect
//! incompatible files. Bump on breaking schema changes.
//! - `run` — algorithm name, seed, evaluations, generations, and
//! optional problem name / wall-clock seconds.
//! - `objectives` — name, direction, and (if set) `label` and
//! `unit` so the explorer can render axes like `Price ($k)`.
//! - `decision_variables` — name, label, unit, and bounds for each
//! decision-variable slot. If `Problem::decision_schema()` returns
//! fewer entries than the decision length, the exporter pads with
//! fallback names like `x[0]`, `x[1]`.
//! - `candidates` — the full population, each tagged with its
//! front rank (from `non_dominated_sort`), feasibility, and
//! whether it sits on the Pareto front.
//!
//! Everything is gated on the `serde` feature, since the export
//! uses `serde_json`.
use std::io::Write;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::core::candidate::Candidate;
use crate::core::decision_variable::DecisionVariable;
use crate::core::objective::Objective;
use crate::core::problem::Problem;
use crate::core::result::OptimizationResult;
use crate::pareto::sort::non_dominated_sort;
use crate::traits::AlgorithmInfo;
/// JSON schema version embedded in every export. The explorer
/// webapp checks this on load and rejects files with an unknown
/// version. Bump on breaking schema changes.
pub const SCHEMA_VERSION: u32 = 1;
/// Serialized envelope describing one optimization run.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExplorerExport {
/// Schema version (always equal to [`SCHEMA_VERSION`] when written).
pub schema_version: u32,
/// Run metadata — algorithm, seed, eval/generation counts.
pub run: RunMeta,
/// Objective definitions, with optional `label` / `unit` if set.
pub objectives: Vec<Objective>,
/// Decision-variable schemas, padded with fallback `x[i]` names
/// when the user didn't override `Problem::decision_schema()`.
pub decision_variables: Vec<DecisionVariable>,
/// One row per candidate in the final population.
pub candidates: Vec<ExplorerCandidate>,
}
/// Per-candidate row in [`ExplorerExport`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExplorerCandidate {
/// Decision values, one entry per decision variable. Numbers,
/// booleans, integers, or strings — whatever the
/// [`ToDecisionValues`] impl produces for the decision type.
pub decision: Vec<serde_json::Value>,
/// Objective values, parallel to the `objectives` array.
pub objectives: Vec<f64>,
/// Constraint violation magnitude (≤ 0 means feasible).
pub constraint_violation: f64,
/// Convenience: `true` iff `constraint_violation <= 0.0`.
pub feasible: bool,
/// Non-domination rank from `non_dominated_sort`. `0` means
/// on the first front (Pareto front).
pub front_rank: usize,
/// `true` iff this candidate is on the first front. (Same as
/// `front_rank == 0` for the rank-0 set, kept as an explicit
/// field so downstream tools don't have to re-derive it.)
pub in_pareto_front: bool,
}
/// Run-level metadata: algorithm name, seed, eval count, etc.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RunMeta {
/// Optional human-readable problem name (e.g. `"Pick a car"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub problem_name: Option<String>,
/// Canonical short algorithm name (e.g. `"NSGA-III"`). Pulled
/// from [`AlgorithmInfo::name`] when an algorithm is provided.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub algorithm: Option<String>,
/// Academic long form (e.g. `"Non-dominated Sorting Genetic
/// Algorithm III"`). Pulled from [`AlgorithmInfo::full_name`]
/// when an algorithm is provided. Display tools render this
/// as a tooltip / aria-label on the short name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub algorithm_full_name: Option<String>,
/// Seed driving this run, if applicable. Pulled from
/// [`AlgorithmInfo::seed`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
/// Wall-clock duration of the run, in seconds. Optional —
/// the user provides this if they timed the run externally.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wall_clock_seconds: Option<f64>,
/// Total number of `Problem::evaluate` calls.
pub evaluations: usize,
/// Number of major optimizer iterations.
pub generations: usize,
/// Optional ISO-8601 timestamp recorded at export time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
/// Adapter trait that converts a decision value into a vector of
/// `serde_json::Value`s (one per element). Implemented for the
/// common decision types out of the box; users with custom
/// decision types implement it themselves.
pub trait ToDecisionValues {
/// Convert the decision into one JSON value per decision-variable
/// slot.
fn to_decision_values(&self) -> Vec<serde_json::Value>;
}
impl ToDecisionValues for Vec<f64> {
fn to_decision_values(&self) -> Vec<serde_json::Value> {
self.iter()
.map(|v| {
serde_json::Number::from_f64(*v)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
})
.collect()
}
}
impl ToDecisionValues for Vec<bool> {
fn to_decision_values(&self) -> Vec<serde_json::Value> {
self.iter().map(|b| serde_json::Value::Bool(*b)).collect()
}
}
impl ToDecisionValues for Vec<usize> {
fn to_decision_values(&self) -> Vec<serde_json::Value> {
self.iter()
.map(|i| serde_json::Value::Number(serde_json::Number::from(*i as u64)))
.collect()
}
}
impl ToDecisionValues for Vec<i64> {
fn to_decision_values(&self) -> Vec<serde_json::Value> {
self.iter()
.map(|i| serde_json::Value::Number(serde_json::Number::from(*i)))
.collect()
}
}
impl ExplorerExport {
/// Build an `ExplorerExport` from a problem and its result.
/// The run metadata is initially empty (no algorithm / seed);
/// chain `with_algorithm_info` or the individual setters to
/// populate it.
pub fn from_result<P>(problem: &P, result: &OptimizationResult<P::Decision>) -> Self
where
P: Problem,
P::Decision: ToDecisionValues,
{
let objective_space = problem.objectives();
let n_obj = objective_space.objectives.len();
let user_schema = problem.decision_schema();
let decision_arity = result
.population
.candidates
.first()
.map(|c| c.decision.to_decision_values().len())
.unwrap_or(user_schema.len());
let decision_variables = pad_decision_schema(user_schema, decision_arity);
let pop_slice: &[Candidate<P::Decision>] = &result.population.candidates;
let fronts = non_dominated_sort(pop_slice, &objective_space);
let mut rank_of: Vec<usize> = vec![0; pop_slice.len()];
for (rank, front) in fronts.iter().enumerate() {
for &idx in front {
rank_of[idx] = rank;
}
}
let candidates = pop_slice
.iter()
.enumerate()
.map(|(i, c)| candidate_to_export(c, rank_of[i], n_obj))
.collect();
Self {
schema_version: SCHEMA_VERSION,
run: RunMeta {
evaluations: result.evaluations,
generations: result.generations,
..RunMeta::default()
},
objectives: objective_space.objectives,
decision_variables,
candidates,
}
}
/// Populate `algorithm`, `algorithm_full_name`, and `seed`
/// from anything implementing [`AlgorithmInfo`] — every
/// built-in algorithm does.
pub fn with_algorithm_info<A: AlgorithmInfo>(mut self, algorithm: &A) -> Self {
self.run.algorithm = Some(algorithm.name().to_owned());
self.run.algorithm_full_name = Some(algorithm.full_name().to_owned());
self.run.seed = algorithm.seed();
self
}
/// Override the problem name shown in the explorer header.
pub fn with_problem_name(mut self, name: impl Into<String>) -> Self {
self.run.problem_name = Some(name.into());
self
}
/// Attach a wall-clock duration in seconds.
pub fn with_wall_clock(mut self, seconds: f64) -> Self {
self.run.wall_clock_seconds = Some(seconds);
self
}
/// Attach an ISO-8601 timestamp string (the caller formats it).
pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
self.run.timestamp = Some(timestamp.into());
self
}
/// Serialize to a pretty-printed JSON string.
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
/// Serialize to any `Write` sink as pretty-printed JSON.
pub fn to_writer<W: Write>(&self, writer: W) -> serde_json::Result<()> {
serde_json::to_writer_pretty(writer, self)
}
/// Write the export to a file as pretty-printed JSON. Creates
/// the file (truncating if it exists) and returns any I/O or
/// serialization error.
pub fn to_file<Q: AsRef<Path>>(&self, path: Q) -> std::io::Result<()> {
let file = std::fs::File::create(path)?;
let writer = std::io::BufWriter::new(file);
self.to_writer(writer)
.map_err(|e| std::io::Error::other(e.to_string()))
}
}
/// Convenience: build an [`ExplorerExport`] from problem +
/// algorithm + result, with `algorithm` and `seed` populated from
/// the [`AlgorithmInfo`] trait, then serialize to a pretty JSON
/// string.
pub fn to_json<P, A>(
problem: &P,
algorithm: &A,
result: &OptimizationResult<P::Decision>,
) -> serde_json::Result<String>
where
P: Problem,
P::Decision: ToDecisionValues,
A: AlgorithmInfo,
{
ExplorerExport::from_result(problem, result)
.with_algorithm_info(algorithm)
.to_json()
}
/// Convenience: same as [`to_json`] but writes to any `Write`.
pub fn to_writer<W, P, A>(
writer: W,
problem: &P,
algorithm: &A,
result: &OptimizationResult<P::Decision>,
) -> serde_json::Result<()>
where
W: Write,
P: Problem,
P::Decision: ToDecisionValues,
A: AlgorithmInfo,
{
ExplorerExport::from_result(problem, result)
.with_algorithm_info(algorithm)
.to_writer(writer)
}
/// Convenience: same as [`to_json`] but writes directly to a
/// file path.
pub fn to_file<Q, P, A>(
path: Q,
problem: &P,
algorithm: &A,
result: &OptimizationResult<P::Decision>,
) -> std::io::Result<()>
where
Q: AsRef<Path>,
P: Problem,
P::Decision: ToDecisionValues,
A: AlgorithmInfo,
{
ExplorerExport::from_result(problem, result)
.with_algorithm_info(algorithm)
.to_file(path)
}
fn candidate_to_export<D: ToDecisionValues>(
c: &Candidate<D>,
front_rank: usize,
n_obj: usize,
) -> ExplorerCandidate {
let objectives = if c.evaluation.objectives.len() == n_obj {
c.evaluation.objectives.clone()
} else {
// Defensive: shouldn't happen in practice, but pad/truncate so
// the export is well-formed even if a buggy algorithm produced
// a mismatched evaluation.
let mut v = c.evaluation.objectives.clone();
v.resize(n_obj, f64::NAN);
v
};
ExplorerCandidate {
decision: c.decision.to_decision_values(),
objectives,
constraint_violation: c.evaluation.constraint_violation,
feasible: c.evaluation.constraint_violation <= 0.0,
front_rank,
in_pareto_front: front_rank == 0,
}
}
fn pad_decision_schema(
mut schema: Vec<DecisionVariable>,
decision_arity: usize,
) -> Vec<DecisionVariable> {
if schema.len() < decision_arity {
let start = schema.len();
for i in start..decision_arity {
schema.push(DecisionVariable::new(format!("x[{i}]")));
}
}
schema
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::candidate::Candidate;
use crate::core::evaluation::Evaluation;
use crate::core::objective::{Direction, Objective, ObjectiveSpace};
use crate::core::population::Population;
use crate::core::problem::Problem;
use crate::core::result::OptimizationResult;
/// Two-objective minimize problem used for most explorer tests.
/// f1 = decision[0], f2 = decision[1] — both minimize, so
/// `(a, b)` dominates `(c, d)` iff `a ≤ c && b ≤ d` with at
/// least one strict.
struct TwoObjMin;
impl Problem for TwoObjMin {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("a")
.with_label("Apples")
.with_unit("count"),
Objective::maximize("b").with_unit("score"),
])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
Evaluation::new(vec![x[0], x[1]])
}
}
struct EnrichedProblem;
impl Problem for EnrichedProblem {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("a")])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
Evaluation::new(vec![x[0]])
}
fn decision_schema(&self) -> Vec<DecisionVariable> {
vec![
DecisionVariable::new("alpha")
.with_label("Alpha")
.with_unit("u")
.with_bounds(0.0, 1.0),
DecisionVariable::new("beta"),
]
}
}
struct DummyAlgo;
impl AlgorithmInfo for DummyAlgo {
fn name(&self) -> &'static str {
"DummyAlgo"
}
fn full_name(&self) -> &'static str {
"Dummy Test Algorithm"
}
fn seed(&self) -> Option<u64> {
Some(123)
}
}
/// Build a result whose evaluations match `objectives_per_candidate`.
/// Each candidate's objective vector is the closure applied to the
/// decision.
fn make_result(
decisions: Vec<Vec<f64>>,
eval: impl Fn(&[f64]) -> Vec<f64>,
) -> OptimizationResult<Vec<f64>> {
let cands: Vec<Candidate<Vec<f64>>> = decisions
.into_iter()
.map(|d| {
let objs = eval(&d);
Candidate::new(d, Evaluation::new(objs))
})
.collect();
let n = cands.len();
OptimizationResult::new(Population::new(cands.clone()), cands, None, n, 1)
}
#[test]
fn schema_version_is_one() {
assert_eq!(SCHEMA_VERSION, 1);
}
/// Single-objective minimize problem (used for tests where the
/// problem only declares one objective).
struct SingleObjMin;
impl Problem for SingleObjMin {
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[0]])
}
}
#[test]
fn zero_config_export_uses_fallback_decision_names() {
let problem = TwoObjMin;
// Two objectives — eval just maps decision to objective values.
let result = make_result(vec![vec![0.0, 1.0], vec![1.0, 0.0]], |d| d.to_vec());
let export = ExplorerExport::from_result(&problem, &result);
assert_eq!(export.schema_version, SCHEMA_VERSION);
assert_eq!(export.decision_variables.len(), 2);
assert_eq!(export.decision_variables[0].name, "x[0]");
assert_eq!(export.decision_variables[1].name, "x[1]");
assert!(export.decision_variables[0].label.is_none());
}
#[test]
fn objectives_carry_label_and_unit_through_export() {
let problem = TwoObjMin;
let result = make_result(vec![vec![0.0, 1.0]], |d| d.to_vec());
let export = ExplorerExport::from_result(&problem, &result);
assert_eq!(export.objectives.len(), 2);
assert_eq!(export.objectives[0].label.as_deref(), Some("Apples"));
assert_eq!(export.objectives[0].unit.as_deref(), Some("count"));
assert_eq!(export.objectives[1].direction, Direction::Maximize);
}
#[test]
fn enriched_decision_schema_passes_through() {
let problem = EnrichedProblem; // 1 objective, 2-element decisions
let result = make_result(vec![vec![0.5, 0.5]], |d| vec![d[0]]);
let export = ExplorerExport::from_result(&problem, &result);
assert_eq!(export.decision_variables.len(), 2);
assert_eq!(export.decision_variables[0].name, "alpha");
assert_eq!(export.decision_variables[0].label.as_deref(), Some("Alpha"));
assert_eq!(export.decision_variables[0].min, Some(0.0));
assert_eq!(export.decision_variables[1].name, "beta");
assert!(export.decision_variables[1].min.is_none());
}
#[test]
fn front_rank_zero_for_pareto_front_members() {
// Use SingleObjMin (1 objective) to make dominance trivial:
// among [3.0, 1.0, 2.0], only 1.0 is non-dominated.
let problem = SingleObjMin;
let result = make_result(vec![vec![3.0], vec![1.0], vec![2.0]], |d| vec![d[0]]);
let export = ExplorerExport::from_result(&problem, &result);
// Index 1 (decision = 1.0) is the unique minimum.
assert_eq!(export.candidates[1].front_rank, 0);
assert!(export.candidates[1].in_pareto_front);
assert_eq!(export.candidates[2].front_rank, 1);
assert!(!export.candidates[2].in_pareto_front);
assert_eq!(export.candidates[0].front_rank, 2);
assert!(!export.candidates[0].in_pareto_front);
}
#[test]
fn algorithm_info_populates_run_meta() {
let problem = TwoObjMin;
let result = make_result(vec![vec![0.0, 1.0]], |d| d.to_vec());
let export = ExplorerExport::from_result(&problem, &result).with_algorithm_info(&DummyAlgo);
assert_eq!(export.run.algorithm.as_deref(), Some("DummyAlgo"));
assert_eq!(
export.run.algorithm_full_name.as_deref(),
Some("Dummy Test Algorithm"),
);
assert_eq!(export.run.seed, Some(123));
}
#[test]
fn round_trip_serde() {
let problem = TwoObjMin;
let result = make_result(vec![vec![0.0, 1.0], vec![1.0, 0.0]], |d| d.to_vec());
let export = ExplorerExport::from_result(&problem, &result)
.with_algorithm_info(&DummyAlgo)
.with_problem_name("Toy")
.with_wall_clock(0.001);
let json = export.to_json().unwrap();
let back: ExplorerExport = serde_json::from_str(&json).unwrap();
assert_eq!(back.schema_version, SCHEMA_VERSION);
assert_eq!(back.run.algorithm.as_deref(), Some("DummyAlgo"));
assert_eq!(back.candidates.len(), 2);
assert_eq!(back.objectives.len(), 2);
}
#[test]
fn vec_bool_decisions_serialize_as_bool_array() {
let v: Vec<bool> = vec![true, false, true];
let values = v.to_decision_values();
assert_eq!(values.len(), 3);
assert_eq!(values[0], serde_json::Value::Bool(true));
assert_eq!(values[1], serde_json::Value::Bool(false));
}
#[test]
fn vec_usize_decisions_serialize_as_int_array() {
let v: Vec<usize> = vec![3, 1, 4];
let values = v.to_decision_values();
assert_eq!(values.len(), 3);
assert_eq!(
values[0],
serde_json::Value::Number(serde_json::Number::from(3u64))
);
}
#[test]
fn nan_decision_renders_as_null() {
let v: Vec<f64> = vec![1.0, f64::NAN, 2.0];
let values = v.to_decision_values();
assert_eq!(values[0].as_f64(), Some(1.0));
assert_eq!(values[1], serde_json::Value::Null);
assert_eq!(values[2].as_f64(), Some(2.0));
}
// ---- Exhaustive coverage to kill cargo-mutants survivors ---------------
/// `ToDecisionValues for Vec<f64>` returns a slot-for-slot float-or-null
/// vector. Pins the exact JSON output rather than just length, killing
/// the "replace body with vec![]" / "vec![Default::default()]" mutants.
#[test]
fn vec_f64_to_decision_values_exact_output() {
let v: Vec<f64> = vec![0.5, -1.25, 2.0];
let got = v.to_decision_values();
assert_eq!(got.len(), 3);
assert_eq!(got[0].as_f64(), Some(0.5));
assert_eq!(got[1].as_f64(), Some(-1.25));
assert_eq!(got[2].as_f64(), Some(2.0));
}
/// Pins the exact JSON output for `Vec<i64>`. There was no test for this
/// impl at all before.
#[test]
fn vec_i64_to_decision_values_exact_output() {
let v: Vec<i64> = vec![-3, 0, 7];
let got = v.to_decision_values();
assert_eq!(got.len(), 3);
assert_eq!(
got[0],
serde_json::Value::Number(serde_json::Number::from(-3i64))
);
assert_eq!(
got[1],
serde_json::Value::Number(serde_json::Number::from(0i64))
);
assert_eq!(
got[2],
serde_json::Value::Number(serde_json::Number::from(7i64))
);
}
/// Pins the *exact* booleans, not just the count.
#[test]
fn vec_bool_to_decision_values_exact_output() {
let v: Vec<bool> = vec![true, false, true, false];
let got = v.to_decision_values();
assert_eq!(
got,
vec![
serde_json::Value::Bool(true),
serde_json::Value::Bool(false),
serde_json::Value::Bool(true),
serde_json::Value::Bool(false),
],
);
}
/// Pins the exact usize-as-u64 numbers, not just the count.
#[test]
fn vec_usize_to_decision_values_exact_output() {
let v: Vec<usize> = vec![0, 5, 42, 7];
let got = v.to_decision_values();
assert_eq!(
got,
vec![
serde_json::Value::Number(serde_json::Number::from(0u64)),
serde_json::Value::Number(serde_json::Number::from(5u64)),
serde_json::Value::Number(serde_json::Number::from(42u64)),
serde_json::Value::Number(serde_json::Number::from(7u64)),
],
);
}
/// `from_result` must set the `evaluations` and `generations` fields of
/// `RunMeta` from the result, not leave them at default zero. Kills the
/// "delete field evaluations / generations" mutants.
#[test]
fn from_result_propagates_evaluation_and_generation_counts() {
let problem = SingleObjMin;
let cands = vec![Candidate::new(vec![1.0], Evaluation::new(vec![1.0]))];
let result = OptimizationResult::new(Population::new(cands.clone()), cands, None, 137, 9);
let export = ExplorerExport::from_result(&problem, &result);
assert_eq!(export.run.evaluations, 137);
assert_eq!(export.run.generations, 9);
}
/// `with_problem_name` must set `run.problem_name`, not return a default.
#[test]
fn with_problem_name_sets_field_and_preserves_other_state() {
let problem = SingleObjMin;
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
let export =
ExplorerExport::from_result(&problem, &result).with_problem_name("Toy Problem");
assert_eq!(export.run.problem_name.as_deref(), Some("Toy Problem"));
// The candidates and objectives should still be intact, proving the
// chained builder isn't replacing the whole struct.
assert_eq!(export.candidates.len(), 1);
assert_eq!(export.objectives.len(), 1);
}
/// `with_wall_clock` must set `run.wall_clock_seconds`.
#[test]
fn with_wall_clock_sets_field_and_preserves_other_state() {
let problem = SingleObjMin;
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
let export = ExplorerExport::from_result(&problem, &result).with_wall_clock(2.5);
assert_eq!(export.run.wall_clock_seconds, Some(2.5));
assert_eq!(export.candidates.len(), 1);
}
/// `with_timestamp` must set `run.timestamp`.
#[test]
fn with_timestamp_sets_field_and_preserves_other_state() {
let problem = SingleObjMin;
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
let export =
ExplorerExport::from_result(&problem, &result).with_timestamp("2025-01-01T00:00:00Z");
assert_eq!(
export.run.timestamp.as_deref(),
Some("2025-01-01T00:00:00Z")
);
assert_eq!(export.candidates.len(), 1);
}
/// `to_json` must serialize the full export, not a fixed string. Look for
/// specific markers — `schema_version`, `candidates`, the problem name
/// — that pin the JSON output enough to kill `Ok(String::new())` and
/// `Ok("xyzzy".into())` mutants.
#[test]
fn to_json_emits_full_export_with_expected_fields() {
let problem = SingleObjMin;
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
let export = ExplorerExport::from_result(&problem, &result)
.with_algorithm_info(&DummyAlgo)
.with_problem_name("MyProblem");
let json = export.to_json().unwrap();
assert!(json.contains("\"schema_version\""), "json: {json}");
assert!(json.contains("\"candidates\""), "json: {json}");
assert!(json.contains("\"MyProblem\""), "json: {json}");
assert!(json.contains("\"DummyAlgo\""), "json: {json}");
}
/// `to_writer` must produce non-empty JSON output matching `to_json`.
/// Kills `Ok(())` mutants which would write nothing.
#[test]
fn to_writer_emits_full_export() {
let problem = SingleObjMin;
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
let export = ExplorerExport::from_result(&problem, &result).with_problem_name("MyProblem");
let mut buf: Vec<u8> = Vec::new();
export.to_writer(&mut buf).unwrap();
assert!(!buf.is_empty());
let json = String::from_utf8(buf).unwrap();
assert!(json.contains("\"MyProblem\""));
assert_eq!(json, export.to_json().unwrap());
}
/// `to_file` writes to disk; round-trip the bytes back through serde to
/// confirm a real (non-empty, parseable) export landed.
#[test]
fn to_file_writes_parseable_json() {
use std::io::Read;
let problem = SingleObjMin;
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
let export = ExplorerExport::from_result(&problem, &result).with_problem_name("OnDisk");
let dir = std::env::temp_dir();
let path = dir.join(format!("heuropt-explorer-test-{}.json", std::process::id()));
export.to_file(&path).unwrap();
let mut s = String::new();
std::fs::File::open(&path)
.unwrap()
.read_to_string(&mut s)
.unwrap();
let _ = std::fs::remove_file(&path);
let back: ExplorerExport = serde_json::from_str(&s).unwrap();
assert_eq!(back.run.problem_name.as_deref(), Some("OnDisk"));
}
/// Free `to_json` convenience must do the same thing as the chained
/// builder. Kills "replace with Ok(String::new())" / "Ok(\"xyzzy\")".
#[test]
fn free_to_json_includes_algorithm_info() {
let problem = SingleObjMin;
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
let json = super::to_json(&problem, &DummyAlgo, &result).unwrap();
assert!(json.contains("\"DummyAlgo\""), "json: {json}");
assert!(json.contains("\"schema_version\""), "json: {json}");
}
/// Free `to_writer` convenience writes the same bytes as `to_json`.
#[test]
fn free_to_writer_writes_bytes() {
let problem = SingleObjMin;
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
let mut buf: Vec<u8> = Vec::new();
super::to_writer(&mut buf, &problem, &DummyAlgo, &result).unwrap();
let json = String::from_utf8(buf).unwrap();
assert!(json.contains("\"DummyAlgo\""));
let expected = super::to_json(&problem, &DummyAlgo, &result).unwrap();
assert_eq!(json, expected);
}
/// Free `to_file` convenience round-trips through a tmp file.
#[test]
fn free_to_file_writes_parseable_json() {
use std::io::Read;
let problem = SingleObjMin;
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
let dir = std::env::temp_dir();
let path = dir.join(format!(
"heuropt-explorer-test-free-{}.json",
std::process::id()
));
super::to_file(&path, &problem, &DummyAlgo, &result).unwrap();
let mut s = String::new();
std::fs::File::open(&path)
.unwrap()
.read_to_string(&mut s)
.unwrap();
let _ = std::fs::remove_file(&path);
let back: ExplorerExport = serde_json::from_str(&s).unwrap();
assert_eq!(back.run.algorithm.as_deref(), Some("DummyAlgo"));
}
/// `pad_decision_schema` should extend the schema only when `schema.len()
/// < decision_arity`. Tests all three boundary cases (less / equal /
/// greater) to pin the `<` comparison so mutants `< → ==`, `< → >`,
/// `< → <=` all fail.
#[test]
fn pad_decision_schema_extends_when_short() {
let in_schema = vec![DecisionVariable::new("alpha")];
let out = pad_decision_schema(in_schema, 3);
assert_eq!(out.len(), 3);
assert_eq!(out[0].name, "alpha");
assert_eq!(out[1].name, "x[1]");
assert_eq!(out[2].name, "x[2]");
}
#[test]
fn pad_decision_schema_unchanged_at_exact_length() {
let in_schema = vec![
DecisionVariable::new("alpha"),
DecisionVariable::new("beta"),
];
let out = pad_decision_schema(in_schema, 2);
assert_eq!(out.len(), 2);
assert_eq!(out[0].name, "alpha");
assert_eq!(out[1].name, "beta");
}
#[test]
fn pad_decision_schema_unchanged_when_longer_than_arity() {
// schema is longer than the arity — pad should be a no-op.
let in_schema = vec![
DecisionVariable::new("alpha"),
DecisionVariable::new("beta"),
DecisionVariable::new("gamma"),
];
let out = pad_decision_schema(in_schema, 2);
assert_eq!(out.len(), 3);
assert_eq!(out[2].name, "gamma");
}
/// `candidate_to_export`'s `front_rank == 0` controls `in_pareto_front`.
/// Test the boundary directly with synthetic candidates so the export
/// builder cannot accidentally mask the bug.
#[test]
fn candidate_to_export_front_rank_zero_is_in_pareto_front() {
let c: Candidate<Vec<f64>> = Candidate::new(vec![1.0], Evaluation::new(vec![1.0]));
let exported = candidate_to_export(&c, 0, 1);
assert!(exported.in_pareto_front);
assert_eq!(exported.front_rank, 0);
}
#[test]
fn candidate_to_export_front_rank_one_is_not_in_pareto_front() {
let c: Candidate<Vec<f64>> = Candidate::new(vec![1.0], Evaluation::new(vec![1.0]));
let exported = candidate_to_export(&c, 1, 1);
assert!(!exported.in_pareto_front);
assert_eq!(exported.front_rank, 1);
}
/// `feasible` flips at `constraint_violation <= 0.0` boundary. Tests
/// the equality case (0.0 is feasible) plus both sides.
#[test]
fn candidate_to_export_feasibility_at_zero_violation() {
let mut ev = Evaluation::new(vec![1.0]);
ev.constraint_violation = 0.0;
let c: Candidate<Vec<f64>> = Candidate::new(vec![1.0], ev);
let exported = candidate_to_export(&c, 0, 1);
assert!(exported.feasible);
}
#[test]
fn candidate_to_export_feasibility_negative_violation() {
let mut ev = Evaluation::new(vec![1.0]);
ev.constraint_violation = -0.1;
let c: Candidate<Vec<f64>> = Candidate::new(vec![1.0], ev);
let exported = candidate_to_export(&c, 0, 1);
assert!(exported.feasible);
}
#[test]
fn candidate_to_export_infeasibility_positive_violation() {
let mut ev = Evaluation::new(vec![1.0]);
ev.constraint_violation = 0.5;
let c: Candidate<Vec<f64>> = Candidate::new(vec![1.0], ev);
let exported = candidate_to_export(&c, 0, 1);
assert!(!exported.feasible);
assert_eq!(exported.constraint_violation, 0.5);
}
/// Defensive branch: if a buggy algorithm returns a mismatched
/// objectives length, candidate_to_export pads or truncates to `n_obj`
/// rather than passing the wrong-length vector through. Tests both
/// the pad (too few objectives) and truncate (too many) cases.
#[test]
fn candidate_to_export_pads_short_objectives_with_nan() {
let c: Candidate<Vec<f64>> = Candidate::new(vec![1.0], Evaluation::new(vec![1.0]));
let exported = candidate_to_export(&c, 0, 3);
assert_eq!(exported.objectives.len(), 3);
assert_eq!(exported.objectives[0], 1.0);
assert!(exported.objectives[1].is_nan());
assert!(exported.objectives[2].is_nan());
}
#[test]
fn candidate_to_export_truncates_long_objectives() {
let c: Candidate<Vec<f64>> =
Candidate::new(vec![1.0], Evaluation::new(vec![1.0, 2.0, 3.0]));
let exported = candidate_to_export(&c, 0, 2);
assert_eq!(exported.objectives.len(), 2);
assert_eq!(exported.objectives[0], 1.0);
assert_eq!(exported.objectives[1], 2.0);
}
}
+5 -13
View File
@@ -39,25 +39,17 @@ pub(crate) fn cholesky(a: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, &'static str> {
Ok(l)
}
/// Solve `L · y = b` (forward substitution) for lower-triangular `L`,
/// writing the result into `out` (reused across calls to avoid allocating).
pub(crate) fn solve_lower_into(l: &[Vec<f64>], b: &[f64], out: &mut Vec<f64>) {
/// Solve `L · y = b` (forward substitution) for lower-triangular `L`.
pub(crate) fn solve_lower(l: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
let n = l.len();
out.clear();
out.resize(n, 0.0);
let mut y = vec![0.0_f64; n];
for i in 0..n {
let mut sum = b[i];
for k in 0..i {
sum -= l[i][k] * out[k];
sum -= l[i][k] * y[k];
}
out[i] = sum / l[i][i];
y[i] = sum / l[i][i];
}
}
/// Solve `L · y = b` (forward substitution) for lower-triangular `L`.
pub(crate) fn solve_lower(l: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
let mut y = Vec::new();
solve_lower_into(l, b, &mut y);
y
}
+3 -13
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 33 algorithms in
//! 2. **Run a built-in optimizer** — pick from 35 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,
@@ -28,19 +28,10 @@
//! - `serde` — derives `Serialize` / `Deserialize` on the core data
//! types ([`Candidate`](crate::core::Candidate),
//! [`Population`](crate::core::Population),
//! [`Evaluation`](crate::core::Evaluation), …) and enables the
//! [`heuropt::explorer`](crate::explorer) JSON export module for the
//! [heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
//! webapp.
//! [`Evaluation`](crate::core::Evaluation), …).
//! - `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
//!
@@ -75,10 +66,9 @@
pub mod algorithms;
pub mod core;
#[cfg(feature = "serde")]
pub mod explorer;
pub(crate) mod internal;
pub mod metrics;
pub mod observer;
pub mod operators;
pub mod pareto;
pub mod prelude;
+19 -191
View File
@@ -14,26 +14,6 @@ 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,
@@ -167,24 +147,6 @@ 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,
@@ -279,58 +241,27 @@ fn hso_recursive(points: &[Vec<f64>], reference: &[f64]) -> f64 {
let sub_reference: &[f64] = &reference[..last];
let mut total = 0.0;
let mut prev = reference[last];
if sub_reference.len() == 2 {
// M == 3: the inner HV is a 2-D staircase sweep. `projected` is in
// last-axis order, so the active set at step `k` is the prefix
// `projected[..=k]`. The generic recursion re-sorts that prefix by
// axis 0 on every step — O(n² log n). Instead, sort the projected
// indices by axis 0 once and, for each `k`, sweep them skipping any
// whose last-axis rank exceeds `k`. The sweep visits points in the
// same (axis-0, then last-axis) order the stable per-prefix sort
// produced, so the result is bit-identical.
let r0 = sub_reference[0];
let r1 = sub_reference[1];
let mut x_order: Vec<usize> = (0..projected.len()).collect();
x_order.sort_by(|&a, &b| {
projected[a][0]
.partial_cmp(&projected[b][0])
.unwrap_or(std::cmp::Ordering::Equal)
});
for k in (0..order.len()).rev() {
let p_last = points[order[k]][last];
let depth = prev - p_last;
if depth > 0.0 {
let mut area = 0.0;
let mut last_y = r1;
for &pi in &x_order {
if pi > k {
continue;
}
let p = &projected[pi];
if p[1] >= last_y {
continue;
}
area += (r0 - p[0]) * (last_y - p[1]);
last_y = p[1];
}
total += depth * area;
}
prev = p_last;
}
} else {
// M >= 4: recurse generically, with the explicit non-dominated
// filter to keep the recursion's upper levels honest.
for k in (0..order.len()).rev() {
let p_last = points[order[k]][last];
let depth = prev - p_last;
if depth > 0.0 {
let active = &projected[..=k];
for k in (0..order.len()).rev() {
let p_last = points[order[k]][last];
let depth = prev - p_last;
if depth > 0.0 {
let active = &projected[..=k];
// The 2-D base case sweeps in sorted-x order and skips any
// point with `y >= last_y`, which is exactly the dominance
// filter — so for M=3 (sub_reference len 2) we can hand
// `active` straight to `hso_recursive` without paying for
// an O(K²) `non_dominated_projection` first. For M≥4 we
// still need the explicit filter to keep the recursion's
// upper levels honest.
let inner = if sub_reference.len() == 2 {
hso_recursive(active, sub_reference)
} else {
let nd = non_dominated_projection(active);
total += depth * hso_recursive(&nd, sub_reference);
}
prev = p_last;
hso_recursive(&nd, sub_reference)
};
total += depth * inner;
}
prev = p_last;
}
total
@@ -514,107 +445,4 @@ mod nd_tests {
let hv_with = hypervolume_nd(&with_dominated, &s, &[2.0, 2.0, 2.0]);
assert!((hv_base - hv_with).abs() < 1e-12, "{hv_base} vs {hv_with}");
}
// ---- Mutation-test pinned helpers --------------------------------------
/// `dominates(a, b)` is true iff `a` is ≤ `b` on every axis and strictly
/// better on at least one. Pin all the boundary cases so the `<` / `>`
/// comparison flips are caught.
#[test]
fn dominates_strict_and_boundary_cases() {
// a strictly dominates b on both axes.
assert!(dominates(&[1.0, 1.0], &[2.0, 2.0], 2));
// b does not dominate a (reverse).
assert!(!dominates(&[2.0, 2.0], &[1.0, 1.0], 2));
// Equal points: neither dominates (no strict improvement).
assert!(!dominates(&[1.0, 1.0], &[1.0, 1.0], 2));
// a better on axis 0, equal on axis 1 → a dominates b.
assert!(dominates(&[1.0, 2.0], &[2.0, 2.0], 2));
// a better on axis 0 but worse on axis 1 → no domination.
assert!(!dominates(&[1.0, 3.0], &[2.0, 2.0], 2));
}
/// `non_dominated_projection` drops dominated members and keeps the
/// rest. Pin the exact retained set.
#[test]
fn non_dominated_projection_drops_dominated() {
let pts = vec![
vec![1.0, 3.0], // non-dominated
vec![3.0, 1.0], // non-dominated
vec![2.0, 2.0], // non-dominated (trade-off)
vec![4.0, 4.0], // dominated by all three
];
let nd = non_dominated_projection(&pts);
assert_eq!(nd.len(), 3);
assert!(!nd.contains(&vec![4.0, 4.0]));
assert!(nd.contains(&vec![1.0, 3.0]));
assert!(nd.contains(&vec![3.0, 1.0]));
assert!(nd.contains(&vec![2.0, 2.0]));
}
#[test]
fn non_dominated_projection_empty_input_is_empty() {
let pts: Vec<Vec<f64>> = Vec::new();
assert!(non_dominated_projection(&pts).is_empty());
}
#[test]
fn non_dominated_projection_all_nondominated_keeps_all() {
let pts = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]];
let nd = non_dominated_projection(&pts);
assert_eq!(nd.len(), 3);
}
/// `hso_recursive` 1-D base case: HV is `reference - min_point`,
/// clamped at 0.
#[test]
fn hso_recursive_1d_base_case() {
let pts = vec![vec![0.5], vec![1.5], vec![0.2]];
// min is 0.2, reference is 2.0 → HV = 1.8
assert!((hso_recursive(&pts, &[2.0]) - 1.8).abs() < 1e-12);
// A point past the reference → clamped to 0 contribution; min still 0.2.
let pts2 = vec![vec![3.0]];
assert_eq!(hso_recursive(&pts2, &[2.0]), 0.0);
}
/// `hso_recursive` 2-D base case: classic staircase area.
#[test]
fn hso_recursive_2d_staircase() {
// Three points (1,3), (2,2), (3,1) against reference (4,4).
// Dominated area = 6 (same as the hypervolume_2d doctest).
let pts = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]];
let hv = hso_recursive(&pts, &[4.0, 4.0]);
assert!((hv - 6.0).abs() < 1e-12, "hv = {hv}");
}
/// `hypervolume_nd_from_evaluations` returns 0 for an empty slice and a
/// positive value for a dominating point.
#[test]
fn hypervolume_nd_from_evaluations_empty_and_nonempty() {
let s = ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]);
let empty: Vec<&Evaluation> = Vec::new();
assert_eq!(
hypervolume_nd_from_evaluations(&empty, &s, &[2.0, 2.0]),
0.0
);
let e = Evaluation::new(vec![1.0, 1.0]);
let evals = vec![&e];
let hv = hypervolume_nd_from_evaluations(&evals, &s, &[2.0, 2.0]);
// Single point (1,1) vs reference (2,2) → 1×1 = 1.
assert!((hv - 1.0).abs() < 1e-12, "hv = {hv}");
}
/// A point that does not strictly dominate the reference contributes 0.
#[test]
fn hypervolume_nd_from_evaluations_skips_non_dominating() {
let s = ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]);
// (2, 1): axis 0 equals the reference → not strictly dominating.
let e = Evaluation::new(vec![2.0, 1.0]);
let evals = vec![&e];
assert_eq!(
hypervolume_nd_from_evaluations(&evals, &s, &[2.0, 2.0]),
0.0
);
}
}
+207
View File
@@ -0,0 +1,207 @@
//! Inverted Generational Distance (IGD) and IGD+ performance indicators.
//!
//! Both quantify how well an approximation set covers a reference set
//! (typically the true Pareto front). Smaller values are better.
use crate::core::candidate::Candidate;
use crate::core::evaluation::Evaluation;
use crate::core::objective::ObjectiveSpace;
/// Inverted Generational Distance.
///
/// For each point in the `reference` set, compute the Euclidean distance
/// to its nearest neighbor in the `approximation` set (in minimization-
/// oriented objective space), then average:
///
/// ```text
/// IGD(A) = (1 / |R|) · Σ_{r ∈ R} min_{a ∈ A} ‖a r‖₂
/// ```
///
/// Lower is better. IGD captures both convergence (close to the front)
/// and spread (the approximation must cover the reference).
///
/// # Panics
///
/// If `reference` is empty.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
/// use heuropt::metrics::igd::igd;
///
/// let space = ObjectiveSpace::new(vec![
/// Objective::minimize("f1"),
/// Objective::minimize("f2"),
/// ]);
/// // Approximation: a sparse 2-point front.
/// let approx = [
/// Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
/// Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
/// ];
/// // Reference: a dense 3-point sample of the true front.
/// let reference = [
/// Evaluation::new(vec![0.0, 1.0]),
/// Evaluation::new(vec![0.5, 0.5]),
/// Evaluation::new(vec![1.0, 0.0]),
/// ];
/// let v = igd(&approx, &reference, &space);
/// // The middle reference point is unfortunately distance √(0.5²+0.5²) = 0.707
/// // from each approximation point; the boundary points are 0 away.
/// // IGD = (0 + 0.707 + 0) / 3 ≈ 0.236.
/// assert!((v - 0.2357).abs() < 1e-3);
/// ```
pub fn igd<D>(
approximation: &[Candidate<D>],
reference: &[Evaluation],
objectives: &ObjectiveSpace,
) -> f64 {
assert!(
!reference.is_empty(),
"igd: reference set must not be empty"
);
let approx_oriented: Vec<Vec<f64>> = approximation
.iter()
.map(|c| objectives.as_minimization(&c.evaluation.objectives))
.collect();
if approx_oriented.is_empty() {
return f64::INFINITY;
}
let mut total = 0.0_f64;
for r in reference {
let r_oriented = objectives.as_minimization(&r.objectives);
let mut min_d = f64::INFINITY;
for a in &approx_oriented {
let d: f64 = a
.iter()
.zip(r_oriented.iter())
.map(|(x, y)| (x - y).powi(2))
.sum::<f64>()
.sqrt();
if d < min_d {
min_d = d;
}
}
total += min_d;
}
total / reference.len() as f64
}
/// IGD+ — a dominance-respecting variant of IGD.
///
/// For each reference point `r`, the distance to an approximation
/// point `a` is computed only on objectives where `a` is *worse than*
/// `r` — i.e. on the "violation" component of the gap. This makes
/// IGD+ a Pareto-compliant indicator: adding a dominated point to the
/// approximation never improves the score.
///
/// ```text
/// IGD+(A) = (1 / |R|) · Σ_{r ∈ R} min_{a ∈ A} ‖max(a r, 0)‖₂
/// ```
///
/// Lower is better.
///
/// # Panics
///
/// If `reference` is empty.
pub fn igd_plus<D>(
approximation: &[Candidate<D>],
reference: &[Evaluation],
objectives: &ObjectiveSpace,
) -> f64 {
assert!(
!reference.is_empty(),
"igd_plus: reference set must not be empty"
);
let approx_oriented: Vec<Vec<f64>> = approximation
.iter()
.map(|c| objectives.as_minimization(&c.evaluation.objectives))
.collect();
if approx_oriented.is_empty() {
return f64::INFINITY;
}
let mut total = 0.0_f64;
for r in reference {
let r_oriented = objectives.as_minimization(&r.objectives);
let mut min_d = f64::INFINITY;
for a in &approx_oriented {
let d: f64 = a
.iter()
.zip(r_oriented.iter())
.map(|(x, y)| (x - y).max(0.0).powi(2))
.sum::<f64>()
.sqrt();
if d < min_d {
min_d = d;
}
}
total += min_d;
}
total / reference.len() as f64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::objective::Objective;
fn space_min2() -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
}
fn cand(obj: Vec<f64>) -> Candidate<()> {
Candidate::new((), Evaluation::new(obj))
}
#[test]
fn igd_perfect_match_is_zero() {
let s = space_min2();
let approx = [cand(vec![0.0, 1.0]), cand(vec![1.0, 0.0])];
let reference = [
Evaluation::new(vec![0.0, 1.0]),
Evaluation::new(vec![1.0, 0.0]),
];
let v = igd(&approx, &reference, &s);
assert!(v < 1e-12);
}
#[test]
fn igd_known_value() {
let s = space_min2();
let approx = [cand(vec![0.0, 0.0])];
let reference = [Evaluation::new(vec![1.0, 1.0])];
let v = igd(&approx, &reference, &s);
assert!((v - 2.0_f64.sqrt()).abs() < 1e-12);
}
#[test]
fn igd_plus_dominated_point_does_not_improve() {
let s = space_min2();
let reference = [
Evaluation::new(vec![0.0, 1.0]),
Evaluation::new(vec![1.0, 0.0]),
];
let base = vec![cand(vec![0.5, 0.5])];
let with_dominated = vec![cand(vec![0.5, 0.5]), cand(vec![1.0, 1.0])];
let v_base = igd_plus(&base, &reference, &s);
let v_with = igd_plus(&with_dominated, &reference, &s);
// Adding a dominated point should not improve the score.
assert!(v_with >= v_base - 1e-12);
}
#[test]
fn igd_empty_approximation_is_infinity() {
let s = space_min2();
let approx: [Candidate<()>; 0] = [];
let reference = [Evaluation::new(vec![0.0, 1.0])];
assert!(igd(&approx, &reference, &s).is_infinite());
}
#[test]
#[should_panic(expected = "reference set must not be empty")]
fn igd_empty_reference_panics() {
let s = space_min2();
let approx = [cand(vec![0.0, 1.0])];
let _ = igd::<()>(&approx, &[], &s);
}
}
+4
View File
@@ -1,7 +1,11 @@
//! Quality metrics for Pareto fronts.
pub mod hypervolume;
pub mod igd;
pub mod r2;
pub mod spacing;
pub use hypervolume::*;
pub use igd::{igd, igd_plus};
pub use r2::r2;
pub use spacing::*;
+173
View File
@@ -0,0 +1,173 @@
//! R2 indicator — a unary quality measure for Pareto fronts.
//!
//! For each weight vector `λ` in a user-supplied set, find the
//! best (smallest) weighted Tchebycheff value across the front;
//! average over all weight vectors. Lower is better.
use crate::core::candidate::Candidate;
use crate::core::objective::ObjectiveSpace;
/// R2 indicator using the weighted Tchebycheff utility.
///
/// ```text
/// R2(A) = (1 / |Λ|) · Σ_{λ ∈ Λ} min_{a ∈ A} max_i { λ_i · |a_i z*_i| }
/// ```
///
/// where `z*` is the ideal point (per-axis minimum across the
/// approximation, in minimization-oriented coordinates) and `Λ` is
/// a set of unit-simplex weight vectors. Lower is better.
///
/// Use [`das_dennis`](crate::pareto::das_dennis) to generate the
/// canonical structured weight set.
///
/// # Panics
///
/// If the approximation is empty, or any weight vector has wrong
/// length / negative entries / zero sum.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
/// use heuropt::metrics::r2::r2;
///
/// let space = ObjectiveSpace::new(vec![
/// Objective::minimize("f1"),
/// Objective::minimize("f2"),
/// ]);
/// let approx = [
/// Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
/// Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
/// ];
/// // Two weight vectors: (1, 0) and (0, 1) — extreme directions.
/// let weights = [vec![1.0, 0.0], vec![0.0, 1.0]];
/// let v = r2(&approx, &weights, &space);
/// // For each direction, the best front member matches that axis exactly.
/// // R2 = 0 since the ideal point is achieved on each direction.
/// assert!(v < 1e-12);
/// ```
pub fn r2<D>(
approximation: &[Candidate<D>],
weights: &[Vec<f64>],
objectives: &ObjectiveSpace,
) -> f64 {
assert!(
!approximation.is_empty(),
"r2: approximation must not be empty"
);
assert!(!weights.is_empty(), "r2: weight set must not be empty");
let m = objectives.len();
for (i, w) in weights.iter().enumerate() {
assert_eq!(
w.len(),
m,
"r2: weight {i} has wrong length ({} vs {m})",
w.len()
);
assert!(
w.iter().all(|&v| v >= 0.0),
"r2: weight {i} has a negative entry"
);
assert!(w.iter().sum::<f64>() > 0.0, "r2: weight {i} has zero sum");
}
// Convert all approximation members to minimization orientation once.
let oriented: Vec<Vec<f64>> = approximation
.iter()
.map(|c| objectives.as_minimization(&c.evaluation.objectives))
.collect();
// Ideal point z* (per-axis minimum).
let mut z_star = vec![f64::INFINITY; m];
for o in &oriented {
for k in 0..m {
if o[k] < z_star[k] {
z_star[k] = o[k];
}
}
}
let mut total = 0.0_f64;
for w in weights {
let mut best = f64::INFINITY;
for o in &oriented {
// Weighted Tchebycheff: max_i { w_i · |o_i z*_i| }
let mut t = 0.0_f64;
for k in 0..m {
let dk = (o[k] - z_star[k]).abs() * w[k];
if dk > t {
t = dk;
}
}
if t < best {
best = t;
}
}
total += best;
}
total / weights.len() as f64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::evaluation::Evaluation;
use crate::core::objective::Objective;
use crate::pareto::das_dennis;
fn space_min2() -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
}
fn cand(obj: Vec<f64>) -> Candidate<()> {
Candidate::new((), Evaluation::new(obj))
}
#[test]
fn r2_extremes_are_perfect_at_endpoints() {
let s = space_min2();
let front = [cand(vec![0.0, 1.0]), cand(vec![1.0, 0.0])];
let weights = [vec![1.0, 0.0], vec![0.0, 1.0]];
assert!(r2(&front, &weights, &s) < 1e-12);
}
#[test]
fn r2_dense_dasdennis_finite_for_uniform_front() {
let s = space_min2();
let weights = das_dennis(2, 5);
let front: Vec<Candidate<()>> = (0..=10)
.map(|i| {
let t = i as f64 / 10.0;
cand(vec![t, 1.0 - t])
})
.collect();
let v = r2(&front, &weights, &s);
assert!(v.is_finite());
assert!(v >= 0.0);
}
#[test]
#[should_panic(expected = "approximation must not be empty")]
fn r2_empty_approximation_panics() {
let s = space_min2();
let weights = vec![vec![1.0, 0.0]];
let _: f64 = r2::<()>(&[], &weights, &s);
}
#[test]
#[should_panic(expected = "weight set must not be empty")]
fn r2_empty_weights_panics() {
let s = space_min2();
let front = [cand(vec![0.0, 1.0])];
let _ = r2(&front, &[], &s);
}
#[test]
#[should_panic(expected = "wrong length")]
fn r2_wrong_dim_weight_panics() {
let s = space_min2();
let front = [cand(vec![0.0, 1.0])];
let weights = vec![vec![1.0, 0.0, 0.0]];
let _ = r2(&front, &weights, &s);
}
}
-55
View File
@@ -11,26 +11,6 @@ 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 {
@@ -119,39 +99,4 @@ mod tests {
let s_val = spacing(&pts, &s);
assert!(s_val > 0.0);
}
/// Pins the exact spacing for a front with *varying* nearest-neighbor
/// distances, exercising the `(a-b).abs()` sum, the `d < nearest`
/// comparison, and both `/ n` divisions in the mean/variance.
#[test]
fn varying_nn_distances_pinned() {
let s = space_min2();
// (0,10), (1,9), (10,0): L1 nearest distances are 2, 2, 18.
// mean = 22/3, variance = 1536/27, spacing = sqrt(1536/27).
let front = [
cand(vec![0.0, 10.0]),
cand(vec![1.0, 9.0]),
cand(vec![10.0, 0.0]),
];
let got = spacing(&front, &s);
let expected = (1536.0_f64 / 27.0).sqrt();
assert!(
(got - expected).abs() < 1e-9,
"got {got}, expected {expected}"
);
}
/// A perfectly even front has zero spacing — the variance term is 0.
/// Distinct from the doctest case in that it uses three points whose
/// nearest-neighbor L1 distances are all equal to 4.
#[test]
fn evenly_spaced_front_is_zero_spacing() {
let s = space_min2();
let front = [
cand(vec![0.0, 4.0]),
cand(vec![2.0, 2.0]),
cand(vec![4.0, 0.0]),
];
assert!(spacing(&front, &s) < 1e-12);
}
}
+418
View File
@@ -0,0 +1,418 @@
//! Built-in observers covering the common stop conditions.
use std::ops::ControlFlow;
use std::time::Duration;
use super::{Observer, Snapshot};
use crate::core::objective::Direction;
/// Halt after a fixed wall-clock duration since `run_with` started.
///
/// # Example
///
/// ```
/// use heuropt::prelude::*;
/// use std::time::Duration;
///
/// let stop = MaxTime::new(Duration::from_millis(50));
/// // pass `&mut stop` to `Optimizer::run_with`.
/// # let _ = stop;
/// ```
#[derive(Debug, Clone, Copy)]
pub struct MaxTime {
pub limit: Duration,
}
impl MaxTime {
pub fn new(limit: Duration) -> Self {
Self { limit }
}
}
impl<D> Observer<D> for MaxTime {
#[inline]
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
if snap.elapsed >= self.limit {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
}
}
/// Halt after a target number of generations.
///
/// Most algorithms already take a `generations` count in their config,
/// so this is mostly useful for capping algorithms whose configured
/// loop is open-ended (or for testing).
#[derive(Debug, Clone, Copy)]
pub struct MaxIterations {
pub limit: usize,
}
impl MaxIterations {
pub fn new(limit: usize) -> Self {
Self { limit }
}
}
impl<D> Observer<D> for MaxIterations {
#[inline]
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
if snap.iteration >= self.limit {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
}
}
/// Halt as soon as the best single-objective fitness reaches `target`.
///
/// Direction-aware: for `Minimize` axes the target is reached when
/// `best ≤ target`; for `Maximize`, when `best ≥ target`.
///
/// Multi-objective snapshots (where `Snapshot::best` is `None` or the
/// problem has more than one objective) are silently ignored — this
/// observer never breaks them.
#[derive(Debug, Clone, Copy)]
pub struct TargetFitness {
pub target: f64,
}
impl TargetFitness {
pub fn new(target: f64) -> Self {
Self { target }
}
}
impl<D> Observer<D> for TargetFitness {
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
if !snap.objectives.is_single_objective() {
return ControlFlow::Continue(());
}
let direction = snap.objectives.objectives[0].direction;
if let Some(best) = snap.best
&& let Some(&v) = best.evaluation.objectives.first()
{
let hit = match direction {
Direction::Minimize => v <= self.target,
Direction::Maximize => v >= self.target,
};
if hit {
return ControlFlow::Break(());
}
}
ControlFlow::Continue(())
}
}
/// Halt when the best single-objective fitness has not improved by
/// more than `tolerance` over the last `window` generations.
///
/// Multi-objective snapshots are silently ignored.
#[derive(Debug, Clone)]
pub struct Stagnation {
pub window: usize,
pub tolerance: f64,
history: std::collections::VecDeque<f64>,
}
impl Stagnation {
pub fn new(window: usize, tolerance: f64) -> Self {
assert!(window > 0, "Stagnation window must be > 0");
assert!(
tolerance >= 0.0,
"Stagnation tolerance must be non-negative"
);
Self {
window,
tolerance,
history: std::collections::VecDeque::with_capacity(window + 1),
}
}
}
impl<D> Observer<D> for Stagnation {
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
if !snap.objectives.is_single_objective() {
return ControlFlow::Continue(());
}
let direction = snap.objectives.objectives[0].direction;
let v = match snap
.best
.and_then(|c| c.evaluation.objectives.first().copied())
{
Some(v) => v,
None => return ControlFlow::Continue(()),
};
// Push to history; cap at window+1 so we always have 1 + window samples.
self.history.push_back(v);
while self.history.len() > self.window + 1 {
self.history.pop_front();
}
if self.history.len() <= self.window {
return ControlFlow::Continue(());
}
let oldest = self.history.front().copied().unwrap();
let newest = self.history.back().copied().unwrap();
let improvement = match direction {
Direction::Minimize => oldest - newest,
Direction::Maximize => newest - oldest,
};
if improvement <= self.tolerance {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
}
}
/// Compose two observers — break if **either** signals a break.
#[derive(Debug, Clone, Copy)]
pub struct AnyOf<A, B> {
pub a: A,
pub b: B,
}
impl<D, A, B> Observer<D> for AnyOf<A, B>
where
A: Observer<D>,
B: Observer<D>,
{
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
// Always poll both so stateful observers (Stagnation) update
// their history, then OR the results.
let ra = self.a.observe(snap);
let rb = self.b.observe(snap);
if ra.is_break() || rb.is_break() {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
}
}
/// Compose two observers — break only if **both** signal a break in
/// the same call.
#[derive(Debug, Clone, Copy)]
pub struct AllOf<A, B> {
pub a: A,
pub b: B,
}
impl<D, A, B> Observer<D> for AllOf<A, B>
where
A: Observer<D>,
B: Observer<D>,
{
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
let ra = self.a.observe(snap);
let rb = self.b.observe(snap);
if ra.is_break() && rb.is_break() {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
}
}
/// Call a user closure every `every` generations (default 1 = every
/// generation). Useful for periodic logging without bloating callback
/// frequency.
pub struct Periodic<F> {
pub every: usize,
counter: usize,
pub callback: F,
}
impl<F> Periodic<F> {
pub fn new(every: usize, callback: F) -> Self {
assert!(every >= 1, "Periodic every must be >= 1");
Self {
every,
counter: 0,
callback,
}
}
}
impl<D, F> Observer<D> for Periodic<F>
where
F: FnMut(&Snapshot<'_, D>),
{
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
self.counter += 1;
if self.counter >= self.every {
self.counter = 0;
(self.callback)(snap);
}
ControlFlow::Continue(())
}
}
/// Tracing-backed observer — emits a structured `debug!` event per
/// generation with iteration / evaluations / elapsed / best fitness.
///
/// Available only with the `tracing` feature.
#[cfg(feature = "tracing")]
#[derive(Debug, Default, Clone, Copy)]
pub struct TracingObserver;
#[cfg(feature = "tracing")]
impl<D> Observer<D> for TracingObserver {
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
let best = snap
.best
.and_then(|c| c.evaluation.objectives.first().copied());
tracing::debug!(
iteration = snap.iteration,
evaluations = snap.evaluations,
elapsed_ms = snap.elapsed.as_millis() as u64,
best = ?best,
front_size = snap.pareto_front.map(|f| f.len()),
"heuropt generation",
);
ControlFlow::Continue(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::candidate::Candidate;
use crate::core::evaluation::Evaluation;
use crate::core::objective::{Objective, ObjectiveSpace};
fn snap_with_best<'a>(
iteration: usize,
elapsed_ms: u64,
best: Option<&'a Candidate<()>>,
objectives: &'a ObjectiveSpace,
empty_pop: &'a [Candidate<()>],
) -> Snapshot<'a, ()> {
Snapshot {
iteration,
evaluations: 0,
elapsed: Duration::from_millis(elapsed_ms),
population: empty_pop,
pareto_front: None,
best,
objectives,
}
}
#[test]
fn max_time_breaks_after_limit() {
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
let pop: Vec<Candidate<()>> = vec![];
let mut o = MaxTime::new(Duration::from_millis(100));
let s = snap_with_best(0, 50, None, &space, &pop);
assert!(o.observe(&s).is_continue());
let s = snap_with_best(1, 100, None, &space, &pop);
assert!(o.observe(&s).is_break());
}
#[test]
fn target_fitness_minimize() {
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
let pop: Vec<Candidate<()>> = vec![];
let cand = Candidate::new((), Evaluation::new(vec![0.005]));
let mut o = TargetFitness::new(0.01);
let s = snap_with_best(0, 0, Some(&cand), &space, &pop);
assert!(o.observe(&s).is_break());
}
#[test]
fn target_fitness_maximize() {
let space = ObjectiveSpace::new(vec![Objective::maximize("f")]);
let pop: Vec<Candidate<()>> = vec![];
let cand_below = Candidate::new((), Evaluation::new(vec![0.5]));
let cand_above = Candidate::new((), Evaluation::new(vec![1.5]));
let mut o = TargetFitness::new(1.0);
let s = snap_with_best(0, 0, Some(&cand_below), &space, &pop);
assert!(o.observe(&s).is_continue());
let s = snap_with_best(1, 0, Some(&cand_above), &space, &pop);
assert!(o.observe(&s).is_break());
}
#[test]
fn stagnation_breaks_on_no_improvement() {
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
let pop: Vec<Candidate<()>> = vec![];
let mut o = Stagnation::new(3, 1e-6);
// Five generations of "no improvement" — same value every time.
for i in 0..3 {
let cand = Candidate::new((), Evaluation::new(vec![1.0]));
let s = snap_with_best(i, 0, Some(&cand), &space, &pop);
// First `window` calls just fill history; should not break.
assert!(o.observe(&s).is_continue());
}
let cand = Candidate::new((), Evaluation::new(vec![1.0]));
let s = snap_with_best(3, 0, Some(&cand), &space, &pop);
assert!(o.observe(&s).is_break());
}
#[test]
fn stagnation_does_not_break_on_improvement() {
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
let pop: Vec<Candidate<()>> = vec![];
let mut o = Stagnation::new(2, 1e-6);
let values = [1.0, 0.9, 0.8, 0.7];
for (i, &v) in values.iter().enumerate() {
let cand = Candidate::new((), Evaluation::new(vec![v]));
let s = snap_with_best(i, 0, Some(&cand), &space, &pop);
assert!(o.observe(&s).is_continue(), "iter {i}");
}
}
#[test]
fn anyof_breaks_when_either_breaks() {
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
let pop: Vec<Candidate<()>> = vec![];
let cand = Candidate::new((), Evaluation::new(vec![5.0]));
let mut o =
<MaxIterations as Observer<()>>::or(MaxIterations::new(3), TargetFitness::new(1.0));
for i in 0..3 {
let s = snap_with_best(i, 0, Some(&cand), &space, &pop);
assert!(o.observe(&s).is_continue(), "iter {i}");
}
// iteration = 3 hits MaxIterations limit → break
let s = snap_with_best(3, 0, Some(&cand), &space, &pop);
assert!(o.observe(&s).is_break());
}
#[test]
fn periodic_calls_callback_every_n() {
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
let pop: Vec<Candidate<()>> = vec![];
let mut count = 0_usize;
{
let mut o = Periodic::new(3, |_: &Snapshot<'_, ()>| count += 1);
for i in 0..10 {
let s = snap_with_best(i, 0, None, &space, &pop);
let _ = o.observe(&s);
}
}
assert_eq!(count, 3); // every 3rd of 10 = generations 2, 5, 8
}
#[test]
fn closure_implements_observer() {
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
let pop: Vec<Candidate<()>> = vec![];
let mut count = 0_usize;
let mut closure = |_: &Snapshot<'_, ()>| -> ControlFlow<()> {
count += 1;
if count >= 2 {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
};
let s = snap_with_best(0, 0, None, &space, &pop);
assert!(<_ as Observer<()>>::observe(&mut closure, &s).is_continue());
assert!(<_ as Observer<()>>::observe(&mut closure, &s).is_break());
}
}
+101
View File
@@ -0,0 +1,101 @@
//! Per-generation observation, callbacks, and stop conditions.
//!
//! Algorithms accept an [`Observer`] via [`Optimizer::run_with`] and call
//! it once per generation (where "generation" makes sense for that
//! algorithm — see each algorithm's docs). Returning
//! [`std::ops::ControlFlow::Break`] from an observer halts the optimizer
//! and the partial [`OptimizationResult`] is returned to the caller.
//!
//! Observers can be composed with [`builtin::AnyOf`] / [`builtin::AllOf`].
//!
//! [`OptimizationResult`]: crate::core::result::OptimizationResult
//! [`Optimizer::run_with`]: crate::traits::Optimizer::run_with
pub mod builtin;
mod snapshot;
pub use snapshot::Snapshot;
use std::ops::ControlFlow;
use crate::core::candidate::Candidate;
use crate::core::objective::ObjectiveSpace;
/// A callback invoked by an [`Optimizer`](crate::traits::Optimizer)
/// after every generation. Return [`ControlFlow::Break`] to halt
/// the optimizer; [`ControlFlow::Continue`] to keep going.
///
/// Implement directly for stateful observers that need to track
/// history (e.g. stagnation detection, convergence trace logging).
/// For simple stop conditions, use the helpers in
/// [`builtin`](crate::observer::builtin).
pub trait Observer<D> {
/// Inspect the latest snapshot. Return [`ControlFlow::Break`] to
/// halt the run; [`ControlFlow::Continue`] to keep going.
fn observe(&mut self, snapshot: &Snapshot<'_, D>) -> ControlFlow<()>;
/// Compose with another observer that fires when *either* of them
/// signals a break.
fn or<O: Observer<D>>(self, other: O) -> builtin::AnyOf<Self, O>
where
Self: Sized,
{
builtin::AnyOf { a: self, b: other }
}
/// Compose with another observer that fires when *both* of them
/// signal a break in the same call.
fn and<O: Observer<D>>(self, other: O) -> builtin::AllOf<Self, O>
where
Self: Sized,
{
builtin::AllOf { a: self, b: other }
}
}
/// `()` is the no-op observer. Used as the default when callers don't
/// want any callbacks (it's what `run` uses internally).
impl<D> Observer<D> for () {
#[inline]
fn observe(&mut self, _: &Snapshot<'_, D>) -> ControlFlow<()> {
ControlFlow::Continue(())
}
}
/// Closures of the right shape implement Observer too — short-form
/// for one-liner callbacks.
impl<D, F> Observer<D> for F
where
F: FnMut(&Snapshot<'_, D>) -> ControlFlow<()>,
{
#[inline]
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
self(snap)
}
}
/// Build a snapshot for the "final notification" path of the default
/// `run_with` impl on [`Optimizer`](crate::traits::Optimizer).
///
/// Algorithm impls that override `run_with` to call the observer per
/// generation should construct their own snapshots inline rather than
/// using this helper, because they have richer per-generation state.
pub fn finalize_snapshot<'a, D>(
iteration: usize,
evaluations: usize,
elapsed: std::time::Duration,
population: &'a [Candidate<D>],
pareto_front: Option<&'a [Candidate<D>]>,
best: Option<&'a Candidate<D>>,
objectives: &'a ObjectiveSpace,
) -> Snapshot<'a, D> {
Snapshot {
iteration,
evaluations,
elapsed,
population,
pareto_front,
best,
objectives,
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Per-generation observation payload passed to [`Observer`](super::Observer).
use std::time::Duration;
use crate::core::candidate::Candidate;
use crate::core::objective::ObjectiveSpace;
/// A view of an optimizer's state at one generation boundary.
///
/// Borrowed (`&'a ...`) rather than owned so the algorithm doesn't
/// have to clone the whole population on every call. Observers that
/// need to retain values across calls should clone what they need
/// out of the snapshot.
#[derive(Debug)]
pub struct Snapshot<'a, D> {
/// Zero-indexed generation count. The first call is `iteration = 0`
/// for "after the initial population was built and evaluated";
/// subsequent calls are after generation 1, 2, …
pub iteration: usize,
/// Total `Problem::evaluate` calls so far, including the initial
/// population.
pub evaluations: usize,
/// Wall-clock time since `run_with` started.
pub elapsed: Duration,
/// The current population (whatever the algorithm considers the
/// "live" set this generation). For steady-state algorithms this
/// is the post-replacement population.
pub population: &'a [Candidate<D>],
/// The current Pareto front, if the algorithm tracks one. `None`
/// for single-objective algorithms.
pub pareto_front: Option<&'a [Candidate<D>]>,
/// The current best candidate. `Some` for single-objective
/// algorithms; `None` for multi-objective unless the algorithm
/// tracks a notion of best (some don't).
pub best: Option<&'a Candidate<D>>,
/// The objective space, useful for observers that need to convert
/// raw objective values to minimization-oriented form.
pub objectives: &'a ObjectiveSpace,
}
-13
View File
@@ -9,19 +9,6 @@ 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]`.
File diff suppressed because it is too large Load Diff
-321
View File
@@ -10,23 +10,6 @@ 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.
@@ -71,19 +54,6 @@ 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.
@@ -118,26 +88,6 @@ 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.
@@ -230,23 +180,6 @@ 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.
@@ -321,23 +254,6 @@ 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.
@@ -399,23 +315,6 @@ 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.
@@ -720,224 +619,4 @@ mod tests {
let mut rng = rng_from_seed(0);
m.vary(&[vec![0.5; 2]], &mut rng);
}
// ---- Pinned numerical snapshots ----------------------------------------
//
// Mutation testing surfaced ~120 arithmetic-flip mutants surviving in
// this file (`+= → *=`, `*` ↔ `+`, `` ↔ `/`, etc.). The existing
// shape/bounds tests pass with most of those flips because they only
// check ranges. The snapshots below pin the *exact* output of each
// operator at a fixed seed so any arithmetic flip changes a value and
// fails the assertion. Snapshots come from running the un-mutated
// implementation; updating an operator's math requires updating its
// snapshot, by design.
fn assert_close_slice(got: &[f64], want: &[f64], tol: f64) {
assert_eq!(
got.len(),
want.len(),
"length mismatch: got {got:?} want {want:?}"
);
for (g, w) in got.iter().zip(want.iter()) {
assert!((g - w).abs() < tol, "got {g}, want {w}; full got = {got:?}");
}
}
#[test]
fn gaussian_mutation_seed_42_pinned() {
let mut m = GaussianMutation { sigma: 0.5 };
let mut rng = rng_from_seed(42);
let parent = vec![1.0_f64, 2.0, 3.0];
let children = m.vary(std::slice::from_ref(&parent), &mut rng);
assert_close_slice(
&children[0],
&[
1.034_713_959_180_981_7,
2.066_469_060_997_062_6,
3.131_288_178_686_977,
],
1e-12,
);
}
#[test]
fn bounded_gaussian_mutation_seed_7_pinned() {
let mut m = BoundedGaussianMutation::new(0.3, vec![(-1.0, 1.0); 3]);
let mut rng = rng_from_seed(7);
let parent = vec![0.0_f64, 0.5, -0.5];
let children = m.vary(std::slice::from_ref(&parent), &mut rng);
assert_close_slice(
&children[0],
&[
-0.313_072_988_018_995_14,
0.326_975_666_440_741_83,
-0.713_376_295_479_132,
],
1e-12,
);
}
#[test]
fn sbx_seed_42_pinned_pair_of_children() {
let bounds = vec![(-1.0, 1.0); 3];
let mut sbx = SimulatedBinaryCrossover::new(bounds, 15.0, 1.0);
let mut rng = rng_from_seed(42);
let p1 = vec![-0.5, 0.0, 0.5];
let p2 = vec![0.5, 0.5, -0.5];
let children = sbx.vary(&[p1, p2], &mut rng);
assert_eq!(children.len(), 2);
assert_close_slice(
&children[0],
&[
-0.501_708_457_102_519_2,
-0.001_399_584_314_974_167_1,
0.510_060_271_407_340_6,
],
1e-12,
);
assert_close_slice(
&children[1],
&[
0.501_708_457_102_519_2,
0.501_399_584_314_974_1,
-0.510_060_271_407_340_6,
],
1e-12,
);
}
/// SBX has the algebraic identity `c1 + c2 = p1 + p2` for any β (before
/// clamping). Pinning this directly catches arithmetic flips in the
/// `(1+β) * p1 + (1-β) * p2` formula that would break the identity.
#[test]
fn sbx_sum_of_children_equals_sum_of_parents_when_unclamped() {
let bounds = vec![(-100.0, 100.0); 3]; // wide so no clamping fires
let mut sbx = SimulatedBinaryCrossover::new(bounds, 15.0, 1.0);
let p1 = vec![-0.5, 0.2, 0.9];
let p2 = vec![0.3, -0.7, 0.1];
for seed in 0..20 {
let mut rng = rng_from_seed(seed);
let kids = sbx.vary(&[p1.clone(), p2.clone()], &mut rng);
for j in 0..p1.len() {
let lhs = kids[0][j] + kids[1][j];
let rhs = p1[j] + p2[j];
assert!((lhs - rhs).abs() < 1e-12, "seed={seed} j={j} {lhs} ≠ {rhs}");
}
}
}
#[test]
fn polynomial_mutation_seed_42_pinned() {
let bounds = vec![(-1.0, 1.0); 3];
let mut pm = PolynomialMutation::new(bounds, 20.0, 1.0);
let mut rng = rng_from_seed(42);
let parent = vec![0.0_f64, 0.5, -0.5];
let children = pm.vary(std::slice::from_ref(&parent), &mut rng);
assert_close_slice(
&children[0],
&[
0.005_191_102_584_008_567,
0.508_488_942_560_315,
-0.469_873_699_029_174_75,
],
1e-12,
);
}
/// PolynomialMutation's δ should scale by `(hi - lo)`. If the
/// `delta * (hi - lo)` arithmetic gets mutated (e.g., `*` → `+`), the
/// per-axis perturbation scale drops out and a 10× bound range no
/// longer produces a 10× larger step. Tests with two different bound
/// widths at the same seed and asserts the perturbation ratio is ≈ 10.
#[test]
fn polynomial_mutation_step_scales_with_bound_width() {
let parent = vec![0.0_f64];
let probe = |bounds: Vec<(f64, f64)>| -> f64 {
let mut pm = PolynomialMutation::new(bounds, 20.0, 1.0);
let mut rng = rng_from_seed(123);
pm.vary(std::slice::from_ref(&parent), &mut rng)[0][0]
};
let narrow = probe(vec![(-1.0_f64, 1.0)]); // hi - lo = 2
let wide = probe(vec![(-10.0_f64, 10.0)]); // hi - lo = 20
// Same seed → same δ; the only difference is the (hi-lo) factor.
// Ratio must be ≈ 10.
let ratio = wide / narrow;
assert!(
(ratio - 10.0).abs() < 1e-12,
"ratio = {ratio}, narrow={narrow}, wide={wide}"
);
}
#[test]
fn levy_mutation_seed_42_pinned() {
let mut m = LevyMutation::new(1.5, 0.1, vec![(-100.0, 100.0); 3]);
let mut rng = rng_from_seed(42);
let parent = vec![0.0_f64; 3];
let children = m.vary(std::slice::from_ref(&parent), &mut rng);
assert_close_slice(
&children[0],
&[
0.018_566_727_273_339_814,
0.049_398_595_670_997_11,
-0.128_765_264_276_263_75,
],
1e-12,
);
}
/// The `mantegna_sigma_u` helper computes `σᵤ` for Mantegna's Lévy
/// algorithm. Pinning a non-degenerate alpha catches arithmetic flips
/// in both the outer formula and the inner `gamma()` Lanczos series.
#[test]
fn mantegna_sigma_u_alpha_1_5_pinned() {
let got = mantegna_sigma_u(1.5);
assert!(
(got - 0.696_574_502_557_698).abs() < 1e-12,
"mantegna_sigma_u(1.5) = {got}",
);
}
#[test]
fn mantegna_sigma_u_alpha_1_0_pinned() {
// alpha = 1.0: sin(π/2) = 1, gamma(2) = 1, gamma(1) = 1 → σᵤ ≈ 1.
let got = mantegna_sigma_u(1.0);
assert!((got - 1.0).abs() < 1e-12, "mantegna_sigma_u(1.0) = {got}",);
}
#[test]
fn mantegna_sigma_u_alpha_2_0_pinned() {
// alpha = 2.0 (Normal limit): sin(π) = 0 numerically → σᵤ → 0.
// Specifically about 1e-8 due to the FP error in sin(π).
let got = mantegna_sigma_u(2.0);
assert!((0.0..1e-7).contains(&got), "mantegna_sigma_u(2.0) = {got}");
}
/// `gamma(z)` at exact integer arguments hits known recurrence values.
/// We probe it indirectly via `mantegna_sigma_u` since gamma is a
/// private inner fn. Pin `σᵤ` at alpha = 1.5 — under any arithmetic
/// mutation inside gamma() the value shifts well beyond f64 precision.
/// (Already covered by the alpha-1.5 test above; left here as docs.)
#[test]
fn mantegna_sigma_u_changes_monotonically_with_alpha() {
// For alpha ∈ [0.5, 1.5], σᵤ is a monotone function of α
// (Mantegna 1994, fig 1). This is a property test that breaks
// under structural changes to the formula even if the snapshot
// values are wrong.
let a = mantegna_sigma_u(0.5);
let b = mantegna_sigma_u(0.8);
let c = mantegna_sigma_u(1.2);
let d = mantegna_sigma_u(1.5);
// Verify (a, b, c, d) all positive and the sequence is monotone
// — direction depends on implementation, just assert non-trivial.
for v in [a, b, c, d] {
assert!(v > 0.0 && v.is_finite(), "non-positive sigma_u: {v}");
}
// a > d (decreasing) or a < d (increasing) — both are valid; just
// require the values aren't all identical (which would happen
// under a `gamma -> const` mutant).
assert!(
(a - d).abs() > 0.01,
"sigma_u barely changes with alpha: a={a}, d={d}",
);
}
}
-52
View File
@@ -8,17 +8,6 @@ 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.
@@ -57,19 +46,6 @@ 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
@@ -249,32 +225,4 @@ mod tests {
assert!(approx_eq(v, 0.25, 1e-12));
}
}
// ---- Mutation-test coverage for ProjectToSimplex ----------------------
//
// The degenerate-magnitude shortcut concentrates mass on argmax(x). The
// next two tests pin the *position* of that argmax precisely.
/// The shortcut picks the **first** index on a tie. Strict `>` keeps
/// the earlier index; `>=` would overwrite with the later equal index.
/// Kills `> → >=` in the argmax scan.
#[test]
fn project_extreme_magnitudes_keeps_first_index_on_tie() {
let mut r = ProjectToSimplex::new(1.0);
let mut x = vec![1e20, 1e20, -1e20];
r.repair(&mut x);
assert_eq!(x, vec![1.0, 0.0, 0.0]);
}
/// The shortcut finds the argmax at a non-zero index. With `> → ==`
/// the scan stops updating because `1e20 == -1e20` is false at i=1
/// and the argmax stays at 0 — but the true argmax is at index 1.
/// Kills `> → ==` in the argmax scan.
#[test]
fn project_extreme_magnitudes_finds_argmax_at_non_zero_index() {
let mut r = ProjectToSimplex::new(1.0);
let mut x = vec![-1e20, 1e20, 5e19];
r.repair(&mut x);
assert_eq!(x, vec![0.0, 1.0, 0.0]);
}
}
-81
View File
@@ -9,23 +9,6 @@ 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.
@@ -265,68 +248,4 @@ mod tests {
a.extend(vec![cand(1, vec![1.0, 4.0]), cand(2, vec![3.0, 2.0])]);
assert_eq!(a.members().len(), 2);
}
/// `truncate` keeps the archive untouched when it is already at or
/// below `max_size`, and trims it when over. Pins the `>` boundary.
#[test]
fn truncate_boundary_behavior() {
let mut a = ParetoArchive::<u32>::new(space_min2());
// Three mutually non-dominated members.
a.insert(cand(1, vec![1.0, 3.0]));
a.insert(cand(2, vec![2.0, 2.0]));
a.insert(cand(3, vec![3.0, 1.0]));
assert_eq!(a.members().len(), 3);
// max_size == len → no-op (kills `>` → `>=`).
a.truncate(3);
assert_eq!(a.members().len(), 3);
// max_size > len → no-op.
a.truncate(10);
assert_eq!(a.members().len(), 3);
// max_size < len → trims.
a.truncate(2);
assert_eq!(a.members().len(), 2);
}
/// A trade-off candidate (better on one axis, worse on the other) is
/// neither dominated nor dominating — it must be *added* alongside the
/// existing member. Pins the per-axis `<` / `>` scan in both
/// `member_dominates_or_equals` and `candidate_dominates_member`.
#[test]
fn trade_off_candidate_is_kept_alongside() {
let mut a = ParetoArchive::<u32>::new(space_min2());
a.insert(cand(1, vec![1.0, 5.0]));
a.insert(cand(2, vec![5.0, 1.0])); // trade-off — must be kept
assert_eq!(a.members().len(), 2);
}
/// An equal-objectives candidate is rejected (a member dominates-or-
/// equals it). Pins the Equal branch — distinguishes `<=` from `<` in
/// `candidate_dominates_member` and the `<=` in
/// `member_dominates_or_equals`'s infeasible branch.
#[test]
fn equal_candidate_is_rejected() {
let mut a = ParetoArchive::<u32>::new(space_min2());
a.insert(cand(1, vec![2.0, 2.0]));
a.insert(cand(2, vec![2.0, 2.0])); // identical objectives → rejected
assert_eq!(a.members().len(), 1);
assert_eq!(a.members()[0].decision, 1);
}
/// Two infeasible candidates: the one with smaller constraint violation
/// wins. Pins the `<` / `<=` in the infeasible branches.
#[test]
fn infeasible_candidate_with_smaller_violation_evicts_larger() {
let mut a = ParetoArchive::<u32>::new(space_min2());
a.insert(Candidate::new(
1u32,
Evaluation::constrained(vec![0.0, 0.0], 1.0),
));
// Smaller violation → dominates the existing infeasible member.
a.insert(Candidate::new(
2u32,
Evaluation::constrained(vec![9.0, 9.0], 0.5),
));
assert_eq!(a.members().len(), 1);
assert_eq!(a.members()[0].decision, 2);
}
}
+16 -77
View File
@@ -11,28 +11,6 @@ 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],
@@ -55,35 +33,33 @@ pub fn crowding_distance<D>(
.map(|&idx| objectives.as_minimization(&population[idx].evaluation.objectives))
.collect();
// Reused across objectives: (objective-k value, front position). Sorting
// these tuples directly keeps the hot comparator a single `f64` compare
// instead of chasing two `Vec<Vec<f64>>` indirections per comparison.
let mut keyed: Vec<(f64, usize)> = Vec::with_capacity(n);
#[allow(clippy::needless_range_loop)] // `k` indexes into nested vectors below.
for k in 0..m {
keyed.clear();
keyed.extend((0..n).map(|i| (oriented[i][k], i)));
keyed.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
// Sort indices into `front` by objective k.
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| {
oriented[a][k]
.partial_cmp(&oriented[b][k])
.unwrap_or(std::cmp::Ordering::Equal)
});
let first = keyed[0].1;
let last = keyed[n - 1].1;
distance[first] = f64::INFINITY;
distance[last] = f64::INFINITY;
distance[order[0]] = f64::INFINITY;
distance[order[n - 1]] = f64::INFINITY;
let span = keyed[n - 1].0 - keyed[0].0;
let f_min = oriented[order[0]][k];
let f_max = oriented[order[n - 1]][k];
let span = f_max - f_min;
if span == 0.0 {
continue;
}
for i in 1..n - 1 {
let idx = keyed[i].1;
if distance[idx] == f64::INFINITY {
if distance[order[i]] == f64::INFINITY {
continue;
}
let prev = keyed[i - 1].0;
let next = keyed[i + 1].0;
distance[idx] += (next - prev) / span;
let prev = oriented[order[i - 1]][k];
let next = oriented[order[i + 1]][k];
distance[order[i]] += (next - prev) / span;
}
}
@@ -162,41 +138,4 @@ mod tests {
assert!(d[2].is_infinite());
assert!(d[1].is_finite());
}
/// Crowding distance pins the exact interior contribution: for a 3-point
/// 2-objective front, the middle point's distance is the sum over both
/// objectives of (next - prev) / span. With evenly-spaced points the
/// value is exactly 2.0 (1.0 per objective).
#[test]
fn interior_point_distance_is_pinned() {
let s = space_min2();
// Front along the line f1 + f2 = 4: (0,4), (2,2), (4,0).
let pop = [
cand(vec![0.0, 4.0]),
cand(vec![2.0, 2.0]),
cand(vec![4.0, 0.0]),
];
let d = crowding_distance(&pop, &[0, 1, 2], &s);
// Boundary points are infinite; the middle point gets
// (4-0)/4 + (4-0)/4 = 2.0 (objective 0 span 4, objective 1 span 4).
assert!(d[0].is_infinite());
assert!(d[2].is_infinite());
assert!((d[1] - 2.0).abs() < 1e-12, "interior distance = {}", d[1]);
}
/// An asymmetric front pins the per-objective `(next - prev) / span`
/// arithmetic: catches the `-` ↔ `+`/`/` and `/` ↔ `*` mutants.
#[test]
fn asymmetric_interior_distance_is_pinned() {
let s = space_min2();
// (0,10), (1,2), (10,0): objective-0 span = 10, objective-1 span = 10.
let pop = [
cand(vec![0.0, 10.0]),
cand(vec![1.0, 2.0]),
cand(vec![10.0, 0.0]),
];
let d = crowding_distance(&pop, &[0, 1, 2], &s);
// middle point: obj0 (10-0)/10 = 1.0; obj1 (10-0)/10 = 1.0 → 2.0.
assert!((d[1] - 2.0).abs() < 1e-12, "got {}", d[1]);
}
}
+7 -65
View File
@@ -1,7 +1,7 @@
//! Pareto dominance enum and pairwise dominance comparison.
use crate::core::evaluation::Evaluation;
use crate::core::objective::{Direction, ObjectiveSpace};
use crate::core::objective::ObjectiveSpace;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
@@ -29,21 +29,6 @@ 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();
@@ -62,27 +47,15 @@ pub fn pareto_compare(a: &Evaluation, b: &Evaluation, objectives: &ObjectiveSpac
(true, true) => {}
}
// Compare in minimization orientation *without* materializing the two
// oriented `Vec<f64>`s that `as_minimization` would allocate.
// `pareto_compare` is called O(n²) times across the multi-objective
// algorithms, so a per-call heap-allocation pair dominates the whole
// program. For a Maximize objective, "a beats b" is just `av > bv` —
// bit-identical to `-av < -bv` after orientation.
let am = objectives.as_minimization(&a.objectives);
let bm = objectives.as_minimization(&b.objectives);
let mut a_better_anywhere = false;
let mut b_better_anywhere = false;
for ((obj, &av), &bv) in objectives
.objectives
.iter()
.zip(a.objectives.iter())
.zip(b.objectives.iter())
{
let (a_better, b_better) = match obj.direction {
Direction::Minimize => (av < bv, av > bv),
Direction::Maximize => (av > bv, av < bv),
};
if a_better {
for (av, bv) in am.iter().zip(bm.iter()) {
if av < bv {
a_better_anywhere = true;
} else if b_better {
} else if av > bv {
b_better_anywhere = true;
}
}
@@ -164,35 +137,4 @@ mod tests {
let b = Evaluation::new(vec![2.0, 0.8]);
assert_eq!(pareto_compare(&a, &b, &s), Dominance::Dominates);
}
/// `a` better on one axis, worse on the other → NonDominated. Pins the
/// `av < bv` / `av > bv` comparisons in the per-objective scan.
#[test]
fn trade_off_is_non_dominated() {
let s = space_min2();
let a = Evaluation::new(vec![1.0, 5.0]);
let b = Evaluation::new(vec![5.0, 1.0]);
assert_eq!(pareto_compare(&a, &b, &s), Dominance::NonDominated);
assert_eq!(pareto_compare(&b, &a, &s), Dominance::NonDominated);
}
/// `a` better on one axis, equal on the other → Dominates. This is the
/// boundary case that distinguishes `<` from `<=` in the scan.
#[test]
fn better_on_one_equal_on_other_dominates() {
let s = space_min2();
let a = Evaluation::new(vec![1.0, 2.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);
}
/// Identical objectives → Equal (neither `<` nor `>` ever fires).
#[test]
fn identical_objectives_are_equal() {
let s = space_min2();
let a = Evaluation::new(vec![3.0, 3.0]);
let b = Evaluation::new(vec![3.0, 3.0]);
assert_eq!(pareto_compare(&a, &b, &s), Dominance::Equal);
}
}
+8 -120
View File
@@ -2,113 +2,30 @@
use crate::core::candidate::Candidate;
use crate::core::objective::ObjectiveSpace;
use crate::pareto::dominance::{Dominance, pareto_compare};
/// Return all candidates that are not dominated by any other candidate.
///
/// 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,
) -> Vec<Candidate<D>> {
let n = population.len();
if n == 0 {
return Vec::new();
}
// Precompute per-individual feasibility, violation, and the
// minimization-oriented objective vectors once, mirroring
// `non_dominated_sort`. The naïve formulation called `pareto_compare`
// (and therefore `as_minimization`) for every ordered pair, re-deriving
// all of this on every comparison; precomputing turns the O(n²) inner
// loop into a branchless scan over a contiguous buffer.
let feasible: Vec<bool> = population
.iter()
.map(|c| c.evaluation.is_feasible())
.collect();
let violation: Vec<f64> = population
.iter()
.map(|c| c.evaluation.constraint_violation)
.collect();
let m = objectives.len();
let mut oriented: Vec<f64> = Vec::with_capacity(n * m);
for c in population {
oriented.extend_from_slice(&objectives.as_minimization(&c.evaluation.objectives));
}
// `dominated[j]` is set the moment some candidate is found to dominate
// `j`. Whenever `i`'s scan finds `i` dominates `j`, mark `j` so the
// outer loop can skip `j` entirely when it reaches it. This never does
// more work than the plain scan — the marks only ever let us *skip* —
// and it stays bit-identical even under NaN-intransitive dominance:
// a mark is set only from a direct pairwise `pareto_compare` result,
// never inferred transitively.
let mut dominated: Vec<bool> = vec![false; n];
let mut out = Vec::new();
'outer: for i in 0..n {
if dominated[i] {
continue 'outer;
}
let ai_feasible = feasible[i];
let ai_violation = violation[i];
let ai = &oriented[i * m..i * m + m];
for j in 0..n {
'outer: for (i, a) in population.iter().enumerate() {
for (j, b) in population.iter().enumerate() {
if i == j {
continue;
}
// Inline both directions of `pareto_compare`: `j` dominating
// `i` excludes `i`; `i` dominating `j` lets us skip `j`'s own
// scan later.
let (i_dominates_j, j_dominates_i) = match (ai_feasible, feasible[j]) {
(true, false) => (true, false),
(false, true) => (false, true),
(false, false) => (ai_violation < violation[j], ai_violation > violation[j]),
(true, true) => {
let aj = &oriented[j * m..j * m + m];
let mut a_better_anywhere = false;
let mut b_better_anywhere = false;
for k in 0..m {
let av = ai[k];
let bv = aj[k];
if av < bv {
a_better_anywhere = true;
} else if av > bv {
b_better_anywhere = true;
}
}
(
a_better_anywhere && !b_better_anywhere,
b_better_anywhere && !a_better_anywhere,
)
}
};
if j_dominates_i {
if matches!(
pareto_compare(&a.evaluation, &b.evaluation, objectives),
Dominance::DominatedBy
) {
continue 'outer;
}
if i_dominates_j {
dominated[j] = true;
}
}
out.push(population[i].clone());
out.push(a.clone());
}
out
}
@@ -117,21 +34,6 @@ 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,
@@ -244,18 +146,4 @@ mod tests {
];
assert!(best_candidate(&pop, &s).is_none());
}
/// `best_candidate` keeps the *first* minimum on a tie — pins the strict
/// `v < best_min` (a `<=` mutant would keep the last tied candidate).
#[test]
fn best_candidate_keeps_first_on_tie() {
use crate::core::objective::Objective;
let s = ObjectiveSpace::new(vec![Objective::minimize("f")]);
let pop = [
Candidate::new(1u32, Evaluation::new(vec![1.0])),
Candidate::new(2u32, Evaluation::new(vec![1.0])),
];
let best = best_candidate(&pop, &s).unwrap();
assert_eq!(best.decision, 1, "should keep the first of two tied minima");
}
}
-15
View File
@@ -10,21 +10,6 @@
///
/// # 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,

Some files were not shown because too many files have changed in this diff Show More