Commit Graph
112 Commits
Author SHA1 Message Date
swaitsandClaude Opus 4.7 7004a572c8 test(permutation): add ERX edge-preservation tests
ERX had five tests, all checking `is_strict_perm` validity -- none
verified the *point* of edge recombination: that children actually
inherit parent edges. A "valid permutation but edge-ignoring" ERX would
have passed every existing test.

Adds:
- erx_identical_parents_inherit_every_edge: with identical parents the
  child's edge set must equal the parent's exactly (zero foreign edges).
- erx_preserves_parent_edges_better_than_order_crossover: ERX must
  strand fewer non-parent edges than Order Crossover -- a direct test of
  ERX's reason to exist.
- erx_pinned_output: locks the adjacency-walk + min-degree tie-break.

Investigation result: ERX is correct and effective. It wins the
tsp_operators_compare showdown on KroAB-25 (hypervolume 638M vs OX 622M,
PMX 609M, CX 593M) and produces the most diverse front. The compare TSP
table's GA underperformance is an Order-Crossover-plus-generational-GA
artifact on a convex-position instance, not an ERX bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:36:44 -06:00
swaitsandClaude Opus 4.7 c50e390969 perf(bayesian_opt): reuse scratch buffers in the EI acquisition loop (2.40M -> 2.26M instr)
The acquisition loop ran acquisition_samples GP predictions per BO
iteration, each allocating three short-lived Vecs: the candidate point,
the k_star kernel vector, and solve_lower's output. Threading reused
buffers through new sample_uniform_in_bounds_into / predict_into /
solve_lower_into entry points removes ~3000 alloc/free pairs from
bayesian_opt_short.

bayesian_opt_short: 2_398_972 -> 2_255_904 (-6%). This is a structural
(allocation) win, not an algorithmic one -- the GP fit (Cholesky) and EI
prediction are inherently O(n^2)/O(n^3) with transcendental kernels, and
that work is unchanged. Output bit-identical -- all 606 tests pass,
including the run() snapshot; async builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:48:22 -06:00
swaitsandClaude Opus 4.7 9b1352e375 perf(tpe): compute KDE bandwidths once per iteration, not per call (383K -> 188K instr)
Each TPE iteration drew `candidate_samples` candidates; every candidate
triggered three scott_bandwidths calls (one in sample_from_kde, two in
log_kde_density) -- each an O(support) two-pass scan plus a powf(-0.2). But
the good / bad supports are fixed for the whole iteration, so only two
distinct bandwidth vectors exist. Deriving them once and threading them
through cuts ~34 of every 36 scott_bandwidths calls. Also hoists the
constant (2*pi).sqrt() out of the inner density loop.

tpe_short: 382_518 -> 187_898 (-51%, 2.04x). scott_bandwidths is
deterministic in its inputs, so the once-vs-many results are identical --
output bit-identical, all 606 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:55:08 -06:00
swaitsandClaude Opus 4.7 cf26243765 perf(ant_colony): hoist powf out of the tour-building hot loop (1.04M -> 540K instr)
build_tour computed pheromone[i][j].powf(alpha) and eta[i][j].powf(beta) for
every candidate at every step of every ant -- two transcendental calls per
edge consideration. But eta is constant for the whole run and pheromone is
constant across a generation's ant loop. Pre-raising eta to beta once and
pheromone to alpha once per generation (into a reused buffer) turns the hot
per-candidate weight into a single multiply.

ant_colony_tsp_short: 1_036_680 -> 539_924 (-48%, 1.92x). build_tour now
takes the pre-raised matrices; the three direct-call tests pre-raise via a
`raise` helper. Output bit-identical -- all 606 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:52:38 -06:00
swaitsandClaude Opus 4.7 3b4f765e03 perf(operators): make PMX and OX crossovers O(n) with value-indexed lookups
Both crossovers had an O(n) inner scan making them O(n^2): PMX located the
value to swap with `child.iter().position`, OX tested segment membership
with `segment.contains`. Both operate on strict permutations of 0..n, so a
value-indexed table (PMX: position kept in sync across swaps; OX: a static
membership bitmap) gives O(1) lookups. debug_asserts document the range
assumption, consistent with ERX and CX.

pmx_crossover_vary n=100: 21_507 -> 5_900 (-73%, 3.65x); n=30 -18%.
order_crossover_vary n=100: 18_265 -> 7_389 (-60%, 2.47x); n=30 -29%.
All four permutation crossovers are now O(n). Bit-identical for valid
permutations -- all 606 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:42:35 -06:00
swaitsandClaude Opus 4.7 47939fc5cb perf(operators): make CycleCrossover O(n) with per-parent position tables (59K -> 12K instr)
cx_child located each cycle's next value with an O(n) `position` scan,
making the walk O(n^2). CX operates on strict permutations of 0..n, so a
direct value-indexed position table per parent gives O(1) lookups; a
debug_assert documents the range assumption (mirroring ERX).

cycle_crossover_vary n=100: 58_762 -> 12_084 (-79%, 4.86x); n=30 -36%.
Bit-identical for valid permutations -- all 606 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:41:05 -06:00
swaitsandClaude Opus 4.7 4aab5c7029 perf(pareto): flatten non_dominated_sort objective buffer (1.29M -> 1.10M instr)
The O(n^2) pair loop reads oriented[j] for every j; with Vec<Vec<f64>>
that chased a separate heap allocation per individual. A flat n*m buffer
keeps those reads contiguous and sequential in j.

non_dominated_sort_2d n=200: 1_288_072 -> 1_096_738 (-15%, 1.17x); n=50
-19%. nsga2 one-generation -5.7%. Combined with the earlier antisymmetry
fix, n=200 is down 55% from the original 2.46M. Pure data-layout change --
output bit-identical, all 606 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:38:03 -06:00
swaitsandClaude Opus 4.7 37821bdd3d perf(metrics): stop re-sorting hypervolume_nd prefixes per slice (361K -> 291K instr)
The HSO M=3 path called the generic 2-D base case for every last-axis
slice, which re-sorted the active prefix by axis 0 each time -- O(n^2 log n)
overall. Since `projected` is already in last-axis order, sorting the
projected indices by axis 0 once and sweeping them with a `pi > k` skip
gives O(n^2) with no per-slice allocation. The M>=4 path is unchanged
(lifted out of the inner branch verbatim).

hypervolume_nd_bench_3d n=100: 361_595 -> 291_247 (-19%, 1.24x); n=30 -16%.
The sweep visits points in the same (axis-0, then last-axis) order the
stable per-prefix sort produced -- output is bit-identical, all 606 tests
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:35:52 -06:00
swaitsandClaude Opus 4.7 532d4a54db perf(pareto): sort crowding-distance keys without Vec<Vec<f64>> indirection (-4.5%)
crowding_distance sorted bare front indices with a comparator that chased
two Vec<Vec<f64>> indirections per comparison. Extracting (objective value,
front position) tuples into a buffer reused across objectives keeps the hot
comparator a single f64 compare.

crowding_distance_2d n=200: 181_493 -> 173_286 (-4.5%); n=50 -4.5%. Stable
sort over the (value, index) pairs preserves the original tie-order, so the
output is bit-identical -- all 606 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:33:25 -06:00
swaitsandClaude Opus 4.7 3f104a4395 perf(operators): make ERX neighbor removal O(degree) per step (397K -> 140K instr)
Edge Recombination Crossover scrubbed `current` from every one of the n
adjacency lists on each step of the walk -- an O(n^2) pass. The parent-tour
adjacency relation is symmetric (b in adj[a] iff a in adj[b]), so `current`
only ever appears in the lists of its own neighbors. Taking adj[current]
out with mem::take and retaining only over those lists is O(degree).

edge_recombination_crossover_vary n=100: 397_214 -> 140_403 (-65%, 2.83x);
n=30: 62_708 -> 39_987 (-36%). Output bit-identical -- all 606 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:29:23 -06:00
swaitsandClaude Opus 4.7 eb4c8a6a9f perf(pareto): halve non_dominated_sort dominance comparisons (2.46M -> 1.27M instr)
The dominance relation is antisymmetric, so the outcome of compare(i, j)
fully determines compare(j, i). Iterating only j > i and applying the
result in both directions does identical work in half the pair scans.

non_dominated_sort_2d n=200: 2_461_178 -> 1_268_372 (-48%); n=50 -1.65x.
Ripples into dependents: nsga2 one-generation -20%, nsga3 / sms_emoa ~-9%.
Output is bit-identical (dominates[] still ascending, first_front order
unchanged) -- all 606 tests including run() snapshots pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:26:08 -06:00
swaits 6aee6d6318 style: rustfmt the Phase 1 test additions
The per-file Phase 1 test commits were written without running rustfmt
as I went; this pass formats the new test code (long assert_eq! lines
wrapped, etc.). Formatting-only — no behavioural change.
2026-05-14 03:41:38 -06:00
swaits 7aa9e627f0 test(sms_emoa,pesa2,paes,random_search): pin remaining algorithm helpers
Phase 1, final algorithm batch:
- sms_emoa: pick_drop_index returns the singleton worst front, and
  finds the least-HV-contributor at a non-zero index.
- pesa2: build_grid empty/corner-point boxing; region_tournament
  prefers the less-crowded grid box (statistical majority).
- paes: deterministic non-empty front + archive cap.
- random_search: evaluation count = iterations*batch; best is no
  worse than any sampled candidate.
2026-05-13 22:58:17 -06:00
swaits c5b003a9c9 test(tlbo,umda,simulated_annealing,tabu_search,tpe,snes,rvea,spea2): pin comparison and geometry helpers
Phase 1 tests for eight more algorithms — the feasibility-first
comparison helpers (better / better_than / compare_so / worse_than),
plus algorithm-specific pure functions:
- tlbo: best_index min/max/tie.
- tpe: oriented_target sign-flip + penalty; split_good_bad partition
  and clamp-to-at-least-one-each.
- snes: nes_utilities sum-to-zero + descending + positive-best.
- rvea: unit_normalize (3-4-5 → 0.6/0.8), zero-vector passthrough;
  closest_reference smallest-angle; smallest_neighbor_angle = π/2 for
  orthogonal refs.
- spea2: euclidean distance basics; binary_tournament prefers lower
  fitness.
2026-05-13 22:58:17 -06:00
swaits 6819ce4091 test(nelder_mead,nsga2,nsga3,one_plus_one_es,particle_swarm): pin selection/geometry helpers
Phase 1 tests:
- nelder_mead: compare / better feasibility-first + direction.
- nsga2: binary_tournament prefers lower rank, then higher crowding
  distance at equal rank (statistical majority over 200 seeds).
- nsga3: solve_intercepts on axis-aligned extremes / singular / empty;
  associate picks the closest reference direction with correct
  perpendicular distance.
- one_plus_one_es: worse_than across feasibility + direction + equal.
- particle_swarm: best_index min/max/tie/single-element.
2026-05-13 22:58:17 -06:00
swaits c2319116b8 test(hyperband,moead,knea,ibea,ipop_cma_es,mopso): pin helper functions
Phase 1 tests:
- hyperband: compare / better feasibility-first + direction branches.
- moead: tchebycheff (max weighted deviation from ideal) and
  weight_distance (Euclidean) pins.
- knea: perpendicular_distance to the simplex hyperplane, zero-on-plane,
  and the too-few-extremes degenerate fallback.
- ibea: compute_fitness empty/dominating/symmetric-tradeoff cases and
  binary_tournament fitness preference.
- ipop_cma_es: better feasibility-first + direction + equal-not-better.
- mopso: population/front sizing and determinism cross-check.
2026-05-13 22:58:17 -06:00
swaits 952d93ac85 test(grea,hill_climber,hype): pin selection sizing and tournament logic
Phase 1 tests:
- grea: environmental_selection truncates the 2N pool to exactly N
  across three population sizes.
- hill_climber: full-run never-worsens and decreases-sphere pins.
- hype: binary_tournament picks the higher-fitness index (statistical
  majority + valid-index invariant).
2026-05-13 22:58:17 -06:00
swaits 8ff43d0240 test(epsilon_moea): pin box_coords, corner_distance, box_dominates
Phase 1 tests for the ε-MOEA box-archive helpers: per-axis floor in
box_coords, Euclidean corner_distance (incl. zero at exact corner),
and box_dominates across the strict/boundary cross-product.
2026-05-13 22:58:17 -06:00
swaits 03b450f050 test(genetic_algorithm): pin compare_for_fitness and survival_selection
Phase 1 tests for GA — feasibility-first fitness comparison across all
branches, and survival_selection's exact elite + best-offspring
composition (including the zero-elitism case).
2026-05-13 22:58:17 -06:00
swaits 4569244a68 test(pareto,metrics,selection): pin shared-utility comparisons and arithmetic
Phase 1, tier 3 of the mutation-testing campaign — the shared Pareto /
metric / selection utilities used by every multi-objective algorithm.
A scoped cargo-mutants run found 75 survivors across these files; the
tests below target them.

- metrics/hypervolume.rs: dominates() boundary cases, non_dominated_
  projection retained-set pins, hso_recursive 1-D/2-D base cases,
  hypervolume_nd_from_evaluations empty/non-dominating skips.
- selection/tournament.rs: challenger_wins across the full feasibility
  cross-product + equal-objective tie; better_by_objective and
  better_by_feasibility branch pins; stochastic_ranking_select pf=0
  feasibility ordering and count-wraps-modulo-population.
- pareto/crowding.rs: exact interior crowding distance on symmetric
  and asymmetric fronts (pins the (next-prev)/span arithmetic).
- pareto/sort.rs: three-non-dominated-then-one-dominated and a strict
  3-chain producing three singleton fronts.
- pareto/dominance.rs: trade-off → NonDominated, better-on-one-equal-
  on-other → Dominates, identical → Equal.
- pareto/archive.rs: truncate boundary, trade-off kept alongside,
  equal candidate rejected, smaller-violation infeasible eviction.
- pareto/front.rs: best_candidate keeps the first of tied minima.
- metrics/spacing.rs: exact spacing for a varying-NN-distance front.

src/core/problem.rs's lone survivor (decision_schema default body
'replace with vec![]') is an equivalent mutant — Vec::new() and vec![]
are identical — and is left in the residue.
2026-05-13 22:58:17 -06:00
swaits 7b8b7170f4 test(differential_evolution): pin pick_three_distinct and convergence
Phase 1 tests for DE — distinct-index helper and a convergence sanity test.
2026-05-13 22:58:17 -06:00
swaits 44b70c34d5 test(cma_es): pin compare_so / better_than_so and exercise full convergence
Phase 1 tests for src/algorithms/cma_es.rs.

- compare_so: feasibility-first ordering, minimize/maximize inversion,
  infeasibility-violation comparison.
- better_than_so: matches compare_so == Less; equal evaluations are
  not strictly better.
- A 30-generation Sphere1D run pins that CMA-ES actually decreases
  the objective (catches mutants that collapse the update rules).
2026-05-13 22:58:17 -06:00
swaits 8003a97dfa test(bayesian_opt): pin GP / EI / erf helpers
Phase 1 tests for src/algorithms/bayesian_opt.rs. Adds 15 tests
pinning the GP regression and EI acquisition machinery:

- rbf_kernel: signal-variance return at zero distance, exp(-0.5) at
  unit distance, monotone in length scale, decays to 0 for far points.
- normal_pdf: symmetric about zero, value at zero equals 1/sqrt(2π).
- normal_cdf: 0.5 at z=0, symmetric tail sums to 1.
- erf: odd function and erf(0) ≈ 0 within the rational approximation's
  ~1e-7 accuracy.
- expected_improvement: zero at sigma=0, monotone in sigma, positive
  when mu < f_best.
- oriented_target: sign flips under direction, infeasible adds 1e6
  penalty.
- better: feasibility-first then objective ordering under both
  directions.
2026-05-13 22:58:16 -06:00
swaits 3456db6cd8 test(ant_colony_tsp): pin better_than_so branches and build_tour invariants
Phase 1 for src/algorithms/ant_colony_tsp.rs. Targets the ~30 remaining
mutants after Phase 0 sweeps — mostly arithmetic flips in build_tour
(pheromone × heuristic weighting) and the feasibility-comparison logic
in better_than_so.

Added:
- Four branch tests for better_than_so covering the full feasibility
  cross-product: feasible-vs-infeasible (both orders), two-infeasible
  (smaller violation wins), and two-feasible under both directions.
  Plus an equal-objectives test pinning the strict-less-than semantics.
- build_tour-is-a-permutation invariant across 20 seeds × 6 start cities.
- A strong-heuristic test: with eta favoring the next-city by 1000x and
  beta=5, build_tour walks the preferred path. Pins the .powf(beta)
  arithmetic.
- Zero-alpha/zero-beta degenerate-case test: uniform random fallback
  still returns a permutation.
2026-05-13 22:58:16 -06:00
swaits e5e979f02a test(age_moea): pin L_p helpers and add full-run snapshots
Phase 1 for src/algorithms/age_moea.rs. Targets the ~50 algorithm-internal
mutants surviving after the Phase 0 sweeps:

Pure helper-fn pins (lp_norm / lp_distance / nearest_neighbor_distance /
estimate_p):
- L_p norm at p ∈ {1, 2} on canonical vectors (unit, all-ones,
  Pythagorean 3-4-5, signed-via-abs).
- L_p distance: zero-to-itself = 0, symmetry, L_1/L_2 sanity values.
- nearest_neighbor_distance: empty selected → ∞, self-only → ∞, picks
  the closest of mixed-distance candidates.
- estimate_p: empty-front fallback to p=2; axis-aligned extremes (CV=0
  for all p, returns first candidate 0.25); corner-vs-diagonal extremes
  (CV minimized at large p).

Full-run pins:
- A 10-generation Schaffer-N1 run with seed 7 verifies the pareto front
  is non-empty and finite/nonneg (catches  body collapse mutants).
- Population size after run matches config across three pop sizes
  (catches  size mutants).
- Evaluation count falls in [pop, pop*(gens+1)] (catches comparison
  flips in the offspring-collection loop).
2026-05-13 22:58:16 -06:00
swaits f63b85900c test(real): pin exact outputs and algebraic invariants for every real-valued operator
Phase 1 of the mutation-testing campaign for src/operators/real.rs (the
file with the largest mutant surface — 128 missed mutants spread across
GaussianMutation, BoundedGaussianMutation, SBX, PolynomialMutation,
LevyMutation, and the Mantegna gamma/sigma helpers).

Added:
- Seed-pinned numerical snapshots for each operator's vary() output on
  a fixed parent and seed. Any arithmetic flip in the operator's math
  changes one of the snapshot values and fails the assertion. The
  snapshot tolerance is 1e-12 so even subtle FP drift is caught.
- An algebraic-identity test for SBX: c1 + c2 = p1 + p2 per dimension
  before clamping. This identity holds for any β and pins the
  (1+β)·p1 + (1-β)·p2 formula cleanly across 20 seeds.
- A scale-coupling test for PolynomialMutation: a 10× wider bound range
  produces a 10× larger perturbation step at the same seed. Catches any
  mutation that breaks the δ·(hi-lo) coupling.
- Three direct pin tests for mantegna_sigma_u (alpha = 1.5, 1.0, 2.0)
  exercising the gamma() Lanczos series and the formula's edge cases
  (alpha = 1.0 → Cauchy, alpha = 2.0 → Normal-limit where sin(π) ≈ 0).
- A monotonicity property test (sigma_u changes with alpha) to catch
  structural mutants that collapse the formula to a constant.
2026-05-13 22:58:16 -06:00
swaits aa8b6e46e6 test(permutation): pin operator outputs and prove they actually mutate
Phase 1 of the mutation-testing campaign for src/operators/permutation.rs.
Adds 13 tests targeting the 30 surviving mutants in the new permutation
toolkit:

Mutation operators (Inversion / Insertion / Scramble):
- Previously only checked that the output was a valid permutation, which
  passes trivially when the mutant 'replace >= 2 with < 2' skips the
  guard entirely (no mutation = identity output = still a permutation).
  New tests run 30 seeds on an 8-element parent and assert at least one
  seed produces a non-identity output. Kills the >= ↔ < flips.

ShuffledMultisetPermutation::initialize:
- Tightened to assert pop.len() == size up-front, killing the 'replace
  with vec![]' mutant.

Crossover operators (OX / PMX / CX / ERX):
- 'Recombines for n >= 3' tests: with 5-element distinct parents, some
  seed must produce a child differing from both parents. Kills the
  < ↔ > / == / <= guard flips that would early-return parents at n >= 3.
- CX-specific pinned tests: the single-cycle case (children = parents)
  and the two-cycle case (exactly known output). Pins the cycle-detection
  arithmetic and the parent-alternation logic — kills the ==↔!= and
  += ↔ *= mutants inside cx_child.
- ERX: 'distinct starts can yield distinct children' across 30 seeds —
  kills the prev/next-index arithmetic mutants in the adjacency table.

Some residual mutants in this file are equivalent (e.g., < ↔ <= when
n=2 still produces the same OX result because for length-2 inputs the
segment-and-fill recombination converges to the parents anyway).
Documented in test comments.
2026-05-13 20:25:43 -06:00
swaits 47cb1d5f79 test(repair): pin ProjectToSimplex argmax tie-breaking and position
The degenerate-magnitude shortcut in ProjectToSimplex::repair scans
`decision` for the argmax and concentrates all mass there. cargo
mutants found that the strict-greater scan was unpinned: replacing
`>` with `>=` (which would shift the argmax to the last tied
index) and `>` with `==` (which would silently skip larger
values further along) both survived.

Two tests:
- A 3-element vector with two tied maxima at the front pins that the
  scan keeps the first index on a tie.
- A 3-element vector whose argmax is at index 1 pins that the scan
  actually walks past the start when later values are larger.

Other mutants in this file (the `>` ↔ `>=` threshold check at line
106, the `*` ↔ `+` in the threshold constant, the `-` ↔ `+` /
`/` in the tau-fallback initializer, and the `>` ↔ `>=` in the
projection loop's rho update) are equivalent mutants for non-pathological
inputs: the normal-path and shortcut-path math converge to the same
projection result for any input the operator is documented to handle.
Leaving them in the residue.
2026-05-13 20:22:40 -06:00
swaits c94357abe3 test(explorer): pin ExplorerExport, builders, ToDecisionValues outputs
Phase 0.3 of the mutation-testing campaign. Extends the inline tests in
src/explorer/mod.rs with 24 new tests covering the gaps cargo-mutants
identified — about 30 surviving mutants in this one file.

Coverage added:
- Exact-output tests for ToDecisionValues impls on Vec<f64>, Vec<i64>,
  Vec<bool>, Vec<usize> (the previous tests only asserted lengths or
  spot-checked individual entries).
- from_result propagates evaluations / generations from the
  OptimizationResult into RunMeta.
- with_problem_name / with_wall_clock / with_timestamp each set their
  field and preserve the rest of the export.
- to_json emits a JSON containing schema_version, candidates, problem
  name, and algorithm name strings.
- to_writer and to_file round-trip the same bytes.
- Free top-level to_json / to_writer / to_file convenience functions
  exercised end-to-end (round-trip through tmp file).
- pad_decision_schema at all three boundaries (< target / == target /
  > target) to pin the < comparison.
- candidate_to_export's in_pareto_front toggles at front_rank == 0.
- candidate_to_export's feasible toggles at constraint_violation <= 0.
- candidate_to_export pads short objective vectors and truncates long
  ones (the defensive branch).
2026-05-13 20:22:40 -06:00
swaits 1d1187f20b feat(operators): expand permutation toolkit
Adds eight operators for permutation (Vec<usize>) decisions:

Initializers
- ShuffledPermutation { n } — random shuffles of [0..n)
- ShuffledMultisetPermutation { repeats_per_id } — random shuffles
  of an arbitrary multiset (e.g., JSS operation strings)

Crossovers (strict permutations only)
- OrderCrossover (OX)
- PartiallyMappedCrossover (PMX)
- CycleCrossover (CX)
- EdgeRecombinationCrossover (ERX)

Mutations (preserve both strict permutations and multisets)
- InversionMutation
- InsertionMutation
- ScrambleMutation

All are re-exported from the prelude. Comprehensive unit tests + doctests
included; the pre-existing SwapMutation is untouched.
2026-05-13 19:33:29 -06:00
swaits 6371d82f40 docs(0.9): release notes, cookbook recipe, README polish
Companion to the feat(explorer) commit. Bumps the version and
brings every cross-referencing doc up to v0.9 currency.

- Cargo.toml: version 0.8.0 -> 0.9.0.
- CHANGELOG: 0.9.0 entry covering the explorer export, the
  Problem-side metadata additions, the AlgorithmInfo trait, the
  pick_a_car example, and the new cookbook recipe.
- README: closing paragraph of the PickACar example points users
  at the explorer with a one-call snippet
  (`ExplorerExport::from_result(...).with_algorithm_info(...)
  .to_file(...)?`). Version snippets bumped 0.8 -> 0.9.
- New cookbook recipe at docs/book/src/cookbook/explorer.md
  covering: enabling the serde feature, enriching Problem with
  labels/units/decision-schema, the export call, the JSON schema,
  and custom decision-type handling.
- SUMMARY.md and cookbook.md link the new recipe.
- migration.md: new "To 0.9" section documenting the additive
  changes (purely backwards-compatible upgrade from 0.8.x).
- introduction.md, comparison.md, choosing-an-algorithm.md,
  stability.md: version refs bumped 0.8 -> 0.9.
- cookbook/parallel.md, cookbook/async.md: version refs bumped
  0.8 -> 0.9.
- getting-started.md: version refs bumped, serde feature
  description expanded to mention the explorer module.
- SECURITY.md: supported-versions table moves to 0.9.x.
2026-05-06 22:45:59 -06:00
swaits 729842c260 feat(explorer): JSON export module + supporting metadata + example
Adds a tiny additive surface that turns any OptimizationResult into
a self-describing JSON file the heuropt-explorer webapp can load.
Real Pareto fronts have 50–200+ candidates spanning 2–7+ objectives;
reading them as numbers in a terminal scales badly. This commit
ships the heuropt-side of the explorer — the schema and the export
API. The webapp itself lives in a separate repo on its own cadence.

Three trait/type extensions, all with working defaults so existing
impls compile untouched:

- Objective gains 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)` are
  unchanged. Both fields are #[serde(default,
  skip_serializing_if = \"Option::is_none\")] so existing JSON
  round-trips cleanly.
- Problem trait gains 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` with builder methods.
- New `heuropt::traits::AlgorithmInfo` trait with `name()`
  (required) and `seed()` (default None). Every built-in algorithm
  — all 33 — implements it. Separate from Optimizer<P> so
  multi-fidelity Hyperband (which uses PartialProblem) implements
  it uniformly.

The new explorer module:

- `heuropt::explorer::ExplorerExport` envelope with versioned
  schema (SCHEMA_VERSION = 1).
- ExplorerCandidate per row, with front_rank from
  non_dominated_sort attached at export time so downstream tools
  don't re-derive it.
- ToDecisionValues adapter trait with provided impls for Vec<f64>,
  Vec<bool>, Vec<usize>, Vec<i64>; custom decision types implement
  one method.
- Free functions to_json / to_writer / to_file plus a builder API
  (with_algorithm_info, with_problem_name, with_wall_clock,
  with_timestamp).
- Gated on the existing `serde` feature, which now also pulls in
  `serde_json` as a dep.

The example:

- `examples/pick_a_car.rs` — promotes the README's PickACar to a
  real example, fully enriched with Objective labels/units and a
  decision_schema. Runs NSGA-III for 200 generations, prints a
  sample slice, writes pick_a_car.json. Gated on `serde`.

10 new explorer unit tests cover round-trip serde, fallback
decision-variable names, enriched export, AlgorithmInfo flow,
front-rank correctness, and the ToDecisionValues impls. Lib test
count went from 229 to 242.
2026-05-06 12:48:01 -06:00
swaits 57a43c260e docs: 0.8.0 release polish — README, guide, changelog
Companion to the feat(async) commit. Brings every cross-referencing
doc up to v0.8 currency, replaces marketing-flavored copy with plain
prose, and replaces toy benchmark problems with relatable ones that
include actual run output and interpretive narrative.

- README: collapses the four-bullet "Read the user guide / API
  reference / Tested with N tests / Hot paths optimized" list into
  a single Docs links line.
- README: replaces the Schaffer-N1 toy problem with a PickACar
  multi-objective design problem — three decision variables
  (displacement, weight, drag), four objectives (price, 0-60,
  fuel, noise), and *nonlinear* cost relationships so the Pareto
  front is a real surface, not a 1D sweep. Includes actual NSGA-III
  run output (representative slice across the 100-car front) and
  a narrative explaining what each row tells you and why hand-
  picking would miss the interesting tradeoffs.
- README: removes rustdoc-style hidden `#` setup lines from code
  blocks. The README is rendered as plain markdown on GitHub /
  crates.io, where those lines are visible garbage instead of
  hidden setup. Code blocks are now self-contained.
- Guide quickstart (getting-started.md): replaces Sphere ( Σ x² )
  with a least-squares LineFit example. Same shape (single-
  objective continuous), but recognizable framing. Includes
  actual CMA-ES output, residual table, and narrative comparing
  the answer to standard regression.
- Algorithm count audit: stale "35 algorithms" claim corrected to
  the actual 33 across README, src/lib.rs, introduction.md, and
  the comparison.md table cell.
- Async feature flag listed in the optional-features sections of
  README, src/lib.rs, getting-started.md.
- introduction.md, choosing-an-algorithm.md, comparison.md,
  stability.md, migration.md, cookbook/parallel.md,
  cookbook/custom-optimizer.md: cross-references updated to
  describe full async coverage and link the new cookbook recipe.
- stability.md: removes the speculative "Observer / Snapshot /
  Checkpoint planned" bullet (those didn't ship); documents the
  AsyncProblem / AsyncPartialProblem trait stability.
- migration.md: new "To 0.8" section with paths from 0.5.x and 0.7.x.
- CHANGELOG: 0.8.0 entry capturing the async feature plus the
  documentation / governance / CI catch-up.
- SECURITY.md: supported versions table reflects 0.8.x.
2026-05-06 11:51:13 -06:00
swaits cbfedd85fa feat(async): add run_async to every algorithm in the catalog
Async coverage was incomplete in 0.7 (only RandomSearch and
DifferentialEvolution had run_async). 0.8 closes the gap: every one
of the 33 algorithms now exposes
run_async(&problem, concurrency).await, gated on the async feature.

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

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

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

The follow-up docs commit reconciles the rest of the user guide
and README to describe the new feature; this commit is the bare
async surface.
2026-05-06 11:51:13 -06:00
swaits c1bc3b0528 docs(rustdoc): add runnable examples across operators, metrics, and Pareto utilities
Completes the rustdoc audit — every public item now has at least one
```rust example block in its docstring, exercised by
`cargo test --doc` (55 doctests, all passing).

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

Each example is short (5-15 lines) and self-contained — copy-paste
into a fresh project and it runs.
2026-05-06 08:16:04 -06:00
swaits 5b5fe50df3 feat(heuropt-plot): v0.1.0 — SVG visualization companion crate
Adds heuropt-plot, a tiny SVG-only plotter that takes heuropt
results and emits scatter plots (pareto_front_svg) and line plots
(convergence_svg). No heavy 'plotters' or 'tiny-skia' dep — hand-
rolled SVG so the crate adds <100 KB to a build.

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

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

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

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

Bumps Cargo.toml to 0.8.0; CHANGELOG entry covers the above plus a
note that 0.6.0/0.7.0 on crates.io are yanked experimentals and 0.8
picks up cleanly from 0.5.
2026-05-06 07:55:56 -06:00
swaits fa3f2e8fb0 feat: v0.5.0 — comprehensive documentation release
Theme: documentation and project polish. No public-API changes; this
is the v0.5 release that elevates heuropt's docs/onboarding/governance
to bar-setting status.

Adds:
- mdbook user guide at docs/book/ with intro, getting-started,
  defining-problems, choosing-an-algorithm, cookbook (7 recipes),
  comparison vs other libraries, stability/SemVer, migration guides.
  Deploys to https://swaits.github.io/heuropt/ via .github/workflows/
  docs.yml.
- Runnable rustdoc examples on every algorithm (35 of them), all
  exercised by cargo test --doc.
- Three real-world examples: portfolio.rs (multi-obj with budget
  constraint), hyperparam_tuning.rs (BO + TPE), scheduling.rs
  (permutation via SA + SwapMutation against Smith's-rule oracle).
- Governance: CONTRIBUTING.md, SECURITY.md, CODE_OF_CONDUCT.md
  (adopting builderscode.org's Builder's Code of Conduct), GitHub
  issue templates, PR template.

Polishes:
- README hero with badges + user-guide link.
- lib.rs crate-level docs.
- CHANGELOG entry for 0.5.0.

Bumps Cargo.toml to 0.5.0.
2026-05-05 14:33:12 -06:00
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 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 4a59041d1a style: apply rustfmt drift across the crate 2026-05-05 11:40:14 -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 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