Skip to content

Latest commit

 

History

History
1693 lines (906 loc) · 257 KB

File metadata and controls

1693 lines (906 loc) · 257 KB

Fugue Ecosystem Audit — July 2026 — fugue-evo findings

Date: 2026-07-10 · Scope: fugue (monadic PPL, ~9.4k LOC) and fugue-evo (evolutionary computation, ~33k LOC incl. WASM crate)

Method: 180-agent orchestrated audit in four phases: 21 domain auditors over disjoint source slices; independent adversarial verification of every math/correctness/performance finding (two verifiers for critical/high, one otherwise, each re-deriving formulas from primary references and checking numerically where decisive); a completeness critic that commissioned three gap-fill audits; and a re-run of one degenerate auditor. 2 findings were refuted during verification and are excluded; 2 duplicates were merged.

Totals: 170 findings — 12 critical, 38 high, 72 medium, 48 low. 110 adversarially confirmed.

Cross-cutting conclusions:

  1. The primitives are right; the composition is wrong. Distribution log-densities, NSGA-II internals, SBX/polynomial mutation, CMA-ES strategy parameters, and benchmark functions are faithful to their references. The confirmed-wrong code concentrates in the layers that join components: proposal corrections, importance weights, optimizer update loops, wiring between subsystems.
  2. Silent wrong answers, not crashes. Every one of fugue's four inference engines (MCMC, SMC, VI, ABC-SMC) had at least one confirmed bug that biases its posterior without raising an error.
  3. Ornamental subsystems. fugue's memory-optimization layer, fugue-evo's Bayesian hyperparameter learner, the fugue_integration module, and 12 of 20 fugue error codes had zero production call sites.
  4. Stapled, not fused. fugue-evo's algorithms are implemented entirely without fugue; the integration existed in marketing only.
  5. Tests are broad but shallow. 837 green tests validate shapes and finiteness more often than values; known-answer tests were the exception.

Full interactive report (with verifier transcripts): https://claude.ai/code/artifact/4c4fc55e-52cf-42e9-90e3-0cf190ed11c3

Finding IDs are stable: FG-* = fugue, EV-* = fugue-evo. Resolutions below are updated as remediation lands; every finding must end fixed or carry a substantive resolution — no deferrals.

Remediation status (2026-07-11): COMPLETE. All 170 findings (64 FG + 106 EV) carry a final resolution below, and every one was independently re-verified by an adversarial verifier against the final code (170/170 verified; 14 initially judged incomplete were repaired by fixup passes and re-verified). Post-remediation regression review over the full diffs surfaced 7 new defects (2 medium, 5 low) — all fixed. Final state: fugue branch audit/2026-07-remediation — 64/64, full gate green (tests, doctests, clippy, fmt, benches); fugue-evo branch audit/2026-07-remediation — 106/106, full suite green, now depending on the co-developed sibling fugue via path (EV-30).


fugue-evo

Severity: critical (8)

EV-01 — Jacobi eigendecomposition never updates the input matrix, so it returns wrong (even negative) eigenvalues and non-diagonalizing eigenvectors

  • Location: fugue-evo/src/algorithms/cmaes.rs:429
  • Severity: critical · Dimension: math · Verification: confirmed · Auditor confidence: certain

jacobi_eigendecomposition takes a: &[Vec<f64>] by immutable reference and NEVER mutates it. The classic cyclic-Jacobi algorithm (Numerical Recipes jacobi, from which this is clearly adapted) requires, at each (p,q) rotation, applying the rotation to the OTHER off-diagonal elements of a and zeroing a[p][q]: for j<p: ROTATE(a,j,p,j,q); for p<j<q: ROTATE(a,p,j,j,q); for q<j<n: ROTATE(a,p,j,q,j); a[p][q]=0. Here those three a-rotation blocks are replaced by a loop at lines 484-491 that computes apj,aqj and then discards them (let _ = (apj, aqj);), and a[p][q]=0 is missing. Only the eigenvector matrix v is rotated (lines 494-499). Consequences: (1) the convergence test sm = Σ|a[p][q]| (line 445-450) reads the ORIGINAL matrix every sweep, never shrinks, so it never breaks early and always runs 50 sweeps; (2) because a[p][q] is frozen, the diagonal accumulators d[p]-=t·a[p][q]; d[q]+=t·a[p][q] keep applying non-vanishing increments every sweep, so d diverges. I reproduced the exact routine in Python: for A=[[4,1],[1,3]] it returns eigenvalues [-3.589, 10.589] (true: [2.382, 4.618]); for [[2,0.9],[0.9,1]] it returns [-4.884, 7.884] (true: [0.470, 2.530]). The reconstruction B·D·Bᵀ does not match A at all. Only the TRACE is preserved (z[p] and z[q] are equal and opposite, so Σd stays constant) — which is why the unit test passes. Because update() calls update_eigensystem() on a lazy cadence (every ~1-20 generations, line 353), every recomputation replaces B and D with garbage. This corrupts (a) sampling y=B·D·z (line 237), (b) C^{-1/2}=B·D^{-1}·Bᵀ in the p_sigma update (lines 264-280), and (c) negative eigenvalues get clamped to 1e-16 (line 390-392), collapsing sampling variance in those directions to ~0 while blowing up C^{-1/2} to ~1e8, which makes p_sigma and the step-size control explode. The covariance-matrix math itself (C stored in covariance) is maintained correctly, but every consumer that needs its eigenstructure is fed garbage. This silently breaks mainline CMA-ES.

Suggested fix: Rewrite as a correct symmetric eigensolver: either operate on a mutable working copy of C, applying the three ROTATE blocks to the off-diagonals and setting the pivot to zero each rotation (full Numerical-Recipes cyclic Jacobi), or pull in a linear-algebra crate (nalgebra SymmetricEigen) for the decomposition. Add a test that checks B·diag(D)·Bᵀ ≈ C AND that each returned (λ,vᵢ) satisfies C·vᵢ ≈ λ·vᵢ, not merely that Σλ = trace.

Resolution: fixed — cmaes.rs's hand-rolled jacobi_eigendecomposition (which took a: &[Vec<f64>] by immutable reference and never mutated it) was replaced with symmetric_eigendecomposition, backed by nalgebra::SymmetricEigen operating on a DMatrix built from the covariance matrix; eigenvalues[j] is paired with column j of the returned eigenvectors matrix.

Regression tests: test_eigendecomposition_known_matrix, test_eigendecomposition_random_spd, test_cmaes_rosenbrock_convergence.

Re-verification: verified (independent adversarial verifier).

EV-02 — Checkpoint RNG state is never actually captured or restored - resumed runs silently diverge, and there's no algorithm-level resume path at all

  • Location: fugue-evo/src/checkpoint/state.rs:31
  • Severity: critical · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

Checkpoint.rng_state: Option<Vec<u8>> (state.rs:31) with builder methods with_rng_state/rng_state() (state.rs:100-104, recovery... CheckpointBuilder at state.rs:253-257) exists but is populated nowhere in the codebase: grepping the whole src/algorithms tree for rng_state/CheckpointBuilder/Checkpoint::new returns zero hits. Every algorithm's run() takes the RNG as a generic caller-owned parameter (e.g. SimpleGA::run<R: Rng>(&self, rng: &mut R) at algorithms/simple_ga.rs:382), so there is no persistent RNG object inside any algorithm to snapshot in the first place, and rand::Rng gives no serialization hook for arbitrary R. The crate's own canonical example confirms the consequence concretely: examples/checkpointing.rs seeds StdRng::seed_from_u64(42) (line 39), saves checkpoints via Checkpoint::new(gen+1, individuals).with_evaluations(...) (lines 112-113, never calling with_rng_state), and then resume_from_checkpoint explicitly re-seeds with a different, hardcoded seed StdRng::seed_from_u64(12345) (line 166) next to the comment '// Different seed for continuation' - the shipped documentation example openly demonstrates that a 'resumed' run does not reproduce the trajectory a continuous run would have taken. There is also no resume()/from_checkpoint() constructor anywhere in src/algorithms (confirmed by grep), so even a user who did save real RNG bytes would have no library API to feed them back into a running algorithm - checkpointing is file-I/O plumbing only, not integrated with any algorithm's run loop.

Suggested fix: Either constrain algorithms to a concrete, serializable RNG (e.g. rand_chacha::ChaCha8Rng behind a serde feature) and thread it through Checkpoint, or clearly document that resumed runs are not bit-reproducible and that RNG continuity is entirely the caller's responsibility; provide an actual resume() entry point on the algorithm types instead of leaving users to hand-roll the loop as the example does.

Verifier correction: The finding's facts are all correct, but "critical" overstates impact. Checkpointing does correctly serialize/restore the load-bearing evolution state (population, fitness values, best, evaluations, statistics, algorithm_state), so a resumed run is a valid continued optimization — what is lost is only bit-for-bit reproducibility of the exact stochastic trajectory (the RNG discontinuity). For a stochastic optimizer this is a reproducibility/API-completeness gap, not a result-corrupting or crashing correctness failure. The two genuine defects are: (a) a dead rng_state field that is never written and thus falsely advertises RNG-continuity/reproducibility, and (b) the absence of any resume()/from_checkpoint() API forcing users to hand-roll the loop as the example does. Both merit a fix (populate rng_state via a concrete serializable RNG such as ChaCha, or document that resume is not bit-reproducible; and provide a resume entry point), but the correct severity is medium.

Resolution: fixed — SimpleGA now provides a first-class checkpoint/resume API: checkpoint_run snapshots population, best individual, evaluations, statistics, and a captured ChaCha SnapshotRng into a Checkpoint; resume/run_from_checkpoint restore all of it and reject a checkpoint with no captured RNG rather than silently diverging. The incremental stepping API (SimpleGaRun/init_run/step_generation/finish_run) is now available in all builds, not just non-parallel ones. examples/checkpointing.rs drives resume purely through this library API (no hand-rolled loop) and is verified bit-identical to an uninterrupted run.

Regression tests: src/checkpoint/rng.rs::tests::test_snapshot_rng_round_trip_is_bit_identical, src/checkpoint/rng.rs::tests::test_snapshot_rng_variants, src/checkpoint/state.rs::tests::test_checkpoint_rng_capture_restore_round_trip, src/checkpoint/state.rs::tests::test_checkpoint_restore_rng_absent, tests/e-checkpoint_resume.rs::resume_is_bit_identical_to_uninterrupted_run, tests/e-checkpoint_resume.rs::library_resume_is_bit_identical, tests/e-checkpoint_resume.rs::library_resume_through_disk_and_rejects_missing_rng.

Re-verification: verified (independent adversarial verifier).

EV-03 — CompositeGenome trace round-trip is silently broken for Permutation (and any non-RealVector/BitString) components

  • Location: fugue-evo/src/genome/composite.rs:133
  • Severity: critical · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

composite.rs's to_trace/from_trace hardcode exactly three address prefixes when copying a component's trace entries: "gene" (real), "bit" (binary), and "element" (assumed for permutations) — see lines 129-135 and 141-147 for to_trace, 169-175 and 181-187 for from_trace. But Permutation::to_trace (permutation.rs:213-219) actually stores values at addr!("perm", i), never "element" — grep for the literal string "element" across fugue-evo/src shows it appears ONLY in composite.rs, nowhere else in the codebase. Consequently: (1) CompositeGenome<Permutation, X>::to_trace() silently drops every permutation value (none of the three prefix checks match "perm"), producing a trace that records only first_dim/second_dim metadata; (2) CompositeGenome<Permutation, X>::from_trace() then reconstructs an empty first_trace for the permutation component and calls Permutation::from_trace(&empty_trace), which unconditionally errors with "No permutation found in trace" (permutation.rs:231-235) — so from_trace ALWAYS fails for this combination. The same failure mode applies to any TreeGenome component, whose to_trace uses entirely different addresses ("tree_is_terminal", "tree_func_idx", etc., tree.rs:594-645) that also match none of the three hardcoded prefixes. This breaks exactly the use case the module's own doc comment showcases: "Topology (permutation) + parameters (continuous)" (composite.rs:20).

Suggested fix: Delegate to each component's own trace_prefix() and to_trace()/from_trace() rather than re-parsing with hardcoded address name literals; merge full nested traces under a namespaced prefix (e.g. "first/perm#0") instead of trying to special-case three known encodings.

Verifier correction: Finding is accurate as stated. One nuance worth recording: the "element" (Usize) branch is not merely wrong for permutations — it is dead code for every genome type currently in the crate, since no genome emits addr!("element", i) (Permutation emits "perm"). So even a hypothetical Usize-based genome would not be handled; the round-trip is only correct for RealVector ("gene") and BitString ("bit") components. Severity is a genuine correctness defect on a documented use case, but I adjust critical->high because (a) the two combinations exercised by tests (RealVector/BitString) work, (b) the module doc-comment explicitly flags this as a "simplified implementation that may lose some type information" and directs users to serde serialization for full fidelity, and (c) there is no evidence of a production caller relying on trace round-trip for Permutation/Tree composites. It is a real, always-reproducible failure for the showcased permutation+continuous case, but scoped to an opt-in codepath with a documented alternative rather than a system-wide critical break.

Resolution: fixed — Rewrote CompositeGenome::to_trace/from_trace to delegate to each component's own to_trace/from_trace and copy every nested trace entry verbatim under a 'first/'/'second/' namespace (helpers namespace_into / extract_namespace), removing the three hardcoded encoding prefixes ('gene'/'bit'/'element'). Permutation and Tree components now round-trip. Added round-trip regression tests for Composite<Permutation,RealVector> and Composite<BitString,Permutation> and rewrote the old mixed test to assert full recovery; empty trace now yields MissingAddress.

Regression tests: genome::composite::tests::test_composite_trace_roundtrip_permutation_realvector, genome::composite::tests::test_composite_trace_roundtrip_bitstring_permutation, genome::composite::tests::test_composite_trace_roundtrip_mixed.

Re-verification: verified (independent adversarial verifier).

EV-04 — TreeGenome to_trace/from_trace discards actual function identity and terminal values

  • Location: fugue-evo/src/genome/tree.rs:708
  • Severity: critical · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

encode_function (tree.rs:708-711) always returns 0 regardless of which function is passed, and decode_function (713-722) decodes index 0 as F::functions()[0] (for ArithmeticFunction that's always Add). encode_terminal (694-698) always returns (0.0, 0.0) regardless of the actual terminal, and decode_terminal (700-706) explicitly ignores its term_type/term_val arguments and calls T::random(&mut rng) to fabricate a brand-new random terminal. So TreeGenome::from_trace(&genome.to_trace()) does NOT reconstruct the genome's semantic content at all: every function node becomes 'Add' and every terminal becomes a fresh random value; only the tree's shape (arity per node, depth, size) survives. This directly contradicts the trait's own doc comment that from_trace 'is the inverse of to_trace()' (traits.rs:42-46) and defeats the crate's headline feature of 'trace-based mutation' and 'trace-based crossover' (traits.rs:22-28) for tree genomes specifically. The crate's own test at tree.rs:888-905 tacitly documents the data loss ('terminal values are generated fresh') but does not flag it as the severe functional gap it is for any pipeline that relies on trace round-tripping (e.g. checkpointing or trace-merging crossover) of tree genomes.

Suggested fix: Either implement real type-specific encode/decode for the built-in ArithmeticTerminal/ArithmeticFunction (e.g. discriminant index + payload float) or drop to_trace/from_trace support for TreeGenome (return an error) rather than silently returning corrupted data that type-checks but is semantically wrong.

Verifier correction: The finding is accurate as written. One clarifying nuance worth adding: not only is function identity collapsed to Add, but because decode_function always returns Add (arity 2) while the stored per-node arity is preserved independently, reconstructed trees can be structurally inconsistent (e.g., an Add node with a single child) — this does not panic only because ArithmeticFunction::apply uses unwrap_or defaults, so the corruption is entirely silent. Severity: I would rate this high rather than critical. It is a genuine, severe, silent-data-corruption defect that defeats the headline round-trip feature for TreeGenome, but it is isolated to one genome type (RealVector/BitString/Permutation round-trip correctly), causes no crash/UB/security impact, and is a documented limitation in the crate's own test — so it falls short of the "critical" bar while remaining a serious functional gap.

Resolution: fixed — TreeGenome trace round-trip is now lossless: encode_terminal/decode_terminal delegate to a Terminal::encode/decode implementation (discriminant + payload) instead of fabricating random terminals, and encode_function/decode_function index into the stable ArithmeticFunction::functions() ordering instead of always returning Add. The fixup closed a gap where Pow was missing from functions(), which had silently collapsed every Pow node to Add on round-trip: Pow is now listed, Pow::apply is hardened to never return NaN/Inf now that it is reachable by generators and mutation, and encode_function debug_asserts on an unlisted variant instead of silently defaulting to index 0.

Regression tests: genome::tree::tests::test_tree_genome_trace_roundtrip, genome::tree::tests::test_arithmetic_function_ordering_is_stable, genome::tree::tests::test_arithmetic_terminal_encode_decode_roundtrip, genome::tree::tests::test_tree_genome_trace_roundtrip_pow_node, genome::tree::tests::test_every_arithmetic_function_variant_roundtrips_losslessly.

Re-verification: verified (independent adversarial verifier).

EV-05 — Self-adaptive log-normal learning rates τ/τ' are swapped between the global and per-coordinate perturbations (default ES path)

  • Location: fugue-evo/src/hyperparameter/self_adaptive.rs:66
  • Severity: critical · Dimension: math · Verification: confirmed · Auditor confidence: certain

The standard Schwefel/Bäck log-normal self-adaptation (Beyer & Schwefel 2002; Eiben & Smith, Introduction to Evolutionary Computing §4.4.2) updates each per-coordinate step size as σ_i' = σ_i·exp(τ'·N(0,1) + τ·N_i(0,1)), where N(0,1) is drawn ONCE per individual and is common to all coordinates, N_i(0,1) is drawn independently per coordinate, and the coefficients are: • coefficient on the shared/global deviate = τ' = 1/√(2n) • coefficient on the per-coordinate deviate = τ = 1/√(2√n). The code (lines 66-95) defines tau = 1/√(2n) and tau_prime = 1/√(2√n), draws n0 ONCE (line 69, the global deviate) and ni per coordinate, then computes *sigma *= (tau_prime * n0 + tau * ni).exp() (line 79/87). That multiplies the GLOBAL shared deviate n0 by tau_prime = 1/√(2√n) and the PER-COORDINATE deviate ni by tau = 1/√(2n) — exactly reversed from the standard. Numerically (verified) for n=10 the global term gets 0.3976 (should be 0.2236) and each coordinate term gets 0.2236 (should be 0.3976); for n=100, global 0.2236 vs correct 0.0707, coordinate 0.0707 vs correct 0.2236. The effect grows with dimension: the shared multiplicative factor is over-driven while the coordinate-specific adaptation (the entire point of non-isotropic self-adaptation) is throttled, degrading the ES's ability to learn anisotropic step sizes and inflating global step-size noise. This is NOT dead code: ESConfig::default() sets self_adaptive: true (evolution_strategy.rs:58) and the self-adaptive branch builds a NonIsotropic strategy (evolution_strategy.rs:687) whose mutate is called every generation (evolution_strategy.rs:642/949), so every default Evolution Strategy run uses the swapped rates. The code's own docstring (lines 64-65) even labels tau=1/√(2n) as 'global' and tau_prime=1/√(2√n) as 'local', yet applies tau_prime (its 'local') to the global n0 and tau (its 'global') to the local ni — internally inconsistent as well as wrong vs the reference. LearningRates::for_dimension (lines 241-247) compounds the confusion by labeling tau_prime = 1/√(2√n) as the 'Global learning rate' — also backwards — though that struct is unused by the mutation path.

Suggested fix: Swap the coefficients so the once-per-individual deviate is scaled by 1/√(2n) and each per-coordinate deviate by 1/√(2√n): *sigma *= (tau * n0 + tau_prime * ni).exp() with the current tau/tau_prime definitions, or equivalently rename the variables and keep the application. Fix LearningRates::for_dimension to match, and align the docstring.

Resolution: fixed — StrategyParams::mutate was changed so the once-per-individual deviate n0 is multiplied by tau_prime = 1/sqrt(2n) and each per-coordinate deviate ni is multiplied by tau = 1/sqrt(2*sqrt(n)) (previously reversed); LearningRates::for_dimension's tau/tau_prime field assignments were swapped to match, and the doc comments were corrected.

Regression tests: test_non_isotropic_learning_rate_assignment, test_learning_rates.

Re-verification: verified (independent adversarial verifier).

EV-06 — Bradley-Terry MLE is never invoked in the interactive loop, so BradleyTerry mode leaves every candidate at its initial strength

  • Location: fugue-evo/src/interactive/aggregation.rs:504
  • Severity: critical · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

recompute_all()/recompute_bradley_terry_mle() (the only code paths that ever call BradleyTerryModel::fit and store strengths/variance into CandidateStats) are called nowhere in the crate except inside two #[cfg(test)] tests (aggregation.rs:854,880). Verified by grep across src/ and examples/. In the live loop, provide_response -> process_pairwise -> record_comparison, and for AggregationModel::BradleyTerry the score-update match arm is intentionally empty (aggregation.rs:376-378, comment 'batched via recompute_all()'). Nothing ever batches it. Consequently get_fitness (aggregation.rs:271) returns stats.model_score which stays frozen at initial_strength for ALL candidates, and get_fitness_estimate (aggregation.rs:309-321) returns mean = initial_strength (identical for every candidate) with a fallback variance 1/n_comparisons. ranked_candidates() then sees all-equal fitness, so pairwise user feedback exerts ZERO selection pressure. The entire 820-line BT MLE module is dead in the integrated algorithm.

Suggested fix: Call aggregator.recompute_all() at the end of provide_response (or before evolve_generation) whenever the model is BradleyTerry, then sync candidate fitness_estimate/fitness_with_uncertainty from the recomputed stats. Add an integration test that runs InteractiveGA in Pairwise+BradleyTerry mode and asserts the winner's fitness exceeds the loser's.

Verifier correction: Finding is accurate. Minor completeness note: the same freezing also affects BradleyTerrySimple mode (its recompute_bradley_terry_simple at aggregation.rs:552 is likewise reached only via recompute_all, i.e. only in tests), so both Bradley-Terry variants are dead in the integrated loop, not just the MLE variant. This broadens rather than weakens the finding.

Resolution: fixed — FitnessAggregator::process_pairwise now calls recompute_all() for BradleyTerry/BradleyTerrySimple immediately after recording a comparison and before returning any fitness, so the live interactive loop re-fits the MLE. algorithm.rs::provide_response now captures BOTH compared ids returned by process_pairwise so winner and loser fitness are synced. Ranked candidates in a pairwise BT session now order A>B>C with separated strengths.

Regression tests: aggregation::tests::test_bradley_terry_process_pairwise_updates_fitness, tests/e-interactive_audit.rs::ev06_bradley_terry_drives_session_ranking.

Re-verification: verified (independent adversarial verifier).

EV-07 — NaN fitness silently corrupts Population::best()/worst() (no panic, wrong answer)

  • Location: fugue-evo/src/population/population.rs:148
  • Severity: critical · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

best()/worst() (lines 144-181) compare via fa.partial_cmp(&fb).unwrap_or(std::cmp::Ordering::Equal). Rust's Iterator::max_by/min_by replace the running result whenever the comparator returns anything other than 'this element is strictly better' (cmp::max_by returns the new element unless compare(old,new)==Greater). Because NaN forces Equal, comparing the running max against ANY element that is NaN (in either position) also returns Equal, which is treated as 'replace'. I verified this by hand-simulating the exact fold semantics: with values [10,30,20,NaN] the final 'max' is NaN, not 30 (verified with a python re-implementation of core::cmp::max_by's replace-on-!=Greater rule). So if a NaN-fitness Some(NaN) individual (e.g. from a fitness function that divides by zero or logs a negative number) ends up later in the Vec than the true best, best()/worst() silently return the NaN individual with no panic, no error - just a garbage 'best' individual propagated into elitism, checkpointing's best field, and reported results. Individual::PartialOrd (individual.rs:135-142) correctly returns None on NaN rather than coercing to Equal, so this is a real regression that population.rs introduces on top of otherwise-correct comparison machinery.

Suggested fix: Reject/guard NaN in Individual::set_fitness, or make best()/worst()/sort_by_fitness() treat NaN as worst-possible (e.g. filter it out, or use a total_cmp-style ordering) instead of silently treating NaN-involving comparisons as Equal.

Verifier correction: Finding is accurate as written. One scope note: the corruption only manifests when a fitness function actually yields NaN (Some(NaN)); it is a conditional silent-wrong-answer bug rather than an unconditional one, so severity is better rated high than critical. Also note the same flaw affects sort_by_fitness() (population.rs:184-198), which the auditor mentions only in the suggested fix.

Verifier correction: The defect is real but the positional condition is more specific than stated. best()/max_by returns the NaN individual only when NaN is the LAST evaluated element (a NaN accumulator is itself overwritten by the next element via the Equal-take-later rule: [10,NaN,30] correctly yields 30). worst()/min_by returns NaN only when NaN is the FIRST evaluated element ([10,30,20,NaN] correctly yields 10). It is not "any NaN later in the Vec than the true best." Regardless, silent corruption of best()/worst() and of sort_by_fitness()/truncate_to_best() is confirmed for those orderings, with no panic or error.

Resolution: fixed — Individual::set_fitness now panics with a clear message when the fitness's to_f64() is NaN (documented invariant; infinities still allowed for ParetoFitness). As defense in depth, best()/worst()/sort_by_fitness() were made NaN-safe by ranking any NaN fitness strictly worst through the new FitnessValue::cmp_by_quality total order, so a population containing a NaN individual returns the true best.

Regression tests: population::individual::tests::test_set_fitness_rejects_nan, population::population::tests::test_best_ignores_nan_fitness.

Re-verification: verified (independent adversarial verifier).

EV-08 — sort_by_fitness()/best()/worst() use to_f64() ordering instead of FitnessValue::is_better_than(), which is provably wrong for the shipped ParetoFitness type

  • Location: fugue-evo/src/population/population.rs:184
  • Severity: critical · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

best()/worst()/sort_by_fitness() (lines 144-198) all rank individuals purely by fitness.to_f64(), bypassing FitnessValue::is_better_than()/PartialOrd. This is only safe if to_f64()'s ordering matches is_better_than()'s ordering, which is a documented convention (fitness/traits.rs:161 'higher = better by convention') but not enforced, and it is actually violated by the crate's own ParetoFitness type (fitness/traits.rs:137-148): to_f64() = -(rank as f64) + crowding_distance * 0.001 while is_better_than correctly compares (rank asc, crowding_distance desc). NSGA-II's own crowding-distance calculation (algorithms/nsga2.rs:196 and 218-219) assigns f64::INFINITY to every front-boundary individual (and to every individual in fronts of size <=2) - this happens on essentially every NSGA-II run with more than one front. Since -(rank) + INFINITY*0.001 == INFINITY for ANY finite rank, all boundary individuals across ALL ranks collapse to the identical to_f64() value of +Infinity, so Population::best() can no longer distinguish a true rank-0 Pareto-optimal boundary point from a poor rank-50 boundary point - the 'winner' is whichever happens to iterate last (see the NaN finding above for why 'last' wins ties), not the actual best. Nsga2Individual::to_individual() (algorithms/nsga2.rs:123) is the crate's own supplied bridge for putting NSGA-II results into the generic Individual<G,ParetoFitness>/Population<G,ParetoFitness> API, so this is a directly reachable, intended usage path, not a hypothetical misuse.

Suggested fix: Have best()/worst()/sort_by_fitness() delegate to F::is_better_than() (as Individual::is_better_than already does) instead of re-deriving an ordering from to_f64(); reserve to_f64() strictly for probabilistic/selection use where an approximate scalar is acceptable.

Verifier correction: The defect is exactly as described. One scoping clarification affecting severity: the core NSGA-II optimization loop does NOT use these Population accessors — it uses its own internal crowded_comparison operator (nsga2.rs:237+) which correctly implements (rank, crowding) ordering. The broken to_f64()-based ranking only affects the generic Population<_, ParetoFitness>::best()/worst()/sort_by_fitness()/truncate_to_best convenience methods when a user bridges NSGA-II results into the generic API via to_individual(). So it silently returns the wrong 'best'/sorted results on that post-processing path, but does not corrupt the NSGA-II search itself.

Verifier correction: Finding is accurate as stated. One nuance on severity: the bug produces a silently-wrong "best"/sorted result in the generic Population helper API rather than a crash, data corruption, or memory-safety issue, and NSGA-II's own internal selection (crowded_compare at nsga2.rs:244) is unaffected because it compares rank/crowding directly. The wrong result only surfaces when a caller uses Population::best()/worst()/sort_by_fitness()/truncate_to_best() on a Population<G, ParetoFitness>. That is a genuine correctness defect in a public API with a plausible usage path, warranting High; Critical slightly overstates blast radius since it does not affect NSGA-II's core loop and yields incorrect-but-not-catastrophic output.

Resolution: fixed — best()/worst()/sort_by_fitness() no longer rank by a to_f64() scalar; they delegate to FitnessValue::is_better_than() via a new cmp_by_quality() total-order helper on the trait. This fixes silently-wrong results for ParetoFitness, where infinite crowding distances collapse every rank's to_f64() to +inf.

Regression tests: population::population::tests::test_best_worst_sort_use_is_better_than_for_pareto.

Re-verification: verified (independent adversarial verifier).

Severity: high (20)

EV-09 — No panic hook installed; a panic anywhere in the fugue-evo call graph permanently traps the WASM instance

  • Location: fugue-evo/crates/fugue-evo-wasm/src/lib.rs:40
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

The #[wasm_bindgen(start)] init() at lib.rs:40-43 is a no-op; Cargo.toml has no console_error_panic_hook dependency and no std::panic::set_hook call anywhere in the crate. wasm-bindgen's Result<T, JsValue> convention only catches errors that are explicitly returned as Err; it does not catch Rust panics. All optimizer .optimize()/.run() paths call deep into fugue-evo (e.g. src/algorithms/simple_ga.rs, cmaes.rs, nsga2.rs, evolution_strategy.rs), which collectively contain 44 .unwrap()/panic!/unreachable! call sites (verified via grep over fugue-evo/src/algorithms/*.rs). If any of those trigger (e.g. from an edge-case population size, degenerate bounds, or NaN fitness from a buggy custom JS function), the panic unwinds to the wasm boundary, which for wasm32 cdylib targets aborts the instance via an unreachable trap. Every subsequent call into the module then fails (JS sees an opaque 'unreachable executed' / RuntimeError with no Rust file/line info), effectively bricking the page's optimizer for the rest of the session with no diagnostic information.

Suggested fix: Add console_error_panic_hook as a dependency, call console_error_panic_hook::set_once() in init(), and audit/replace unwrap-that-could-fail paths in the hot call graph with proper Result propagation where user input (population_size=0, dimension=0, degenerate bounds) can reach them.

Verifier correction: The missing panic hook is real: init() (lib.rs:40-43) is a no-op, Cargo.toml lacks console_error_panic_hook, and no set_hook call exists in the crate. Adding console_error_panic_hook::set_once() is a valid improvement. BUT two parts of the finding are inaccurate: (1) The '44 reachable panic sites in src/algorithms/*.rs' is wrong — 38 of the 44 are inside #[cfg(test)] modules and never compile into the wasm binary; the 6 real library sites are all the guarded pattern best_individual.fitness.unwrap(), which cannot panic because best_individual comes from Population::best() which only returns is_evaluated() (fitness.is_some()) individuals. Empty population returns Err(EmptyPopulation), and NaN JS fitness is caught with unwrap_or(NEG_INFINITY). No reachable panic is demonstrated in the cited call graph. (2) 'Permanently traps / bricks the instance for the rest of the session' overstates WASM semantics: a Rust panic emits an unreachable trap that aborts only the current call; the instance's memory persists and subsequent calls generally still succeed. The real, reliable harm is loss of diagnostics (opaque RuntimeError, no Rust file/line) should any panic occur elsewhere — a robustness/observability gap, not a demonstrated correctness bug.

Verifier correction: init() at lib.rs:40-43 is a no-op and the crate installs no panic hook (no console_error_panic_hook dependency, no std::panic::set_hook anywhere), so any Rust panic in the call graph surfaces as an opaque wasm unreachable/RuntimeError with no Rust file/line — a real diagnostics/robustness gap that console_error_panic_hook::set_once() in init() would fix. However: of the 44 unwrap/panic/unreachable matches in src/algorithms/*.rs, 38 are inside #[cfg(test)] modules (not compiled into the cdylib, not reachable); only ~6 are production sites, all best_individual.fitness.unwrap(). A NaN fitness does NOT trigger these unwraps (Some(NaN).unwrap() returns NaN). And a panic does not permanently brick the instance — a wasm trap ends only the current call and throws a RuntimeError to JS while the instance/memory remain callable (subject to a known wasm-bindgen shadow-stack degradation after panics, not a guaranteed permanent failure of all future calls).

Resolution: fixed — Added console_error_panic_hook dependency and called console_error_panic_hook::set_once() in the #[wasm_bindgen(start)] init(), so Rust panics log real file/line to the JS console instead of an opaque unreachable trap. Documented that panics still trap the current call but are now diagnosable. Audited the wasm crate's own JS-boundary unwraps: only non-panicking unwrap_or(...) saturating fallbacks exist (confirmed via grep: zero .unwrap()/.expect()/panic!/unreachable! in src), and all fallible entry points return Result<_, JsValue>; documented this in init()'s rustdoc.

Regression tests: wasm_tests.rs::test_structured_error_has_type_field (browser: exercises the panic-hook-adjacent structured error boundary).

Re-verification: verified (independent adversarial verifier).

EV-10 — Algorithm-level tests pass even if the model learns nothing (pure random search satisfies both thresholds)

  • Location: fugue-evo/src/algorithms/eda/umda.rs:647
  • Severity: high · Dimension: testing · Verification: judgment · Auditor confidence: certain

test_continuous_umda_sphere asserts only result.best_fitness > -50.0 and test_binary_umda_onemax asserts only result.best_fitness >= 15. Both are best-so-far values accumulated over all evaluations (5000 and 3000 respectively). I simulated pure random search (no EDA update at all): best of 5000 uniform 10-D points in [-5.12,5.12] gives Sphere fitness -12.07 (> -50 PASS), and best of 3000 random 20-bit strings gives 16 ones (>= 15 PASS). Therefore a completely broken UMDA whose model never converged would still pass. The tests verify 'it runs and tracks a best' but NOT that the learned distribution converges toward the optimum. There is no assertion on model.means -> 0 (Sphere) or model.probabilities -> ~1 (OneMax), nor on the final-generation population mean.

Suggested fix: Assert on convergence of the model itself: after run, check the learned means are within e.g. 0.5 of 0 for Sphere and probabilities > 0.8 for OneMax, or tighten thresholds to values unreachable by random search (e.g. Sphere > -2.0, OneMax == 20). Optionally expose the final model from the result for direct inspection.

Resolution: fixed-with-design-change — Rewrote the UMDA algorithm tests so pure random search fails them: seeded 10-D sphere UMDA must beat a seeded same-budget pure-random-search baseline by a fixed margin AND the learned means must move within 0.5 of the optimum; same pattern for binary OneMax (>=19 ones, probabilities >0.8). Added public run_with_model() on both ContinuousUMDA and BinaryUMDA to expose the final learned model for inspection.

Regression tests: test_continuous_umda_beats_random_search, test_binary_umda_beats_random_search.

Re-verification: verified (independent adversarial verifier).

EV-11 — FullyConnected and Star topologies never broadcast; migration only sends to the first target

  • Location: fugue-evo/src/algorithms/island.rs:471
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

MigrationTopology::targets() returns multiple destinations for FullyConnected and for a Star hub, but migrate() only honors multiplicity for Random; the catch-all arm (lines 471-481) sends emigrants to targets.first() only. FullyConnected degenerates to a single fixed edge and a Star hub reaches only one spoke. The chosen topology is largely a no-op.

Suggested fix: Loop over all targets in the non-Random arm, cloning emigrants into each; keep Random single-pick.

Resolution: fixed — migrate() now broadcasts each source island's emigrants to EVERY target the topology defines (FullyConnected reaches all peers, a Star hub reaches all spokes) instead of only targets.first(); Random still picks a single target.

Regression tests: test_fully_connected_broadcasts_to_all_targets.

Re-verification: verified (independent adversarial verifier).

EV-12 — Island model evolution is non-deterministic even with a seeded RNG

  • Location: fugue-evo/src/algorithms/island.rs:406
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

IslandModel::new seeds each island initial population deterministically from the master RNG (line 357 StdRng::from_seed(rng.gen())), but step() creates the per-island working RNG with StdRng::from_entropy() (line 406) inside par_iter_mut, and migrate() selects emigrants with from_entropy() too (line 454). So selection, crossover, mutation, and random-emigrant choice all draw from OS entropy, and a seeded run produces a different trajectory and global_best every time. The rng passed to run/step has essentially no effect on search dynamics. For a library whose own tests seed with seed_from_u64(42), this silently defeats reproducibility.

Suggested fix: Draw one child seed per island from the master rng before the parallel section, zip into par_iter_mut, and build each island_rng via from_seed(seed); likewise for migrate emigrant selection.

Verifier correction: Core claim confirmed: per-island evolution RNG (line 406) and emigrant-selection RNG (line 454) are built with StdRng::from_entropy(), so selection/crossover/mutation and random-emigrant choice draw from OS entropy, making seeded runs non-reproducible (init at line 357 is correctly seeded from the master rng). Correction to one detail: the master rng passed to run/step is NOT entirely without effect — it still drives migration target routing (migrate line 468, rng.gen_range) and accept_immigrants (lines 469/474); it merely has no effect on the per-island search operators or emigrant selection.

Resolution: fixed-with-design-change — IslandModel now stores one persistent StdRng per island, seeded once from the master RNG via seed_from_u64(master.gen()) at construction, and reuses it across generations for both per-island evolution (zipped into par_iter_mut) and emigrant selection. Seeded runs are now bit-reproducible even with parallel evaluation. Used StdRng (a ChaCha-based CSPRNG already in scope) rather than adding a rand_chacha dependency, since editing Cargo.toml is out of my ownership; the reproducibility guarantee is identical.

Regression tests: test_island_model_reproducible_under_seed.

Re-verification: verified (independent adversarial verifier).

EV-13 — Crowding distance is recomputed over the entire mixed-rank population instead of per non-dominated front

  • Location: fugue-evo/src/algorithms/nsga2.rs:426
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

Deb et al. 2002's crowding-distance-assignment operator is defined WITHIN a single non-dominated front: it sorts the members of one front by each objective and, for interior member i, adds (I[i+1].m - I[i-1].m)/(f_m^max - f_m^min) where the neighbors and the min/max are taken from that front only (paper Section III-B, the crowding-distance-assignment pseudocode). Here, after environmental selection builds new_pop, the code calls calculate_crowding_distance(&mut new_pop, &all_indices) over ALL survivors regardless of rank (step() lines 424-426; step_bounded() lines 557-558; run() init lines 445-446; run_bounded() init lines 576-577). Because all_indices mixes ranks 0..k, sorting by an objective interleaves individuals of different fronts, so a member's 'neighbors' (and the min/max range) are frequently from a different front, corrupting every crowding value. These corrupted values are exactly what the crowded-comparison operator (line 244) reads during binary tournament PARENT selection in the following generation (tournament_select line 322), so the diversity bias of parent selection is wrong every generation, and the crowding_distance field returned to callers on the final population (used e.g. by get_pareto_front consumers) is wrong. Concrete trace (2 objectives, minimize): survivors A(1,4) B(2,3) C(3,2) D(4,1) at rank 0 and E(3,3) at rank 1. Correct per-front crowding: B=C=4/3, A=D=inf, E=inf (singleton front). The code's whole-population computation yields B=C=1.0, A=D=inf, and E=0.667 (finite) — E, a boundary of its own front, is wrongly given a finite, small value, and the rank-0 interior values are shrunk. (Verified numerically.) Note the mitigation: the environmental-selection truncation at line 406 DOES use correct per-front crowding, so Pareto convergence is largely preserved; the defect degrades the tournament diversity pressure and the public crowding output rather than corrupting survival directly.

Suggested fix: After the fill loop, group new_pop by rank (ranks are already assigned by the sort) and call calculate_crowding_distance once per rank-group, instead of once over all_indices. Same fix for step_bounded, run, and run_bounded. This also removes the redundant recompute that currently discards the per-front crowding computed at line 406.

Verifier correction: Finding is accurate as written, including the numeric trace and the mitigation note. One severity nuance: because environmental selection (survival) at nsga2.rs:406/539 uses correct per-front crowding, Pareto convergence and the SET of survivors are not corrupted. The defect only (a) degrades diversity pressure in binary-tournament PARENT selection each generation and (b) makes the public crowding_distance field on the returned population wrong. That is a genuine correctness bug in a core NSGA-II mechanism, but it does not corrupt the algorithm's primary output (the Pareto front membership) — hence medium rather than high.

Verifier correction: Confirmed as stated. One severity nuance: the bug does NOT corrupt survival/environmental selection (line 406 uses correct per-front crowding, and the whole-pop recompute at 424-426 runs after survival and does not feed back into it), so Pareto convergence is preserved. The actual damage is limited to (1) degraded within-rank diversity pressure during binary-tournament parent selection each generation, and (2) an incorrect public crowding_distance field on the returned population (boundary members of non-first fronts wrongly get finite values; interior first-front members are shrunk relative to true per-front values). Because the primary optimization output (the converged Pareto set and its survival) is intact and only diversity bias plus reported metadata are affected, medium is a more accurate severity than high, though the deviation from the published NSGA-II algorithm is genuine and every-generation. Suggested fix (group new_pop by rank and call calculate_crowding_distance once per rank-group) is correct and also eliminates the redundant recompute discarding the per-front values computed at line 406.

Resolution: fixed — Added recompute_crowding_distance_per_front(), which groups survivors by their already-assigned rank and computes crowding distance once per non-dominated front (Deb 2002). Replaced the four whole-population calculate_crowding_distance(&mut pop, &all_indices) call sites in step/step_bounded/run/run_bounded, so tournament parent selection and the returned crowding_distance field are per-front correct.

Regression tests: algorithms::nsga2::tests::test_crowding_distance_per_front.

Re-verification: verified (independent adversarial verifier).

EV-14 — evolutionary_rhat mis-normalizes chain mean/variance when chains have unequal length

  • Location: fugue-evo/src/diagnostics/convergence.rs:363
  • Severity: high · Dimension: math · Verification: confirmed · Auditor confidence: n/a

evolutionary_rhat sets n = the MINIMUM chain length (line 356) evidently intending to align chains of unequal length, but then computes chain_means as runs.iter().map(|r| r.iter().sum::() / n) (line 363) — this sums over the FULL (possibly longer) chain r but divides by the shorter n, rather than truncating r to its first n elements first. The same bug repeats in the within-chain variance at lines 375-376 (mean = r.iter().sum::()/n; then r.iter().map(|x|(x-mean).powi(2)).sum::()/(n-1.0), again summing over all of r but dividing by n-1). The standard Gelman & Rubin (1992) split-R-hat requires equal-length chains, computed as chain_mean_j = (1/n)*sum_{i=1..n} x_ij using only the first n draws of each chain; the code violates this whenever chain lengths differ. Verified numerically: for chain1 (len 10) and chain2 (len 11, similar trend), the buggy formula gives R-hat=1.298 vs the correctly-truncated computation's R-hat=0.949 — a difference that flips the standard 1.1 convergence threshold. This directly affects ConvergenceDetector::compute_rhat (lines 294-307), which splits mean_fitness_history into chain1=history[..n/2] and chain2=history[n/2..]; whenever the total history length is odd (roughly half of all generation counts once len>=10), chain2 is one element longer than chain1, triggering the bug on every R-hat check.

Suggested fix: Truncate each chain to its first n elements before summing, e.g. let r_trunc = &r[..n]; and use r_trunc for both the mean and the sum-of-squares in both the between- and within-chain variance computations.

Verifier correction: The mismatch (sum over full chain r, divide by min-length n) at lines 363 and 375-376, combined with the odd-length split in compute_rhat, is confirmed. However the impact is overstated: the specific numeric example (R-hat 1.298 vs 0.949) could not be reproduced, and for realistic smooth fitness histories the discrepancy is a small O(1/n) perturbation that rarely crosses the 1.1 convergence threshold, so it does not 'flip the threshold on every check'. Severity is better characterized as medium (a real but low-magnitude correctness bug in a convergence diagnostic) rather than high.

Resolution: fixed — evolutionary_rhat now truncates every chain to the common minimum length (n_len) before computing chain means and within-chain variances, instead of summing over the full chain while dividing by the shorter n.

Regression tests: test_evolutionary_rhat_truncates_unequal_chains.

Re-verification: verified (independent adversarial verifier).

EV-15 — DixonPrice::optimal_solution() computes the wrong optimum (off-by-one in exponent)

  • Location: fugue-evo/src/fitness/benchmarks.rs:670
  • Severity: high · Dimension: math · Verification: confirmed · Auditor confidence: n/a

The canonical Dixon-Price global minimum (1-based index i = 1..d) is x_i = 2^{-(2^i - 2)/2^i}, giving f(x*) = 0. The code's own comment states this formula, but the implementation computes exp_num = (1u64<<i) as f64 - 2.0 while exp_den = (1u64<<(i+1)) as f64 — i.e. it mixes a 0-based numerator exponent with a 1-based-shifted denominator exponent, so the value returned is neither the 0-based nor 1-based version of the textbook formula. Numerically verified with python3: evaluating DixonPrice::evaluate_raw at the code's claimed optimum gives f=0.858 (d=2), 1.373 (d=3), 1.858 (d=4), 2.368 (d=5) — not 0. Using the corrected formula (exp_num = (1u64<<(i+1)) as f64 - 2.0, matching x_i=2^{-(2^i-2)/2^i} for 1-based i=i_code+1) gives f≈1e-31 (i.e. 0) at every tested dimension. evaluate_raw() itself is correct (it matches the textbook Dixon-Price sum); only the reported 'optimal_solution()' metadata is wrong, which silently corrupts any test/example that validates an optimizer's convergence against this benchmark's declared optimum.

Suggested fix: Fix the numerator to use the 1-based exponent consistently with the denominator: exp_num = (1u64 << (i + 1)) as f64 - 2.0; (keep exp_den = (1u64 << (i + 1)) as f64 unchanged), which reproduces x_i = 2^{-(2^i-2)/2^i} for i=1..d and yields f(x*)=0.

Resolution: fixed — DixonPrice::optimal_solution() now uses the canonical 1-based exponent consistently (x_i = 2^{-(2^{i+1}-2)/2^{i+1}} for 0-based i), so f(optimal_solution()) is ~0 at every dimension instead of >0.85.

Regression tests: test_dixonprice_optimal_solution_is_optimum.

Re-verification: verified (independent adversarial verifier).

EV-16 — EvolutionarySMC uses absolute target values as importance weights instead of incremental ratios, so it is not a valid SMC sampler on the tempered posterior

  • Location: fugue-evo/src/fugue_integration/evolution_model.rs:411
  • Severity: high · Dimension: math · Verification: confirmed · Auditor confidence: certain

In run() the per-iteration reweight (lines 410-413) OVERWRITES each particle's weight with the absolute log-target log w = f(x)/T_i, discarding the prior weight, then normalizes/resamples/moves. For an annealed SMC sampler (Del Moral, Doucet & Jasra 2006, 'Sequential Monte Carlo Samplers', JRSS-B; equivalently Neal 2001 Annealed Importance Sampling), with target sequence gamma_t(x) ∝ prior(x)·exp(f(x)/T_t) and a pi_t-invariant MCMC move K_t, the correct incremental importance weight is w_t = gamma_t(x_{t-1})/gamma_{t-1}(x_{t-1}) = exp( f(x_{t-1})·(1/T_t − 1/T_{t-1}) ), accumulated as W_t ∝ W_{t-1}·w_t and reset to uniform only immediately after a resample. The code instead sets log W_t = f(x)/T_i: it omits the −f/T_{t-1} correction and discards W_{t-1}. This is only correct at i=0, where particles are exact uniform prior draws (there gamma_0/prior = exp(f/T_0)). For i>=1 the particles entering the reweight are ≈pi_{T_{i-1}}-distributed (they were just moved by K at T_{i-1}), so the required proposal-density correction (the −f/T_{i-1} term) is missing. Consequently the resampling weights are systematically over-concentrated by a factor exp(f(x)/T_{i-1}) and the algorithm does NOT have the tempered posterior as its target/stationary object; the ESS-triggered resampling and posterior_mean are computed against biased weights. It still 'works' as an optimization heuristic (it does bias toward high fitness) but it is not the principled SMC the docs claim.

Suggested fix: Track weights incrementally: log_w += f(x)·(1/T_i − 1/T_{i-1}) using the pre-move state, and reset to uniform only right after a resample; keep a running log-normalizer if a marginal-likelihood/Z estimate is wanted. Or reframe it in prose as a temperature-annealed resampling heuristic, not an SMC sampler.

Verifier correction: The finding is accurate. One reinforcement: the bug is not only the missing −f/T_{i-1} correction term but ALSO the unconditional overwrite (line 412 uses =, not +=), which discards accumulated weight even on iterations where no resample fires (ESS ≥ N/2). Thus both required behaviors of an SMC sampler are violated: (a) incremental ratio γ_t/γ_{t-1} instead of absolute γ_t, and (b) accumulation across non-resampling steps. The suggested fix (track log_w += f(x)(1/T_i − 1/T_{i-1}) from the pre-move state, reset to uniform only right after resample) is correct; alternatively, relabel it as a temperature-annealed resampling heuristic rather than an SMC sampler.

Resolution: fixed — Rewrote EvolutionarySMC as a valid tempered SMC sampler: incremental importance weights w_t = exp((beta_t - beta_{t-1})·f(x)) computed from the pre-move state and accumulated across non-resampling steps (log_weight += dbeta·fitness, no absolute overwrite), self-normalised via log-sum-exp, ESS-triggered systematic resampling with weights reset to uniform, and pi_beta-invariant MH mutation + joint MH crossover rejuvenation over a beta ladder 0→1. Regression test test_smc_matches_gaussian_conjugate_posterior asserts the seeded beta=1 weighted mean/variance match the analytic Gaussian conjugate posterior (mean 2.4, var 0.8) within MC tolerance.

Regression tests: test_smc_matches_gaussian_conjugate_posterior, test_smc_basic_normalized, e_integration_smc_targets_conjugate_posterior.

Re-verification: verified (independent adversarial verifier).

EV-17 — The two crates are loosely stapled: fugue-evo's real value is implemented entirely without fugue

  • Location: fugue-evo/src/fugue_integration/evolution_model.rs:4
  • Severity: high · Dimension: usefulness · Verification: judgment · Auditor confidence: certain

The 'evolution as Bayesian inference over solution spaces' / 'deep Fugue integration' positioning (README.md, SPEC.md executive summary, lib.rs core concepts) substantially overstates the actual coupling. Concrete evidence: (1) grep of src/algorithms/ (simple_ga, cmaes, nsga2, island, eda, evolution_strategy, steady_state) finds ZERO fugue references — every headline algorithm is pure standalone EC. (2) src/operators/ has NO Trace usage at all — SBX, polynomial mutation, tournament selection operate directly on genome vectors. (3) src/hyperparameter/ (the flagship 'Bayesian learning of operators' differentiator) uses standalone conjugate-prior math with NO fugue dependency. (4) evolution_model.rs:1-23 claims it 'integrates with Fugue's inference engine (SMC, MCMC)' and 'wraps an evolutionary genome in Fugue's Model monad', but grep confirms it calls none of fugue's inference functions (adaptive_mcmc_chain, adaptive_smc, optimize_meanfield_vi, abc_*) and never constructs a fugue::Model — EvolutionModel is a bespoke struct that hand-rolls its own weighting loops. fugue is consumed only as a HashMap-like key/value container: to_trace/from_trace round-trip genomes through fugue::Trace, and that round-trip is used ONLY inside the optional fugue_integration/ module and its own tests (trace_operators.rs, effect_handlers.rs). A plain HashMap<String, f64> or serde map would serve identically. The SPEC's fugue-integrated trait method generate<R>(model: &Model<Self>, rng) (SPEC.md ~line 116) was never implemented; the real trait is G::generate(rng, &bounds) with no Model. A staff engineer evaluating the 'ecosystem' story should treat fugue-evo as a standalone EC library, not a genuine PPL-powered one.

Suggested fix: Either make the integration load-bearing (route at least one flagship path — e.g. UMDA/EDA or the Bayesian hyperparameter learner — through fugue's real SMC/MCMC/VI so the 'Bayesian inference' claim delivers capability a plain GA lacks), or honestly reframe fugue-evo as a broad standalone EC library that optionally interops with fugue traces, and remove the 'deep integration'/'evolution as Bayesian inference' framing from README/SPEC.

Resolution: fixed — fugue_integration now performs genuine Bayesian inference: EvolutionModel builds real fugue Models via PriorHandler-driven sample() sites with accumulated log_prior, and injects fitness through a real factor() call run under a Handler, exercised by examples/bayesian_evolution.rs (tempered SMC posterior checked against the analytic conjugate truth). The fixup corrected the crate-level framing in lib.rs, README.md, and SPEC.md so 'evolution as Bayesian inference' is explicitly scoped to fugue_integration, while the default algorithms (SimpleGA, CMA-ES, NSGA-II, Island, ES, EDA/UMDA) are documented as standalone EC using fugue::Trace only as a data container, not for inference.

Regression tests: e_integration_smc_targets_conjugate_posterior, e_integration_bayesian_ga_learns_and_optimises.

Re-verification: verified (independent adversarial verifier).

EV-18 — The entire fugue_integration layer is unused by every algorithm, example, and test outside itself — the PPL framing is ornamental, not load-bearing

  • Location: fugue-evo/src/fugue_integration/mod.rs:13
  • Severity: high · Dimension: usefulness · Verification: judgment · Auditor confidence: certain

grep across the crate shows the only references to EvolutionModel, EvolutionStep, EvolutionarySMC, HBGA, mutate_trace/crossover_traces, and the handler types outside src/fugue_integration/ are (a) the pub mod fugue_integration; line and prelude re-export in lib.rs, and (b) a doc comment. No algorithm (simple_ga, cmaes, nsga2, evolution_strategy, island, steady_state, eda) references any of it. The SPEC's promised algorithms/smc_evolution.rs and algorithms/hbga.rs do not exist; the checkpoint AlgorithmState::Hbga is unrelated dead state. Crucially, fugue-evo never constructs a fugue Model<T>, never calls sample/observe/factor or prob!, and never invokes fugue's own MCMC/SMC/VI inference engines — grep for those returns nothing. fugue's Trace/ChoiceValue are used only as a plain address->value container. The flagship path examples actually run (SimpleGA with TournamentSelection + SBX + PolynomialMutation, per lib.rs quick-start) is an ordinary GA with zero probabilistic-programming content. So the project's central claim ('evolution is Bayesian inference over solution spaces', 'deep Fugue integration enables novel probabilistic operators') is not realized in any reachable-and-exercised code path: the module is a self-contained, prelude-exported island with only its own unit tests. Verdict on the framing: ornamental. The one place the exp(f/T)<->conditioning correspondence is genuinely realized (BoltzmannSelection, softmax of f/T) lives entirely outside this module in operators/selection.rs and involves no fugue types.

Suggested fix: Either wire the trace operators/handlers into at least one real algorithm and example (e.g. a Boltzmann-selection GA expressed as SMC over the tempered posterior, using fugue's Model + inference), or drop the 'Bayesian inference'/'deep Fugue integration' claims from README/SPEC/lib.rs and describe the crate honestly as a conventional GA library that happens to use fugue::Trace as a data structure.

Resolution: fixed — The layer is now exercised outside itself by a flagship example and an integration test that run the SMC/MH/factor pipeline via the public prelude API. Genuine fugue::Handler implementations (TraceScoringHandler, RecordingHandler) and fugue Model/factor calls replace the previous HashMap-only usage. mod.rs docs rewritten from ornamental 'deep integration' prose to a precise statement of the probabilistic objects computed.

Regression tests: e_integration_weighted_trace_is_boltzmann_weight, e_integration_mh_stays_in_bounds.

Re-verification: verified (independent adversarial verifier).

EV-19 — Permutation::distance() reports 0.0 ("identical") for permutations of mismatched length

  • Location: fugue-evo/src/genome/permutation.rs:252
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

EvolutionaryGenome::distance() is implemented as self.kendall_tau_distance(other).unwrap_or(0) as f64. kendall_tau_distance() (line 159) correctly returns Err(GenomeError::DimensionMismatch) when self.perm.len() != other.perm.len(), but distance() swallows that Err via unwrap_or(0), silently converting a genuine mismatch into the SAME value (0.0) that means 'perfectly identical' for equal-length identical permutations. Concrete failing input: p1 = Permutation::identity(3) = [0,1,2], p2 = Permutation::identity(5) = [0,1,2,3,4]; p1.distance(&p2) == 0.0, exactly as if p1==p2, even though the permutations are of totally different sizes and cannot even be meaningfully compared. Any diversity-preservation, niching, or nearest-neighbor operator that trusts distance()==0.0 to mean 'duplicate/identical' will silently treat differently-sized permutations as clones. Verified the underlying kendall_tau_distance/inversions math is itself correct (see strengths) — this bug is purely in the unwrap_or(0) fallback of distance().

Suggested fix: Change EvolutionaryGenome::distance() for Permutation to panic, or better, change the trait's distance() signature to return Result, or at minimum return f64::INFINITY / f64::NAN on length mismatch instead of 0.0 so callers can't mistake it for equality.

Verifier correction: Finding is accurate as written. Minor severity note: within a normal GA run all individuals share a fixed permutation length (dimension is set by the problem instance), so mismatched-length comparisons rarely arise in practice, making this a latent/defensive-correctness bug rather than one that fires on typical inputs — hence medium rather than high. The bug and its concrete failing input are otherwise exactly as described.

Resolution: fixed — Removed the unwrap_or(0) in Permutation::distance; distance now panics on a length mismatch (via kendall_tau_distance's DimensionMismatch) instead of silently reporting 0.0 ('identical'). Added try_distance returning Result for fallible callers.

Regression tests: genome::permutation::tests::test_permutation_try_distance_length_mismatch, genome::permutation::tests::test_permutation_distance_length_mismatch_panics.

Re-verification: verified (independent adversarial verifier).

EV-20 — RealVector::distance() silently truncates to the shorter length on dimension mismatch instead of erroring

  • Location: fugue-evo/src/genome/real_vector.rs:173
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

distance() is self.genes.iter().zip(other.genes.iter()).map(|(a,b)| (a-b).powi(2)).sum().sqrt() with NO length check at all, unlike add()/sub() in the very same file (lines 72-105) which explicitly validate lengths and return Err(GenomeError::DimensionMismatch). Rust's Iterator::zip stops at the shorter of the two iterators, so any trailing elements of the longer vector are silently ignored. Concrete failing input: v1 = RealVector::new(vec![0.0, 0.0]), v2 = RealVector::new(vec![0.0, 0.0, 1_000_000.0]); v1.distance(&v2) == 0.0 (sqrt of 0), falsely reporting the vectors as identical despite v2 having an enormous extra component. This is the same silent-zero-distance footgun class as the Permutation bug above, but here it isn't even routed through a Result — there is no dimension check whatsoever.

Suggested fix: Add the same length check used in add()/sub() and either return Result<f64, GenomeError> from a checked variant, or panic/return f64::INFINITY on mismatch so downstream consumers can't mistake truncated distance for true similarity.

Verifier correction: Finding is accurate as stated. One clarification on severity: unlike add()/sub() which are natural public arithmetic helpers, distance() is the EvolutionaryGenome trait method and in normal GA operation is called on genomes drawn from the same bounds/population, so they share dimension — the silent-truncation path requires a caller mixing genomes of different dimensions, which the library's own generate()/bounds machinery does not do. The defect is real and the fix (add the same length guard) is warranted, but the practical exploitability is lower than add()/sub() mismatches, so medium rather than high is more appropriate.

Resolution: fixed — RealVector::distance now length-checks and panics on mismatch (was a bare zip that silently truncated to the shorter length and returned 0.0). Added try_distance returning DimensionMismatch.

Regression tests: genome::real_vector::tests::test_real_vector_try_distance_dimension_mismatch, genome::real_vector::tests::test_real_vector_distance_dimension_mismatch_panics.

Re-verification: verified (independent adversarial verifier).

EV-21 — Entire Bayesian learner, adaptive-control, and schedule machinery is unused scaffolding — never wired into any algorithm

  • Location: fugue-evo/src/hyperparameter/bayesian.rs:368
  • Severity: high · Dimension: completeness · Verification: judgment · Auditor confidence: certain

Grepping the whole crate for call sites: BayesianHyperparameterLearner, HyperparameterPosteriors, OperatorParams, BetaPosterior, GammaPosterior, LogNormalPosterior appear ONLY inside bayesian.rs plus the standalone example examples/hyperparameter_learning.rs — no algorithm (SimpleGA, CMA-ES, NSGA-II, Island, ES) constructs or consumes them. Similarly every type in adaptive.rs (OneFifthRule, AdaptiveOperatorSelection, AdaptiveMutationRate, DiversityBasedAdaptation, SlidingWindowStats) has zero references outside adaptive.rs, and every schedule in schedules.rs (ParameterSchedule, LinearAnnealing, CosineAnnealing, ExponentialDecay, PolynomialDecay, CyclicalSchedule, CompositeSchedule, DynamicSchedule) has zero references outside schedules.rs. Only self_adaptive.rs is actually integrated (Evolution Strategy). So the library's headline 'Bayesian Learning: online hyperparameter adaptation using conjugate priors' (lib.rs:34) and its adaptive-control offering do not feed back into any optimizer — they are exercised solely by unit tests and one hand-rolled example that reimplements its own GA loop. Combined with findings above (the Bayesian operator model is not valid inference and the Gamma model inverts its target), the headline claim is substantially unsupported: the one place a posterior IS sampled to drive a GA (the example) is mathematically incoherent.

Suggested fix: Either wire these into a real algorithm (e.g. an adaptive-operator-selection or Thompson-sampling GA that samples OperatorParams each generation and calls observe with per-value credit) or downgrade the marketing/doc claims to reflect that only self-adaptive ES is integrated. If kept as a toolkit, document them as building blocks, not as an active learning loop.

Resolution: fixed-with-design-change — The broken Bayesian operator-parameter learner (BayesianHyperparameterLearner/OperatorParams) was replaced with a Thompson-sampling bandit (ThompsonSamplingTuner/BanditParameter), wired into SimpleGA via SimpleGABuilder::adaptive_operators and SimpleGA::run_adaptive, sampling mutation-rate/crossover-probability arms per generation from improvement-over-parent outcomes. The fixup covers the finding's remaining scaffolding: schedules.rs and adaptive.rs (OneFifthRule, AdaptiveOperatorSelection, etc.) are still unconsumed by any built-in algorithm, so module docs and a hyperparameter/mod.rs status section now label them as unintegrated building blocks, matching the accepted Beta/Gamma treatment.

Regression tests: test_run_adaptive_feeds_tuner, test_thompson_tuner_from_config, test_bandit_parameter_thompson_selects_a_value, adaptive_ga_feeds_the_tuner, adaptive_ga_explores_and_stays_consistent.

Re-verification: verified (independent adversarial verifier).

EV-22 — Gamma-exponential posterior inverts the quantity it is supposed to learn (returns ≈ reciprocal of observed values)

  • Location: fugue-evo/src/hyperparameter/bayesian.rs:144
  • Severity: high · Dimension: math · Verification: confirmed · Auditor confidence: certain

GammaPosterior is documented as the posterior for a positive rate parameter (temperature, sbx_eta, pm_eta) with a 'conjugate update for exponential likelihood' (line 144). The update shape += 1; rate += value (lines 146-147) IS the correct Gamma(α,β)→Gamma(α+n, β+Σx_i) update for the RATE λ of an Exponential(λ) likelihood with data x_i. The bug is what plays the role of x_i and what is returned. BayesianHyperparameterLearner::observe feeds the hyperparameter VALUE itself as the exponential datum (temperature.observe(params.temperature), line 415; likewise sbx_eta line 416, pm_eta line 417). For Exponential data with mean m the MLE/posterior rate is λ ≈ 1/m. So mean() = shape/rate (line 152) converges to 1/E[value], and sample() (lines 170-173) draws λ and returns it as the new parameter — i.e. the RECIPROCAL of the values that were observed. Verified numerically: observing value=20 repeatedly drives the posterior mean to 0.0550 (n=10) → 0.0500 (n=1000), i.e. 1/20, not 20; observing value=5 gives mean 0.20 = 1/5. Consequences in OperatorParams: default sbx_eta/pm_eta = 20 (lines 322-323); successful trials feed 20, the posterior/MAP for eta converges to ~0.05, and sample_from/map_estimate then clamp with .max(1.0) (lines 333-334, 357-363) — so the 'learned' distribution index is pinned at the clamp floor 1.0 regardless of evidence, the opposite of the desired ~20. Temperature (default 1.0) happens to look plausible only because 1.0 is its own reciprocal. This is not a coherent conjugate model: the parameter value is not a sample from a likelihood indexed by that same parameter.

Suggested fix: If the intent is to model the parameter's own mean, put a Gamma prior on the exponential MEAN (Inverse-Gamma on the rate, or model 1/value), or return 1/λ (posterior mean of the exponential mean is β/(α-1)). More fundamentally, as with the Beta case, feeding the used parameter value as a likelihood observation is not inference about which value is good — reconsider the model.

Resolution: fixed — GammaPosterior keeps mean() = shape/rate as the posterior mean of the exponential rate (unchanged semantics) and gains posterior_mean_of_mean() = rate/(shape-1) (the Inverse-Gamma posterior mean of the underlying value, returning None when shape <= 1), documented as the quantity to use when the caller wants the average of observed positive values rather than the rate; the old code path that fed hyperparameter values (e.g. sbx_eta=20) into an exponential likelihood and returned the rate as the 'learned' parameter was removed along with OperatorParams::sample_from/map_estimate.

Regression tests: test_gamma_rate_and_mean_of_mean, test_gamma_recovers_mean_not_reciprocal.

Re-verification: verified (independent adversarial verifier).

EV-23 — The 'Bayesian' operator-parameter learner is not a valid Bayesian model — a P(improvement) success counter is mislabeled and sampled as the mutation rate itself

  • Location: fugue-evo/src/hyperparameter/bayesian.rs:405
  • Severity: high · Dimension: math · Verification: confirmed · Auditor confidence: certain

The headline claim is 'online hyperparameter adaptation using conjugate priors.' The Beta-Bernoulli PRIMITIVE (BetaPosterior, lines 42-58: Beta(a+s,b+f)) is textbook-correct. But the way BayesianHyperparameterLearner::observe (lines 405-430) uses it is not a Bayesian model of the hyperparameter: (1) Category error / wrong likelihood: the Beta posterior stored in posteriors.mutation_rate is updated with success = (child_fitness - parent_fitness) > 0 (line 406-410). That posterior therefore models θ = P(offspring improves on parent). It is then SAMPLED and returned AS the mutation rate (OperatorParams::sample_from, line 331; and the shipped example examples/hyperparameter_learning.rs:52,91 does exactly this: current_mutation_rate = mutation_posterior.sample(rng) then GaussianMutation::with_probability(current_mutation_rate)). P(improvement) and the per-gene mutation probability are different quantities with no functional identity; equating them has no Bayesian justification. Worse, as the search converges improvements become rare, so the posterior mean → 0 and the 'learned' mutation rate is driven toward 0 precisely when more exploration is typically wanted. (2) The likelihood ignores the parameter it claims to learn: the mutation rate actually used (params.mutation_rate) never enters the update at all — every observation updates the same single global Beta regardless of which rate produced it. There is thus no mechanism to infer WHICH rate is good; it is a global success-frequency estimator, i.e. heuristic bookkeeping, not inference over the parameter. (3) Broken credit assignment / duplicated posteriors: lines 410-411 feed the identical success boolean to BOTH mutation_rate and crossover_prob, so with equal priors those two posteriors are provably always identical — crossover probability and mutation rate can never be learned to differ. Moreover a single offspring is produced by crossover AND mutation jointly, so attributing one joint success independently to each operator's parameter is not a valid decomposition. This should be described plainly as heuristic reinforcement dressed as Bayes for these parameters, not conjugate inference of the hyperparameters.

Suggested fix: To make it genuinely Bayesian either (a) maintain a separate posterior per discretized parameter value / per operator arm (a proper Thompson-sampling bandit over the parameter space, as AdaptiveOperatorSelection already sketches), so the update conditions on the value used; or (b) drop the 'conjugate prior for the hyperparameter' framing. At minimum, stop feeding one success signal to two distinct posteriors and stop sampling a P(improvement) posterior as if it were the mutation rate.

Resolution: fixed — BayesianHyperparameterLearner::observe (which fed one shared improvement boolean into both the mutation_rate and crossover_prob Beta posteriors, then sampled the resulting P(improvement) posterior directly as the mutation rate) was removed and replaced by BanditParameter/ThompsonSamplingTuner: each tunable parameter is discretized into arms with its own value and its own Beta posterior over P(improve | this arm), Thompson selection draws from each arm's posterior only to choose which arm to pull, and BanditParameter::select returns the arm's concrete value (never the Beta draw) as the operator parameter; mutation_rate and crossover_prob now hold independent arm sets so they can diverge.

Regression tests: test_bandit_concentrates_on_better_arm, test_thompson_tuner_parameters_are_independent, test_tunable_mutation_sets_probability, test_bandit_parameter_thompson_selects_a_value.

Re-verification: verified (independent adversarial verifier).

EV-24 — Self-adaptation learning rates τ and τ' are swapped between the shared and per-coordinate normals (default ES path)

  • Location: fugue-evo/src/hyperparameter/self_adaptive.rs:67
  • Severity: high · Dimension: math · Verification: confirmed · Auditor confidence: certain

Canonical uncorrelated mutation with n step sizes (Schwefel; Bäck; Eiben & Smith): σ_i' = σ_i·exp(τ'·N(0,1) + τ·N_i(0,1)), where the SINGLE shared normal N(0,1) (drawn once per individual) has the overall coefficient τ' = 1/√(2n), and the PER-COORDINATE normal N_i (drawn afresh per i) has τ = 1/√(2√n). The shared factor gets the SMALLER coefficient (its effect is coherent across all n coords) and the per-coordinate factor the LARGER one. In the code, n0 is sampled once outside the loop (line 69) — it is the shared normal — and ni is per-coordinate (line 78). But the update is *sigma *= (tau_prime * n0 + tau * ni).exp() with tau = 1/√(2n) (line 67) and tau_prime = 1/√(2√n) (line 68). So the shared normal n0 is multiplied by 1/√(2√n) and the per-coordinate ni by 1/√(2n) — exactly the reverse of canonical. Numerically (n=10): code applies 0.3976 to the shared draw and 0.2236 per coordinate; canonical is 0.2236 shared, 0.3976 per coordinate. This over-weights the global step-scaling and under-weights per-coordinate scale learning — defeating the purpose of the n-step-size scheme. This is the DEFAULT path: ESConfig default has self_adaptive=true and the run loop constructs AdaptiveGenome::new_non_isotropic (evolution_strategy.rs:380/687), which hits NonIsotropic at line 76-82.

Suggested fix: Swap the coefficients so the shared normal carries 1/√(2n) and the per-coordinate normal carries 1/√(2√n): e.g. multiply n0 by 1/√(2n) and ni by 1/√(2√n). Fix the doc comment at lines 64-65 to match, and update the test at lines 338-341 which currently just checks the two numeric values without checking which normal each multiplies.

Resolution: fixed — Same code change as EV-05 (same lines in self_adaptive.rs): the shared once-per-individual normal now carries tau_prime = 1/sqrt(2n) and the per-coordinate normal carries tau = 1/sqrt(2*sqrt(n)); LearningRates::for_dimension was also fixed so tau_prime < tau for the shared/per-coordinate split.

Regression tests: test_non_isotropic_learning_rate_assignment, test_learning_rates.

Re-verification: verified (independent adversarial verifier).

EV-25 — Newton-Raphson covariance inverts the singular Fisher information with ridge damping, inflating every variance by ~1/(n·reg)

  • Location: fugue-evo/src/interactive/bradley_terry.rs:400
  • Severity: high · Dimension: math · Verification: confirmed · Auditor confidence: certain

The BT log-likelihood in θ=log π is invariant under θ→θ+c·1, so the Fisher information M=−H is singular with null vector u=1/√n·(1,…,1) (M·1=0). The code forms final_hessian = H, subtracts regularization from each diagonal (lines 401-403), then sets covariance = (−final_hessian)^{-1} = (M + reg·I)^{-1} (line 406). Spectrally, (M+reg·I)^{-1} has eigenvalue 1/reg along u, so each diagonal variance = Σ_k u_{k,i}²/(λ_k+reg) ⊇ (1/n)/reg. I verified numerically on balanced cyclic data (π=1 for all, n=3, reg=1e-6): the code's covariance diagonal = 333333.8, exactly 1/(n·reg)=333333.3, whereas the correct reference-constrained variance (invert the (n−1)×(n−1) submatrix after fixing θ_0=0, equivalently the Moore-Penrose pseudo-inverse of M) is 1.333. That is a 250,000× inflation, dominated entirely by the arbitrary reg constant rather than the data, so reported uncertainty barely shrinks as comparisons accumulate. It also degrades selection_strategy: pairwise_entropy computes z=(μ_a−μ_b)/sqrt(var_a+var_b) with var3e5, giving z≈0, p≈0.5, and near-maximal entropy for every pair, so ExpectedInformationGain becomes ~uniform. Masked in the live loop only because finding #1 means the covariance is never stored, but wrong for any direct user of the public BradleyTerryModel API (exposed in prelude, doc example calls estimate.std_error()).

Suggested fix: Do not ridge-invert. Fix a reference parameter (θ_ref=0) and invert the (n−1)×(n−1) Fisher submatrix, or use M's pseudo-inverse (projecting out the all-ones null space). Return that as the θ-space covariance.

Verifier correction: The mechanism and inflation are confirmed, but two numeric values in the finding are slightly off (immaterial to the conclusion). My exact reproduction of the code path (balanced cyclic n=3, pi=1, reg=1e-6) gives code covariance diagonal = 333334.22 (finding said 333333.8), matching 1/(n*reg)=333333.33 plus a small data term. The "correct" reference variance is not 1.333: fixing theta_0=0 and inverting the 2x2 Fisher submatrix gives 2.667, while the Moore-Penrose pseudo-inverse diagonal gives 0.889. Every valid identifiability convention yields an O(1) variance, so the ~250,000x inflation and the claim that reported uncertainty is governed by reg rather than the data both hold.

Verifier correction: All core claims confirmed. One minor numeric correction: for the exact balanced 3-cycle I reproduced, the code's covariance diagonal is 333334.22 (auditor said 333333.8 — same magnitude, matching 1/(n·reg)=333333.33), and the correct reference-constrained free-parameter variance is 2.667 (auditor's quoted "1.333" is not what a fixed-reference or pseudo-inverse gives for this dataset — pseudo-inverse diagonal is 0.889). The exact "correct" reference number is data/parametrization dependent and immaterial: the defect — inversion of the singular Fisher information with ridge damping yielding ~1/(n·reg) ≈ 3.3e5 variances dominated by the arbitrary reg constant, a ~10^5× inflation — is confirmed, as is its propagation to the public get_estimate/std_error API (lines 157–159, doc example line 33) and to pairwise_entropy-based selection.

Resolution: fixed — Replaced the ridge-inverted (M+regI)^-1 covariance (which put a spurious 1/(nreg) variance along the all-ones null direction) with the Moore-Penrose pseudo-inverse of the pure likelihood Fisher information M on the sum-to-zero subspace, then delta-mapped to the strength scale. For a balanced 3-candidate round-robin the reported diagonal equals the analytic constrained-Fisher inverse (4/9) within 1e-6.

Regression tests: bradley_terry::tests::test_constrained_fisher_covariance_matches_analytic.

Re-verification: verified (independent adversarial verifier).

EV-26 — evaluation_count is double-incremented every rating evaluation because session update_fitness* methods secretly call record_evaluation() and algorithm.rs also calls it explicitly

  • Location: fugue-evo/src/interactive/session.rs:263
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

session.rs update_fitness (lines 251-256) and update_fitness_with_uncertainty (lines 259-264) each call candidate.record_evaluation() as a hidden side effect, incrementing evaluation_count. In algorithm.rs process_evaluation (lines 480-500), the driver first loops over updated ids calling update_fitness_with_uncertainty/update_fitness (increment #1), then in a SECOND loop (lines 491-499) calls candidate.record_evaluation() again for every id in pending_request_ids (increment #2). For Rating mode every presented candidate is both in updated and pending_request_ids, so evaluation_count grows by 2 per real evaluation. For Pairwise mode updated = only the winner (algorithm.rs:464), so the winner is counted twice and the loser once — both wrong and inconsistent. This corrupts everything downstream that reads evaluation_count: coverage_stats() (avg/min/max/coverage), the CoverageAware min_evaluations gate (reached in half the intended evaluations, selection_strategy.rs:508/218), coverage_bonus = 1/(count+1) (selection_strategy.rs:315), and exploration_bonus/(count+1) (selection_strategy.rs:525). Note the aggregator's own counts (rating_count, comparisons) are separate and correct, so the point estimates are fine but coverage/selection accounting is not.

Suggested fix: Give the session methods a single clear responsibility: either make update_fitness/update_fitness_with_uncertainty NOT call record_evaluation() (leave counting to the caller's explicit loop, matching sync_fitness_estimates which correctly does not increment), or remove the explicit record_evaluation() loop in algorithm.rs:495-499. Add a test asserting evaluation_count == number of times a candidate was actually shown.

Verifier correction: Substance is exact; only two cosmetic labels differ. (a) The enclosing method is provide_response (algorithm.rs:420), not process_evaluation. (b) The first update loop begins at line 481 (response match starts at 446), not 480; the explicit second loop is 495-499. One nuance: loop #1's increment is conditional on the aggregator returning an estimate or fitness for the id — on the normal post-process_response path it does, yielding the double-count; only if the aggregator had neither would loop #1 skip, degrading to a single count. This does not weaken the finding for the normal path.

Resolution: fixed-by-removal — Removed the hidden candidate.record_evaluation() side effect from InteractiveSession::update_fitness and update_fitness_with_uncertainty, making them pure setters. The explicit pending_request_ids loop in algorithm.rs::provide_response is now the single owner of evaluation_count, so each presented candidate is counted exactly once per response.

Regression tests: tests/e-interactive_audit.rs::ev26_rating_counts_exactly_once.

Re-verification: verified (independent adversarial verifier).

EV-27 — SubtreeMutation can silently violate the genome's max_depth bloat-control invariant

  • Location: fugue-evo/src/operators/mutation.rs:877
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

SubtreeMutation::mutate (mutation.rs:862-886) computes remaining_depth = genome.max_depth.saturating_sub(position.len()) and generates a replacement subtree via TreeGenome::generate_grow(rng, subtree_depth, ...), then replaces the subtree unconditionally — there is no check afterward that genome.root.depth() <= genome.max_depth. This is inconsistent with SubtreeCrossover, which explicitly checks child depth and falls back to cloning the parents if the limit is exceeded (crossover.rs:1078-1087). Worse, generate_grow itself has an off-by-one: TreeGenome::generate_grow_node(rng, max_depth, current_depth, ...) only emits a terminal when current_depth >= max_depth (tree.rs:469), so a function node created at current_depth = max_depth-1 has a terminal child at depth max_depth, giving the produced subtree an actual TreeNode::depth() of max_depth+1 (this exact off-by-one is acknowledged in the crate's own test comment: 'Grow can create trees up to max_depth + 1 levels', tree.rs:869-870). Combining the two: SubtreeMutation can produce offspring whose depth exceeds genome.max_depth by up to 1 with no rejection, silently defeating bloat control for the mutation operator (while crossover correctly enforces it).

Suggested fix: After replace_subtree, check genome.root.depth() against genome.max_depth and reject/retry (mirroring SubtreeCrossover's fallback), and/or fix generate_grow_node's off-by-one so the produced depth budget is respected exactly.

Verifier correction: The finding is correct. One refinement to the magnitude: the auditor states the overrun is 'up to 1'. Because of the .max(1) floor on mutation.rs:878, subtree_depth is forced to at least 1 even when remaining_depth == 0. On a tree that is already at depth max_depth+1 (produced by a prior mutation), selecting the deepest leaf gives L = max_depth, remaining_depth = 0, subtree_depth = 1, and generate_grow(_, 1, _) can still produce a depth-2 subtree, yielding total depth max_depth+2. Thus the violation is not strictly bounded to +1 per the invariant; it can compound across repeated mutations, which strengthens rather than weakens the finding. Severity 'high' is reasonable given this compounding and the direct inconsistency with the enforced crossover guard.

Resolution: fixed — SubtreeMutation now sizes the replacement subtree from a real depth budget: budget = max_depth - position.len(), and because TreeGenome::generate_grow(m) can emit depth m+1, it asks for budget-1 so the subtree depth is <= budget, guaranteeing point_depth + subtree.depth() <= max_depth. A terminal-fallback guard (mirroring SubtreeCrossover) covers any residual overrun. tree.rs's generate_grow off-by-one could not be touched (outside ownership), so the +1 is compensated in the operator.

Regression tests: operators::mutation::tests::test_subtree_mutation_respects_max_depth (fuzz 500 mutations, empirically confirmed to fail on reverted pre-fix logic).

Re-verification: verified (independent adversarial verifier).

EV-28 — Fitness cache (Individual.fitness) is never invalidated when the genome is mutated

  • Location: fugue-evo/src/population/individual.rs:100
  • Severity: high · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

genome_mut() (individual.rs:100-102) hands out &mut G without touching self.fitness, and both genome: G (line 23) and fitness: Option<F> (line 25) are pub fields, so external code can also do individual.genome = new_genome; directly and leave the old Some(fitness) in place. Population::evaluate()/evaluate_parallel() (population.rs:236-246, 315-326) only evaluate individuals where !individual.is_evaluated(), so once an individual's genome is changed through either path, its stale fitness (computed for the OLD genome) will be reused forever by evaluate(), and will silently poison best(), sort_by_fitness(), mean_fitness(), and any checkpoint saved from that population. I confirmed via grep that no in-tree algorithm currently calls genome_mut() or assigns .genome = directly (offspring are instead built via fresh Individual::new(...) / struct literals with fitness: None), so this is not exploited today, but it is a live, undocumented public-API footgun with zero compiler or runtime protection.

Suggested fix: Make genome_mut() clear self.fitness = None (or make genome mutation only possible through a method like set_genome(g) that resets the cache), and consider making the fields private with accessor methods that preserve the invariant.

Verifier correction: Everything as stated is accurate. Severity lowered from high to medium because no in-tree code path currently triggers the stale-fitness reuse (auditor confirms this): there is no present-day incorrect output, only a latent public-API footgun. It warrants a fix, but "high" is normally reserved for defects that actually produce wrong results on some current execution path, which this does not until a caller uses genome_mut()/direct field assignment on an already-evaluated individual.

Verifier correction: The finding is accurate as stated. One severity nuance: this is a purely latent footgun, not a bug in current behavior — no in-tree code path triggers it (offspring are always freshly constructed with fitness: None, and genome_mut() is only exercised in a unit test). Because there is zero incorrect behavior in the shipping library today and reproduction requires external callers to misuse the public API, this is better classified as medium (API-hygiene / correctness footgun) rather than high (which would imply an active incorrectness in the code as used). The recommended remediation is unchanged.

Resolution: fixed — genome_mut() now clears self.fitness (resets the evaluated flag) since a mutated genome invalidates the cached value; added set_genome() with the same invalidation. Documented that the still-public genome field bypasses invalidation on direct assignment and that callers should prefer these methods.

Regression tests: population::individual::tests::test_genome_mut_clears_cached_fitness, population::individual::tests::test_set_genome_clears_cached_fitness.

Re-verification: verified (independent adversarial verifier).

Severity: medium (45)

EV-29 — License metadata is inconsistent and fugue-evo ships no LICENSE files — a compliance blocker for corporate adoption

  • Location: fugue-evo/Cargo.toml:11
  • Severity: medium · Dimension: usability · Verification: judgment · Auditor confidence: certain

fugue-evo/Cargo.toml declares license = "MIT OR Apache-2.0" and README says dual-licensed, but ls fugue-evo/LICENSE* finds NO LICENSE-MIT or LICENSE-APACHE file in the repo — a dual-license claim with no license texts is unusable for legal review and violates the Apache-2.0 requirement to include the license. Separately, fugue's metadata is internally inconsistent: fugue/Cargo.toml says license = "MIT" (MIT-only) and README badge/section say MIT, but fugue/src/lib.rs:1-3 header states 'Licensed under the Apache License, Version 2.0 … or the MIT license, at your option.' A staff engineer running an OSS-compliance gate will bounce both crates until this is reconciled.

Suggested fix: Add LICENSE-MIT and LICENSE-APACHE files to fugue-evo and make Cargo license fields, README, and source headers agree in both crates (pick one scheme — likely dual MIT OR Apache-2.0 — and apply it uniformly).

Resolution: fixed — Set license = "MIT" (matching fugue-ppl) in both fugue-evo/Cargo.toml and crates/fugue-evo-wasm/Cargo.toml, replacing the unsupported "MIT OR Apache-2.0" claim. Added a root LICENSE file with the standard MIT text, copyright Alex Nodeland 2025-2026. Updated fugue-evo/README.md and crates/fugue-evo-wasm/README.md License sections to point at the new LICENSE file instead of the old dual-license claim.

Re-verification: verified (independent adversarial verifier).

EV-30 — fugue-evo depends on published fugue-ppl 0.1.0 from crates.io, not a path dependency to the sibling crate

  • Location: fugue-evo/Cargo.toml:26
  • Severity: medium · Dimension: completeness · Verification: judgment · Auditor confidence: n/a

fugue-evo/Cargo.toml:26 declares fugue-ppl = "0.1.0" with no path key. Cargo.lock confirms it resolves to source = "registry+https://github.com/rust-lang/crates.io-index" (fugue-evo/Cargo.lock, 'name = "fugue-ppl" / version = "0.1.0"'), and cargo check/cargo test output shows Checking fugue-ppl v0.1.0 with no local path notation, i.e. it is pulled from the registry even though the sibling crate fugue (package name fugue-ppl) lives one directory up in the same repository. This means local edits to fugue-ecosystem/fugue are invisible to fugue-evo builds/tests until fugue-ppl is republished to crates.io -- a real risk for a monorepo-style ecosystem where the two crates are meant to be developed together and where this audit was explicitly asked to check for exactly this inconsistency.

Suggested fix: Add a path = "../fugue" (with version = "0.1.0" retained for publishing) so local development picks up in-tree changes; keep the registry version for the published crates.io release via [patch]/path-only-in-dev pattern, or document explicitly why they are decoupled.

Resolution: fixed — The fugue-ppl dependency was flipped from a crates.io version pin to the co-developed sibling: fugue-ppl = { path = "../fugue", version = "0.1.0" } (commit 0386bd1), after the sibling completed its own audit remediation. One API adaptation was required (src/genome/composite.rs: Address::new()/as_str() for the new Arc-backed Address). README/CHANGELOG rewritten to describe the path dependency. Full suite green against the remediated fugue: 706 passed / 0 failed, and examples/bayesian_evolution.rs runs end-to-end posterior inference through the fugue layer.

Regression tests: e_integration_bayesian (now exercised against the sibling fugue).

Re-verification: verified (independent adversarial verifier).

EV-31 — fugue-evo-wasm's [profile.release] settings are silently ignored because it is a non-root workspace member

  • Location: fugue-evo/crates/fugue-evo-wasm/Cargo.toml:32
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

fugue-evo-wasm/Cargo.toml sets [profile.release] opt-level = "s" and lto = true (lines 32-34), intended to shrink/optimize the WASM binary. But fugue-evo/Cargo.toml declares a workspace with members [".", "crates/fugue-evo-wasm"], and every cargo invocation in this crate (cargo check --all-targets, cargo clippy --all-targets, cargo doc) emits: 'warning: profiles for the non root package will be ignored, specify profiles at the workspace root'. Cargo only honors [profile.*] from the workspace root manifest, so these size/LTO settings never actually apply to wasm-pack build unless invoked completely outside the workspace or the profile is duplicated at the workspace root -- the intended optimization is a no-op today.

Suggested fix: Move the [profile.release] block into the workspace-root fugue-evo/Cargo.toml (or restrict it to a [profile.release] cfg that wasm-pack's own manifest picks up if fugue-evo-wasm is built standalone), and remove the dead block from the member manifest.

Verifier correction: The [profile.release] block is at lines 36-38 of fugue-evo/crates/fugue-evo-wasm/Cargo.toml, not lines 32-34. Otherwise the finding stands as stated. The impact is best characterized as a build/bundle-size optimization no-op (larger, non-LTO WASM output) rather than a runtime-behavior correctness bug; the adjacent wasm-pack metadata wasm-opt = ["-Os"] still applies because it is read by wasm-pack itself, not cargo.

Resolution: fixed — Moved [profile.release] (opt-level = "s", lto = true) from crates/fugue-evo-wasm/Cargo.toml (a non-root workspace member, where cargo silently ignores it) to the workspace-root fugue-evo/Cargo.toml, where cargo actually honors it. Left a comment in the member manifest pointing developers at the root manifest.

Re-verification: verified (independent adversarial verifier).

EV-32 — Structured error conversion (evolution_error_to_js) is dead code — every real call site discards error-type information

  • Location: fugue-evo/crates/fugue-evo-wasm/src/error.rs:6
  • Severity: medium · Dimension: usability · Verification: judgment · Auditor confidence: n/a

error.rs defines evolution_error_to_js, which builds a {type, message} JS object so callers can branch on err.type (e.g. "ConfigError" vs "NumericalError"). Grepping the whole crate shows it is never called; every real error path in optimizers.rs/fitness.rs/interactive.rs/result.rs instead does .map_err(|e| JsValue::from_str(&e.to_string())), producing a bare JS string. This throws away the richer EvolutionError variant distinction that was clearly designed to be exposed, and forces JS consumers to pattern-match on error message text instead of a stable type field.

Suggested fix: Replace the JsValue::from_str(&e.to_string()) call sites in optimizers.rs (e.g. lines 245, 247, 291, 400, 404, 458, 462, 542, 751, 755, 973, 977, 1023, 1027, 1115, 1119, 1198) with evolution_error_to_js(e) for EvolutionError cases, or delete the unused function if the design intent changed.

Resolution: fixed — Replaced all 17 EvolutionError map_err sites (.map_err(|e| JsValue::from_str(&e.to_string()))?) across optimizers.rs with .map_err(evolution_error_to_js)?, making the previously-dead evolution_error_to_js function live so JS receives a structured {type, message} object it can branch on. serde_json error sites (no ?) were left as string errors. Imported evolution_error_to_js into optimizers.rs.

Regression tests: wasm_tests.rs::test_structured_error_has_type_field (browser: asserts err.type == "ConfigError" and a message field for a pop_size=0 build failure).

Re-verification: verified (independent adversarial verifier).

EV-33 — MultiObjectiveResult.evaluations is a fabricated formula, not the actual NSGA-II evaluation count

  • Location: fugue-evo/crates/fugue-evo-wasm/src/optimizers.rs:553
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: n/a

Nsga2Optimizer::optimize (and optimize_zdt at line 1209) construct the result with self.config.population_size * self.config.max_generations * 2 as the evaluations count. But Nsga2::run (fugue-evo/src/algorithms/nsga2.rs:432-453) evaluates the initial population once (population_size evals) and then, each generation, create_offspring/create_offspring_bounded (nsga2.rs:330-361, 470-511) generates exactly population_size offspring, evaluating each exactly once — there is no factor of 2 anywhere in the offspring-creation loop. The true evaluation count is population_size * (max_generations + 1), not population_size * max_generations * 2. For typical configs (e.g. pop=30, gens=20 as used in the crate's own tests) the reported number (1200) overstates the true count (630) by roughly 90%, and the relative error grows as max_generations shrinks. Nsga2::run doesn't even return an evaluation count, so this value is synthesized rather than measured.

Suggested fix: Either have Nsga2::run return an actual evaluation counter (mirroring SimpleGA's result.evaluations) and thread it through, or compute it correctly as population_size * (max_generations + 1) and document that it's derived rather than measured.

Resolution: fixed — Added a CountingMoFitness decorator (AtomicUsize-backed, so it stays Send+Sync when the inner fitness is) that wraps the multi-objective fitness and counts real evaluate() calls. Threaded fitness.evaluations() into MultiObjectiveResult for both Nsga2Optimizer::optimize and run_zdt, and deleted the fabricated population_sizemax_generations2 formula. Verified empirically the real count equals population_size*(max_generations+1) (84 vs the old 144 for pop=12,gens=6). nsga2.rs itself was not modified (not owned); the count is measured at the wasm layer via the decorator.

Regression tests: e_wasm_regressions.rs::ev33_nsga2_reports_real_evaluation_count (host; asserts ==84 and !=144), wasm_tests.rs::test_nsga2_real_evaluation_count (browser).

Re-verification: verified (independent adversarial verifier).

EV-34 — No incremental/step API for the main GA algorithms — long optimize() calls fully block, even inside a Worker

  • Location: fugue-evo/crates/fugue-evo-wasm/src/optimizers.rs:193
  • Severity: medium · Dimension: completeness · Verification: judgment · Auditor confidence: n/a

RealVectorOptimizer::optimize, BitStringOptimizer::optimize, PermutationOptimizer::optimize, Nsga2Optimizer::optimize, EvolutionStrategyOptimizer::optimize, UmdaOptimizer::optimize, and SymbolicRegressionOptimizer::optimize* all synchronously run the entire generation loop to completion inside one Rust call with no way to yield or report progress; InteractiveOptimizer is the only type with a step() API (interactive.rs:426). The wasm-bindgen-futures dependency (Cargo.toml:21) is never used anywhere in the crate (confirmed via grep — no async fn, no Promise), so there is no async/Promise-based entry point either. The README documents running these calls inside a Web Worker to keep the main UI thread responsive, which is a valid mitigation for freezing the page, but even inside a worker there's no way for JS to receive incremental progress (e.g. per-generation postMessage for a progress bar / cancel button) since the call is one opaque synchronous block until it returns.

Suggested fix: Either expose a step_generation()/run_n_generations() method on the deterministic optimizers (mirroring InteractiveOptimizer's step model) so JS can drive the loop and report progress/allow cancellation, or add an optional progress callback (&js_sys::Function) invoked per generation.

Resolution: fixed — SimpleGA exposes a native incremental stepping API (init_run/step_generation/finish_run over SimpleGaRun), first surfaced to WASM only for RealVector via SteppedRealOptimizer. The fixup extended progress/cancel callbacks to every remaining WASM optimizer: BitString/Permutation/Nsga2Optimizer gained optimizeWithProgress (driven by SimpleGA's step API or Nsga2::step), SymbolicRegressionOptimizer gained optimizeCustomWithProgress, and EvolutionStrategy/UmdaOptimizer gained optimizeWithProgress backed by new native run_with_callback hooks. Returning false from the callback cancels the run, so every optimizer can now report progress or be cancelled from a Web Worker.

Regression tests: simple_ga.rs::test_step_api_matches_run, e_wasm_regressions.rs::ev34_stepped_optimizer_advances_and_reports_progress, e_wasm_regressions.rs::ev34_stepped_optimizer_finishes_and_matches_one_shot, e_wasm_regressions.rs::ev34_stepped_optimizer_supports_early_cancel, wasm_tests.rs::test_stepped_optimizer_*, eda/umda.rs::tests::test_umda_run_with_callback_reports_progress, eda/umda.rs::tests::test_umda_run_with_callback_cancels_early, evolution_strategy.rs::tests::test_es_run_with_callback_reports_progress, evolution_strategy.rs::tests::test_es_run_with_callback_cancels_early, wasm_tests.rs::test_bitstring_progress_runs_and_cancels, wasm_tests.rs::test_permutation_progress_cancels, wasm_tests.rs::test_nsga2_progress_cancels, wasm_tests.rs::test_es_progress_cancels, wasm_tests.rs::test_umda_progress_cancels, wasm_tests.rs::test_symbolic_regression_progress_cancels.

Re-verification: verified (independent adversarial verifier).

EV-35 — SimpleGABuilder exposes 7 generic type params with turbofish in the very first quickstart — high first-30-minute friction

  • Location: fugue-evo/examples/sphere_optimization.rs:30
  • Severity: medium · Dimension: usability · Verification: judgment · Auditor confidence: certain

The canonical quickstart (README.md, lib.rs doc, and every example) opens with SimpleGABuilder::<RealVector, f64, _, _, _, _, _>::new() — struct defined as SimpleGABuilder<G, F, S, C, M, Fit, Term> (simple_ga.rs:50), 7 type parameters. A new user copying this must keep the turbofish and the exact _ count/order; omitting it or reordering the operator/fitness calls yields the classic 'type annotations needed' / 'cannot infer type' wall of trait-bound errors that Rust newcomers can rarely decode. This is the single biggest stumbling block in the mental walk-through of the first 30 minutes: the happy path works only if pasted verbatim, and any deviation fails opaquely.

Suggested fix: Provide a task-focused constructor or preset (e.g. SimpleGA::real_vector(bounds).tournament(3).sbx(20.0).polynomial(20.0)) or a SimpleGABuilder::real_valued(fitness, bounds) shortcut that fixes G/F and infers the rest, so the first example needs no turbofish.

Resolution: fixed — SimpleGABuilder::real_valued()/bit_string()/permutation() pin the genome/fitness type parameters and pre-install default operators (tournament selection plus SBX+polynomial mutation, uniform crossover+bit-flip mutation, or OX crossover+swap mutation respectively), eliminating the 7-parameter turbofish; defaults stay overridable via .selection()/.crossover()/.mutation(). The fixup propagated real_valued() to every remaining turbofish site the original fix missed: README.md, lib.rs's quickstart doc, examples/island_model.rs, examples/rastrigin_benchmark.rs, and docs/quickstart.md/first-optimization.md, so no documented example still needs the turbofish form.

Regression tests: test_real_valued_constructor_no_turbofish, test_real_valued_constructor_override_operator, test_bit_string_constructor_no_turbofish, test_permutation_constructor_no_turbofish, ergonomic_constructor_quickstart_has_no_turbofish.

Re-verification: verified (independent adversarial verifier).

EV-36 — CMA-ES box-constraint handling repairs (clamps) samples before using them in the distribution update, biasing the mean and covariance

  • Location: fugue-evo/src/algorithms/cmaes.rs:581
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: likely

step() clamps offspring with genome.apply_bounds(bounds) (lines 581-585) BEFORE evaluation, and update() then reconstructs the steps as y_k=(x−m)/σ directly from the (clamped) genes for the weighted mean (lines 256-261), the rank-μ covariance update (lines 322-330), and hence p_c and the mean move. Feeding repaired points back into the distribution update is exactly the bias Hansen warns against: the sampled distribution N(m,σ²C) no longer matches the points used to adapt it, which systematically shrinks/shifts C and m near active bounds and can stall or distort convergence. The standard remedies are to evaluate a repaired/penalised point but update the distribution from the ORIGINAL unrepaired sample, or add a boundary penalty to the fitness while adapting on the true samples.

Suggested fix: Keep the original sampled y (pre-clamp) for the mean/covariance/path updates; apply clamping only to the copy passed to the fitness function, or switch to a penalty-based boundary handler as in Hansen's reference implementation.

Resolution: fixed — CmaEs::step now samples unrepaired offspring, evaluates fitness at a bound-repaired (feasible) copy of each sample (optionally adding a quadratic boundary_penalty weighted by squared distance to the repair, set via with_boundary_penalty/CmaEsBuilder::boundary_penalty), but calls state.update with the original unrepaired samples so the mean/covariance/evolution-path update is never adapted from clamped points; best-solution tracking was moved into step() and records the feasible point.

Regression tests: test_cmaes_update_uses_unrepaired_samples, test_cmaes_boundary_penalty_option.

Re-verification: verified (independent adversarial verifier).

EV-37 — Eigendecomposition recompute cadence is a factor of λ too infrequent versus Hansen/purecmaes

  • Location: fugue-evo/src/algorithms/cmaes.rs:353
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: certain

purecmaes.m recomputes the eigensystem when counteval - eigeneval > lambda/(c1+cmu)/N/10, where counteval counts EVALUATIONS (incremented by λ each generation). This code instead compares a GENERATION difference self.generation - self.eigen_eval against the same right-hand side lambda/(c1+cmu)/n/10 (lines 353-354). Since generations = counteval/λ, the correct generation-unit threshold is 1/(c1+cmu)/n/10 — i.e. the code's threshold is λ× too large. Computed values: n=10,λ=10 → code recomputes every 2 gens vs canonical ~every gen; n=30,λ=14 → every 6 gens vs ~every gen; n=100,λ=17 → every 20 gens vs ~every 1.2 gens. This makes B,D lag the maintained C much more than intended, degrading sampling and C^{-1/2} accuracy. (Note: this compounds with, but is independent of, the broken Jacobi routine above; it must be fixed too once the decomposition is corrected.)

Suggested fix: Divide the threshold by λ: compare self.generation - self.eigen_eval against (1.0/(c1+cmu)/n/10.0) (guarded to at least 1), or equivalently track an evaluation counter and keep the purecmaes form counteval - eigeneval > lambda/(c1+cmu)/N/10.

Resolution: fixed — The eigendecomposition recompute check was replaced with a new CmaEsState::eigen_update_interval() = max(1, floor(1/(10n(c1+cmu)))) generations, and update() now recomputes when generation - eigen_eval >= that interval, matching purecmaes's evaluation-scaled cadence converted to generation units (the previous code compared a generation-count difference directly against the lambda-scaled evaluation threshold).

Regression tests: test_eigen_recompute_cadence.

Re-verification: verified (independent adversarial verifier).

EV-38 — Unit test for the eigendecomposition only checks the trace, which is invariant even for the broken routine

  • Location: fugue-evo/src/algorithms/cmaes.rs:909
  • Severity: medium · Dimension: testing · Verification: judgment · Auditor confidence: certain

test_jacobi_eigendecomposition (lines 909-920) asserts only that Σ(eigenvalues) equals the trace (7.0). As shown in finding #1, the buggy routine preserves the trace by construction (the rotation increments to d[p] and d[q] are equal and opposite) while returning entirely wrong eigenvalues (e.g. [-3.589, 10.589] for [[4,1],[1,3]]). So this test passes on a decomposition that is completely incorrect and even non-positive-definite. The test gives false confidence in the single most math-critical routine in the crate.

Suggested fix: Assert the actual eigenvalues against known values (4.618, 2.382), that they are all positive, that eigenvectors are orthonormal, and that C·vᵢ = λᵢ·vᵢ and B·diag(D)·Bᵀ = C to tolerance.

Resolution: fixed — test_jacobi_eigendecomposition (which asserted only that the sum of returned eigenvalues equaled the trace) was replaced by test_eigendecomposition_known_matrix, which checks the actual eigenvalues of [[4,1],[1,3]] against the closed-form (7±sqrt(5))/2, asserts all eigenvalues are positive, and verifies Cv = lambdav for each eigenpair; a second new test checks positivity, eigenpair equations, and B*diag(lambda)*B^T reconstruction on random SPD matrices.

Regression tests: test_eigendecomposition_known_matrix, test_eigendecomposition_random_spd.

Re-verification: verified (independent adversarial verifier).

EV-39 — Post-sampling clamp biases probability mass onto box boundaries and shrinks the next-generation variance estimate

  • Location: fugue-evo/src/algorithms/eda/umda.rs:236
  • Severity: medium · Dimension: math · Verification: unverified · Auditor confidence: certain

sample() draws value = Normal(mean, sqrt(var)).sample() then value.clamp(bound.min, bound.max). Clamping (projection) is a known bias-inducing constraint handler: the entire tail beyond a bound is collapsed onto a single point at the boundary. I computed the piled mass: when the model mean sits exactly at a bound, 50% of samples land exactly on the boundary; at mean = bound - 0.5sigma it is 30.9%, at bound - 1sigma it is 15.9%. These clamped, spread-zero samples then enter the selected set and feed the next update() (lines 208-215), which computes sample variance from them and systematically UNDER-estimates spread. Direction confirmed by simulation of a boundary-located optimum: the model variance drives straight to the min_variance floor (0.01) as the mean pins to the boundary. Consequence: when the model wanders near a bound the variance collapses prematurely to min_variance, freezing exploration on that face of the box. This is the same bug class already CONFIRMED for CMA-ES box-constraints and SBX-bounded. Note the harm is milder than in CMA-ES because truncation selection partially compensates and mainline benchmarks here (Sphere, symmetric box, optimum at center) never trigger it; hence medium, not high.

Suggested fix: Use reflection or resampling (reject-and-redraw) at the bound instead of clamping, or compute the update from unclamped/pre-clamp values. At minimum document that box-boundary optima and near-boundary means bias the variance estimate.

Resolution: fixed — ContinuousUnivariateModel::sample() now draws each coordinate by rejection from the truncated Gaussian (retry up to MAX_REJECTION_RETRIES=100, then clamp as a fallback), so boundary atoms vanish for interior-optimum problems and the accepted pre-clamp values feed an unbiased next-generation variance estimate.

Regression tests: test_sample_rejection_avoids_boundary_pileup.

Re-verification: verified (independent adversarial verifier).

EV-40 — Default ES pairs (μ+λ) elitist selection with σ self-adaptation, which suppresses step-size adaptation

  • Location: fugue-evo/src/algorithms/evolution_strategy.rs:53
  • Severity: medium · Dimension: usefulness · Verification: judgment · Auditor confidence: likely

ESConfig::default() sets selection=MuPlusLambda and self_adaptive=true (lines 54-59). Classical ES theory (Schwefel; Beyer & Schwefel 2002) holds that mutative σ self-adaptation requires comma selection: with plus/elitist selection, an individual that happens to have an over-small σ can survive indefinitely on the merit of its object variables, so badly-scaled strategy parameters are never purged and σ tends to collapse, stalling adaptation. The canonical self-adaptive recommendation is (μ,λ) with λ/μ ≈ 7 (e.g. (15,100)). Pairing the elitist default with self-adaptation is a questionable default that will under-perform on the very problems self-adaptation targets.

Suggested fix: Make MuCommaLambda the default when self_adaptive is true (or at minimum document the interaction and warn), matching standard ES guidance.

Resolution: fixed-with-design-change — ESSelectionStrategy's #[default] attribute was moved from MuPlusLambda to MuCommaLambda, and ESConfig::default() now sets selection: ESSelectionStrategy::MuCommaLambda (self_adaptive remains true); MuPlusLambda remains available as an explicit option, and doc comments on both enum variants and ESConfig::default explain that elitist selection suppresses mutative sigma self-adaptation.

Regression tests: test_default_selection_is_comma.

Re-verification: verified (independent adversarial verifier).

EV-41 — Migration immigrants replace RANDOM members, potentially overwriting an island best

  • Location: fugue-evo/src/algorithms/island.rs:291
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

accept_immigrants for Best and Random policies replaces gen_range(0..len) individuals (290-296), which can overwrite the receiving island best with a worse migrant. Only BestReplaceWorst protects good members. global_best is tracked separately so the final result is safe, but per-island search is harmed by randomly discarding good material each migration interval.

Suggested fix: For Best/Random import, replace the island worst k (sort then overwrite the tail) instead of random indices, or make the import-replacement policy explicit.

Resolution: fixed — Island::accept_immigrants() now always replaces the island's WORST members (sort best-first, overwrite the tail) regardless of migration policy, so an island best is never overwritten. Dropped the now-unused policy/rng parameters and updated migrate().

Regression tests: test_immigrants_replace_worst_not_best.

Re-verification: verified (independent adversarial verifier).

EV-42 — Steady-state ReplaceRandom is silently elitist and never accepts a worse offspring

  • Location: fugue-evo/src/algorithms/steady_state.rs:497
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

After the victim index is chosen, the swap is gated by an is_better_than check (497-502). For ReplaceRandom the documented 'replace a randomly selected individual' contract is violated: the child replaces the random pick only when strictly better, turning an exploratory diversity-preserving operator into a monotone elitist one. The comment at 495-496 shows this was known but hardcoded.

Suggested fix: Make the accept-if-better guard part of the strategy enum, or skip it for ReplaceRandom so it behaves as documented.

Verifier correction: Confirmed as stated, with an added scope correction: the same accept-if-better guard defeats ReplaceRandom (and also TournamentWorst, though that is more defensible) in BOTH the sequential path (lines 497-502) and the parallel-evaluation path (lines 662-667). A fix must address both loops. Also note ReplaceParent is unaffected in practice (it already gates on being better than a parent and uses continue), but the extra unconditional guard still applies to its chosen index redundantly.

Resolution: fixed-with-design-change — Removed the blanket accept-if-better guard so ReplaceRandom/ReplaceWorst/TournamentWorst replace unconditionally (their documented meaning). Added a new elitist ReplaceIfBetter variant (replace worst only if strictly better) and a requires_improvement() gate; ReplaceParent keeps its own acceptance test. Documented all strategies. Fix applied via the shared place_offspring() used by both run() and run_bounded().

Regression tests: test_replace_random_accepts_worse_offspring.

Re-verification: verified (independent adversarial verifier).

EV-43 — Steady-state always generates and counts 2 offspring regardless of offspring_count

  • Location: fugue-evo/src/algorithms/steady_state.rs:464
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

run (464-474) and run_bounded (632-642) always build two children, evaluate both, and do evaluations += 2, then take(offspring_count). With offspring_count 1 the second child is evaluated (a real fitness call) then discarded, wasting work and inflating the eval counter so MaxEvaluations ends about 2x early. With offspring_count above 2 the value is silently capped at 2. Neither is signaled.

Suggested fix: Produce/evaluate only offspring_count children and increment evaluations by the number actually evaluated; validate offspring_count against children per crossover.

Resolution: fixed — Steady-state now generates and evaluates EXACTLY offspring_count children via generate_offspring()/generate_offspring_bounded() (looping crossover for counts >2, mutating/evaluating only children that will be used), and increments the evaluation counter by the number actually evaluated, so offspring_count=1 no longer wastes an evaluation or trips MaxEvaluations ~2x early.

Regression tests: test_offspring_count_one_evaluates_one_per_step.

Re-verification: verified (independent adversarial verifier).

EV-44 — Steady-state has no duplicate handling; population can converge to identical genomes

  • Location: fugue-evo/src/algorithms/steady_state.rs:473
  • Severity: medium · Dimension: completeness · Verification: judgment · Auditor confidence: likely

Offspring are inserted directly with no check for an identical existing genome. With ReplaceWorst plus the accept-if-better guard, a strong genome and its clones repeatedly displace the worst, filling the population with duplicates and collapsing diversity, a classic steady-state failure mode. The audit asked about duplicate handling; there is none in any strategy.

Suggested fix: Add an optional duplicate-avoidance policy (reject offspring equal to an existing member), or document that no de-duplication occurs.

Resolution: fixed — Added SteadyStateConfig.prevent_duplicates and a builder prevent_duplicates(bool) flag; place_offspring() rejects an offspring whose genome equals any existing population member (O(population_size) genome comparison, documented). run()/run_bounded() now require G: PartialEq (all built-in genomes satisfy it).

Regression tests: test_prevent_duplicates_rejects_existing_genome.

Re-verification: verified (independent adversarial verifier).

EV-45 — Checkpoint version compatibility is checked for binary formats but silently skipped for JSON, and VersionTooOld is dead code

  • Location: fugue-evo/src/checkpoint/recovery.rs:137
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

The Binary and CompressedBinary branches of load_checkpoint (recovery.rs:100-108, 114-120) read a version number from the raw header and reject it with CheckpointError::VersionMismatch if version > CHECKPOINT_VERSION. The JSON fallback branch (recovery.rs:137-144) never inspects the embedded checkpoint.version field at all - it just deserializes and returns, so a checkpoint saved by a newer/incompatible library version in JSON format is silently accepted with no version gate, unlike its binary siblings. Separately, CheckpointError::VersionTooOld (error.rs:88-89) is declared but never constructed anywhere in the codebase (confirmed by grep across checkpoint/ and interactive/), meaning there is no actual schema-migration path for old checkpoints - the only implemented check is 'too new', and there is no forward migration logic for CHECKPOINT_VERSION bumps at all (it has only ever been 1).

Suggested fix: Check checkpoint.version against CHECKPOINT_VERSION uniformly after deserializing in every format branch (including JSON), and either implement the VersionTooOld path or remove the unused variant; add an actual migration mechanism before CHECKPOINT_VERSION is ever bumped.

Resolution: fixed — Added a check_version() helper and MIN_SUPPORTED_CHECKPOINT_VERSION const, applied uniformly across JSON, binary, and compressed-binary load paths (including the previously-ungated JSON branch, which now validates the embedded checkpoint.version). VersionTooOld is now actually constructed/returned for schemas below the minimum (previously dead code).

Regression tests: src/checkpoint/recovery.rs::tests::test_json_version_gate_too_new, src/checkpoint/recovery.rs::tests::test_json_version_gate_too_old.

Re-verification: verified (independent adversarial verifier).

EV-46 — CheckpointManager is not restart-safe: its index counter always resets to 0, risking silent overwrite/shadowing of pre-crash checkpoints

  • Location: fugue-evo/src/checkpoint/recovery.rs:237
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

CheckpointManager::new() (recovery.rs:237-246) always sets current_index: 0, and there is no constructor/method that scans the checkpoint directory to resume the index sequence from existing files. A realistic resume workflow - load via load_latest() after a restart, then construct a fresh CheckpointManager and keep checkpointing as evolution continues - will start writing evolution_0000.ckpt, evolution_0001.ckpt, ... again from scratch. If the old run had already progressed past keep_n rotations (e.g. only evolution_0037..0039.ckpt survive on disk), the new session's low indices don't collide immediately, but load_latest() (recovery.rs:335-339) picks strictly by lexicographic filename order with no timestamp/generation tiebreak, so once the new session's index counter climbs back up to 37-39 it will overwrite those files with unrelated, much-lower-generation content; and before that point, a second crash would cause load_latest() to resolve to the still-present, stale, higher-numbered file from the pre-crash run instead of the genuinely newer low-numbered post-restart file.

Suggested fix: Add a CheckpointManager::resume(directory, base_name) that scans existing files to initialize current_index to (max existing index + 1), or embed a monotonic run/session id in the filename so files from different sessions never alias.

Verifier correction: Confirmed as stated, with one minor imprecision in the finding's overwrite framing. The clause "once the new session's index counter climbs back up to 37-39 it will overwrite those files with unrelated, much-lower-generation content" is scenario-dependent: if the new session correctly resumed from the loaded checkpoint (as the shipped example examples/checkpointing.rs does at line 183, iterating checkpoint.generation..200), then by the time its index counter reaches 37-39 its content is actually a HIGHER generation, not lower. The genuinely-harmful, always-true part of the defect is the stale-file SHADOWING in load_latest: after the index reset, lexicographically-larger leftover files from the prior run mask the genuinely-newer low-index files, so a post-restart crash recovers stale state. Also worth noting: the repo's own resume example (resume_from_checkpoint) does NOT reconstruct a CheckpointManager or continue checkpointing — it just loads and continues in-memory — so the exact triggering workflow is not demonstrated in-repo, but nothing in the API prevents it and it is the natural way to keep checkpointing after a resume.

Resolution: fixed — CheckpointManager::new now scans the directory (via scan_max_index/parse_checkpoint_index) and sets current_index to max-existing-index + 1, so a manager reconstructed after a restart continues the sequence instead of resetting to 0 and shadowing/overwriting pre-crash checkpoints. Exposed current_index() for observability.

Regression tests: src/checkpoint/recovery.rs::tests::test_manager_is_restart_safe.

Re-verification: verified (independent adversarial verifier).

EV-47 — No atomic checkpoint writes - a crash mid-save leaves a truncated/corrupted file

  • Location: fugue-evo/src/checkpoint/recovery.rs:45
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

save_checkpoint (recovery.rs:36-79) calls File::create(path) directly (which truncates the destination immediately) and serializes straight into the resulting BufWriter, with no write-to-temp-file-then-rename step. If the process is killed mid-write - precisely the failure mode checkpointing exists to survive - the checkpoint file is left truncated/corrupted. CheckpointManager partially mitigates this because it always writes to a new incrementing filename and load_latest() will skip a corrupted newest file and fall back to the next-older one on deserialize failure (recovery.rs:343-348, 'Try next if corrupted'), but any direct use of save_checkpoint against a fixed/repeated path (which the API fully allows and the standalone function's doc comment implies as a normal use) has zero protection and can destroy the only good copy on disk.

Suggested fix: Write to a temporary file in the same directory and rename() it into place atomically once the write+flush succeeds, both in save_checkpoint and inside CheckpointManager::save.

Resolution: fixed — save_checkpoint now serializes to a sibling .tmp file, flushes, fsyncs (File::sync_all) before rename, then fs::rename atomically into place; a failed write is cleaned up and leaves the destination untouched. Applies to both the free function and CheckpointManager::save (which calls it).

Regression tests: src/checkpoint/recovery.rs::tests::test_atomic_save_preserves_destination_on_failure, src/checkpoint/recovery.rs::tests::test_atomic_save_leaves_no_temp_file.

Re-verification: verified (independent adversarial verifier).

EV-48 — bincode deserialization of checkpoint files has no size/quota limit

  • Location: fugue-evo/src/checkpoint/recovery.rs:110
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

bincode::deserialize_from/deserialize (recovery.rs:110, 135) are invoked with bincode's default (unbounded) configuration on bytes read straight from a possibly-corrupted or truncated file - exactly the scenario load_checkpoint/load_latest exist to handle gracefully. bincode's own documentation warns that deserializing untrusted/corrupted input without a size limit is unsafe: a corrupted length-prefix on a Vec/String field (e.g. population: Vec<Individual<G>>) can cause an attempted allocation of an enormous amount of memory before any content validation occurs, which can abort the process rather than return a clean CheckpointError::Deserialization/Corrupted.

Suggested fix: Use bincode's DefaultOptions::new().with_limit(max_reasonable_bytes) (or bincode 2's Limit) when deserializing checkpoint files instead of the default unbounded config.

Verifier correction: bincode 1.3.3's top-level deserialize/deserialize_from use DefaultOptions with an Infinite (unbounded) size limit — confirmed. The unbounded-allocation risk applies to recovery.rs:110 (deserialize_from, IoReader): its fill_buffer does temp_buffer.resize(length) up-front for String/byte fields (e.g. metadata: HashMap<String,String>), so a corrupted length-prefix can force a huge allocation and an abort instead of a clean CheckpointError, defeating load_latest's fall-through recovery. However, recovery.rs:135 (deserialize on a slice via SliceReader) is NOT affected: SliceReader::get_byte_slice bounds-checks against the actual buffer and returns a clean EOF error. Also, the cited population: Vec<Individual<G>> is not the real vector — serde caps sequence preallocation via size_hint::cautious; the true over-allocation is on String-typed fields. Fix (with_limit) still applies to the line-110 path.

Resolution: fixed — Added DEFAULT_MAX_CHECKPOINT_BYTES (256 MiB) and load_checkpoint_with_limit(); load_checkpoint uses it. Reading now (a) rejects files larger than the limit up front, (b) rejects an out-of-range compressed length prefix, and (c) caps bincode field allocation via DefaultOptions::with_fixint_encoding().allow_trailing_bytes().with_limit() — kept byte-compatible with the free serialize functions (verified by existing round-trip tests). Oversized inputs return the new typed CheckpointError::TooLarge. CheckpointManager gained a max_bytes field and with_max_bytes() builder used by load_latest.

Regression tests: src/checkpoint/recovery.rs::tests::test_load_rejects_oversized_file, src/checkpoint/recovery.rs::tests::test_load_rejects_corrupt_length_prefix.

Re-verification: verified (independent adversarial verifier).

EV-49 — ConvergenceDetector::check() target-fitness test uses the (possibly non-monotonic) last per-generation value instead of the tracked running best

  • Location: fugue-evo/src/diagnostics/convergence.rs:253
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

ConvergenceDetector maintains best_fitness_overall as the authoritative running maximum (updated at line 223-226, exposed via best_fitness() at line 310-312), specifically to be robust to a caller supplying a possibly-fluctuating per-generation 'best_fitness' value. However the target-fitness check at line 253 reads self.best_fitness_history.last() — the raw value passed to the most recent update() call — rather than self.best_fitness_overall. Since best_fitness_history simply records whatever 'best_fitness' argument update() was called with each generation (line 218), and callers in this crate (e.g. SimpleGA with elitism disabled, or any algorithm whose per-generation population best is not monotonic) can legitimately pass a value lower than a previously-achieved best, check() can fail to report TargetReached in a generation where the current population's snapshot dipped below target even though best_fitness_overall (and the detector's own public best_fitness() getter) already shows the target was reached in an earlier generation. This is an internal inconsistency: the struct's own bookkeeping (best_fitness_overall) and its convergence check (best_fitness_history.last()) disagree about what 'best fitness' means.

Suggested fix: Use self.best_fitness_overall (or fold max over best_fitness_history) instead of best_fitness_history.last() in the target-fitness check, so target detection is consistent with the running-best semantics the struct otherwise maintains.

Verifier correction: The defect is real as stated. One correction: best_fitness_overall is not strictly the "running maximum" — it is a thresholded running best, updated only when best_fitness > best_fitness_overall + config.stagnation_threshold (line 223), so it can lag a true max by up to stagnation_threshold. This does not change the inconsistency with .last(). Severity lowered to low because no in-crate code path automatically wires ConvergenceDetector into an algorithm's generation loop (it is used standalone and in unit tests), and triggering the wrong behavior requires a caller to both supply a non-monotonic per-generation best and depend on the target-fitness convergence reason; typical elitist GAs supply monotonic bests, so last() == overall and no divergence occurs.

Resolution: fixed — ConvergenceDetector::check() target-fitness test now reads self.best_fitness_overall (the tracked running best, same value returned by best_fitness()) instead of best_fitness_history.last(), so a later per-generation dip below a target already reached does not un-converge the detector.

Regression tests: test_target_fitness_uses_running_best.

Re-verification: verified (independent adversarial verifier).

EV-50 — TerminationCriterion::Stagnation's configured threshold is never read (dead parameter)

  • Location: fugue-evo/src/diagnostics/convergence.rs:589
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

TerminationCriteria::stagnation(generations, threshold) (line 556-558) stores threshold in TerminationCriterion::Stagnation(usize, f64), implying it configures how stagnation is detected. But should_terminate's match arm at lines 589-591 (TerminationCriterion::Stagnation(gens, _threshold) => stagnation_generations >= *gens) explicitly discards the threshold (bound to _threshold), and to_reason's arm at line 649 (Self::Stagnation(gens, _) => ConvergenceReason::fitness_stagnation(*gens)) discards it again. The actual epsilon-based stagnation math never happens inside this type at all — should_terminate takes a pre-computed stagnation_generations: usize as a raw external parameter (line 577), so whatever threshold the caller used to compute that count is invisible to and independent of the value configured via the builder. A user calling .stagnation(50, 1e-9) reasonably expects 1e-9 to be the epsilon used, but it is silently ignored — it is dead data carried only for Debug/Clone purposes.

Suggested fix: Either remove the unused threshold field from the enum variant (and from the builder signature) to avoid implying it does something, or change should_terminate's signature to accept the raw fitness_history so this variant can compute its own stagnation count using its own threshold, consistent with detect_stagnation().

Resolution: fixed-with-design-change — TerminationCriteria::should_terminate now takes fitness_history: &[f64] in place of a pre-computed stagnation_generations count, and the Stagnation(gens, threshold) arm computes detect_stagnation(fitness_history, threshold) >= gens so the configured threshold is actually honored. Updated all in-crate call sites (its own unit tests).

Regression tests: test_stagnation_threshold_is_wired.

Re-verification: verified (independent adversarial verifier).

EV-51 — effect_handlers.rs does not implement fugue's Handler contract and emits traces with stale/zero log-probabilities despite claiming 'replay' and 'log-weight bookkeeping'

  • Location: fugue-evo/src/fugue_integration/effect_handlers.rs:57
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

The module header advertises 'Poutine-style handlers' and 'Replay of evolutionary traces', but the MutationHandler/CrossoverHandler/SelectionHandler traits are ad-hoc before/after callbacks unrelated to fugue's actual Handler trait (fugue/src/runtime/handler.rs:29: on_sample_f64/bool/u64/usize, on_observe_*, on_factor, finish -> Trace). None of these types implement fugue::Handler, so they cannot intercept a fugue Model or participate in replay/scoring — the correspondence to Poutine is name-only. Separately, the traces these operators produce carry incorrect probability bookkeeping: handled_mutate_trace (line 441) reinserts every address with the ORIGINAL choice.logp even for mutated values (stale log-prob of the new value under its distribution), while handled_crossover_traces (lines 524-525) and crossover_traces write logp 0.0 for all children; none of the operators ever set log_prior/log_likelihood/log_factors, so every produced trace has total_log_weight()==0. The operators are functionally fine as value-shufflers (from_trace only reads values) but the 'log-weight bookkeeping / valid probabilistic trace' claim is unmet.

Suggested fix: Recompute per-choice logp from the generating distribution when a site is resampled and accumulate into log_prior (true replay semantics), or drop the probabilistic-handler framing and document these as plain operation hooks. To genuinely be Poutine-style, implement fugue::Handler (on_sample_/on_observe_) and run via fugue::runtime::handler::run.

Resolution: fixed — Replaced the name-only 'Poutine handlers' with genuine fugue::Handler implementations: TraceScoringHandler (scores a fixed trace: on_sample→log_prior, on_observe→log_likelihood, on_factor→log_factors) and RecordingHandler (records sampled sites while delegating all bookkeeping to an inner handler). The ad-hoc before/after callback traits were honestly renamed to *Hook (operation hooks, explicitly documented as NOT fugue handlers). The stale-logp bug is fixed: hooked_mutate_trace no longer copies the pre-mutation value's logp onto resampled sites (writes neutral 0.0), and docs point callers to TraceScoringHandler / to_weighted_trace for probability mass.

Regression tests: test_recording_handler_preserves_bookkeeping, test_trace_scoring_handler_scores_prior, test_hooked_mutate_does_not_copy_stale_logp.

Re-verification: verified (independent adversarial verifier).

EV-52 — 'Fitness as likelihood' is never injected into the fugue Trace's probability mass — to_weighted_trace leaves total_log_weight() at 0 (and is dead code)

  • Location: fugue-evo/src/fugue_integration/evolution_model.rs:82
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

to_weighted_trace (77-89) is the one function that claims to encode fitness as log-probability in a fugue trace: it calls trace.insert_choice(addr!("fitness"), F64(log_weight), log_weight). But fugue's Trace::insert_choice (fugue/src/runtime/trace.rs:335, with an explicit doc note) ONLY writes the choices map; it does NOT touch the log_prior/log_likelihood/log_factors accumulators. Trace::total_log_weight() = log_prior+log_likelihood+log_factors (trace.rs:198), so the produced trace's total log-weight is 0.0 regardless of fitness — the fitness lives only as an inert choice value and an unused per-choice logp. Any fugue inference routine (which reads total_log_weight / the accumulators) would see an unweighted trace. This directly falsifies the SPEC's 'fitness defines a likelihood via observe/factor' story. Compounding it, to_weighted_trace has no callers anywhere in the crate (dead code). To actually condition, the code would need fugue's factor/observe (which route through on_factor and update log_factors), not insert_choice.

Suggested fix: If fitness-as-likelihood is intended, build the trace by running a fugue Model that emits factor(f(x)/T) (updating log_factors), and score with total_log_weight(); or set trace.log_likelihood directly. As written the function should be removed or documented as producing an unweighted trace.

Resolution: fixed — to_weighted_trace now runs the genuine fugue model factor(beta·f(x)) through TraceScoringHandler seeded with the genome's choices, so the returned trace's total_log_weight() == beta·f(x) lives in log_factors (not an inert fitness choice). Removed the old insert_choice hack. Regression test asserts total_log_weight and log_factors equal beta·f.

Regression tests: test_to_weighted_trace_carries_fitness_mass, test_trace_scoring_handler_injects_factor, e_integration_weighted_trace_is_boltzmann_weight.

Re-verification: verified (independent adversarial verifier).

EV-53 — HBGA is labeled 'Hierarchical Bayesian' but performs no Bayesian inference: no posterior update, and 'sample from prior' does not sample the stated Beta/Gamma

  • Location: fugue-evo/src/fugue_integration/evolution_model.rs:536
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: certain

The struct stores a Beta(alpha,beta) mutation-rate prior and a Gamma(shape,rate) sigma prior, but sample_mutation_rate (537-543) and sample_mutation_sigma (546-552) return prior_mean + uniform(-0.05,0.05) noise (self-documented 'Simple approximation: use mean with some noise') — that is not a draw from a Beta or Gamma, it collapses the prior to its mean. The 'adaptive hyperparameter update' (588-599) is a deterministic multiplicative rule: mutation_rate *= 1.05 if mean fitness improved else *= 0.95. There is no likelihood, no prior->posterior update, no conjugate update of (alpha,beta) despite the SPEC's BetaPosterior.update machinery; sigma is never updated at all. Selection is plain binary tournament (602-611), not Boltzmann conditioning. So none of the 'hierarchical', 'Bayesian', or 'inferred hyperparameters' claims hold — it is a fixed-heuristic GA with unused prior parameters. The 1.05/0.95 rule is also not the referenced Rechenberg 1/5 rule (which targets a 0.2 success rate over a window, not per-generation mean-improvement).

Suggested fix: Either implement a real conjugate/posterior update over the operator-success observations (the SPEC's BetaPosterior/GammaPosterior) and sample hyperparameters from those posteriors, or rename HBGA to something like AdaptiveGA and remove the Bayesian claims.

Resolution: fixed-with-design-change — Replaced HBGA (mean-collapsed priors + fixed 1.05/0.95 rule) with BayesianAdaptiveGA in a new bayesian_ga.rs: genuine conjugate Beta(alpha,beta) posteriors over each mutation operator's success probability (updated from observed improvement events), a conjugate Gamma-Poisson posterior over the improvement rate, and Thompson sampling of the operator each generation from the CURRENT posteriors. Renamed honestly (single-level, not 'hierarchical'). Local BetaSuccessPosterior/GammaRatePosterior avoid the buggy hyperparameter module and name-clashes with it.

Regression tests: test_beta_posterior_conjugate_update, test_beta_posterior_sampling_matches_beta_moments, test_gamma_posterior_conjugate_update, test_adaptive_ga_updates_posteriors_and_improves, test_thompson_prefers_better_operator, e_integration_bayesian_ga_learns_and_optimises.

Re-verification: verified (independent adversarial verifier).

EV-54 — gaussian_mutation is not Gaussian and does not achieve the requested sigma (uniform kernel with std ≈ 0.816·sigma)

  • Location: fugue-evo/src/fugue_integration/trace_operators.rs:345
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: certain

gaussian_mutation computes mutated = v + sigma·noise·sqrt(2) with noise ~ U(-1,1). U(-1,1) has variance 1/3, so the perturbation has std = sigma·sqrt(2)·sqrt(1/3) = sigma·sqrt(2/3) ≈ 0.8165·sigma (I verified numerically: empirical std 0.8167 for sigma=1). It is a bounded uniform perturbation on [v−1.414·sigma, v+1.414·sigma], not a Gaussian, and its std is neither sigma nor any clean multiple — the sqrt(2) 'Scale to approximate gaussian' factor is unjustified (a single uniform is a poor Gaussian approximation, and to hit std=sigma from U(-a,a) one needs a = sigma·sqrt(3), not sqrt(2)). bounded_mutation (line 365) shares the identical flaw. The analogous mutate_value in evolution_model.rs:189-191 uses U(-sigma,sigma) (std sigma/sqrt(3) ≈ 0.577·sigma). For the MH proposal this is harmless (symmetry is what matters), but for an operator named/documented as Gaussian with a sigma parameter it silently misparameterizes the mutation strength, undermining the SPEC's 'mathematical rigor' goal.

Suggested fix: Use rand_distr::Normal::new(0.0, sigma) for a true Gaussian, or if a uniform kernel is intended, rename it and use noise·sigma·sqrt(3) to match the stated std; apply the same fix to bounded_mutation.

Resolution: fixed — gaussian_mutation and bounded_mutation now use rand_distr::Normal::new(0.0, sigma) instead of a sqrt(2)-scaled U(-1,1), so the perturbation standard deviation is exactly sigma. The EvolutionStep MH proposal also uses a true Gaussian (fugue Normal). Statistical regression test confirms empirical std ≈ sigma (old kernel gave ≈0.816·sigma).

Regression tests: test_gaussian_mutation_achieves_sigma, test_bounded_mutation_respects_bounds.

Re-verification: verified (independent adversarial verifier).

EV-55 — BitString::hamming_distance()/distance() silently truncate to the shorter length on mismatched sizes

  • Location: fugue-evo/src/genome/bit_string.rs:109
  • Severity: medium · Dimension: correctness · Verification: unverified · Auditor confidence: n/a

hamming_distance() is self.bits.iter().zip(other.bits.iter()).filter(|(a,b)| a!=b).count(), again with no length check, unlike and()/or()/xor() in the same file (lines 118-169) which validate and return Err(GenomeError::DimensionMismatch). EvolutionaryGenome::distance() (line 226-228) forwards straight to hamming_distance() as f64. Concrete failing input: bs1 = BitString::new(vec![true, true]), bs2 = BitString::new(vec![true, true, true, true, true, true]) (4 extra set bits); bs1.distance(&bs2) == 0.0 because zip only compares the first 2 positions, both of which match — the extra 4 differing bits are never examined. Same bug class as the RealVector and Permutation distance findings; all three base genome types share this inconsistency between their validated combinator ops (add/sub, and/or/xor, compose) and their unvalidated distance ops.

Suggested fix: Add a length check to hamming_distance() (mirroring and()/or()/xor()) and have distance() propagate an error or a sentinel large value instead of silently truncating.

Resolution: fixed — BitString::hamming_distance now length-checks and panics on mismatch (added try_hamming_distance returning Result); EvolutionaryGenome::distance/try_distance forward to them. Previously both truncated to the shorter length and reported 0.

Regression tests: genome::bit_string::tests::test_bit_string_try_hamming_distance_mismatch, genome::bit_string::tests::test_bit_string_distance_mismatch_panics.

Re-verification: verified (independent adversarial verifier).

EV-56 — Bounds::normalize/denormalize divide by zero silently for degenerate (min==max) bounds

  • Location: fugue-evo/src/genome/bounds.rs:62
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: n/a

Bounds::range() = self.max - self.min (line 43); normalize(value) = (value - self.min) / self.range() (62-64); denormalize(value) = self.min + value * self.range() (67-69). Bounds::new only asserts min <= max (line 22-27), so min == max (a fixed/degenerate dimension, e.g. a parameter pinned to a constant) is a legal, constructible Bounds. For such bounds, range() == 0.0, so normalize(value) evaluates to 0.0/0.0 = NaN whenever value == min, or ±inf/NaN otherwise, propagating silently (f64 arithmetic never panics). Contrast with PolynomialMutation::mutate_gene in operators/mutation.rs:54-58, which explicitly guards if range <= 0.0 { return gene; } before doing the equivalent division — i.e. call sites that need the guard have to reimplement it themselves because Bounds itself provides no protection and no documentation warning for this input.

Suggested fix: Guard normalize/denormalize (return 0.5/self.center() or an explicit Option/Result) when range() <= 0.0, matching the defensive pattern already used in PolynomialMutation::mutate_gene.

Verifier correction: The divide-by-zero applies ONLY to Bounds::normalize, not denormalize. normalize(value) = (value - self.min) / self.range() divides by range()==0.0 for degenerate (min==max) bounds, yielding NaN (0.0/0.0 when value==min) or ±inf otherwise — silent because Rust f64 division follows IEEE-754 and never panics. denormalize(value) = self.min + value * self.range() does NOT divide; it multiplies by range()==0.0, producing self.min (a finite, valid value), so it is unaffected. Everything else in the finding is accurate: Bounds::new (bounds.rs:21-29) asserts only min<=max so degenerate bounds are constructible; PolynomialMutation::mutate_gene (mutation.rs:55-57) guards if range <= 0.0 { return gene; }, illustrating that call sites must reimplement the guard Bounds itself lacks. Suggested fix should target normalize specifically (return 0.5 / self.center() or Option/Result when range()<=0.0).

Resolution: fixed-with-design-change — Added Bounds::try_new(min,max) -> Result (rejects min>max, and NaN, allowing min==max); Bounds::new now delegates to it and panics on error. normalize() returns 0.5 for degenerate (range==0) bounds instead of dividing by zero (NaN/inf); denormalize() explicitly returns min. Design asked for a Result-returning constructor: I added try_new alongside the existing panicking new (mirroring the crate's Permutation new/try_new convention) rather than changing new's signature, which would have broken ~dozens of Bounds::new call sites in files owned by other work packages (nsga2.rs, examples, tests).

Regression tests: genome::bounds::tests::test_bounds_try_new_rejects_min_gt_max, genome::bounds::tests::test_bounds_degenerate_normalize_denormalize.

Re-verification: verified (independent adversarial verifier).

EV-57 — DynamicRealVector has no crossover or mutation operators anywhere in the crate

  • Location: fugue-evo/src/genome/dynamic_real_vector.rs:1
  • Severity: medium · Dimension: completeness · Verification: judgment · Auditor confidence: n/a

DynamicRealVector fully implements EvolutionaryGenome and RealValuedGenome, including push/pop/insert/remove and can_grow/can_shrink helpers clearly intended to be used by length-changing variation operators. However, grepping fugue-evo/src for "DynamicRealVector" only returns matches inside dynamic_real_vector.rs itself — none of operators/crossover.rs or operators/mutation.rs implement any CrossoverOperator or MutationOperator, and all real-valued operators (PolynomialMutation, GaussianMutation, UniformMutation, SbxCrossover, BlxAlphaCrossover, UniformCrossover, ArithmeticCrossover, etc.) are implemented concretely for RealVector, not generically over RealValuedGenome. So a user cannot actually evolve a population of DynamicRealVector genomes with any built-in operator today — the length-changing genome type is unreachable from the operator layer entirely, which also means the interaction between length-changing operations and fixed-length operators (the audit's specific concern) cannot even occur in practice; it's a dead/unfinished representation.

Suggested fix: Either implement at least one length-preserving mutation/crossover pair for DynamicRealVector (reusing the same math as RealVector) plus a grow/shrink mutation using push/pop/insert/remove, or make the fixed-length operators generic over RealValuedGenome so they work for both.

Resolution: fixed — Added length-aware variation operators for DynamicRealVector in a new src/genome/dynamic_ops.rs (not touching src/operators/): cut_and_splice crossover (recombines two variable-length parents into two children whose lengths are repaired into the shared [min,max] window) and DynamicGaussianMutation (per-gene Gaussian perturbation clamped to bounds, plus length-changing insert/delete gated by can_grow/can_shrink). Registered the module in genome/mod.rs and prelude. Property tests assert children/mutant lengths stay within [min,max] and values within bounds.

Regression tests: genome::dynamic_ops::tests::cut_and_splice_lengths_and_values_within_range, genome::dynamic_ops::tests::mutation_preserves_length_window_and_bounds, genome::dynamic_ops::tests::cut_and_splice_incompatible_constraints_errors, genome::dynamic_ops::tests::cut_and_splice_is_usable_for_evolution, genome::dynamic_ops::tests::mutation_can_grow_and_shrink.

Re-verification: verified (independent adversarial verifier).

EV-58 — DynamicRealVector::generate panics on an empty (0-dimension) MultiBounds

  • Location: fugue-evo/src/genome/dynamic_real_vector.rs:294
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

generate<R: Rng>(rng, bounds) sets let min_len = 1; let max_len = bounds.dimension(); (lines 296-297) then, since min_len != max_len is checked first, calls rng.gen_range(min_len..=max_len) (line 301) whenever min_len != max_len. If bounds.dimension() == 0 (a legally constructible MultiBounds::new(vec![])), this becomes rng.gen_range(1..=0), an inverted/empty range, which rand::Rng::gen_range panics on ("cannot sample empty range" / low > high assertion). No other genome's generate() has this failure mode for dimension 0: RealVector::generate and BitString::generate both degrade gracefully to an empty Vec via a 0-iteration map/collect.

Suggested fix: Special-case bounds.dimension() == 0 (return an empty/minimal genome or a GenomeError) before computing the gen_range call, or clamp max_len to be at least min_len.

Resolution: fixed — DynamicRealVector::generate no longer panics on empty (0-dimension) MultiBounds (previously gen_range(1..=0)); it degrades gracefully to an empty genome like RealVector/BitString. Added try_generate() -> Result that reports the degenerate case as an error, and generate delegates to it.

Regression tests: genome::dynamic_real_vector::tests::test_generate_empty_bounds_does_not_panic, genome::dynamic_real_vector::tests::test_try_generate_empty_bounds_errors.

Re-verification: verified (independent adversarial verifier).

EV-59 — from_trace() on RealVector/BitString/Permutation conflates 'address missing' with 'wrong type at address', silently truncating instead of raising the TypeMismatch error the crate already defines

  • Location: fugue-evo/src/genome/real_vector.rs:133
  • Severity: medium · Dimension: correctness · Verification: unverified · Auditor confidence: n/a

from_trace() reads sequentially via while let Some(val) = trace.get_f64(&addr!("gene", i)). Trace::get_f64 (fugue/src/runtime/trace.rs:203-205) returns None both when the address is absent AND when a choice exists at that address but is a different ChoiceValue variant (as_f64() only matches F64). These two very different situations are indistinguishable to the caller, so a wrong-typed choice mid-sequence is treated exactly like 'no more genes' and everything from that index onward is silently dropped with no error. Concrete failing input: trace.insert_choice(addr!("gene",0), ChoiceValue::F64(1.0), 0.0); trace.insert_choice(addr!("gene",1), ChoiceValue::Bool(true), 0.0) /* wrong type, e.g. from a buggy trace-mutation operator */; trace.insert_choice(addr!("gene",2), ChoiceValue::F64(3.0), 0.0); RealVector::from_trace(&trace) returns Ok(RealVector{genes:[1.0]}) — silently losing gene 2's value entirely, with no error surfaced. The exact same pattern exists in bit_string.rs:190-203 (get_bool) and permutation.rs:224-237 (get_usize); for Permutation it's worse because the truncated prefix can itself pass try_new()'s validity check (any permutation of 0..k is 'valid'), so a 3-element permutation silently becomes a 'valid' 1-element permutation with zero indication anything went wrong. Notably, GenomeError already defines MissingAddress and TypeMismatch variants precisely for this purpose (fugue-evo/src/error.rs:13-23), and the sibling wrapper types composite.rs and tree.rs (already audited) DO use GenomeError::MissingAddress in their from_trace paths — the infrastructure and precedent exist, but real_vector.rs/bit_string.rs/permutation.rs never construct MissingAddress or TypeMismatch at all (grep confirms zero occurrences in these three files).

Suggested fix: Use trace.get_f64_result()/get_bool_result()/get_usize_result() (which already exist in fugue::Trace and return FugueResult distinguishing 'not found' from 'type mismatch') to detect a present-but-wrong-typed choice and return GenomeError::TypeMismatch immediately instead of silently stopping the scan.

Resolution: fixed — RealVector/BitString/Permutation from_trace now iterate trace.choices directly: an absent address ends the scan (normal termination) while a present-but-wrong-typed choice returns GenomeError::TypeMismatch (with address/expected/actual) instead of silently truncating. Uses the crate's existing MissingAddress/TypeMismatch semantics.

Regression tests: genome::real_vector::tests::test_real_vector_from_trace_type_mismatch, genome::real_vector::tests::test_real_vector_from_trace_missing_stops_cleanly, genome::bit_string::tests::test_bit_string_from_trace_type_mismatch, genome::permutation::tests::test_permutation_from_trace_type_mismatch.

Re-verification: verified (independent adversarial verifier).

EV-60 — TreeNode has no protection against stack overflow on deep trees (recursive Drop, eval, depth/size)

  • Location: fugue-evo/src/genome/tree.rs:22
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

TreeNode<T,F> is defined as enum TreeNode { Terminal(T), Function(F, Vec<TreeNode<T,F>>) } (tree.rs:18-23) with no custom impl Drop. Rust's compiler-generated drop glue for this shape recurses one stack frame per level of tree depth (dropping a Vec drops each element, which for a Function variant recursively drops its own Vec, etc.), so a sufficiently deep tree (e.g. ~10k+ levels, achievable via repeated HoistMutation/SubtreeCrossover with SubtreeCrossover::without_depth_limit() (crossover.rs:993-997) or a hand-constructed/deserialized genome) will overflow the stack purely from being dropped. The same unguarded recursion pattern also underlies TreeNode::depth() (47-54), size() (57-62), evaluate_node (421-432), collect_positions/collect_terminal_positions/collect_function_positions (71-80, 149-160, 169-178), and PointMutation::mutate_recursive (mutation.rs:762-785) — none of these use an explicit work-stack/iterative traversal, so any of them can also overflow on a sufficiently deep tree. In normal use TreeGenome::generate bounds max_depth to [3,10] (tree.rs:576-579) and SubtreeCrossover defaults to max_depth=17 (crossover.rs:975), so the risk is latent rather than triggered by default configuration, but nothing in the type itself enforces this — max_depth is advisory metadata, not a structural guarantee (TreeGenome::new and replace_subtree never validate depth).

Suggested fix: For genuinely bloat-uncontrolled configurations, convert the hot recursive traversals (Drop, eval, depth/size) to explicit-stack iterative algorithms, or hard-cap max_depth to a value provably safe for the default thread stack size and reject trees that exceed it at construction time.

Resolution: fixed-with-design-change — TreeNode::depth/size and TreeGenome::evaluate were converted to explicit-stack iterative algorithms, with an iterative TreeGenome::dismantle/drop_node_iteratively for manual teardown. The fixup closed the gap the original fix had flagged as impossible: TreeNode now implements a stack-safe Drop directly (iterative teardown via std::mem::take), so an implicit drop of a deep tree no longer overflows even without calling dismantle(); the position collectors were converted to iterative traversal, and PointMutation was rewritten to iterative in-place mutation in src/operators/mutation.rs. A ~100k-deep tree can now be implicitly dropped, traversed, and point-mutated without overflow.

Regression tests: genome::tree::tests::test_tree_deep_no_stack_overflow, genome::tree::tests::test_drop_node_iteratively_frees_deep_tree, genome::tree::tests::test_deep_tree_implicit_drop_no_overflow, genome::tree::tests::test_deep_tree_position_collectors_no_overflow, operators::mutation::tests::test_point_mutation_deep_tree_no_stack_overflow.

Re-verification: verified (independent adversarial verifier).

EV-61 — LogNormalPosterior is an ad-hoc moment tracker mislabeled as a Bayesian posterior, with prior-contaminated small-n variance

  • Location: fugue-evo/src/hyperparameter/bayesian.rs:216
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: likely

LogNormalPosterior is presented as a posterior over step sizes, but the update (lines 216-232) is a Welford-style running mean/variance of log(σ), not a conjugate Bayesian update — the code itself admits 'This is a simplified update, not fully Bayesian' (line 227). Two concrete issues: (a) It is not the conjugate posterior for a log-normal with unknown mean and variance (that would be Normal-Inverse-Gamma with sufficient statistics n, Σlogσ, Σ(logσ)²), so mu/sigma_sq are point moment estimates, and sample() (lines 245-249) samples a single log-normal using those point estimates rather than integrating parameter uncertainty (no posterior-predictive). (b) The variance recursion sigma_sq = (sigma_sq*(n-1) + delta*delta2)/n is a correct population-variance (M2/n) Welford recursion ONLY if M2_{n-1} = sigma_sq_{n-1}*(n-1); but at n=1 the code leaves sigma_sq at its prior value 1.0 (the if self.n > 1 guard skips the first update), so at n=2 it computes (1.0·1 + delta·delta2)/2 instead of the correct delta·delta2/2 — i.e. it injects the prior's variance as if it were an extra sum-of-squares term. The bias in the variance estimate is ≈1.0/n and decays but is material for small samples, and the '1.0' has no principled prior weight. The class is also unused (step_sizes defaults to an empty Vec and is never observed), so this is latent, but it undercuts the 'conjugate priors' framing.

Suggested fix: Either implement the proper Normal-Inverse-Gamma conjugate update (track n, Σx, Σx² as sufficient statistics; report posterior-predictive Student-t for sampling) or rename the type to reflect that it is a moment tracker. If keeping Welford, initialize M2=0 at n=1 (don't carry the prior 1.0 into the sum of squares) or give the prior an explicit pseudo-count.

Resolution: fixed — LogNormalPosterior was replaced by RunningLogMoments, a Welford-style running mean/M2 tracker on ln(x) explicitly documented as a moment tracker rather than a Bayesian posterior. observe() updates mean_log and the M2 accumulator using the standard Welford recursion with no prior-derived seed value, so var_log() (M2/n) is exactly 0 after a single observation instead of carrying over a spurious prior variance of 1.0 into the n=2 update; sample_var_log() exposes the unbiased M2/(n-1) estimate separately.

Regression tests: test_running_log_moments_no_prior_contamination, test_running_log_moments_mean_original_space.

Re-verification: verified (independent adversarial verifier).

EV-62 — MIN_SIGMA = 1e-10 is numerical-underflow protection, not the advertised guard against premature step-size collapse

  • Location: fugue-evo/src/hyperparameter/self_adaptive.rs:60
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

The only lower bound on self-adaptive step sizes is MIN_SIGMA: f64 = 1e-10 (line 60), applied via sigma.max(Self::MIN_SIGMA) (lines 74,80,88). The doc-comment calls it 'the minimum step size to prevent collapse' (line 59), but a floor of 1e-10 does nothing to prevent premature convergence: for a domain such as Rastrigin's [-5.12, 5.12] a step size of 1e-10 is ~11 orders of magnitude below the domain scale, so the population has long since stagnated before the floor engages. Log-normal self-adaptation is well known to be able to shrink σ too fast and stall (Rechenberg's 1/5 rule and CMA-ES step-size control exist precisely to counter this). The floor here only prevents literal zero / underflow to a degenerate delta. There is no domain-relative or coordinate-relative lower bound and no coupling to bounds width. Note also the floor is absolute, so on problems whose natural scale is < 1e-10 it would be a hard over-estimate, and on large-scale problems it provides effectively no protection.

Suggested fix: Introduce a problem-scaled floor (e.g. a small fraction of each coordinate's bound width, or a configurable sigma_min relative to initial_sigma), and consider an upper bound too. Keep 1e-10 only as an absolute underflow guard, and correct the doc-comment which overstates what it does.

Verifier correction: The finding is accurate as stated. Refinement: this is a design-robustness/documentation issue, not a computational-correctness defect — the code correctly implements a fixed 1e-10 floor; the problem is that (a) the doc-comment "minimum step size to prevent collapse" overstates what a 1e-10 absolute floor achieves (it only prevents literal underflow to zero/degenerate delta, not premature convergence), and (b) there is no domain- or bounds-relative σ_min and no upper bound. Verified there is no other σ lower bound anywhere in the crate.

Resolution: fixed — The MIN_SIGMA constant was renamed to SIGMA_UNDERFLOW_FLOOR with a doc comment clarifying it is only a numerical-underflow guard, not protection against premature convergence. StrategyParams::mutate gained a min_sigma parameter (effective floor = max(min_sigma, SIGMA_UNDERFLOW_FLOOR)); ESConfig gained a min_sigma: Option field with resolved_min_sigma() defaulting to 1e-8 * initial_sigma, exposed via ESBuilder::min_sigma() and threaded through both mutation call sites in evolution_strategy.rs.

Regression tests: test_strategy_params_underflow_floor, test_configurable_min_sigma_prevents_collapse, test_resolved_min_sigma, test_min_sigma_builder_threads_through.

Re-verification: verified (independent adversarial verifier).

EV-63 — Elites are reassigned brand-new CandidateIds each generation, orphaning their aggregator feedback history despite the documented stable-ID contract

  • Location: fugue-evo/src/interactive/algorithm.rs:635
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

evaluator.rs:15-18 documents that a candidate's ID 'remains stable across generations. This allows tracking evaluation history and aggregating feedback over time.' But evolve_generation assigns each surviving elite a fresh id via self.session.next_id() (algorithm.rs:636) before copying its genome. The FitnessAggregator keys CandidateStats and ComparisonRecords by CandidateId, so the elite's accumulated ratings/wins/comparisons remain under the OLD id (now absent from the population) while the new copy starts from empty stats. get_fitness_estimate(new_id) returns None -> treated as f64::MAX uncertainty (selection_strategy.rs:283), so elites are re-evaluated from scratch every generation and their prior feedback is silently discarded. For BradleyTerry this also leaves the comparison graph accumulating orphan nodes for dead ids. This contradicts the core design premise of cross-generation feedback aggregation.

Suggested fix: Preserve the original CandidateId for elites carried into the next generation (do not mint a new id), so the aggregator continues accumulating their history.

Verifier correction: The defect is real: elites receive a new CandidateId (algorithm.rs:636), orphaning their FitnessAggregator history (stats + comparison records keyed by the old CandidateId) and violating the documented stable-ID contract. Correction to the finding: the elite's scalar fitness_estimate field IS preserved (algorithm.rs:639 candidate.fitness_estimate = fitness), and elites are excluded from unevaluated_indices (algorithm.rs:693-694), so they are not unconditionally re-rated from scratch under DirectRating and reproduction selection still uses the carried value. What is truly lost is the aggregator's accumulated ratings/wins/comparisons and uncertainty for that individual (get_fitness_estimate(new_id) -> None -> treated as f64::MAX unobserved), plus permanently orphaned ComparisonRecords for the dead IDs. Suggested fix (preserve the original CandidateId for carried-over elites) is correct.

Resolution: fixed — evolve_generation now carries elites into the next generation by cloning the full Candidate (preserving its CandidateId, fitness estimate, uncertainty, and evaluation_count) instead of minting a fresh id via next_id(). The FitnessAggregator keys stats/comparisons by CandidateId, so an elite's accumulated history now survives generation boundaries per the documented stable-ID contract.

Regression tests: tests/e-interactive_audit.rs::ev63_elite_keeps_candidate_id_and_history.

Re-verification: verified (independent adversarial verifier).

EV-64 — evaluation_count is double-incremented per response (asymmetrically in pairwise mode), corrupting coverage and active-learning bonuses

  • Location: fugue-evo/src/interactive/algorithm.rs:481
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

In provide_response, the loop over updated (algorithm.rs:481-488) calls session.update_fitness_with_uncertainty / update_fitness, and BOTH of those call candidate.record_evaluation() (session.rs:255,263). Then the separate loop over pending_request_ids (algorithm.rs:491-500) calls record_evaluation() AGAIN for the same candidates. For Rating, updated == pending ids, so each rated candidate's evaluation_count increases by 2 per response. For Pairwise, updated = winner only (algorithm.rs:464) but pending = both, so the winner gets +2 and the loser +1 — an asymmetric bias. evaluation_count drives coverage_stats().avg_evaluations, the CoverageAware min_evaluations gate, and the coverage bonuses 1/(evaluation_count+1) in select_by_uncertainty (selection_strategy.rs:315) and select_coverage_aware (line 525), so the active-learning acquisition is skewed and winners are systematically under-prioritized for re-showing.

Suggested fix: Increment evaluation_count exactly once per response. Remove record_evaluation() from the update_fitness/update_fitness_with_uncertainty helpers (make them pure setters) and keep only the explicit pending-id loop, or vice-versa.

Verifier correction: The double-increment in the first loop only occurs when the aggregator has a fitness value for the id (get_fitness_estimate or get_fitness returns Some); otherwise record_evaluation is not called there. In the normal path after process_response/process_pairwise this is satisfied, so the described +2 (Rating, symmetric) and +2/+1 (Pairwise winner/loser, asymmetric) behavior holds. Everything else in the finding, including the affected downstream call sites, is accurate.

Resolution: fixed — Same root cause and fix as EV-26 (single counting owner). Pairwise mode is now symmetric: both the winner and loser increment evaluation_count by exactly one (previously winner +2, loser +1). Verified in both rating and pairwise modes.

Regression tests: tests/e-interactive_audit.rs::ev64_pairwise_counts_symmetric.

Re-verification: verified (independent adversarial verifier).

EV-65 — Armijo line-search condition has a sign error on the directional-derivative term, disabling the backtracking safeguard

  • Location: fugue-evo/src/interactive/bradley_terry.rs:360
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: certain

The sufficient-increase (Armijo) condition for maximizing f along an ascent direction δ is f(θ+tδ) ≥ f(θ) + c·t·(∇f·δ), where here ∇f·δ = g^T·δ = g^T(−H)^{-1}g ≥ 0 because δ solves (−H)δ=g and (−H) is positive definite (after ridge). The code (line 360) instead tests new_ll > current_ll − 1e-4·step_size·gradient.dot(&delta), i.e. it SUBTRACTS the (non-negative) directional-derivative term, placing the acceptance threshold BELOW current_ll. This accepts steps that decrease the log-likelihood by up to c·t·(g·δ), and since the condition is essentially always true at t=1, backtracking never triggers — the safeguard is inert. For strictly concave BT the full Newton step usually increases the likelihood anyway, but the guard provides no protection against overshoot/non-monotonicity in ill-conditioned or near-degenerate graphs.

Suggested fix: Flip the sign: accept when new_ll >= current_ll + 1e-4step_sizegradient.dot(&delta).

Verifier correction: The sign error at line 360 is real and confirmed: gradient.dot(&delta) is strictly positive (gᵀ(-H)^{-1}g with -H positive definite), so subtracting it lowers the acceptance threshold below current_ll instead of raising it. The proposed fix (accept when new_ll >= current_ll + 1e-4step_sizegradient.dot(&delta)) is correct. One correction to the impact wording: the guard is NOT fully inert. With the minus sign it degenerates into a sufficient-DECREASE test, so it still rejects and backtracks when a step decreases the log-likelihood by more than c·t·(gᵀδ) — i.e., it does protect against large overshoot. What it fails to do is enforce the sufficient-INCREASE (it accepts small non-monotonic/decreasing steps up to the margin and never enforces monotone progress). So 'backtracking never triggers / safeguard is inert' is overstated; the safeguard is weakened/incorrect rather than completely disabled. Severity medium is reasonable given BT is strictly concave (with ridge) so the full Newton step usually increases the likelihood and the residual large-decrease protection remains.

Resolution: fixed — Extracted an armijo_sufficient_increase predicate (candidate >= current + ct(grad.dot(delta))) with the correct sign for maximizing the penalized log-likelihood along the ascent direction, replacing the buggy condition that subtracted the directional-derivative term and accepted decreases. The backtracking line search is now a testable method that returns the number of halvings.

Regression tests: bradley_terry::tests::test_armijo_sign_rejects_small_decrease, bradley_terry::tests::test_backtracking_triggers_on_overshoot.

Re-verification: verified (independent adversarial verifier).

EV-66 — Newton-Raphson reports Var(log-strength) while the point estimate is the strength itself; MM reports Var(strength) — the two optimizers are on incompatible scales

  • Location: fugue-evo/src/interactive/bradley_terry.rs:153
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: certain

fit_newton_raphson returns strengths = exp(θ_i) (line 376, probability scale) but covariance is the inverse θ-space Hessian, i.e. Var(θ_i)=Var(log π_i). get_estimate (lines 153-167) then builds FitnessEstimate::new(mean=π_i, variance=Var(θ_i),…), and uncertainty.rs forms CI = π_i ± 1.96·sqrt(Var(θ_i)) — mixing a probability-scale mean with a log-scale standard error. By the delta method the correct strength-scale variance is Var(π_i) = (dπ/dθ)²·Var(θ_i) = π_i²·Var(θ_i); the code omits the π_i² factor, so the std error is wrong by a factor of π_i (correct only when π_i≈1). Separately, fit_mm's bootstrap_covariance (lines 583-598) resamples and refits π directly, returning Var(π) on the strength scale. Thus for identical inputs NewtonRaphson yields Var(θ) and MM yields Var(π); downstream consumers (stats.model_variance, pairwise_entropy) treat them identically and cannot be simultaneously correct.

Suggested fix: Pick one scale. Either apply the delta-method transform Var(π_i)=π_i²·Var(θ_i) before returning from the Newton-Raphson path, or return strengths and variances both on the θ scale; document the chosen convention so MM and NR agree.

Resolution: fixed — Both optimizers now report on the same (strength) scale, documented on BradleyTerryResult. Newton-Raphson applies the delta method Var(pi)=diag(pi)*Cov(theta)*diag(pi) to the constrained theta-covariance so it returns Var(pi) like MM's bootstrap, instead of mixing a probability-scale mean with a log-scale variance.

Regression tests: bradley_terry::tests::test_constrained_fisher_covariance_matches_analytic.

Re-verification: verified (independent adversarial verifier).

EV-67 — No prior/penalty prevents strength divergence for candidates that win (or lose) all their comparisons

  • Location: fugue-evo/src/interactive/bradley_terry.rs:322
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: likely

With disconnected comparison graphs or a candidate that wins all its games, the BT MLE is unbounded (θ→+∞, π→∞). The Newton-Raphson 'regularization' is added only to the Hessian diagonal (lines 322-324, 401-403) — it damps the linear solve but adds NO penalty term to the gradient, so the stationary point remains at infinity; such runs simply hit max_iterations with converged=false and produce extreme θ (and after mean-centering, extreme exp(θ) strengths). The MM path only special-cases the zero-WINS case with an arbitrary pi_new=0.01 floor (lines 496-499) and has nothing for the all-wins case. Early interactive generations with few comparisons per candidate routinely produce all-win/all-loss items, so this is not a rare edge case. The docstrings advertise 'L2 regularization' implying MAP-style shrinkage that is not actually applied to the estimates.

Suggested fix: Add a genuine prior: a Beta(α,α)-style pseudo-count (add α wins and α losses to every pair, i.e. a Gamma prior on π), or an L2 penalty (reg/2)·||θ||² that also contributes −reg·θ to the gradient. This bounds the estimates and makes the ridge term consistent between gradient and Hessian.

Verifier correction: The structural/math claim is fully correct: the regularization is added only to the Hessian diagonal (lines 322-325, 401-403) with no matching -reg·θ term in the gradient (lines 311-312), so it is Levenberg-Marquardt/ridge damping rather than a MAP/L2 prior; the BT MLE stationary point for an all-win/all-loss/disconnected candidate remains at ±∞, and estimates are limited only by tolerance/max_iterations, producing extreme exp(θ) strengths (verified numerically: θ≈11.5, strength≈1e5 for an all-win candidate; θ grows from 7.84 to 10.08 as tolerance tightens from 1e-6 to 1e-8). Correction: these runs do NOT typically end with converged=false at max_iterations — with the default tolerance (1e-6) they report converged=true because the gradient component q=1-σ(θ_i-θ_j) decays to ~0 as θ grows, dropping the gradient norm below tolerance while θ is already extreme. The failure mode is 'converges to an extreme, tolerance-dependent estimate' rather than 'fails to converge.' The suggested fix (genuine (reg/2)||θ||² penalty contributing -reg·θ to the gradient, or Beta(α,α) pseudo-counts / Gamma prior on π) is correct and was numerically shown to bound the estimate (θ=±0.74 vs unbounded).

Resolution: fixed-with-design-change — Newton-Raphson now applies a genuine Gaussian prior on log-strengths (penalized objective LL - (lambda/2)||theta||^2, contributing -lambda*theta to the gradient and -lambda to the Hessian diagonal), configurable via prior_lambda (renamed from regularization, serde alias kept), default 0.1. MM uses a Gamma(1+eps,eps) pseudo-count prior (closed-form regularized MM, Caron & Doucet 2012) since a Gaussian-on-log-strength prior has no closed-form MM update; both shrink toward pi=1 and keep all-win/all-loss candidates finite.

Regression tests: bradley_terry::tests::test_prior_keeps_all_win_all_loss_finite.

Re-verification: verified (independent adversarial verifier).

EV-68 — CoverageAware pair selection can return a self-pair (a, a) via find_informative_pair

  • Location: fugue-evo/src/interactive/selection_strategy.rs:223
  • Severity: medium · Dimension: correctness · Verification: unverified · Auditor confidence: likely

In select_pair CoverageAware branch (lines 203-229): a = indices[0].0 is the least-evaluated candidate. When a_eval >= min_evaluations (all candidates adequately covered), b = self.find_informative_pair(...) (line 223). find_informative_pair (lines 543-579) returns the argmax-entropy candidate among the top-3 (with random tie-break) over ALL candidates and nothing excludes index a. So b can equal a, producing Some((a, a)) — a comparison of a candidate against itself. Downstream process_pairwise(id_a, id_b, winner) (aggregation.rs:671) would then record a self win/loss into the Bradley-Terry/Elo history, corrupting the model, or at best waste a user evaluation. Concretely: 5 candidates all with evaluation_count >= min_evaluations, the least-evaluated is index 3, and index 3 also happens to be the highest-entropy candidate -> pair (3,3).

Suggested fix: Have find_informative_pair take an exclude: usize (or the already-chosen a) and skip it, or after selecting b, if b == a fall back to the next-best distinct index.

Resolution: fixed — find_informative_pair now takes an exclude: Option and filters out that index before scoring, so the CoverageAware branch that already committed to candidate a can never receive a self-pair (a,a). The caller passes Some(a).

Regression tests: selection_strategy::tests::test_coverage_aware_never_returns_self_pair.

Re-verification: verified (independent adversarial verifier).

EV-69 — exploration_bonus / coverage_bonus are added to raw variance whose scale varies by orders of magnitude across aggregation models, making the bonus inert or dominant

  • Location: fugue-evo/src/interactive/selection_strategy.rs:527
  • Severity: medium · Dimension: correctness · Verification: unverified · Auditor confidence: likely

select_coverage_aware (line 527): score = uncertainty + exploration_bonus/(count+1), and select_by_uncertainty (line 317): score = uncertainty_weight*uncertainty + coverage_bonus with coverage_bonus in (0,1]. The uncertainty term is the raw FitnessEstimate.variance, whose magnitude depends entirely on the aggregation model: DirectRating variance-of-mean is O(1) (e.g. ~1 on a 1-10 scale), Elo variance is base_var/n = K^2*0.25/n ~ 256/n for K=32 (aggregation.rs:304), ImplicitRanking variance is p(1-p)/n <= 0.25/n ~ O(0.01) (aggregation.rs:329). So a fixed exploration_bonus=1.0 is completely swamped by Elo variances (256) and completely dominates ImplicitRanking variances (0.01). The exploration/coverage term is therefore either inert or dominant depending on the model, rather than providing a tunable balance. Only DirectRating (the default) happens to be on a comparable scale.

Suggested fix: Normalize uncertainty (e.g. divide each variance by the max/mean variance in the batch, or rank-normalize) before adding the bonus, or scale the bonus by the typical variance magnitude of the active model.

Resolution: fixed — Added mean_variance_scale and normalized_uncertainty_score helpers: select_by_uncertainty and select_coverage_aware now divide each candidate's variance by the batch's mean variance before adding the exploration/coverage bonus, so the bonus has a model-agnostic influence. Unobserved candidates get a finite large normalized score instead of f64::MAX (avoids overflow while keeping top priority).

Regression tests: selection_strategy::tests::test_normalized_uncertainty_scale_invariant.

Re-verification: verified (independent adversarial verifier).

EV-70 — pairwise_entropy returns MAXIMUM entropy for zero-variance (perfectly-known) candidates, inverting active-learning priority

  • Location: fugue-evo/src/interactive/selection_strategy.rs:592
  • Severity: medium · Dimension: math · Verification: unverified · Auditor confidence: certain

Line 592: if var_diff.is_infinite() || var_diff <= 0.0 { return 1.0; }. Bundling var_diff <= 0.0 with is_infinite() under the comment "Maximum entropy when we know nothing" is backwards for the zero case. var_diff = var_A + var_B == 0 means BOTH estimates have zero variance (perfectly measured). If their means differ, P(A>B)=Phi((mu_A-mu_B)/0)-> 0 or 1, so the comparison outcome is fully determined and its entropy should be ~0 (the LEAST informative pair to show). Returning 1.0 (the max) makes the strategy preferentially spend comparisons on candidates it is already certain about. Zero variance is reachable in the mainline DirectRating path: identical repeated ratings (e.g. [7,7,7]) give rating_variance()=0 -> rating_variance_of_mean()=0 -> FitnessEstimate.variance=0 (aggregation.rs:293). Correct behavior: only the is_infinite() (or None) case should return the max sentinel; for finite var_diff (including 0 with differing means) fall through to z = mean_diff/sqrt(var_diff) which naturally yields entropy ~0. Guard the divide by treating var_diff==0 with a tiny epsilon or by returning binary_entropy(0.5) only when mean_diff is also ~0.

Suggested fix: Change the guard to if var_diff.is_infinite() { return MAX_SENTINEL; } and handle var_diff==0 by comparing means directly (entropy 0 unless means are equal).

Resolution: fixed — pairwise_entropy now returns the max-entropy sentinel only when var_diff is infinite (truly unobserved). For var_diff==0 it compares means directly: entropy 0 when the means differ (outcome determined) and ln(2) only when the means also tie. A perfectly-known pair now scores below an unobserved pair; unobserved pairs keep priority via the coverage/exploration term.

Regression tests: selection_strategy::tests::test_pairwise_entropy_known_below_unknown.

Re-verification: verified (independent adversarial verifier).

EV-71 — SBX "bounded" variant merely clamps children instead of using Deb's bounds-aware spread factor, biasing mass onto the boundaries

  • Location: fugue-evo/src/operators/crossover.rs:82
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: likely

apply_sbx (lines 65-100) computes the unbounded spread factor beta = spread_factor(u) and forms c1=0.5[(1+beta)x1+(1-beta)x2], c2=0.5[(1-beta)x1+(1+beta)x2], then, for the bounded path, simply calls bound.clamp(child) at lines 91-92. This is NOT the bounded SBX of Deb (used in NSGA-II). Deb's bounded SBX instead recomputes the spread factor from the bounds so that children land inside [xl,xu] without truncation. Concretely, for the lower child one sets beta_l = 1 + 2(x1-xl)/(x2-x1), alpha = 2 - beta_l^-(eta+1), and betaq = (ualpha)^(1/(eta+1)) if u <= 1/alpha else (1/(2-ualpha))^(1/(eta+1)); c1 = 0.5[(x1+x2) - betaq*(x2-x1)]; symmetrically with beta_u = 1 + 2(xu-x2)/(x2-x1) for the upper child. The library's clamp approach produces a distribution with delta spikes of probability piled exactly at min and max (every child that would fall outside is mapped onto the bound), which over generations biases the search toward box boundaries — a qualitatively different distribution than the referenced algorithm. The docstring cites Deb & Agrawal (1995), the unbounded paper, so the unbounded core is correctly attributed, but the BoundedCrossoverOperator impl advertises bounds handling it does not truly implement.

Suggested fix: Implement the bounds-aware betaq recomputation (Deb & Agrawal / NSGA-II crossover.c) for crossover_bounded, or document explicitly that bound handling is clamp-only and will accumulate probability mass at the bounds.

Resolution: fixed — Bounded SBX now implements Deb & Agrawal's bounds-aware spread factor: per gene it computes beta_l/beta_u from parent distances to each bound, draws betaq from the truncated polynomial distribution, and forms c_low/c_high that lie inside [min,max] by construction (no clamp-based boundary pile-up). A final clamp(yl,yu) remains only as a floating-point drift guard, matching Deb's reference. The unbounded path keeps the classic spread factor.

Regression tests: operators::crossover::tests::test_sbx_bounded_no_atom_at_bounds_mean_preserving (Monte Carlo: all children in-bounds, boundary-atom fraction <1% vs ~10.8% under the old clamp, mean preserved).

Re-verification: verified (independent adversarial verifier).

EV-72 — SBX per-gene crossover probability defaults to 0.9 (canonical is 0.5) and the same field is overloaded as the trait-level per-pair crossover probability

  • Location: fugue-evo/src/operators/crossover.rs:41
  • Severity: medium · Dimension: math · Verification: confirmed · Auditor confidence: likely

The canonical SBX (Deb's NSGA-II code) uses a per-variable exchange probability of 0.5: for each variable a fair coin decides whether that variable is recombined via the spread factor or the children inherit the parents' values unchanged, and separately an overall per-pair probability pcross (~0.9) gates the whole operation. Here SbxCrossover::new sets crossover_probability = 0.9 (line 41) and apply_sbx uses rng.gen::<f64>() < self.crossover_probability per gene (line 76). So ~90% of variables are recombined rather than the canonical ~50%, changing the operator's characteristic mixing behavior. Worse, this single field is dual-purpose: it is the per-gene mixing rate AND is returned verbatim by crossover_probability() (lines 125-127), whose trait doc (traits.rs:42-43) describes it as "probability of crossover being applied" i.e. the per-pair rate. SimpleGA/NSGA-II happen to apply their own config.crossover_probability (also 0.9) per pair (simple_ga.rs:467, nsga2.rs:346) and ignore the operator method, so the numbers coincide by luck, but a user calling .with_probability(0.5) intending a per-pair rate would silently only change the per-gene SBX mixing rate.

Suggested fix: Default the per-gene SBX exchange probability to 0.5 to match Deb, and separate the per-gene mixing rate from the per-pair crossover_probability() the trait method reports (they are different quantities).

Resolution: fixed-with-design-change — Separated the two conflated probabilities into distinct fields: crossover_probability (per-pair, default 0.9, returned by CrossoverOperator::crossover_probability) and exchange_probability (per-gene SBX exchange, default 0.5, canonical). with_probability now sets the per-pair rate; new with_exchange_probability sets the per-gene rate; the per-gene loop uses exchange_probability. Behavior change: per-gene mixing default moves 0.9 -> 0.5.

Regression tests: operators::crossover::tests::test_sbx_default_probabilities_are_distinct.

Re-verification: verified (independent adversarial verifier).

EV-73 — FitnessStagnation::should_terminate only compares window endpoints, blind to non-monotonic fitness history

  • Location: fugue-evo/src/termination/mod.rs:110
  • Severity: medium · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

should_terminate computes improvement = (last - first).abs() over just the two endpoints of the trailing window (lines 108-110) and terminates if improvement < epsilon. This is only a valid stagnation measure when fitness_history is guaranteed non-decreasing (so first is the window minimum and last is the window maximum, making the endpoint delta equal the true total improvement). But fitness_history in this crate is populated per-generation from GenerationStats::from_population(&population, ...).best_fitness — the CURRENT population's best individual, not the separately-tracked running-best variable (confirmed via grep of src/algorithms/simple_ga.rs lines 411/538/etc., where best_individual — the true running max — is a distinct variable from what's pushed into fitness_history). Monotonicity of fitness_history therefore depends on elitism preserving the previous best individual in the new population; with .elitism(false) (a supported, non-default SimpleGA config) or in algorithms without unconditional single-best elitism, the population's per-generation best can regress. Concretely: window=[10.0, 90.0, 10.0] gives improvement=0 (falsely 'stagnant') despite huge intra-window fitness swings, while window=[10.0, 10.0, 90.0] with a slightly different epsilon boundary could misclassify true stagnation as improvement depending on where the endpoints happen to land — the endpoint-only comparison discards all information about what happened between them.

Suggested fix: Compute stagnation from the trailing window's running maximum minus its minimum (or max(window) - window[0]) rather than just last-first, so intra-window fluctuation can't be mistaken for stagnation (or lack thereof) when the input series isn't guaranteed monotonic.

Resolution: fixed — FitnessStagnation::should_terminate now measures best-so-far improvement across the window (max(window) - window[0], always >=0) instead of the endpoint delta |last-first|, so a non-monotonic window like [10,90,10] is correctly seen as improved rather than stagnant. Single fix shared with EV-105.

Regression tests: test_fitness_stagnation_nonmonotonic_window.

Re-verification: verified (independent adversarial verifier).

Severity: low (33)

EV-74 — Duplicate major versions of rand/rand_core/rand_chacha in fugue-evo's dependency graph

  • Location: fugue-evo/Cargo.lock
  • Severity: low · Dimension: elegance · Verification: judgment · Auditor confidence: n/a

fugue-evo/Cargo.lock contains both rand 0.8.5 (used by the crate itself and rand_distr) and rand 0.9.2 (pulled in transitively, along with rand_core 0.9.3 and rand_chacha 0.9.0) -- almost certainly via the proptest = "1.5" dev-dependency, which on newer proptest releases depends on rand 0.9. This doubles compile units for the RNG stack in dev/test builds and is a known source of confusing 'wrong RNG trait' type errors when mixing APIs.

Suggested fix: Pin proptest to a version compatible with rand 0.8 (as fugue does with proptest = "1.0") or upgrade the whole crate to rand 0.9 to collapse the duplication; run cargo tree -d periodically to catch new duplicate majors.

Resolution: fixed — The original fix pinned proptest = "1.5" as a documentation-only mitigation, asserting the rand 0.9 duplication was upstream-forced and unfixable by version pinning. The fixup falsified that premise empirically: proptest only began requiring rand 0.9 at its 1.7.0 release, so pinning proptest = ">=1.5, <1.7" (the 1.5.x/1.6.x line, which still uses rand 0.8) collapses Cargo.lock to a single rand major, verified via cargo tree -d and a full property-test pass. A new make deps-check target (a cargo tree -d guard) is wired into make ci to fail the build if a duplicate rand major reappears, and the Cargo.toml/CHANGELOG comments were corrected to match.

Re-verification: verified (independent adversarial verifier).

EV-75 — Unused serde-wasm-bindgen and wasm-bindgen-futures dependencies bloat the crate/binary

  • Location: fugue-evo/crates/fugue-evo-wasm/Cargo.toml:23
  • Severity: low · Dimension: elegance · Verification: judgment · Auditor confidence: n/a

serde-wasm-bindgen = "0.6" (Cargo.toml:23) and wasm-bindgen-futures = "0.4" (Cargo.toml:21) are both declared but never referenced anywhere in src/ or tests/ (confirmed via grep for serde_wasm_bindgen and wasm_bindgen_futures/async fn/Promise). All JS boundary crossings instead use raw js_sys types (Float64Array/Array) and serde_json::to_string, which is actually the more efficient choice for this crate, but it means these two dependencies are dead weight pulled into every downstream build/compile.

Suggested fix: Remove the unused dependencies, or wire them in if an async/Promise-based streaming API (see the blocking-behavior finding) is added later.

Resolution: fixed-by-removal — Removed the unused serde-wasm-bindgen and wasm-bindgen-futures dependencies from Cargo.toml after verifying via grep that neither serde_wasm_bindgen nor wasm_bindgen_futures/async fn/Promise appears anywhere in src/ or tests/.

Re-verification: verified (independent adversarial verifier).

EV-76 — CMA-ES fitness_history is a single point, not a per-generation trajectory, despite sharing the OptimizationResult type with SimpleGA/ES

  • Location: fugue-evo/crates/fugue-evo-wasm/src/optimizers.rs:298
  • Severity: low · Dimension: completeness · Verification: judgment · Auditor confidence: n/a

run_cmaes builds OptimizationResult with vec![-cmaes.state.best_fitness] as the fitness_history (a 1-element vector containing only the final value), whereas the SimpleGA/UMDA/ES paths populate it with result.stats.best_fitness_history() — a full per-generation trace. CmaEsState (fugue-evo/src/algorithms/cmaes.rs:40) has no history field, so this is a genuine native-library gap, but the wasm layer papers over it silently rather than surfacing the difference (e.g. returning an empty array, or documenting that fitnessHistory is not meaningful for the CMA-ES algorithm choice). A JS caller plotting result.fitnessHistory() for a convergence chart gets a single flat point for CMA-ES runs with no indication why.

Suggested fix: Return an empty Vec for CMA-ES fitness_history and document in the JSDoc/README that convergence history is unavailable for the CmaES algorithm choice, or track best-fitness-per-generation in CmaEs::run_generations and thread it through.

Resolution: fixed — Rewrote run_cmaes to drive CMA-ES one generation at a time via the existing native step(), recording the per-generation best-so-far (sign-flipped -state.best_fitness) into a real fitness_history trajectory, instead of the previous single hard-coded 1-element vector. Tracks the best feasible individual across generations and honors internal convergence.

Regression tests: e_wasm_regressions.rs::ev76_cmaes_fitness_history_is_a_trajectory (host; len>1, monotonic non-decreasing), wasm_tests.rs::test_cmaes_fitness_history_is_trajectory (browser).

Re-verification: verified (independent adversarial verifier).

EV-77 — Island model (parallel-population GA) has no WASM binding at all

  • Location: fugue-evo/crates/fugue-evo-wasm/src/optimizers.rs:1
  • Severity: low · Dimension: completeness · Verification: judgment · Auditor confidence: n/a

fugue-evo/src/algorithms/ contains island.rs (Island Model GA) alongside simple_ga.rs, cmaes.rs, nsga2.rs, evolution_strategy.rs, steady_state.rs, and eda/umda.rs. Every one of those except island.rs and steady_state.rs is wrapped by a wasm optimizer type; steady_state.rs is explicitly and correctly documented as excluded (optimizers.rs:767-768, "SteadyStateGA requires Sync ... JS functions don't support"), but Island Model has no corresponding comment or wrapper, silently omitting it from the exposed subset with no indication of why.

Suggested fix: Either add an IslandModelOptimizer wrapper (single-threaded island migration doesn't require Sync the way SteadyStateGA does) or add a short comment next to the SteadyStateGA note explaining the Island Model omission for parity with the rest of the crate's self-documenting exclusions.

Resolution: fixed-with-design-change — Added an IslandModelOptimizer wasm binding with Ring/FullyConnected/Star topologies and best-k migration, built on the SimpleGA incremental step API plus two new native migration helpers (SimpleGaRun::best_genomes and SimpleGA::inject_migrants, which evaluates migrants under the receiving island's fitness and replaces its worst). Design change vs. the literal ask ("reuse the native island step API"): the native algorithms::island module requires the parallel/rayon feature, which is disabled for the wasm build (default-features=false) and unusable in wasm32 (no threads); a self-contained single-threaded island model that reuses the step API is the mathematically-equivalent, wasm-viable approach, documented inline.

Regression tests: simple_ga.rs::test_inject_migrants_replaces_worst (native, non-parallel), e_wasm_regressions.rs::ev77_island_model_runs_with_migration, e_wasm_regressions.rs::ev77_island_model_all_topologies_smoke, wasm_tests.rs::test_island_model_runs_with_migration / test_island_model_topologies (browser).

Re-verification: verified (independent adversarial verifier).

EV-78 — Misleading negation comment in the sphere quickstart confuses users writing their own fitness

  • Location: fugue-evo/examples/sphere_optimization.rs:23
  • Severity: low · Dimension: docs · Verification: judgment · Auditor confidence: certain

Lines 23-24 comment 'We negate because fugue-evo maximizes by default' immediately precede let fitness = Sphere::new(DIM); — but the user does NOT negate anything here; the built-in Sphere already negates internally (benchmarks.rs:80 -self.evaluate_raw(...)). A newcomer reading this the first time (the most-run example) will either think the builder auto-negates, or be unsure whether their own custom fitness needs a manual -. Compounding it, result.best_fitness then prints as a negative number (e.g. -0.000012) under 'Best fitness: {:.6}', which reads as wrong to someone expecting the Sphere minimum of 0.

Suggested fix: Reword to 'Sphere already returns negated values internally because fugue-evo maximizes; when you write a custom minimization fitness, negate it yourself' and print -result.best_fitness (or the raw objective) so the displayed number matches the textbook optimum.

Resolution: fixed — The misleading "We negate because fugue-evo maximizes by default" comment in examples/sphere_optimization.rs had already been reworded by an earlier work package (e-hyper, commit 835db28) to correctly state that Sphere negates internally and no user negation is needed. Completed the fix by also un-negating the printed "Best fitness" value (previously printed the raw internal negated fitness, e.g. -0.000012, which reads as wrong against the textbook Sphere optimum of 0); it now prints -result.best_fitness labeled "Best fitness (sum of squares)" so it displays as the expected near-zero, non-negative value. Scanned all other examples/*.rs files for the same misleading-negation pattern; found none -- the only other "negate" comment, in symbolic_regression.rs, describes a genuine user-authored MSE negation and is accurate as written.

Re-verification: verified (independent adversarial verifier).

EV-79 — Builder silently clamps selection_ratio and prob_bounds to undocumented ranges, and all clamps are bypassed by direct struct construction

  • Location: fugue-evo/src/algorithms/eda/umda.rs:106
  • Severity: low · Dimension: usability · Verification: judgment · Auditor confidence: certain

selection_ratio() does ratio.clamp(0.1, 0.9) (line 106) and prob_bounds() does (min.clamp(0.001,0.5), max.clamp(0.5,0.999)) (line 118). Neither the field docs (lines 31-36) nor the method docs state these ranges, so a caller requesting selection_ratio(0.05) silently gets 0.1, and prob_bounds(0.1, 0.4) silently gets (0.1, 0.5) because max is forced >= 0.5. Worse, UMDAConfig has all-public fields and is constructed directly elsewhere (e.g. the tests at line 724 build UMDAConfig { learning_rate: 0.5, ..Default::default() }), which bypasses every clamp entirely, so the invariants the builder tries to enforce are not enforced on the type. learning_rate.clamp(0.0,1.0) additionally permits 0.0, which makes update() a no-op (model never learns) with no warning.

Suggested fix: Document the accepted ranges on the fields/methods, return an error (or debug_assert) on out-of-range instead of silently clamping, and either make config fields private or validate in build().

Resolution: fixed-with-design-change — Removed the silent clamps from UMDABuilder::selection_ratio/prob_bounds/learning_rate; build() now calls UMDAConfig::validate() and returns EvolutionError::Configuration for out-of-range values (selection_ratio and learning_rate in (0,1], prob_bounds 0<min<=max<1, learning_rate 0 rejected as a no-op). Documented the ranges on fields/setters and that direct struct construction is an escape hatch that bypasses validation.

Regression tests: test_umda_builder_validation_rejects_out_of_range.

Re-verification: verified (independent adversarial verifier).

EV-80 — Population sort/select relies on to_f64 rather than is_better_than, which is only correct for scalar fitness (silently wrong for ParetoFitness)

  • Location: fugue-evo/src/algorithms/eda/umda.rs:347
  • Severity: low · Dimension: correctness · Verification: unverified · Auditor confidence: likely

Both run loops call population.sort_by_fitness() (lines 347/559), which in population.rs:184-198 orders by f.to_f64() descending, and truncation then takes the top select_count. For every scalar FitnessValue impl used with UMDA (f64, f32, usize, i64, i32; benchmarks.rs negates so higher==better) to_f64 descending is order-consistent with is_better_than (self > other), so truncation selection is CORRECT for the documented use. The divergence flagged elsewhere only bites ParetoFitness, whose to_f64 = -rank + 0.001*crowding (traits.rs:141) can order two individuals differently from is_better_than (rank-then-crowding, traits.rs:144). UMDA's univariate model is single-objective by construction so multi-objective use is nonsensical, but nothing prevents instantiating BinaryUMDA/ContinuousUMDA with F=ParetoFitness, in which case selection would be subtly mis-ranked. Not a mainline defect.

Suggested fix: Either add a trait bound / doc note that UMDA requires scalar fitness, or make sort_by_fitness use is_better_than so it is correct for all FitnessValue impls.

Resolution: fixed — Both UMDA run loops now select the top individuals via a new select_top_genomes() helper that orders by Individual::is_better_than rather than relying on Population::sort_by_fitness (to_f64 descending), keeping truncation selection correct for non-scalar fitnesses.

Regression tests: test_select_top_uses_is_better_than.

Re-verification: verified (independent adversarial verifier).

EV-81 — Sample variance uses biased /n (MLE) denominator

  • Location: fugue-evo/src/algorithms/eda/umda.rs:215
  • Severity: low · Dimension: math · Verification: unverified · Auditor confidence: certain

update() divides the squared deviations by n = selected.len() (line 215), the biased maximum-likelihood estimator, rather than the unbiased /(n-1) (Bessel-corrected) estimator. This slightly under-estimates the true variance (factor (n-1)/n), which nudges toward faster contraction. This is defensible: the classic UMDA_c formulation (Larranaga & Lozano, Estimation of Distribution Algorithms, 2002) does use the ML estimate, and the min_variance floor (line 220) dominates any small bias here. Reporting for completeness, not as a defect. With default selection giving n = ceil(100*0.5)=50 the factor is 0.98, negligible.

Suggested fix: Optionally switch to /(n-1) for an unbiased estimate; low priority given min_variance.

Resolution: fixed — ContinuousUnivariateModel::update() now uses the unbiased (Bessel, n-1) sample variance (guarding n<=1 -> 0.0, with the min_variance floor still applied) instead of the biased /n MLE.

Regression tests: test_continuous_variance_is_bessel_corrected.

Re-verification: verified (independent adversarial verifier).

EV-82 — Island evaluation counter over-counts by elitism every generation

  • Location: fugue-evo/src/algorithms/island.rs:172
  • Severity: low · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

evolve_one_generation adds self.population.len() (172) after evaluate, but evaluate only scores individuals with fitness None (population.rs:241); from generation 2 on, the carried-over elites are already scored and skipped. True fitness calls are len minus elitism but the counter adds len, inflating total_evaluations by elitism times generations. Minor since run is generation-bounded.

Suggested fix: Increment by the count of newly evaluated individuals (via count_evaluated before/after) or add len only on the first generation.

Verifier correction: Mechanism confirmed exactly. Minor formula correction: the total over-count across G generations is elitism*(G-1), not elitism*G, because generation 1 starts from a fully-unevaluated random population (Population::random -> Individual::new -> fitness None) and is counted correctly; over-counting begins at generation 2 (which the finding already states). Per-generation over-count from gen 2 on is exactly elitism.

Resolution: fixed — Island::evolve_one_generation now increments the evaluation counter by the number of individuals actually needing evaluation (population.len() - count_evaluated()) before calling evaluate(), so carried-over elites (already scored and skipped by Population::evaluate) are not over-counted.

Regression tests: test_island_evaluation_counter_counts_actual_evaluations.

Re-verification: verified (independent adversarial verifier).

EV-83 — Potential usize underflow panics for extreme elitism/elite_count configs

  • Location: fugue-evo/src/algorithms/island.rs:207
  • Severity: low · Dimension: correctness · Verification: confirmed · Auditor confidence: likely

island.rs uses population.len() minus elitism as a loop bound (207, 227) which underflows and panics if elitism exceeds population size; simple_ga.rs uses new_population.len() minus elite_count for eval accounting (e.g. 509) which underflows if elite_count exceeds population_size (only min(elite_count,len) elites were added but the full count is subtracted). Both are unguarded usize subtractions on user config values, panicking rather than erroring cleanly.

Suggested fix: Validate at config time that elitism/elite_count is at most population_size, or use saturating_sub capped to elites actually inserted.

Resolution: fixed — Island::evolve_one_generation caps elite_count = min(elitism, population_len) and derives target_offspring = population_len - elite_count, replacing the unguarded population.len() - elitism loop bounds that underflowed and panicked when elitism exceeded population size. The fixup added the regression test the original fix lacked: elitism = 25 against a 20-member island now returns Ok, with all 20 members carried as elites and zero offspring produced, instead of panicking.

Regression tests: test_island_evaluation_counter_counts_actual_evaluations, test_island_elitism_exceeding_population_does_not_underflow.

Re-verification: verified (independent adversarial verifier).

EV-84 — Binary tournament can draw the same individual as both competitors

  • Location: fugue-evo/src/algorithms/nsga2.rs:319
  • Severity: low · Dimension: correctness · Verification: confirmed · Auditor confidence: likely

tournament_select draws i and j independently from 0..len (lines 319-320) with no guard against i == j, so with probability 1/N a candidate competes against itself and is trivially 'selected'. Deb's binary tournament selects two competitors from the pool; drawing the same individual weakly reduces effective selection pressure. Minor and common in practice, but a distinctness resample would match the reference more faithfully.

Suggested fix: Resample j while j == i (for population size > 1).

Resolution: fixed — Nsga2::tournament_select now draws the second competitor from the remaining len-1 indices and shifts past the first (sampling without replacement), guaranteeing two distinct competitors without a rejection loop; with a single individual the two are unavoidably the same.

Regression tests: algorithms::nsga2::tests::test_tournament_select_draws_distinct_competitors.

Re-verification: verified (independent adversarial verifier).

EV-85 — Closure MultiObjectiveFitness impl hardcodes num_objectives() = 2

  • Location: fugue-evo/src/algorithms/nsga2.rs:51
  • Severity: low · Dimension: usability · Verification: judgment · Auditor confidence: possible

The blanket impls for closures (lines 48-52 parallel, 65-67 non-parallel) return a hardcoded 2 from num_objectives(), with a comment acknowledging the limitation. This is not currently exercised by the algorithm itself (the crowding code derives the objective count from objectives.len() at line 206, not from this trait method), so it is harmless for a 3+ objective run TODAY. But it is a latent trap: any future caller or extension that trusts num_objectives() to size buffers will silently mis-size for problems with != 2 objectives when a closure fitness is used.

Suggested fix: Either remove num_objectives() from the trait and always derive the count from an evaluated objective vector, or drop the closure blanket impl so users must supply an explicit impl that reports the true count.

Resolution: fixed-with-design-change — Removed the blanket impl MultiObjectiveFitness for closures that hardcoded num_objectives()=2 and replaced it with a ClosureMultiObjective<G,F> wrapper whose constructor takes an explicit objective count (ClosureMultiObjective::new(num_objectives, closure)). No in-crate/example/test call site used the removed blanket impl, so no call sites needed updating.

Regression tests: algorithms::nsga2::tests::test_closure_multiobjective_reports_true_count, doctest: ClosureMultiObjective.

Re-verification: verified (independent adversarial verifier).

EV-86 — Builder enforces operators at compile time but bounds only at runtime

  • Location: fugue-evo/src/algorithms/simple_ga.rs:257
  • Severity: low · Dimension: usability · Verification: judgment · Auditor confidence: certain

Unset operator/fitness/termination slots are the unit type which implements none of the required traits, so build() is uncallable until they are set (inference fills the 7 type params, so the turbofish form is not actually needed). But bounds is a plain Option checked with ok_or_else at build() (257-259), so a missing bounds is a runtime Err while every other missing field is a compile error. The contract is inconsistent and the bounds-only error path is easy to miss.

Suggested fix: Promote bounds into the type-state for uniform compile-time enforcement, or clearly document that bounds is the sole runtime-validated field.

Resolution: fixed — Added a validate_config(&SimpleGAConfig, &MultiBounds) function, called from both build() code paths in SimpleGABuilder before constructing the SimpleGA, which returns EvolutionError::Configuration for population_size == 0, bounds.dimension() == 0, elite_count > population_size when elitism is enabled, and crossover_probability outside [0.0, 1.0]; these were previously unchecked at build time.

Regression tests: test_build_rejects_zero_population, test_build_rejects_out_of_range_crossover_probability, test_build_rejects_elite_count_exceeding_population.

Re-verification: verified (independent adversarial verifier).

EV-87 — Checkpoint filename ordering breaks once the index reaches 5 digits

  • Location: fugue-evo/src/checkpoint/recovery.rs:335
  • Severity: low · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

load_latest() sorts candidate files purely as strings: name_b.cmp(&name_a) (recovery.rs:335-339) over names formatted as {base_name}_{index:04}.{ext} (recovery.rs:277-280, 304-305). {:04} is a minimum-width specifier, not a fixed width, so once current_index reaches 10000 the filename becomes 5 digits (e.g. "10000") and lexicographic string comparison no longer matches numeric order ("10000" < "9999" as strings, since '1' < '9'). In a long-running experiment with a small checkpoint interval, current_index can realistically exceed 9999, at which point load_latest() will pick a stale checkpoint instead of the true latest one - exactly during the long-running workloads checkpointing exists for.

Suggested fix: Pad to a fixed width large enough to never overflow (e.g. {:010}), or parse and compare indices numerically instead of comparing filenames as strings.

Resolution: fixed — Checkpoint filenames now zero-pad the index to 8 digits ({:08}) in current_path and rotation. load_latest sorts by the parsed numeric index (parse_checkpoint_index) rather than lexicographically, and the parser is width-agnostic so legacy 4-digit names still parse. This fixes the wrong-latest selection at the 5->6 digit boundary.

Regression tests: src/checkpoint/recovery.rs::tests::test_load_latest_orders_across_digit_boundary, src/checkpoint/recovery.rs::tests::test_current_path_is_zero_padded_to_8.

Re-verification: verified (independent adversarial verifier).

EV-88 — ConvergenceDetector recomputes statistics over unbounded full history instead of maintaining running (Welford-style) state

  • Location: fugue-evo/src/diagnostics/convergence.rs:172
  • Severity: low · Dimension: performance · Verification: confirmed · Auditor confidence: n/a

best_fitness_history, mean_fitness_history, and diversity_history (lines 172-176) grow by one element every update() call for the lifetime of a run and are never trimmed. compute_rhat() (lines 294-307) reprocesses the entire mean_fitness_history from scratch on every check() call when use_rhat is enabled, giving O(generations) work and memory per generation (O(generations^2) total over a run) where a Welford-style incremental mean/variance update (as implemented elsewhere in this crate, e.g. src/interactive/uncertainty.rs's WelfordVariance) would give O(1) per-generation cost. This is not a correctness bug — it produces the same numeric answer modulo the separate rhat bug above — but for long-running optimizations with use_rhat enabled and a large max_generations it is a real, avoidable per-generation cost that scales with total run length.

Suggested fix: Maintain running Welford mean/variance accumulators per half-chain (or a fixed-size rolling window) for the R-hat and stagnation computations instead of storing and rescanning the full unbounded history on every check().

Resolution: fixed-with-design-change — ConvergenceDetector maintains incremental prefix sums (mean_cumsum/mean_cumsq, extended O(1) per update()) and compute_rhat() now derives each half-chain's mean/variance from those in O(1), eliminating the O(generations) full-history rescan per check(). Used exact running sum/sum-of-squares accumulators (a running-statistics scheme) rather than literal Welford reverse-update because the second R-hat chain is a sliding window (needs removal), which makes Welford's inverse update numerically fragile; a regression test asserts the incremental R-hat matches the from-scratch recompute within 1e-9 across a 40-step sequence (behavior-identical).

Regression tests: test_compute_rhat_matches_naive_recompute.

Re-verification: verified (independent adversarial verifier).

EV-89 — Checkpoint (de)serialization errors are stringified, discarding the underlying error's source chain

  • Location: fugue-evo/src/error.rs:71
  • Severity: low · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

CheckpointError::Serialization(String)/Deserialization(String) (error.rs:71-77) are always constructed via .map_err(|e| CheckpointError::Serialization(e.to_string())) (recovery.rs:51, 60, 68, 111, 136, 143), converting bincode::Error/serde_json::Error - both of which implement std::error::Error and could be attached with #[source]/#[from] exactly like CheckpointError::Io already does at error.rs:64 - into an opaque formatted string. Callers lose the ability to source()/downcast_ref() into the real cause (e.g. to distinguish an EOF/truncation from a type mismatch), which cuts against the otherwise-good structured-error design used elsewhere in this file (DimensionMismatch{expected,actual}, VersionMismatch{expected,found}).

Suggested fix: Box the underlying error and attach it with #[source] (e.g. Serialization(#[source] Box<dyn std::error::Error + Send + Sync>)) instead of formatting it to a String immediately.

Resolution: fixed-with-design-change — Added source-preserving CheckpointError::SerializeError/DeserializeError variants carrying #[source] Box<dyn Error + Send + Sync>, and routed all six stringifying recovery.rs call sites (plus the RNG snapshot path) through them so callers can source()/downcast_ref() into the real bincode/serde_json cause. Design change vs the audit's 'replace in place' suggestion: the existing Serialization(String)/Deserialization(String) variants are retained because src/interactive/session.rs (owned by a different parallel agent) constructs them with a String; changing their payload shape would break that out-of-ownership file. New variants achieve the finding's goal without that conflict.

Regression tests: src/error.rs::tests::test_checkpoint_error_preserves_source_chain.

Re-verification: verified (independent adversarial verifier).

EV-90 — EvolutionStep MH silently drops the prior/bounds, so its stationary distribution is exp(f/T) over all of R^n, not the on-bounds posterior in the SPEC

  • Location: fugue-evo/src/fugue_integration/evolution_model.rs:206
  • Severity: low · Dimension: math · Verification: confirmed · Auditor confidence: certain

SPEC states the target as P(x)·exp(f(x)/T) with P(x) the uniform-within-bounds prior. But propose() (170-184) adds unbounded noise and never clamps to bounds, and acceptance_probability (206-212) uses only min(1, exp((f(x')−f(x))/T)) with no prior/bounds term. The resulting stationary distribution is therefore ∝ exp(f(x)/T) with an implicit improper-uniform prior over all of R^n, which matches the SPEC's formula only if the prior is taken as an (improper) uniform over R^n rather than the uniform-on-bounds prior the code's sample_prior/generate actually uses to initialize. For bounded-support problems this lets the chain wander outside the feasible region with no rejection. (For Sphere, f = −||x||^2 makes exp(f/T) a proper Gaussian, so the demo tests still converge — masking the issue.) The MH acceptance formula itself is correct for the symmetric proposal; only the target's prior/support is misrepresented.

Suggested fix: Add a prior/bounds term to the acceptance ratio (reject or log-penalize out-of-bounds proposals), or document that EvolutionStep targets the improper-uniform-prior Boltzmann distribution exp(f/T) over R^n.

Resolution: fixed — EvolutionStep MH acceptance now uses the full log target log_boltzmann_target = log_prior_density(x) + beta·f(x). Chose REJECTION of out-of-support proposals (documented): with the uniform-on-bounds prior, log p = -inf outside the box so proposals leaving bounds are rejected, making the on-bounds Boltzmann posterior the exact stationary law. Regression test confirms no sample escapes bounds and the mean matches the analytic truncated-exponential value (boundary not over-weighted).

Regression tests: test_mh_respects_bounds, e_integration_mh_stays_in_bounds.

Re-verification: verified (independent adversarial verifier).

EV-91 — DynamicRealVector::trace_prefix() ('dyn_gene') does not match the address actually used by to_trace/from_trace ('gene')

  • Location: fugue-evo/src/genome/dynamic_real_vector.rs:339
  • Severity: low · Dimension: correctness · Verification: confirmed · Auditor confidence: n/a

trace_prefix() returns "dyn_gene" (line 339), but to_trace (lines 228-250) writes gene values via addr!("gene", i) (line 231) and from_trace (253-284) reads them back via the same literal "gene" (line 266) — never via Self::trace_prefix() or "dyn_gene". The genome's own round trip is therefore self-consistent (both sides use "gene"), so this doesn't break DynamicRealVector in isolation, but trace_prefix() is documented on the trait as 'the address prefix used for trace storage' (traits.rs:72-75); any generic code that trusts trace_prefix() to locate a DynamicRealVector's data in a trace (e.g. composite/nested genome handling, or debugging tools) will look for "dyn_gene#i" and find nothing.

Suggested fix: Either use Self::trace_prefix() consistently inside to_trace/from_trace, or change trace_prefix() to return "gene" to match reality.

Resolution: fixed — DynamicRealVector::to_trace/from_trace now derive the gene address from Self::trace_prefix() (addr!(Self::trace_prefix(), i)) so the advertised prefix ('dyn_gene') and the address actually used can no longer diverge.

Regression tests: genome::dynamic_real_vector::tests::test_trace_prefix_matches_addresses.

Re-verification: verified (independent adversarial verifier).

EV-92 — Permutation::new_unchecked bypasses validation with no unsafe marker; invalid permutations can panic or silently corrupt inverse()/compose()

  • Location: fugue-evo/src/genome/permutation.rs:46
  • Severity: low · Dimension: correctness · Verification: unverified · Auditor confidence: n/a

new_unchecked() is a plain safe fn (pub fn new_unchecked(perm: Vec<usize>) -> Self { Self { perm } }) carrying a '# Safety' doc comment that implies an invariant contract, but nothing in the type system enforces it — any caller can construct a structurally-invalid Permutation without unsafe code. If it flows into inverse() (lines 94-101, inv[j] = i where j comes directly from perm) with an out-of-range value, it panics with an out-of-bounds index; if it has in-range duplicates instead, inv[j] is silently overwritten by the later occurrence and the result is NOT the mathematical inverse (some entries default to 0 / are wrong) with no error at all. compose() (lines 106-115, other.perm[i]) similarly panics on out-of-range indices from an invalid self. Concrete example: Permutation::new_unchecked(vec![0,0,2]).inverse() does not panic (all values in range) but produces inv=[1,0,2] which is a plausible-looking but mathematically meaningless 'inverse' of an input that was never a real permutation, with zero indication of the problem. Grepping the fugue-evo crate confirms new_unchecked has zero internal call sites today, so this is dormant risk for library consumers/custom operator authors rather than an actively triggered bug.

Suggested fix: Either mark it unsafe fn (matching the doc's Safety contract) or drop the unchecked constructor in favor of always routing through try_new()/is_valid_permutation(), given the crate's own operators already prefer the validated path.

Resolution: fixed — Renamed Permutation::new_unchecked to from_vec_unchecked (zero in-crate call sites), documented the exact validity invariants and the concrete corruption modes of inverse()/compose() when violated, and added debug_assert!(is_valid_permutation()) so contract violations are caught in debug builds.

Regression tests: genome::permutation::tests::test_permutation_from_vec_unchecked_debug_asserts, genome::permutation::tests::test_permutation_from_vec_unchecked_rejects_invalid_in_debug.

Re-verification: verified (independent adversarial verifier).

EV-93 — EvolutionaryGenome::distance defaults to 0.0, a silent 'always identical' footgun

  • Location: fugue-evo/src/genome/traits.rs:68
  • Severity: low · Dimension: elegance · Verification: judgment · Auditor confidence: n/a

fn distance(&self, _other: &Self) -> f64 { 0.0 } (traits.rs:67-70) is the trait default. Every built-in genome type overrides it sensibly (Euclidean for RealVector/DynamicRealVector, Hamming for BitString, Kendall-tau for Permutation, size+depth delta for TreeGenome), but any third-party EvolutionaryGenome impl that forgets to override distance() will silently report all genome pairs as distance 0 (i.e. identical) rather than erroring or being forced to implement it. Diversity-dependent mechanisms (niching, crowding distance, speciation) built on top of this trait would then silently collapse to treating the whole population as one cluster with no compile-time or runtime signal that anything is wrong.

Suggested fix: Make distance() a required method (no default), or default to f64::NAN/panic!() so mistaken omission is loud rather than silently wrong.

Resolution: fixed — Removed the silent { 0.0 } default: EvolutionaryGenome::distance is now a required trait method (no default), and a companion required try_distance -> Result was added for the fallible path. Updated all in-crate impls (RealVector, BitString, Permutation, DynamicRealVector, TreeGenome, CompositeGenome, and the three test mocks) to provide both. This is a breaking trait change (0.1.x); a forgotten distance override is now a compile error rather than a silent 'all identical'.

Regression tests: (covered by per-type try_distance/distance regression tests above).

Re-verification: verified (independent adversarial verifier).

EV-94 — EvolutionaryGenome::generate forces every non-real-valued genome type to repurpose MultiBounds' dimension count as a stand-in for length/depth, ignoring its min/max fields

  • Location: fugue-evo/src/genome/traits.rs:55
  • Severity: low · Dimension: elegance · Verification: judgment · Auditor confidence: n/a

The trait signature fn generate<R: Rng>(rng: &mut R, bounds: &MultiBounds) -> Self (traits.rs:55) is shared by every genome kind. MultiBounds is semantically 'per-dimension numeric [min,max] intervals' (bounds.rs:86-89), yet: BitString::generate (bit_string.rs:213-216) only reads bounds.dimension() to decide bit-string length and never touches any bound.min/max; Permutation::generate (permutation.rs:247-250) does the same for permutation size; TreeGenome::generate (tree.rs:576-579) reinterprets bounds.dimension() as an ad hoc max_depth via .max(3).min(10), a magic-number remapping that has nothing to do with what MultiBounds represents anywhere else in the crate. None of this is type-checked — a caller passing genuinely meaningful bounds (e.g. Bounds::new(-100.0, 100.0) per gene, intended for a RealVector) to TreeGenome::generate silently gets a max_depth clamped from the count of those bounds, not their values, which is easy to misuse when genomes are swapped generically (e.g. inside CompositeGenome<A,B>::generate, composite.rs:204-226, which further splits the same bounds vector in half between components regardless of what each component type actually needs from it).

Suggested fix: Consider a genome-specific generation-config associated type instead of overloading MultiBounds for all four genome kinds, or at minimum document per-impl exactly which MultiBounds fields are consulted.

Resolution: fixed — Documented on EvolutionaryGenome::generate exactly which MultiBounds fields each genome kind consults (real types use min/max; BitString/Permutation use only dimension() as length; TreeGenome remaps dimension() to a max depth), and added honest per-type constructors so non-real genomes don't have to abuse bounds: BitString::generate_with_len, Permutation::generate_with_len, TreeGenome::generate_with_depth, DynamicRealVector::generate_with_len (explicit min/max length + value bounds). Each type's generate() now delegates to its honest constructor; trait signature unchanged.

Regression tests: genome::bit_string::tests::test_bit_string_generate_with_len, genome::permutation::tests::test_permutation_generate_with_len, genome::tree::tests::test_tree_generate_with_depth_explicit, genome::dynamic_real_vector::tests::test_generate_with_len_explicit.

Re-verification: verified (independent adversarial verifier).

EV-95 — BetaPosterior::observations() is only correct for the uniform prior; returns wrong counts for Jeffreys/custom priors

  • Location: fugue-evo/src/hyperparameter/bayesian.rs:103
  • Severity: low · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

observations() returns alpha + beta - 2.0 (line 104) with the comment 'Subtract prior pseudo-counts for uniform.' This hardcodes the assumption of a Beta(1,1) prior. With the Jeffreys prior Beta(0.5,0.5) — which the type explicitly supports via jeffreys() (line 29) — the true observation count after n Bernoulli trials is n, but the method returns (0.5+s)+(0.5+f)-2 = n-1. With an informative prior like the example's Beta(2,2) it returns n+2 pseudo-counts minus 2 = n+2, over-counting. Any caller using observations() for a convergence/enough-data check gets an off-by-prior error.

Suggested fix: Store the initial (α0,β0) prior and return (α−α0)+(β−β0), or document that the method is valid only for the uniform prior and gate it accordingly.

Resolution: fixed — BetaPosterior gained alpha0/beta0 fields recording the original prior at construction (set by new()/uniform()/jeffreys()), and observations() now returns (alpha - alpha0) + (beta - beta0) instead of the hardcoded alpha + beta - 2.0, so it reports the correct trial count for Jeffreys Beta(0.5,0.5) and other informative priors, not only the uniform Beta(1,1) case; decay() was likewise changed to decay toward the stored prior (alpha0, beta0) rather than a hardcoded 1.0.

Regression tests: test_beta_observations_uses_stored_prior.

Re-verification: verified (independent adversarial verifier).

EV-96 — PolynomialDecay doc formula does not match the (correct) implementation

  • Location: fugue-evo/src/hyperparameter/schedules.rs:192
  • Severity: low · Dimension: docs · Verification: judgment · Auditor confidence: certain

The doc-comment states p(t) = p₀ * (1 - t/T)^power + p_min (line 192). Taken literally that yields p(0) = p₀ + p_min (not p₀) and p(T) = p_min, which is inconsistent. The implementation (lines 226-228) correctly computes minimum + (initial - minimum) * (1-t)^power, giving p(0)=initial and p(T)=minimum, matching the test at line 462 (t=0.5, power=2 → 0.25). So the code is right and standard; only the docstring formula is wrong and could mislead a reader into expecting an offset that isn't there.

Suggested fix: Update the doc to p(t) = p_min + (p₀ − p_min)·(1 − t/T)^power.

Resolution: fixed — The doc comment on PolynomialDecay was changed from 'p(t) = p0 * (1 - t/T)^power + p_min' to 'p(t) = p_min + (p0 - p_min) * (1 - t/T)^power', matching the pre-existing, unchanged implementation (which already computed minimum + (initial - minimum) * (1-t)^power).

Regression tests: test_polynomial_decay_no_offset_at_start.

Re-verification: verified (independent adversarial verifier).

EV-97 — Isotropic self-adaptation uses a non-standard learning rate 1/√(2√n)

  • Location: fugue-evo/src/hyperparameter/self_adaptive.rs:73
  • Severity: low · Dimension: math · Verification: confirmed · Auditor confidence: likely

For a single global step size σ, the standard mutative self-adaptation rule is σ' = σ·exp(τ₀·N(0,1)) with the learning rate τ₀ = 1/√n (Schwefel). The Isotropic branch (line 73) instead uses tau_prime = 1/√(2√n) (line 68), i.e. (1/√2)·n^{-1/4}, which decays far more slowly with n and is not the canonical single-step-size rate. For n=10 this is 0.398 versus the standard 0.316; the discrepancy grows with n (n=100: 0.224 vs 0.10). Lower impact than the non-isotropic swap because self_adaptive defaults to the non-isotropic path, but it is still a deviation from the reference constant.

Suggested fix: Use τ₀ = 1/√n for the isotropic (single step size) case.

Resolution: fixed — StrategyParams::mutate's Isotropic branch was changed to use a new tau_0 = 1/sqrt(n) coefficient (Schwefel's single-step-size rate) instead of reusing tau_prime = 1/sqrt(2*sqrt(n)).

Regression tests: test_isotropic_learning_rate.

Re-verification: verified (independent adversarial verifier).

EV-98 — ImplicitRanking and Elo uncertainty formulas attach a variance to a quantity on a different scale than the reported mean

  • Location: fugue-evo/src/interactive/aggregation.rs:322
  • Severity: low · Dimension: math · Verification: confirmed · Auditor confidence: certain

For ImplicitRanking (aggregation.rs:322-331), FitnessEstimate is built with mean = stats.model_score (base_fitness plus accumulated ±bonus/penalty, e.g. ~5±k) but variance = p(1−p)/n, the sampling variance of the selection PROPORTION p∈[0,1]. Mean and variance are on unrelated scales, so the resulting CI (mean ± 1.96·sqrt(p(1−p)/n)) is not a valid interval for model_score. Similarly the Elo branch (lines 297-308) uses variance = k²·0.25/n_games as if the rating were an average of n_games Bernoulli draws, but an Elo rating is a running exponentially-weighted estimate whose steady-state variance does not scale as 1/n_games. Both are heuristic placeholders rather than correct uncertainties, which matters because these variances feed uncertainty-sampling and information-gain acquisition.

Suggested fix: Either derive the variance on the same scale as the reported score (e.g. propagate the proportion variance through the bonus/penalty mapping for ImplicitRanking) or document these as coarse heuristics and avoid using them as calibrated variances in the acquisition math.

Resolution: fixed — ImplicitRanking variance is now propagated to the score scale: since model_score = C + (bonus+penalty)S with S~Binomial(n,p), Var(score) = (bonus+penalty)^2 * n * p * (1-p), instead of the [0,1] proportion variance p(1-p)/n. Elo variance is now ks/2 (s=400/ln10, the logistic rating scale) plus a 1/n transient, both in rating^2 units, so it approaches a positive steady-state floor rather than decaying to ~0.

Regression tests: aggregation::tests::test_implicit_ranking_variance_on_score_scale, aggregation::tests::test_elo_variance_has_positive_floor.

Re-verification: verified (independent adversarial verifier).

EV-99 — Binary entropy computed in nats (ln) but the unobserved/degenerate sentinel is 1.0, mixing units and mildly miscalibrating unobserved-vs-observed pair scoring

  • Location: fugue-evo/src/interactive/selection_strategy.rs:611
  • Severity: low · Dimension: math · Verification: unverified · Auditor confidence: certain

binary_entropy (line 609-612) uses natural log, so its maximum (at p=0.5) is ln(2)=0.6931, not 1.0 — confirmed by test_binary_entropy asserting max == LN_2. But the 'we know nothing' sentinel returns 1.0 (lines 594 and 604), and the doc comment (line 584) claims max at p=0.5 giving '1 bit'. Consequences: (1) the doc/units are wrong (nats, not bits). (2) In select_by_information_gain and find_informative_pair, a candidate's score is a SUM of pairwise entropies mixing 1.0 sentinels with <=0.6931 real entropies; one unobserved pair (1.0) is worth ~1.44 maximally-uncertain observed pairs (0.6931), which is an arbitrary calibration rather than a principled one (a truly unknown comparison arguably deserves MORE weight than any observed one, not ~1.44x). Ordering is not catastrophically broken (unobserved still scores high), but the weighting is ad hoc.

Suggested fix: Make binary_entropy use log2 so its max is exactly 1.0 bit and matches the sentinel, OR set the sentinel to std::f64::consts::LN_2 to stay in nats. Fix the doc comment. If unobserved pairs should be prioritized above all observed ones, use a sentinel strictly greater than the max entropy (and document it).

Resolution: fixed — Introduced MAX_BINARY_ENTROPY_NATS = ln(2) and replaced every 1.0 sentinel in pairwise_entropy with it, so entropy is consistently in nats and the unobserved/degenerate sentinel equals binary_entropy's actual maximum. Fixed the misleading doc comments (nats, not bits).

Regression tests: selection_strategy::tests::test_entropy_sentinel_is_nats.

Re-verification: verified (independent adversarial verifier).

EV-100 — Softmax without-replacement sampler defaults chosen_idx to 0 when floating-point cumsum never exceeds r, biasing selection toward the first remaining candidate

  • Location: fugue-evo/src/interactive/selection_strategy.rs:393
  • Severity: low · Dimension: correctness · Verification: unverified · Auditor confidence: possible

In select_by_information_gain's softmax branch (lines 387-405), chosen_idx is initialized to 0 and only overwritten if some prefix cumsum exceeds r = rng.gen() in [0,1). Because the remaining weights are renormalized to sum to ~1 with floating-point rounding, the total can be slightly below 1.0; if r lands in that residual gap, no branch triggers and index 0 is chosen by default, adding a small systematic bias toward remaining[0]. The same pattern in select_pair_by_information_gain (lines 476-484) instead falls through to returning the top-scoring pair, so behavior is inconsistent between the two.

Suggested fix: After the loop, if no element was selected, pick the last remaining element (standard inverse-CDF fallback) rather than defaulting to index 0; or clamp/guard against total<1.

Resolution: fixed — Added an inverse_cdf_pick helper whose residual fallback (when floating-point cumsum never exceeds r) returns the LAST index instead of 0, and used it in both select_by_information_gain (batch) and select_pair_by_information_gain (pair) so the two softmax paths are consistent and free of the index-0 bias.

Regression tests: selection_strategy::tests::test_inverse_cdf_pick_fallback_is_last.

Re-verification: verified (independent adversarial verifier).

EV-101 — SwapMutation / PermutationSwapMutation / InsertMutation derive Default, yielding num_swaps=0 (silent no-op) while new() yields 1

  • Location: fugue-evo/src/operators/mutation.rs:281
  • Severity: low · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

SwapMutation (line 281), PermutationSwapMutation (line 403) and InsertMutation (line 442) all #[derive(..., Default)]. Because the count field is usize, Default sets num_swaps/num_inserts = 0, but new() sets it to 1 (lines 290, 412, 451). The mutate loops are for _ in 0..self.num_swaps (e.g. line 306, 428, 467), so a value produced by Default performs zero swaps/inserts — a completely silent no-op mutation operator. Any code path that constructs these via ::default() (or relies on a Default bound) gets an operator that never mutates, which would stall a GA's exploration with no error.

Suggested fix: Hand-write Default to delegate to new() (num=1), or drop the derived Default so the no-op configuration cannot be produced accidentally.

Resolution: fixed — Removed the derived Default (which set the count field to 0, a silent no-op) from SwapMutation, PermutationSwapMutation, and InsertMutation and hand-implemented Default to delegate to new() (count = 1), so Default::default() actually mutates.

Regression tests: operators::mutation::tests::test_default_swap_mutations_actually_mutate.

Re-verification: verified (independent adversarial verifier).

EV-102 — Unbounded PolynomialMutation fabricates +/-1e10 bounds, making the unbounded path near-random/destructive

  • Location: fugue-evo/src/operators/mutation.rs:79
  • Severity: low · Dimension: usability · Verification: judgment · Auditor confidence: certain

The MutationOperator (non-bounded) impl for PolynomialMutation builds default_bounds = MultiBounds::symmetric(1e10, n) and delegates to mutate_bounded (lines 79-80). Polynomial mutation is intrinsically bounds-relative: for a gene near 0 with min=-1e10,max=1e10 we get delta1=delta2~=0.5 and the perturbation delta_q*range spans up to ~1e10 in magnitude, so an 'unbounded' polynomial mutation effectively replaces the gene with an astronomically large value rather than a local perturbation. UniformMutation similarly fabricates [-1,1] (line 207). The bounded core formula itself is correct (verified numerically: for [0,1] bounds and eta=20 the gene stays in [0,1] and is symmetric about a centered gene), so this is purely about the degenerate fallback bounds, but a user who calls mutate() without bounds gets a wildly destructive operator with no warning.

Suggested fix: Have the non-bounded polynomial mutation require bounds (or return the genome unchanged / debug-assert) instead of silently substituting +/-1e10, since polynomial mutation is undefined without a finite range.

Resolution: fixed-with-design-change — Removed the fabricated +/-1e10 bounds from the unbounded PolynomialMutation path; it now perturbs each gene with Gaussian noise (per-gene sigma default 0.1*(1+|x|), pinnable via new with_unbounded_sigma), keeping the mutation local instead of near-random/destructive. UniformMutation's unbounded [-1,1] default range is documented as an intentional, non-destructive fallback.

Regression tests: operators::mutation::tests::test_unbounded_polynomial_mutation_stays_local.

Re-verification: verified (independent adversarial verifier).

EV-103 — mutation_probability() reports 1.0 while the actual default per-gene rate applied is 1/n

  • Location: fugue-evo/src/operators/mutation.rs:83
  • Severity: low · Dimension: correctness · Verification: confirmed · Auditor confidence: certain

PolynomialMutation::mutation_probability() returns self.mutation_probability.unwrap_or(1.0) (lines 83-85) and GaussianMutation likewise returns unwrap_or(1.0) (lines 149-151), but the operators actually apply the default per-gene probability 1/n internally (mutate_bounded line 91 unwrap_or(1.0 / n as f64), Gaussian line 139/157 the same). UniformMutation does not override the method at all, so it reports the trait default 1.0 while also using 1/n internally. Thus the publicly queryable per-gene rate is off by a factor of n from what is applied whenever the probability is left unset (the documented default). It is not consumed by the current algorithm implementations (SimpleGA calls mutate unconditionally, NSGA-II uses its own config rate), so impact is limited to API correctness/observability, but any external consumer trusting the reported value would be misled.

Suggested fix: Return the effective rate (1/n requires knowing n; either take dimension or return an Option/None to signal the 1/n default) rather than a constant 1.0 that contradicts the applied rate.

Resolution: fixed-with-design-change — Changed MutationOperator::mutation_probability to return Option: None truthfully signals the genome-length-dependent 1/n default (previously misreported as 1.0), Some(p) reports a configured rate. Updated all in-crate impls (Polynomial, Gaussian, Uniform [new override], BitFlip, Point) and the trait default (Some(1.0)). No in-crate or WASM callers of the method exist, so no call sites needed updating.

Regression tests: operators::mutation::tests::test_mutation_probability_reports_effective_rate.

Re-verification: verified (independent adversarial verifier).

EV-104 — Tournament selection samples competitors without replacement, altering selection pressure and becoming deterministic when tournament_size >= population size

  • Location: fugue-evo/src/operators/selection.rs:43
  • Severity: low · Dimension: math · Verification: confirmed · Auditor confidence: certain

select() uses indices.choose_multiple(rng, tournament_size) (line 43), which samples distinct indices WITHOUT replacement. Textbook tournament selection samples WITH replacement (each of k competitors drawn i.i.d.), which gives the clean closed-form selection probability P(rank i wins) and lets pressure grow smoothly with k. Without replacement the win probabilities differ (a given individual cannot face itself), and because tournament_size is clamped to population.len() (line 38), a k >= n selects the global maximum deterministically every call, collapsing diversity. This is a legitimate variant, not a bug, but the pressure differs from the standard model and the deterministic-at-k>=n behavior is a sharp edge worth documenting.

Suggested fix: Either sample with replacement (rng.gen_range in a loop / choose_multiple over a with-replacement sampler) for canonical pressure, or document that this is without-replacement tournament selection and that k>=n is fully elitist.

Resolution: fixed-with-design-change — TournamentSelection::new now samples competitors WITH replacement (canonical, k competitors drawn i.i.d.), so tournament_size >= population size is no longer deterministic. Added without_replacement() constructor retaining the distinct-competitor variant (still capped at n and fully elitist at k>=n) and a with_replacement field; corrected the determinism documentation.

Regression tests: operators::selection::tests::test_tournament_with_replacement_is_not_deterministic_at_full_size, operators::selection::tests::test_tournament_without_replacement_full_size_is_elitist.

Re-verification: verified (independent adversarial verifier).

EV-105 — FitnessStagnation uses abs(last minus first) of the window, masking non-monotonic regressions

  • Location: fugue-evo/src/termination/mod.rs:110
  • Severity: low · Dimension: correctness · Verification: confirmed · Auditor confidence: likely

should_terminate computes improvement = abs(last minus first) over the trailing window (108-112). The abs() flags a run that improved then fell back near its start as stagnant, and with elitism disabled a net regression is read as positive improvement and never terminates. A robust test should use the window range (max minus min) or best-so-far improvement, not the endpoint difference.

Suggested fix: Use the peak-to-peak range or best-over-window improvement and drop the abs().

Resolution: fixed — Same fix as EV-73: FitnessStagnation::should_terminate uses the best-so-far window improvement (dropping abs() and the endpoint-only delta), robust to non-monotonic/regressing histories.

Regression tests: test_fitness_stagnation_nonmonotonic_window (includes a regressing [90,50,10] case).

Re-verification: verified (independent adversarial verifier).

EV-106 — fugue-evo property tests use unseeded RNG throughout; no seeded-determinism regression test exists in fugue-evo analogous to fugue's ReplayHandler test

  • Location: fugue-evo/tests/property_tests.rs:13
  • Severity: low · Dimension: testing · Verification: judgment · Auditor confidence: n/a

Every proptest in property_tests.rs calls rand::thread_rng() (lines 13, 24, 73, 99, 107, 121, 165, 186, 227, 235) rather than a seeded RNG, so proptest failures are only reproducible via its own shrink-seed mechanism, not a fixed application seed. More importantly, fugue itself has a genuine determinism test - tests/model_execution.rs:139-188 test_replay_and_score_handlers runs PriorHandler then ReplayHandler with the same base trace and asserts original_value == replayed_value and matching addresses, which is exactly the seeded-RNG-determinism guarantee a PPL needs. Searching fugue-evo's cmaes.rs, bradley_terry.rs, and other algorithm modules for an equivalent 'same seed => identical output across two full runs' test turned up none; algorithm tests (e.g. test_cmaes_optimization, cmaes.rs:846) use a seeded StdRng but only to make a single run's outcome assertion stable, not to compare two independent seeded runs for byte-identical reproducibility.

Suggested fix: Add at least one fugue-evo test that runs an algorithm (e.g. CmaEs::run_generations or SimpleGA) twice with StdRng::seed_from_u64(same_seed) and asserts the resulting genomes/fitness trajectories are identical, to catch any accidental hidden nondeterminism (e.g. HashMap iteration order, thread_rng leakage) introduced by future changes.

Resolution: fixed — Replaced rand::thread_rng() with StdRng::seed_from_u64(seed) (seed itself drawn as a proptest any::() input) in all 10 affected proptests in tests/property_tests.rs, tying the RNG draw into proptest's reproducible/shrinkable input space instead of drawing fresh, non-reproducible entropy each run. Added tests/e_tests_seeded_determinism.rs with a same-seed-is-deterministic / different-seed-differs pair of tests for SimpleGA, CMA-ES, (mu+lambda)-ES, UMDA, and NSGA-II, analogous to fugue's ReplayHandler determinism test. Added tests/e_tests_nan_policy.rs with should_panic regression tests proving Population::evaluate, SimpleGA::run, EvolutionStrategy::run, and ContinuousUMDA::run all panic (rather than silently corrupting comparisons) when a fitness function returns NaN, exercising the existing EV-07 wave-A Individual::set_fitness guard end-to-end.

Regression tests: tests/e_tests_seeded_determinism.rs::simple_ga_same_seed_is_deterministic, tests/e_tests_seeded_determinism.rs::simple_ga_different_seed_differs, tests/e_tests_seeded_determinism.rs::cmaes_same_seed_is_deterministic, tests/e_tests_seeded_determinism.rs::cmaes_different_seed_differs, tests/e_tests_seeded_determinism.rs::es_same_seed_is_deterministic, tests/e_tests_seeded_determinism.rs::es_different_seed_differs, tests/e_tests_seeded_determinism.rs::umda_same_seed_is_deterministic, tests/e_tests_seeded_determinism.rs::umda_different_seed_differs, tests/e_tests_seeded_determinism.rs::nsga2_same_seed_is_deterministic, tests/e_tests_seeded_determinism.rs::nsga2_different_seed_differs, tests/e_tests_nan_policy.rs::population_evaluate_panics_on_nan_fitness, tests/e_tests_nan_policy.rs::simple_ga_run_panics_on_nan_fitness, tests/e_tests_nan_policy.rs::evolution_strategy_run_panics_on_nan_fitness, tests/e_tests_nan_policy.rs::umda_run_panics_on_nan_fitness, tests/property_tests.rs (all 10 seeded proptests, e.g. real_vector_dimension_preserved, permutation_is_valid, sbx_crossover_produces_valid_offspring, population_maintains_size).

Re-verification: verified (independent adversarial verifier).