Commit Graph
107 Commits
Author SHA1 Message Date
swaits d60a3c3fe8 perf(pareto_archive): cache oriented + inline dominance checks in insert
`ParetoArchive::insert` calls `pareto_compare` twice per existing
member (once per pass), and each call re-allocates two Vec<f64>s
via `as_minimization` — 4N allocations per insert. Cache the
candidate's oriented + feasibility/violation once, build each
member's oriented vector once for the call, then inline the
dominance test against those cached arrays.

Used by PESA-II (per offspring per generation), PAES (per child),
ε-MOEA, and any user code working through the archive directly.

Wall-clock (compare harness, 10-seed mean):
- PESA-II / DTLZ2: 498 → 426 ms (-14 %)
- PESA-II / ZDT1:   87 →  75 ms (-14 %)

Smaller wins on PAES / MOPSO / IBEA / HypE / ε-MOEA where the
archive isn't the dominant per-generation cost.

Bit-identical via the compare harness.
2026-05-05 13:28:38 -06:00
swaits 060841d011 build(release): enable thin LTO + codegen-units=1 in release profile
Cuts ~150 ms (-2.5 %) off the compare harness via better cross-crate
inlining of small Pareto/HV helpers. Costs ~20 s extra on a from-
scratch `cargo build --release`, but is essentially free on
incremental rebuilds.

Only applies when this crate is the workspace root (i.e. when
developing heuropt or running its own examples). Downstream users
who consume heuropt as a dependency see whatever profile their own
Cargo.toml configures.
2026-05-05 13:28:18 -06:00
swaits f01089e9d0 perf(hypervolume): index-sort instead of cloning point vectors
The M≥3 branch of `hso_recursive` cloned every input point into
`sorted: Vec<Vec<f64>>` solely so it could sort. Each clone is M
f64s allocated; with N points per call and ~30 HV calls per SMS-EMOA
generation × 30 k generations, that's millions of small Vec<f64>
allocations.

Sort indices into a `Vec<usize>` instead, then iterate the original
points by index. The pre-projection step still produces a
Vec<Vec<f64>> (which the active-prefix slicing requires), but we
save the outer N inner-Vec clones per call.

gungraun (instructions):
- hypervolume_nd_3d n=30:    87 969 →  70 334  (-20 %, 1.25×)
- hypervolume_nd_3d n=100:  422 767 → 367 767  (-13 %, 1.15×)

Cumulative vs the v0.3.0 baseline:
- hypervolume_nd_3d n=30:    676 902 →  70 334  (9.6×)
- hypervolume_nd_3d n=100: 13 523 760 → 367 767 (37×)

Wall-clock impact is in the noise on the compare harness because the
SMS-EMOA worst-front HV calls operate on small fronts (5–10 points
once converged). The win is most visible in synthetic dense-front
HV benchmarks.
2026-05-05 13:28:18 -06:00
swaits adf18950dc perf(spea2): incremental truncation sort + cache compute_fitness inputs
Two independent wins in SPEA2's per-generation hot path. Both
bit-identical against the compare harness.

# 1. compute_fitness — cache oriented + distance matrix

`compute_fitness` is called twice per generation. The strength-graph
loop calls `pareto_compare` in an N² loop, allocating two Vec<f64>s
per call via `as_minimization`. Inline the dominance test against
cached oriented arrays. The density loop's per-row euclidean recompute
is replaced by a symmetric N×N distance matrix built once.

# 2. build_archive — incremental sort maintenance in truncation

The archive-truncation loop was O(K³ log K) — each pruning iteration
recomputed every alive member's pairwise distances and re-sorted them,
when the only change since the prior iteration was that one specific
neighbor (the just-removed victim) became dead. Compute the distance
matrix and sorted neighbor vectors once, then on victim removal use
binary-search-remove on every survivor's still-sorted vector. Total
truncation cost drops from O(K³ log K) to O(K² log K). Victim choice
is bit-identical.

gungraun (instructions):
- spea2_short: 179 113 → 133 783 (-25 %, 1.34×)

Wall-clock (compare harness, 10-seed mean):
- SPEA2 / ZDT1:   458 → 241 ms (1.9×, cumulative)
- SPEA2 / DTLZ2: 4304 → 513 ms (8.4×, cumulative)
2026-05-05 13:28:18 -06:00
swaits 4c7126070b perf(age_moea): cache lp_norm + maintain nearest-neighbor incrementally
The splitting-front survival selection in AGE-MOEA recomputed two
expensive things per while-iteration:

* `lp_norm(translated[i], p)` for every remaining i — even though the
  value is constant across iterations.
* `nearest_neighbor_distance(i, …, &keep, p)` — a fresh full scan
  over the keep list, even though only one new candidate was added
  since the last scan.

Both are `powf`-heavy in the L_p frame.

Compute lp_norm once per candidate at function entry. Maintain a
`nearest[]` array seeded from the initial keep set and updated on
every pick by a single `min(nearest[i], lp_distance(i, pick, p))`
per remaining i. That cuts the score loop from O(R · K · M) to
O(R · M) per iteration, with the dominant powf calls in
lp_distance counted once per (remaining, pick) pair instead of per
(remaining, full-keep).

Wall-clock (compare harness, 10-seed mean):
- AGE-MOEA / DTLZ1: 2266 → 430 ms on top of v0.3.0 baseline (5.3×)
- AGE-MOEA / ZDT3:   935 → 376 ms (2.5×)
2026-05-05 13:28:18 -06:00
swaits 214f07975a perf(non_dominated_sort): cache oriented values + inline pareto_compare
The Deb fast non-dominated sort calls `pareto_compare` twice for
every (i, j) pair, and each `pareto_compare` call invokes
`ObjectiveSpace::as_minimization` twice — so for an N-point
population that's 4·N·(N-1) fresh `Vec<f64>` allocations per sort.
At N=100 with thousands of generations across the compare harness,
this dominated the per-generation cost of every Pareto-based MOEA.

Cache `as_minimization`/feasibility/violation once per individual
up front, then inline the dominance test against those cached
arrays. The output (per-pair dominance outcome and the per-i
`dominates` lists) is bit-identical to `pareto_compare`.

gungraun (instructions):
- non_dominated_sort_2d n=50:    852 317 →   198 574 (-77 %, 4.3×)
- non_dominated_sort_2d n=200: 13 513 271 → 2 601 813 (-81 %, 5.2×)

Wall-clock (compare harness, 10-seed mean):
- NSGA-II / ZDT1:        268 →  65 ms (4.1×)
- NSGA-II / ZDT3:        267 →  65 ms (4.1×)
- NSGA-II / DTLZ2:       344 → 106 ms (3.2×)
- NSGA-II / Rastrigin:   260 →  71 ms (3.7×)
- NSGA-III / DTLZ2:      318 → 122 ms (2.6×)
- NSGA-III / DTLZ1:      303 → 122 ms (2.5×)
- SMS-EMOA / DTLZ2:     1413 → 1369 ms (small additional win on top of HV)
- AGE-MOEA / DTLZ1:      430 → 229 ms (1.9×, on top of the AGE-MOEA caching)
- HypE / DTLZ2:           80 →  44 ms (1.8×)
2026-05-05 13:28:18 -06:00
swaits 4745a6bb16 perf(hypervolume): cut HSO recursion overhead by ~30× on n=100/3-D
The HSO recursion in `hypervolume_nd` had three overheads that
dominated SMS-EMOA's per-generation cost on DTLZ2 (5.6 s baseline,
~30 k generations × ~40 HV calls per generation = ~1.2 M HV calls
per run):

1. `active = sorted.clone()` plus `active.iter().position(...)`
   linear scan to remove the just-processed point each band — O(N)
   per band, total O(N²) per HV call.
2. Per-band re-projection
   `active.iter().map(|q| q[..last].to_vec())` — full
   Vec<Vec<f64>> rebuild for every band, O(N·M) allocations per HV
   call.
3. `non_dominated_projection` called even when recursing into the
   M=2 base case, whose sweep already filters dominated points
   internally.

Replace (1) with prefix-slicing `projected_all[..=k]` (sort points
ascending by last axis once; the active set at each band is just a
prefix). Pre-project once outside the loop (2). Skip the explicit
non-dominance filter when the inner recursion is M=2 (3).

Bit-identical output verified by re-running the compare harness and
diffing against the v0.3.0 snapshot — every quality metric matches
to the last decimal.

gungraun (instructions):
- hypervolume_nd_3d n=30:    676 902 →    87 969  (-87 %, 7.7×)
- hypervolume_nd_3d n=100: 13 523 760 →   422 767 (-97 %, 32×)

Wall-clock (compare harness, 10-seed mean):
- SMS-EMOA / DTLZ2: 5643 ms → 1413 ms (-4230 ms, -75 %)
2026-05-05 12:11:45 -06:00
swaits 345e3ea296 docs(readme): align decision tree with v0.3.0 comparison results
The compare harness (re-run on 2026-05-05 produced bit-identical
results to the v0.3.0 snapshot) doesn't square with four claims in
the DT. Adjust:

- BayesianOpt: was "gold standard". At 60 evals on 5-D Rosenbrock
  with the default RBF kernel it produces f≈3172 (worse than
  RandomSearch). Add the caveat that BO is the gold standard *with*
  per-problem kernel tuning, not out of the box.
- MOPSO: was buried under "swarm style". On ZDT1 it wins HV outright
  and beats every dominance-based method on convergence by ~100×.
  Promote to its own "smooth real-valued 2-obj front" branch.
- SMS-EMOA: was "great on 2–3 obj at higher per-step cost". On these
  benches it loses to NSGA-II on both ZDT1 (HV 102.9 vs 118.3) and
  DTLZ2 (mean dist 0.048 vs 0.033). Reframe as "elegant in theory but
  underperforms NSGA-II on these benches at our budgets".
- NSGA-III: was "strong default" for many-objective. On DTLZ1 (the
  canonical linear-simplex test) it gets beaten by GrEA 3× and
  MOEA/D 2×. Split the many-obj branch by front geometry: linear /
  simplex → GrEA + MOEA/D; curved / unknown → NSGA-III + AGE-MOEA +
  RVEA.

The quick-reference one-liners below the DT got the matching tweaks
so the table and the tree agree.
2026-05-05 11:40:14 -06:00
swaits 4a59041d1a style: apply rustfmt drift across the crate 2026-05-05 11:40:14 -06:00
swaits 84cee3f29e ci: add GitHub Actions workflow with full feature matrix and fuzz smoke 2026-05-05 11:28:33 -06:00
swaits 1eab8e4805 docs: add testing section to README and CHANGELOG entries for fuzz, fixes, and CI 2026-05-05 11:28:29 -06:00
swaits f67b5c5160 fix(pareto): partition NaN-cycle orphan indices into a residual front 2026-05-05 11:28:24 -06:00
swaits 51fc7271b1 fix(operators): make ProjectToSimplex robust to extreme magnitudes 2026-05-05 11:28:21 -06:00
swaits c7a8f43a99 test(fuzz): add cargo-fuzz harness for Pareto and operator hot paths 2026-05-05 11:28:09 -06:00
swaits aba9dbf469 build(bench): expand gungraun bench suite to cover every algorithm
Goes from 6 benchmarks to ~25:

Pareto utilities (existing):
- non_dominated_sort_2d (n=50, 200)
- crowding_distance_2d (n=50, 200)
- hypervolume_2d (n=30, 100)
- hypervolume_nd_3d (n=30, 100)

Single-objective algorithms (all measured at "one short run"):
- random_search, hill_climber, one_plus_one_es, simulated_annealing
- genetic_algorithm, particle_swarm, differential_evolution, tlbo
- cma_es, separable_nes, nelder_mead, bayesian_opt, tpe

Multi-objective algorithms (one short run each):
- nsga2, nsga3, spea2, moead, mopso, ibea, sms_emoa
- hype, pesa2, epsilon_moea, age_moea, grea, knea, rvea

Each uses a tiny problem with realistic-shape parameters (small pop,
few generations, tight bounds) so the benchmark exercises each
algorithm's *inner loop cost* rather than dominated by RNG init or
config parsing.
2026-05-05 11:03:00 -06:00
swaits 8a8c32f125 test(proptest): massive property-test expansion for every algorithm and operator
Goes from 10 properties to 50+, organized into four files:

- tests/properties.rs (existing) — Pareto-utility invariants
- tests/algorithm_properties.rs (new) — every Optimizer impl gets:
  * determinism-with-seed property
  * no-panic-on-random-valid-input property
  * population-size-as-documented property where applicable
- tests/operator_properties.rs (new) — every Variation/Initializer/
  Repair impl gets the right size + in-bounds + no-panic properties
- tests/metric_properties.rs (new) — every metric gets monotonicity
  / non-negativity / dim-checking properties
- tests/numerical_stability.rs (new) — single-point populations,
  duplicate populations, near-zero bounds, very large bounds,
  algorithms-on-flat-fitness — none of which should panic.

Total: 226 unit tests + this much-larger property suite. Strategies
are factored into a small `prop_helpers` module shared across files
so the random-input generators stay consistent.
2026-05-05 11:01:36 -06:00
swaits 36e1d9d796 test(mutants): add cargo-mutants config for advisory mutation testing
Adds `.cargo/mutants.toml` configuring cargo-mutants to focus on the
algorithmic core (skipping benches, examples, tests_support) and pass
`--test-tool=cargo --no-shuffle` so a mutation that breaks the suite
gets caught quickly.

Mutation testing modifies the source one operator at a time (`>` →
`>=`, `+` → `-`, `true` → `false`, etc.) and re-runs the test suite.
A mutation that *survives* (tests still pass) is a hint that the test
suite isn't checking that bit of behavior — usually because:
- The mutated branch is dead code
- The unit tests rely on side-effects rather than return values
- A property test or invariant is missing

Not wired into CI as a gating check (it's slow — every mutation
re-runs the whole suite). Run locally with `cargo install cargo-mutants`
followed by `cargo mutants --in-diff HEAD~1` for incremental coverage,
or `cargo mutants` for a full sweep.

The config exclusions list explains *why* each module is skipped — most
are the "obvious" kind (benchmark harness, example problems) where
mutation kills are not informative.
2026-05-05 10:39:32 -06:00
swaits dcf63316f6 test(proptest): add property-based tests for invariants
Adds proptest as a dev-dependency and a `tests/properties.rs`
integration suite that probes invariants on randomly generated
inputs:

Pareto invariants:
- `pareto_compare` is anti-symmetric: A→B is opposite of B→A for
  Dominates / DominatedBy
- `pareto_compare` is reflexive on equal candidates (returns Equal)
- `pareto_front` output is internally non-dominated
- `non_dominated_sort` puts every member into exactly one front
- `crowding_distance` returns Vec same length as front; boundary
  points are infinity for fronts of size ≥ 2 in any axis-sortable
  configuration

Operator invariants:
- `SimulatedBinaryCrossover` returns 2 children of the right length,
  all in bounds
- `PolynomialMutation` returns 1 child of the right length, in bounds
- `BoundedGaussianMutation` returns 1 child in bounds
- `ClampToBounds` repair always lands in bounds
- `ProjectToSimplex` repair always sums to total and is non-negative

Algorithm invariants:
- For any seed, `Optimizer::run` is deterministic across two calls
- Final population has the documented size for population-based
  algorithms

These are the invariants the existing 226 fixed-input unit tests
collectively check; proptest gives us coverage on inputs they don't
cover individually.
2026-05-05 10:38:53 -06:00
swaits 0b31b266ef build(deps): add gungraun (was iai-callgrind) instruction-count benches
Wire `gungraun` 0.18 as a dev-dependency and a `benches/` directory
with instruction-count benchmarks for the algorithmic hot paths.

Why gungraun and not criterion: heuropt's hot paths are deterministic
numerical loops where wall-clock noise dominates real differences.
gungraun runs each benchmark under valgrind/callgrind once and reports
exact instruction counts — stable across machines and CI runners,
detects sub-microsecond regressions cleanly.

Benchmarks added:
- pareto::non_dominated_sort  (the inner loop of every Pareto MOEA)
- pareto::crowding_distance   (NSGA-II survival selection)
- metrics::hypervolume_nd     (HSO recursion, used by SMS-EMOA)
- internal::cholesky          (BO's per-step posterior factorization)
- algorithms::nsga2 single generation (end-to-end smoke check)
- algorithms::cma_es single generation (eigendecomposition cost)

Tracked size only — these aren't part of the regular CI matrix because
they need valgrind installed. Run with `cargo bench` locally.

Wired via the standard `[[bench]]` Cargo entries with `harness = false`
so gungraun's main_macro does the dispatch.
2026-05-05 10:36:29 -06:00
swaits 15d3b2752c docs(examples): capture full comparison run output as compare-results.md
Snapshot of `cargo run --release --example compare` after the v0.3.0
algorithm cohort. The harness runs 7 benchmark problems × ~20
algorithms × 10 seeds each (≈3 minutes wall-clock); this file is the
reference output so readers can scan results without running it
themselves.

Highlights worth reading even if you're skipping the file:
- ZDT1: MOPSO and MOEA/D dominate convergence; (1+1)-ES and DE tie
  at f = 0 on Rastrigin
- IPOP-CMA-ES drops vanilla CMA-ES from f=2.35 to f=0.13 on
  Rastrigin (the multimodal failure-mode it was added to fix)
- IBEA wins DTLZ2 (15× closer to true front than NSGA-III)
- GrEA wins DTLZ1 (linear simplex front matches grid-based niching)
- Nelder-Mead = 0 exactly on Rosenbrock; CMA-ES at machine epsilon
- Bayesian optimization at 60 evals is honestly bad on 5-D problems
  with the default kernel — flagged so readers don't conclude BO is
  weak in general; it just needs more evals or hyperparameter tuning
2026-05-05 10:35:05 -06:00
swaits 6faff0204d docs(readme): update algorithm-selection decision tree for v0.3.0
The DT was written when v0.2.0 shipped. v0.3.0 added a whole regime
(expensive evaluation, multi-fidelity) plus new entries in existing
regimes (CMA-ES restart variant, smooth SO direct search, parameter-
free SO, etc.) — fold them in.

Specifically:
- New top-level branch on "how expensive is each evaluation?" so the
  sample-efficient algorithms (BayesianOpt, Tpe) and multi-fidelity
  ones (Hyperband) have a clear home.
- Continuous-SO branch gains IPOP-CMA-ES (multimodal), Nelder-Mead
  (smooth, low-dim), (1+1)-ES (cheap baseline), sNES (high-dim
  alternative to CMA-ES), Tlbo (parameter-free).
- Multi-objective branches gain SMS-EMOA, HypE, ε-MOEA, PESA-II,
  AGE-MOEA, GrEA, KnEA, RVEA — placed by their distinguishing
  characteristic (geometry-aware, knee-points, grid-based, etc.)
- Quick-reference table extended to all 35 algorithms and grouped by
  paradigm.
2026-05-05 10:30:45 -06:00
swaits 9ae1df68cb chore(release): roll up v0.3.0 — expensive-eval, gradient-free, multi-fidelity
CHANGELOG entry for the v0.3.0 cohort, version bump in Cargo.toml and
README. Theme: filling heuropt's expensive-evaluation and constraint-
handling gaps.

Algorithms (9 new): OnePlusOneEs, NelderMead, IpopCmaEs, BayesianOpt,
SeparableNes, Tpe, Hyperband.

Operators (1 new): LevyMutation. Repair operators (1 trait + 2 impls):
Repair<D> with ClampToBounds and ProjectToSimplex.

Selection helpers (1 new): stochastic_ranking_select.

Internal helpers: Cholesky factorization (used by BO).

API additions:
- CmaEsConfig.initial_mean: Option<Vec<f64>> (None preserves existing
  midpoint-of-bounds behavior; used by IpopCmaEs to inject restart
  diversity).
- New PartialProblem trait — multi-fidelity contract used by
  Hyperband.

No breaking changes to v0.2.0 public API.
2026-05-05 10:00:00 -06:00
swaits bfc2875d62 feat(traits,algorithms): add PartialProblem trait and Hyperband
Multi-fidelity optimization. Hyperband (Li et al. 2017) and its
foundation Successive Halving (Karnin et al. 2013) tune
hyperparameters by allocating *uneven* compute across configurations:
sample many cheap-to-evaluate-at-low-budget configs, then promote
the survivors to higher budgets. Crucial for ML hyperparameter
tuning where each evaluation is a partial training run.

This requires a new trait — `Problem::evaluate` is a single-shot
black box, but Hyperband needs to evaluate the SAME decision at
different fidelity budgets:

  pub trait PartialProblem {
      type Decision: Clone;
      fn objectives(&self) -> ObjectiveSpace;
      fn evaluate_at_budget(&self, decision: &Self::Decision,
                            budget: f64) -> Evaluation;
  }

`PartialProblem` is intentionally NOT a sub-trait of `Problem`.
Implementors who already have a `Problem` and want their
`evaluate_at_budget` to ignore budget can write a one-line wrapper.

`Hyperband` is the optimizer:

  pub struct HyperbandConfig {
      max_budget: f64, eta: f64, max_brackets: usize, seed: u64,
  }
  pub struct Hyperband<I> { config, initializer, ... }

Single-objective only. The decision sampler is an `Initializer<D>` so
it works the same way as every other heuropt algorithm. Generic over
decision type.
2026-05-05 09:59:03 -06:00
swaits 27f80fb2a3 feat(traits): add Repair<D> trait + ClampToBounds and ProjectToSimplex impls
Spec §22 Round 4-D listed bounded mutation / repair operators as future
work; this is the second piece of that. A `Repair<D>` trait that nudges
infeasible decisions back to feasibility, intended to be called from a
user's Variation operator (or a CompositeVariation pipeline) when
projection-style constraint handling is preferred over the
penalty-style `constraint_violation` approach.

Trait:
  pub trait Repair<D> {
      fn repair(&mut self, decision: &mut D);
  }

Provided impls:
- `ClampToBounds` — clamps each variable of a Vec<f64> to per-axis bounds
- `ProjectToSimplex` — projects a Vec<f64> onto the (clipped) probability
  simplex (Σ x_i = total, x_i ≥ 0), useful for portfolio-style problems
  and reference-direction normalization

Both stay in the existing `operators` module (alongside Variation
operators) since they share the same "transforms decisions" theme. Re-
exported from the prelude.
2026-05-05 09:56:44 -06:00
swaits 66f6cf6e86 feat(selection): add stochastic_ranking_select for constrained problems
Runarsson & Yao 2000 stochastic ranking: a probabilistic alternative
to feasibility-first tournament selection. Each pairwise comparison
during a bubble-sort pass uses the *objective* value with probability
`pf` even when one or both candidates are infeasible. The classic
recommendation `pf = 0.45` reliably outperforms strict
feasibility-first on heavily-constrained problems where occasionally
exploring the infeasible region helps cross narrow feasible corridors.

New helper: `stochastic_ranking_select` lives next to
`tournament_select_single_objective` in `selection::tournament`.
Single-objective only; same signature pattern (population, objectives,
count, rng, plus the new `pf` knob).
2026-05-05 09:55:43 -06:00
swaits 358e441b36 feat(algorithms): add Tpe (Tree-structured Parzen Estimator)
Bergstra et al. 2011: sample-efficient sequential optimizer that's the
workhorse of Hyperopt and Optuna. Different surrogate from BO's
Gaussian process — TPE models p(x | y < y*) with one KDE and
p(x | y >= y*) with another, then samples candidates from the 'good'
KDE and ranks by the ratio l(x) / g(x). The acquisition is implicit
in the ratio (a closed-form analog of Expected Improvement).

Implementation:
- 1-D Gaussian KDE per axis, with bandwidth chosen by Scott's rule
- Per-step:
  - Evaluate observations into 'good' (top γ fraction by target) and
    'bad'
  - Sample n_candidates from the good distribution (independent per
    axis) and pick the one with the largest l(x)/g(x)
  - Evaluate it, append to history

Vec<f64> only, single-objective only. Compared with BayesianOpt:
- Cheaper per-step (no GP factorization)
- Doesn't need kernel hyperparameter tuning to work well
- Naturally extends to mixed/categorical decision types (future work)
- Generally less sample-efficient than well-tuned BO on smooth
  continuous problems, but more robust out of the box

Tests cover convergence on 1-D Sphere within a tight budget,
deterministic reruns, panic on multi-objective.
2026-05-05 09:55:01 -06:00
swaits e7355ebb8a feat(algorithms): add SeparableNes (Natural Evolution Strategy)
Wierstra et al. 2008/2014 NES with the diagonal-covariance "separable"
variant (sNES). Different theoretical foundation from CMA-ES: rather
than tracking a full covariance matrix and adapting it through
evolution paths, sNES updates the sampling distribution's parameters
by following the natural gradient of expected fitness.

Each generation:
- Sample λ offspring from N(μ, diag(σ²))
- Rank-shape the fitnesses (utility weights from the standard NES table)
- Update μ along the natural gradient: μ ← μ + η_μ · σ · sum(u_i · z_i)
- Update σ multiplicatively: σ_j ← σ_j · exp(η_σ/2 · sum(u_i · (z_i,j² - 1)))

Vec<f64> decisions only, single-objective only. The diagonal covariance
makes per-step cost O(λ·n) instead of CMA-ES's O(λ·n²) — much faster on
high-dimensional problems where full-covariance tracking is expensive
or numerically fragile, at the cost of being unable to handle strongly
rotated landscapes.
2026-05-05 09:53:20 -06:00
swaits 8a34fd94b8 feat(examples): wire (1+1) ES, Nelder-Mead, IPOP-CMA-ES, BO into compare harness
Adds runners for the four expensive-eval / gradient-free additions to
the appropriate single-objective sections of `examples/compare.rs`:

- Rastrigin (multimodal): now also shows IPOP-CMA-ES alongside vanilla
  CMA-ES so the restart benefit is directly visible.
- Rosenbrock (smooth valley): adds Nelder-Mead (well-suited) and (1+1)
  ES (cheap baseline).
- Ackley + Rosenbrock: BayesianOpt run with a deliberately TINY budget
  (60 evaluations vs 30k for the population-based methods) so the
  sample-efficiency claim is visible — BO with 60 evals vs DE/CMA-ES
  with 30k.

The compare harness now sides-by-sides 23 algorithms total across the
seven benchmark problems.
2026-05-05 09:51:12 -06:00
swaits a70500406c feat(algorithms): add BayesianOpt — GP-based Bayesian Optimization
The first sample-efficient algorithm in heuropt. Bayesian optimization
maintains a Gaussian-process surrogate of the objective and at each
step picks the next decision by maximizing an acquisition function on
that surrogate, so the evaluation budget is used surgically.

Implementation:
- **Kernel**: anisotropic RBF (squared-exponential) with per-axis
  length scales, signal variance, and a small noise/jitter floor.
  Hyperparameters are exposed in the config; a future version can add
  marginal-likelihood maximization.
- **Posterior**: standard formulation. Cholesky factorizes K (using
  the new internal helper); mean and variance predictions follow.
- **Acquisition**: Expected Improvement against the best observed
  feasible point. Optimized by best-of-N random sampling — simple,
  predictable cost, no inner-optimizer footgun.
- **Initial design**: `initial_samples` uniform-random points in
  bounds before the BO loop starts.
- **Constraints**: feasibility-aware EI — best observed value uses
  only feasible points; infeasible candidates are penalized.

Vec<f64> decisions, single-objective only. Targets the regime no
existing heuropt algorithm covers: 50–500 evaluations on an
expensive black-box function (CFD sim, ML training run, real-world
measurement).

Tests cover convergence on the 1-D sphere within a tight evaluation
budget (~30 evals get to f < 1e-6 — vs population-based methods
needing thousands), deterministic reruns, and panic on
multi-objective + dim mismatches.
2026-05-05 09:51:12 -06:00
swaits 284f1143de feat(internal): add Cholesky factorization helper for SPD matrices
Hand-rolled `A = L · L^T` factorization plus forward/backward triangular
solves, used by the upcoming Bayesian Optimization implementation for
the GP posterior. Same f64 row-major Vec<Vec<f64>> interface as the
existing Jacobi eigen helper so we don't pull in nalgebra for one
algorithm.

Returns Err on non-positive-definite input (a small jitter is the
typical caller-side fix). Tested against the standard 2x2 case, the
3x3 known-result case, A·x = b round-trip, and the SPD-failure case.
2026-05-05 09:51:12 -06:00
swaits 60b17f58c9 feat(algorithms): add IpopCmaEs (CMA-ES with restart) for multimodal problems
Auger & Hansen 2005 IPOP-CMA-ES: wraps the existing CmaEs in a restart
loop that doubles the population size and re-randomizes the mean
whenever a restart trigger fires. Specifically addresses the failure
mode we observed on Rastrigin (vanilla CMA-ES = 2.3 vs DE = 0).

Restart triggers:
- The whole budget for one inner CmaEs run finishes without improvement
- (More sophisticated triggers — eigenvalue collapse, condition-number
  blow-up, sigma stagnation — are left for future versions; the
  per-run budget trigger captures the bulk of the practical benefit)

Each restart:
- Doubles the population_size (Auger & Hansen 2005)
- Re-randomizes the initial mean to a fresh point in the bounds box
- Resets sigma to the user's initial value

Same Vec<f64> + single-objective constraints as CmaEs. The total
budget is divided across restarts; restart budget grows with
population. Tests verify it beats vanilla CMA-ES on Rastrigin.
2026-05-05 09:51:12 -06:00
swaits b78e5ed2fc feat(algorithms): add NelderMead simplex direct-search optimizer
Nelder & Mead 1965: gradient-free local optimizer that maintains a
simplex of n+1 points in n-D and at each iteration replaces the worst
vertex by one of {reflect, expand, outside-contract, inside-contract,
shrink} relative to the centroid of the rest. The five standard
coefficients (reflection α=1, expansion γ=2, contraction ρ=0.5,
shrinkage σ=0.5) are exposed in the config but default to canonical
values so users can leave them alone.

Single-objective only, Vec<f64> only, bounds enforced by clamping
each new vertex. Termination is purely iteration-count for v0.2;
"vertices have collapsed" stopping is a future enhancement.

Filling a real gap: heuropt had population-based local search
(SimulatedAnnealing, HillClimber) but no classical direct-search
algorithm. Excellent for low-dim smooth-ish problems where a
population is overkill.
2026-05-05 09:51:12 -06:00
swaits 7d8a29df2b feat(algorithms): add OnePlusOneEs (1+1)-ES with Rechenberg's one-fifth rule
Rechenberg 1973's elemental evolution strategy: one parent, one child
each generation, accept the child if it is no worse, and adapt the
mutation step size by tracking the success rate. If more than 1/5 of
recent moves were accepted the search is too cautious — multiply σ by
`step_increase` (typical 1.22). Below 1/5 — divide by the same factor.
At 1/5 — leave it alone. The success window has length `adaptation_period`.

Single-objective only. Vec<f64> only. Generic Gaussian step bounded by
the embedded `RealBounds`.

Why ship it: it's the smallest possible self-adapting evolution strategy
and a useful pedagogical / baseline endpoint. Pairs well as the budget
floor ("give me anything cheaper than CMA-ES").
2026-05-05 09:51:12 -06:00
swaits 5a0475c678 chore(release): bump to v0.2.0 and update CHANGELOG
Substantial v0.2.0 release on top of v0.1.0:

**21 new algorithms:**
- Single-objective: HillClimber, SimulatedAnnealing, GeneticAlgorithm,
  ParticleSwarm, CmaEs, TabuSearch, AntColonyTsp, Umda, Tlbo
- Multi-objective: Mopso, Ibea, SmsEmoa, Hype, Rvea, PesaII,
  EpsilonMoea, AgeMoea, Grea, Knea

**5 new operators:**
BoundedGaussianMutation, SimulatedBinaryCrossover (SBX),
PolynomialMutation, CompositeVariation, LevyMutation

**New utility:** `hypervolume_nd` (HSO algorithm) for arbitrary
dimensionality

**New examples:** `compare` (multi-seed harness across 7 benchmark
problems and 19 algorithms), `benchmarks` (canonical reference runs),
`jiggly_tuning` (real-world 4-objective firmware tuning)

**New feature flag:** `parallel` (rayon-backed population evaluation)

**README:** added an explanatory algorithm-selection decision tree

No breaking changes to v0.1.0 public API.
2026-05-05 09:51:12 -06:00
swaits 26385fdb43 feat(examples): add ZDT3, DTLZ1, Rosenbrock, Ackley benchmark problems
Expands the comparison harness with four new test problems chosen for
their distinct geometry:

- **Rosenbrock** (single-obj, smooth valley): the classic non-convex
  smooth function. Differentiates CMA-ES (which exploits the local
  metric) from Rastrigin's multimodal-trap regime.
- **Ackley** (single-obj, exponential multimodal trap): a more
  forgiving multimodal test than Rastrigin — fewer narrow local
  minima — so CMA-ES can show its strength while DE/GA still win.
- **ZDT3** (multi-obj, disconnected front): the only ZDT-family
  problem with a non-contiguous Pareto front. Tests an algorithm's
  ability to maintain spread across gaps.
- **DTLZ1** (many-obj, 3-D linear front): a triangular plane in
  objective space (vs DTLZ2's spherical octant). Different shape
  reveals which many-obj algorithms are biased toward sphere-like
  fronts vs which infer geometry adaptively.

Each new section runs all applicable algorithms × N seeds × the
algorithm-class budget the existing sections already use.
2026-05-05 09:51:12 -06:00
swaits f0faf93b87 feat(algorithms): add KnEA (Knee point-driven EA)
Zhang, Tian & Jin 2015 KnEA: many-objective MOEA that biases survival
selection toward 'knee points' on the Pareto front — points where a
small improvement in one objective costs a large degradation in
another.

Each generation:
- NSGA-II-like loop with offspring + non_dominated_sort
- For the splitting front, identify knee points by perpendicular
  distance from the hyperplane connecting the front's extreme points.
  Members further from the hyperplane (= more 'kneeness') are preferred.
- Survival keeps every knee-tagged member; if room remains, fill from
  remaining members by largest perpendicular distance.

Knee points are intuitively the most attractive points on a Pareto
front when no preference information is available. KnEA pushes the
search toward them at the cost of less uniform front coverage.
2026-05-05 09:51:12 -06:00
swaits a95380376e feat(algorithms): add Grea (Grid-based Evolutionary Algorithm)
Yang, Li, Liu & Zheng 2013 GrEA: many-objective MOEA whose secondary
ranking is a grid-based diversity score instead of crowding distance
or reference vectors.

Each generation:
- NSGA-II-like loop with offspring + non_dominated_sort
- For the splitting front:
  - Translate by ideal/nadir; partition objective space into a
    (`grid_divisions` per axis) grid
  - For every member compute three grid scores:
    - GR (grid rank)         = sum of grid coordinates (closer to ideal = lower)
    - GCD (grid crowding distance) = #neighbors within 1 grid unit (in any axis)
    - GCPD (grid coordinate point distance) = max coord - min coord
  - Sort F_l ascending by GR, then by GCD, then by GCPD
  - Take the top `n - already_selected` survivors

GrEA's grid-based niching is a different lens from NSGA-III's reference
points and RVEA's reference vectors — particularly effective on
non-convex fronts where reference-vector approaches struggle.
2026-05-05 09:51:12 -06:00
swaits 6bfa52c149 feat(algorithms): add AgeMoea (Adaptive Geometry Estimation MOEA)
Panichella 2019 AGE-MOEA: a many-objective MOEA that *infers* the
front's geometry (its L_p shape, where p = 1 is linear, p = 2 is
spherical, p < 1 is convex etc.) from the current non-dominated set
and uses that estimate to drive both proximity and diversity in
survival selection.

Each generation:
- NSGA-II-like loop: random parent selection + variation + evaluation
- Combine + non_dominated_sort
- Fill front-by-front; for the splitting front:
  - Translate by ideal point z*
  - Find extreme points by ASF (same as NSGA-III) and intercepts
  - Estimate the geometry parameter p by minimizing
    \|f − ideal\|_p constancy on the extreme points
  - Score every member by survival_score = (proximity_to_ideal) +
    (1 / nearest-neighbor distance in the same L_p frame)
  - Keep the top scorers

The geometry estimation is the novel contribution; with 3+ objectives
it produces fronts whose spread better matches the true shape than
NSGA-III's reference points (which assume a known geometry).
2026-05-05 09:51:12 -06:00
swaits 9a336da43e feat(algorithms): add Tlbo (Teaching-Learning-Based Optimization)
Rao 2011 TLBO: parameter-free single-objective optimizer for Vec<f64>.
The selling point — uniquely among the metaheuristics we ship — is that
it has NO algorithm-specific hyperparameters: no F, CR, w, c1, c2, σ,
mutation rate, etc. Just population_size and generations.

Each generation has two phases:
- **Teacher phase**: identify the best individual (the 'teacher'). For
  every learner, compute a 'mean' learner and try replacing it with a
  candidate moved toward the teacher by a random fraction, scaled by
  the gap between teacher and (TF · mean), where TF ∈ {1, 2}.
- **Learner phase**: each learner picks a random partner and tries
  moving toward the better one of the pair. Only successful moves are
  kept.

Single-objective only, Vec<f64> only, bounds enforced via clamping.
Tests cover Sphere1D convergence, deterministic reruns, and panic on
multi-objective.
2026-05-05 09:51:11 -06:00
swaits 1b8070476b feat(operators): add LevyMutation real-valued heavy-tailed mutation
Lévy-flight perturbation: each variable receives a step drawn from a
heavy-tailed Lévy(α) distribution rather than a Normal. The result is
"mostly small steps with rare big jumps," which gives a more
exploratory mutation than Gaussian without abandoning local search.

Decision type: Vec<f64>, with optional bounds (clamped per-axis if
`bounds` is non-empty). The step is sampled via Mantegna's algorithm
which generates Lévy(α) by combining two Normal samples and taking
the right power, controlled by the tail exponent `alpha` (typical
1.5; 1 is heavy, 2 collapses to Normal).

This is the only genuinely-different mutation kernel from Cuckoo
Search and other Lévy-flight metaheuristics; ship it as a Variation
operator usable from any algorithm rather than as a separate
algorithm.
2026-05-05 09:51:11 -06:00
swaits 3400124541 feat(examples): wire SMS-EMOA, HypE, RVEA, PESA-II, ε-MOEA into compare harness
Adds runners for the five new MO algorithms in both the ZDT1 (2-obj)
and DTLZ2 (3-obj) sections of `examples/compare.rs`. The harness now
side-by-sides 11 multi-/many-objective optimizers (RandomSearch + 10
real ones) on each problem.
2026-05-05 09:51:11 -06:00
swaits 4fa8250c24 feat(algorithms): add EpsilonMoea (ε-dominance MOEA, Deb et al. 2003)
Replaces strict Pareto dominance with ε-dominance: A ε-dominates B when
`floor(A_i / ε) ≤ floor(B_i / ε)` for every objective and strictly
less in at least one (minimization frame). The result is a regular
discretization of objective space — at most one archive member per
ε-box — so the front spreads out automatically and the archive size
self-limits without truncation tricks.

Steady-state design: each generation samples one parent from the main
population and one from the ε-archive, applies variation, evaluates
the child, and offers it to both archives. Every member's
ε-coordinates and the box-tie rules are precomputed each insertion.

Tests: produces a front on Schaffer N.1 with reasonable spread,
deterministic reruns, panic on `epsilon[i] <= 0.0` and on
`epsilon.len() != objectives.len()`.
2026-05-05 09:51:11 -06:00
swaits f8fd3880ac feat(algorithms): add PesaII (Pareto Envelope-based Selection Algorithm II)
Corne, Jerram, Knowles & Oates 2001: divides objective space into a
hyperbox grid and uses per-box population counts to drive selection
toward sparsely-populated regions.

Each generation:
- Maintain an external archive of non-dominated members
- Build a hyperbox grid (`grid_divisions` per axis on the archive's
  current axis ranges); count members per box
- Selection picks two parents by region-based tournament: choose two
  random non-empty boxes and take a uniform-random member from the
  one with fewer occupants
- Variation produces an offspring; insert into archive, dropping
  dominated members and (if archive overflows) the most-crowded
  occupant of the most-occupied box

Tests cover non-empty front on Schaffer N.1, deterministic reruns,
and panic on `archive_size == 0`.
2026-05-05 09:51:11 -06:00
swaits 283d7429bb feat(algorithms): add Rvea (Reference Vector-guided EA)
Cheng, Jin, Olhofer & Sendhoff 2016 RVEA: many-objective MOEA built
around a fixed set of Das–Dennis reference vectors. Each generation:
- Generate offspring via random parent selection + variation +
  evaluation
- Combine population + offspring; translate by ideal point z*
- Associate every member with the reference vector whose angle to
  the translated objective vector is smallest
- For each occupied vector, keep the member with the smallest
  Angle-Penalized Distance (APD) score; the rest are dropped
- APD = (1 + α(t)·θ_max·γ) · |f − z*| where γ is the angle to the
  associated reference and α(t) = (t / t_max)^2 anneals the angle
  penalty over the run

This produces well-spread fronts at high objective counts where
Pareto-rank methods (NSGA-II, SPEA2) lose discrimination.
2026-05-05 09:51:11 -06:00
swaits d8d580e414 feat(algorithms): add HypE (Hypervolume Estimation)
Bader & Zitzler 2011: HypE estimates hypervolume contributions via
Monte Carlo sampling instead of computing them exactly. The point of
the trick is that exact hypervolume becomes prohibitively expensive
beyond ~5 objectives, while MC sampling stays cheap and accurate
enough at any dimension.

Each generation:
- Generate offspring via parent selection + variation + evaluation
- Combine, run non_dominated_sort, fill front-by-front
- For the splitting front, estimate each member's HV contribution
  by drawing `n_samples` uniform points in the box [ideal, reference]
  and counting how many points are dominated by *exactly* one front
  member — that count, divided by n_samples and multiplied by the
  box volume, is the member's expected unique HV contribution.
- Drop members one at a time from the splitting front by smallest
  estimated contribution.

Public API matches the rest of the MO algorithms (Config + Optimizer).
The reference point is supplied in the config so the user controls
the integration domain. Tests cover non-empty front, deterministic
reruns, and panic on dim-mismatched reference.
2026-05-05 09:51:11 -06:00
swaits cfc241980c feat(algorithms): add SmsEmoa (S-Metric Selection EMOA)
Beume, Naujoks & Emmerich 2007: a steady-state MOEA that uses
hypervolume contribution as the secondary survival selection criterion.

Each generation:
- Generate ONE child via parent selection + variation + evaluation.
- Combine population + child, run non_dominated_sort.
- The discarded individual is the worst-front member with the
  smallest hypervolume contribution (computed via the new
  hypervolume_nd_from_evaluations helper).

Selection-quality is excellent at moderate objective counts (2–4) at
the cost of higher per-step compute (each survival selection requires
N+1 hypervolume evaluations of size ≤ N each). Best paired with a
tightly-bounded objective space — the user supplies a fixed reference
point in the config.

Tests: produces a non-empty front on Schaffer N.1, deterministic
reruns, panic on `population_size == 0`, panic on
`reference_point.len() != objectives.len()`.
2026-05-05 09:51:11 -06:00
swaits e2d8b4e4c2 feat(metrics): add hypervolume_nd via Hypervolume-by-Slicing-Objectives (HSO)
Generalizes the existing 2-D hypervolume to arbitrary M ≥ 1 dimensions
using the standard recursive Hypervolume-by-Slicing-Objectives (HSO)
algorithm from While et al. 2006:

- For M = 1: return reference[0] - min(points[0])
- For M = 2: sort by axis 0, sweep accumulating rectangles (matches
  hypervolume_2d's existing exact behavior)
- For M ≥ 3: sort by the last axis, peel off slices of increasing
  thickness and recursively compute the (M−1)-dimensional HV of each
  slice's projected non-dominated subset

Direction-aware: minimization-oriented input is the entry point, so
maximize objectives are negated by the caller via
`ObjectiveSpace::as_minimization` before the recursion runs.

Tested against:
- the existing 2-D analytical case (3 points → area 6)
- a known 3-D unit-cube case (1 point at origin, ref [1,1,1] → 1)
- empty front → 0
- agreement with hypervolume_2d on random 2-D fronts
2026-05-05 09:51:11 -06:00
swaits 6c2b989c4a docs(readme): add explanatory algorithm-selection decision tree
A substantial README section walking newcomers through choosing an
optimizer. Defines the terminology as it comes up — single- vs multi-
vs many-objective, Pareto front, dominance, multimodality, evaluation
cost — so a reader who has never touched heuristic optimization can
still pick a sensible starting algorithm.

Five-step decision flow:
1. What is the decision?
2. How many objectives?
3. What's the landscape like? (multimodal, smooth, discrete)
4. How expensive is each evaluation?
5. Are there constraints?

Each branch ends with 1–3 algorithm recommendations and a one-line
rationale, plus a compact "quick reference" table at the bottom for
returning users.
2026-05-05 09:51:11 -06:00
swaits 7f67e58b27 feat(examples): wire new SO algorithms into the compare harness
Adds runners for HillClimber, SimulatedAnnealing, GeneticAlgorithm,
ParticleSwarm, CmaEs, and Umda to `examples/compare.rs`. Rastrigin
section now compares 8 single-objective optimizers against each other
on a fixed evaluation budget.

The MO sections (ZDT1, DTLZ2) are unchanged for now — MOPSO and IBEA
get added in a follow-up commit so each algorithm's debut shows up
clearly in the harness.
2026-05-05 09:51:11 -06:00
swaits 8c4b8013b8 feat(algorithms): add Umda Univariate Marginal Distribution EDA for binary problems
Mühlenbein 1997 UMDA: simplest Estimation-of-Distribution Algorithm for
`Vec<bool>` problems. Each generation:
- Evaluate the current population
- Select the top μ members by fitness
- Estimate per-bit marginal probability p_i = (count of 1s at bit i in
  the μ-best) / μ
- Sample population_size new individuals from the resulting product-of-
  Bernoullis distribution

Single-objective only. Bit-wise probabilities are clamped to
`[1 / (2 · μ), 1 - 1 / (2 · μ)]` to keep the population from collapsing
to a deterministic single string before convergence is meaningful
(standard Laplace-style smoothing for UMDA).

Tests: solves OneMax (maximize Σ bits) on a 20-bit instance,
deterministic reruns, panic on multi-objective.
2026-05-05 09:51:11 -06:00