The `compare` harness contradicts two recommendations in the decision
trees:
- "Disconnected or non-convex front -> AGE-MOEA, KnEA, IBEA" had it
backwards. Added KnEA to the ZDT3 table (the disconnected-front
benchmark) so the claim is actually exercised: AGE-MOEA and KnEA
finish *last and second-last*; IBEA wins, MOEA/D and NSGA-II follow.
The trees now split "disconnected" from "non-convex contiguous",
lead disconnected with IBEA, and note the geometry-aware methods
trail when the front is in pieces.
- The book filed Simulated Annealing on permutations as a "one-decision
baseline" and led the JSS row with GA. On the harness SA *wins* the
FT06 job-shop table and ties for the TSP optimum; SA/Tabu edge out
the GA. Reframed SA/Tabu as strong sequencing methods.
Also regenerated examples/compare-results.md for the new ZDT3 row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The terminal output was misaligned: headers and data were right-aligned
with hardcoded column widths and separator lengths, and the mean ± std
cells contained the non-ASCII `±` (plus `ε`, `↑`, `↓`) -- on any terminal
that renders those at a non-1 column width the columns drift, and the
hardcoded `-`.repeat(n) separators didn't match the real table width
anyway.
Changes:
- New `print_table` helper: column widths derived from the actual cell
contents (header + every row), separator length computed to match.
- All table cells are now ASCII: `+/-` instead of `±`, `eps-MOEA`
instead of `ε-MOEA`, arrows dropped from headers.
- Every table is sorted best-first by its primary quality metric.
- Added three combinatorial / sequencing problems with their own
(permutation- / bitstring-native) algorithm rosters: a convex-position
ring TSP (known optimum), FT06 job-shop makespan (known optimum 55),
and a bi-objective 0/1 knapsack scored by hypervolume.
- Expanded every problem's preamble: what it is, why it's hard, and the
best-known / optimal result.
- Regenerated examples/compare-results.md to match.
Continuous-problem quality metrics are unchanged (bit-identical to prior
snapshots); only ms columns and row order move.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Expand benches/hot_paths.rs so the instruction-count harness exercises the
whole library. Adds four groups — permutation_ops_group (all 10 permutation
operators at n=30/100), variation_ops_group (BitFlip, Levy, BoundedGaussian,
ClampToBounds, ProjectToSimplex), combinatorial_group (TSP/JSS/knapsack
end-to-end plus AntColonyTsp), and multi_fidelity_group (Hyperband) — and
folds tabu_search_short and umda_short into single_objective_group. All 33
algorithms and 20 operators are now on the benchmarking surface (69 benches).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Records the outcome of the 2026-05 mutation-testing campaign and the
gotchas for future runs:
- always pass --all-features so the async runners and explorer module
are compiled (otherwise their mutants are unviable/missed noise);
- --test-tool nextest needs --no-config because the libtest-style
--test-threads=1 arg isn't accepted there;
- where the new invariant tests live, and what the residual MISSED /
TIMEOUT categories actually represent.
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.
The full-codebase mutants run showed ~600 of the 902 surviving mutants
are arithmetic / comparison flips inside algorithm run() bodies — the
per-helper Phase 1 tests don't reach the optimization loop itself, and
the existing deterministic-with-same-seed tests can't catch them (both
the clean and mutated runs use the same seed, so they still match).
This extends the async-parity sweep: each of the 33 parity tests now
also asserts the sync run's result against an exact captured snapshot
(best objectives for single-objective algorithms; sorted pareto-front
objective tuples for multi-objective ones). Any arithmetic flip
anywhere in run() perturbs at least one f64 and breaks the snapshot.
Fixtures are deliberately multi-dimensional — SnapSphere (3-D sum of
squares), SnapMo (3-variable / 2-objective), a 6-city TinyTsp, a 3-D
SnapSpherePartial for Hyperband. A 1-D problem leaves the per-axis /
covariance-matrix / simplex machinery degenerate, so arithmetic
mutations there wouldn't change the result; 3-D exercises the full
loop body.
Snapshots captured from the un-mutated implementation; an intentional
algorithm change requires regenerating them, by design. The assertions
live in the async-gated module because they reuse its per-algorithm
constructions — active during the mutation campaign
(--features async,serde) and under cargo test --features async.
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.
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).
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.
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).
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.
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).
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.
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.
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).
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.
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.
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.
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).
Phase 0.2 of the mutation-testing campaign. Adds a module gated on
#[cfg(feature = "async")] that, for every algorithm with a run_async,
asserts that the async runner produces the same result as the sync
runner given the same Config + seed + problem.
Before: nothing exercised run_async, so cargo mutants survived
'replace run_async body with OptimizationResult::new()' and every
comparison/arithmetic mutant inside the async loop for every
async-capable algorithm — about 25-30 algorithms * 5-10 mutants each.
After: every such mutant is killed because the parity test detects
any divergence in best.evaluation.objectives or pareto-front
objective tuples.
Coverage:
- Single-objective real (Sphere1D fixture): RandomSearch,
HillClimber, OnePlusOneEs, SimulatedAnnealing, GA, PSO, DE, CmaEs,
IpopCmaEs, sNES, TLBO, NelderMead, BayesianOpt, TPE.
- Multi-objective real (SchafferN1 fixture): NSGA-II/III, SPEA2,
MOEA/D, MOPSO, IBEA, SMS-EMOA, HypE, PESA-II, ε-MOEA, AGE-MOEA,
GrEA, KnEA, RVEA, PAES.
- Binary (OneMax): UMDA.
- Permutation (TinyTsp fixture): AntColonyTsp.
- Integer (AbsInt fixture): TabuSearch.
- Multi-fidelity (Sphere1DPartial fixture): Hyperband.
Run with: cargo test --features async --test algorithm_properties async_parity
Phase 0.1 of the mutation-testing campaign: a sweep test per algorithm
(33 total) asserting the exact strings returned by AlgorithmInfo::name()
and AlgorithmInfo::full_name() plus the seed propagated through
AlgorithmInfo::seed().
Before: cargo mutants survived dozens of mutants per algorithm replacing
the name/full_name return values with "" or "xyzzy", and the seed
return with None/Some(0)/Some(1). After: every such mutant is caught
by an exact-equality assertion.
NelderMead is deterministic and has no seed override (intentionally);
its test asserts seed() == None to pin the default-trait-impl behavior.
- Rewrites cookbook/permutation.md to cover the new operator toolkit:
initializers, crossovers (OX/PMX/CX/ERX), mutations, a 'what should I
use' picker, and a worked GA-on-TSP example. JSS multiset section
explains why the strict-permutation crossovers don't compose with
operation-string encodings and shows the local POX pattern.
- Adds cookbook/multi-objective-combinatorial.md: bi-objective TSP via
NSGA-II, bi-objective knapsack (binary encoding), 3-objective JSS
via NSGA-III, and a hypervolume-based operator comparison.
- Updates choosing-an-algorithm.md to reference the new operators in
the single- and multi-objective decision tables, plus a noting
NSGA-II/III's genericity over Vec<usize> and Vec<bool> decisions.
- SUMMARY.md and cookbook.md updated to list the new recipe.
tsp_operators_compare.rs runs NSGA-II four times on the KroAB-25
bi-objective TSP, holding everything constant except the crossover
operator. Ranks OX, PMX, CX, ERX by hypervolume against a fixed
reference point, plus front size, unique-point count, and runtime.
Pedagogical demonstration that the right comparison metric for a
Pareto search is hypervolume, not single-objective fitness.
Three harder Pareto-front demos:
- btsp_kroab.rs — Lust-Teghem bi-objective TSP (KroAB-25 subset of
TSPLIB KroA100/KroB100). NSGA-II with EdgeRecombinationCrossover +
InversionMutation. Reports hypervolume vs a fixed reference.
- mo_jss_la01.rs — 3-objective JSS on Lawrence LA01 (10x5 instance).
Objectives: makespan, total flow time, total tardiness (with
synthetic due dates dj = 1.3 * sum_processing_times(j)). NSGA-III
with reference_divisions = 12 (91 Das-Dennis points).
- mo_knapsack.rs — bi-objective 0/1 knapsack a la Zitzler-Thiele.
30 items, two profit vectors, one capacity. NSGA-II with a local
one-point binary crossover + BitFlipMutation; weight overruns
penalized in both objectives.
Two canonical combinatorial optimization benchmarks demonstrating the
new permutation toolkit:
- tsp_ulysses16.rs — single-objective TSP via GeneticAlgorithm using
OrderCrossover + InversionMutation. Reaches the known TSPLIB
optimum (6859) for the 16-city Ulysses GEO-distance instance.
- jss_ft06_bi.rs — bi-objective JSS (makespan + total flow time) on
the Fisher-Thompson 6x6 benchmark via NSGA-II using a local POX
crossover + SwapMutation. Makespan corner reaches the known
single-objective optimum (55).
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.
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.
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.
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.
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.
Pages is now enabled on the repo (Settings → Pages → 'Build and
deployment: GitHub Actions'), so the workflow can use the standard
configure-pages → upload-pages-artifact → deploy-pages chain
without needing the GITHUB_TOKEN to enable Pages itself.
PR builds run the build job (catches mdbook breakage) but skip the
deploy job, so PRs don't republish the live site.
Removes the heuropt-plot subcrate, the visualize example that used
it, and the related workspace plumbing (root [workspace] table, the
[workspace] override added to fuzz/Cargo.toml to detach from it,
heuropt-plot dev-dep, CHANGELOG mention).
The visualization concern is better served as an independent third-
party project than as a companion crate in this repo. No effect on
heuropt's public API or the async work in 0.8.0.
Two CI fixes; the previous `enablement: true` attempt didn't work
because the default GITHUB_TOKEN can write to Pages but can't enable
it on a repo that doesn't yet have it configured.
1. .github/workflows/docs.yml: drop the Pages deploy job entirely.
Build mdbook on every push and upload it as a CI artifact. When
Pages is enabled manually (Settings → Pages → 'Build and
deployment: GitHub Actions'), this file can grow back a deploy
job using actions/configure-pages + actions/deploy-pages.
2. fuzz/fuzz_targets/clamp_to_bounds.rs: the simplex projection's τ
computation operates on values up to `simplex_total · 1e6` per
the input filter, so its FP precision floor is ~1e-4 of the
input scale. Outputs near the `max(x_i − τ, 0)` clamp boundary
can flip between 0 and a small positive value across
re-applications without that being a correctness bug. The fuzz
target is meant to catch *gross* non-idempotence (the all-zeros
bug that the v0.4 cleanup fixed), not ULP-level slop. Loosen the
per-element tolerance to `1e-4 · max(simplex_total, max|x_i|, 1)`.
Verified clean over a 10 M-run soak.
The Docs workflow was failing on `actions/configure-pages@v5` with
"Get Pages site failed" because Pages isn't enabled on the repo
yet. Setting `enablement: true` lets the action auto-enable it so
the deploy can proceed without a manual Settings → Pages click.