Skip to content

Commit 16c10bf

Browse files
basvanwestingclaude
andcommitted
Compile-time crossover safety, sensible defaults, framework-owned age lifecycle
- Add SupportsGeneCrossover/SupportsPointCrossover marker traits; UniqueGenotype + incompatible crossover is now a compile error instead of runtime panic - Remove has_crossover_indexes/has_crossover_points runtime checks from builder - Default target_population_size from 0 to 100 - Move increment_age from crossover implementations to evolve loop - Update AGENTS.md: remove solved gotchas (1,5,7,9), renumber remaining Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent de5ef41 commit 16c10bf

41 files changed

Lines changed: 203 additions & 372 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
This file helps AI coding agents use this library correctly. It covers decision
44
guidance, API reference, gotchas, and copy-paste templates.
55

6-
**Read the Gotchas section before writing code.** Gotchas 1, 2, 3, and 7 cause
6+
**Read the Gotchas section before writing code.** Gotchas 1, 2, and 5 cause
77
compilation or runtime failures that are hard to debug after the fact.
88

99
## Quick Start
@@ -84,17 +84,18 @@ When implementing `calculate_for_chromosome`, access genes via `chromosome.genes
8484
| `BinaryGenotype` | All | `CrossoverUniform` or `CrossoverSinglePoint` |
8585
| `ListGenotype<T>` | All | `CrossoverUniform` |
8686
| `MultiListGenotype<T>` | All | `CrossoverUniform` |
87-
| `UniqueGenotype<T>` | `CrossoverClone`, `CrossoverRejuvenate` ONLY | `CrossoverClone` |
88-
| `MultiUniqueGenotype<T>` | Point-based + `CrossoverClone`, `CrossoverRejuvenate` (NO gene-based) | `CrossoverSinglePoint` |
87+
| `UniqueGenotype<T>` | `CrossoverClone`, `CrossoverRejuvenate` ONLY (others are compile errors) | `CrossoverClone` |
88+
| `MultiUniqueGenotype<T>` | Point-based + `CrossoverClone`, `CrossoverRejuvenate` (gene-based are compile errors) | `CrossoverSinglePoint` |
8989
| `RangeGenotype<T>` | All | `CrossoverMultiPoint` |
9090
| `MultiRangeGenotype<T>` | All | `CrossoverSingleGene` |
9191

92-
**WARNING**: `UniqueGenotype` will cause a **runtime panic** with gene-based or
93-
point-based crossovers. Use `CrossoverClone` (clones parents, relies on mutation
94-
for diversity) or `CrossoverRejuvenate` (like Clone but optimized for less memory copying).
95-
`MultiUniqueGenotype` supports point-based crossovers (`CrossoverSinglePoint`,
96-
`CrossoverMultiPoint`) but panics on gene-based ones (`CrossoverUniform`,
97-
`CrossoverSingleGene`, `CrossoverMultiGene`).
92+
**Compile-time safety**: `UniqueGenotype` does not implement `SupportsGeneCrossover`
93+
or `SupportsPointCrossover`, so incompatible crossovers are **compile errors**.
94+
Use `CrossoverClone` (clones parents, relies on mutation for diversity) or
95+
`CrossoverRejuvenate` (like Clone but optimized for less memory copying).
96+
`MultiUniqueGenotype` implements `SupportsPointCrossover` only, so gene-based
97+
crossovers (`CrossoverUniform`, `CrossoverSingleGene`, `CrossoverMultiGene`) are
98+
compile errors.
9899

99100
### Which Select?
100101

@@ -250,7 +251,7 @@ ExtensionMassDeduplication::new(
250251
Required:
251252
- `.with_genotype(genotype)` — the search space
252253
- `.with_fitness(fitness)` — the evaluation function
253-
- `.with_target_population_size(n)` — number of chromosomes (**defaults to 0, always set this**)
254+
- `.with_target_population_size(n)` — number of chromosomes (defaults to 100)
254255
- `.with_select(select)` — parent selection strategy
255256
- `.with_crossover(crossover)` — how parents combine
256257
- `.with_mutate(mutate)` — how offspring are varied
@@ -348,22 +349,20 @@ let (best, _all_runs) = Evolve::builder()
348349
HillClimb also supports `.call_repeatedly(n)` and `.call_par_repeatedly(n)`.
349350

350351
Both `.call()` and `.build()` return `Result<_, TryFromEvolveBuilderError>`.
351-
Builder validation catches: missing required fields, incompatible genotype +
352-
crossover combinations, and missing ending conditions. The error message includes
353-
an actionable fix suggestion.
352+
Builder validation catches: missing required fields and missing ending conditions.
353+
Incompatible genotype + crossover combinations are caught at compile time via
354+
trait bounds (`SupportsGeneCrossover`, `SupportsPointCrossover`). The error
355+
message includes an actionable fix suggestion.
354356

355357
### Common mistakes
356358

357359
```rust
358-
// WRONG: UniqueGenotype + CrossoverUniform = RUNTIME PANIC
360+
// WRONG: UniqueGenotype + CrossoverUniform = COMPILE ERROR
359361
// FIX: Use CrossoverClone or CrossoverRejuvenate
360362

361363
// WRONG: No ending condition = COMPILE/BUILD ERROR
362364
// FIX: Add .with_max_stale_generations(1000)
363365

364-
// WRONG: target_population_size not set (defaults to 0) = SILENT FAILURE
365-
// FIX: Add .with_target_population_size(100)
366-
367366
// WRONG: Fitness returns f64 = TYPE ERROR
368367
// FIX: Return Some((score / precision) as FitnessValue)
369368

@@ -721,13 +720,10 @@ Combine with `max_stale_generations` to trigger phase transitions automatically
721720

722721
## Gotchas
723722

724-
1. **UniqueGenotype + standard crossover = runtime panic.** Use `CrossoverClone`. MultiUniqueGenotype supports point-based but not gene-based crossovers.
725-
2. **FitnessValue is isize.** Scale floats manually: `(score / precision) as FitnessValue`.
726-
3. **Ending condition required.** Evolve/HillClimb need at least one of: `target_fitness_score`, `max_stale_generations`, `max_generations`.
727-
4. **Fitness struct must be `Clone + Debug`.**
728-
5. **RangeGenotype requires a type parameter.** Use turbofish: `RangeGenotype::<f64>::builder()`, `RangeGenotype::<i32>::builder()`.
729-
6. **Permutate + RangeGenotype** requires `MutationType::Step`, `StepScaled`, or `Discrete`.
730-
7. **`target_population_size` defaults to 0.** Always set it for Evolve.
731-
8. **Custom Crossover/Mutate/Extension must call `chromosome.reset_metadata(genotype.genes_hashing)`** after modifying genes directly.
732-
9. **Custom Crossover must call `state.population.increment_age()`** on existing chromosomes.
733-
10. **For deterministic tests:** use `.with_rng_seed_from_u64(0)`. Exact results may change between library versions, but deterministic within a version.
723+
1. **FitnessValue is isize.** Scale floats manually: `(score / precision) as FitnessValue`.
724+
2. **Ending condition required.** Evolve/HillClimb need at least one of: `target_fitness_score`, `max_stale_generations`, `max_generations`.
725+
3. **Fitness struct must be `Clone + Debug`.**
726+
4. **Permutate + RangeGenotype** requires `MutationType::Step`, `StepScaled`, or `Discrete`.
727+
5. **`target_population_size` defaults to 100.** Override with `.with_target_population_size(n)` if needed.
728+
6. **Custom Crossover/Mutate/Extension must call `chromosome.reset_metadata(genotype.genes_hashing)`** after modifying genes directly.
729+
7. **For deterministic tests:** use `.with_rng_seed_from_u64(0)`. Exact results may change between library versions, but deterministic within a version.

src/crossover.rs

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
1414
//! High values converge faster, but risk losing good solutions. Low values have poor exploration
1515
//! and risk of premature convergence
1616
//!
17-
//! As crossover adds offspring (with age zero) it is also the responsibility of the crossover to
18-
//! increment the age of the parents. This is done because the definition of "offspring" is "age
19-
//! zero". So this should be done in-sync and is therefore not a generational loop concern of Evolve.
17+
//! Age tracking: the evolve loop increments the age of all chromosomes immediately before calling
18+
//! crossover. Crossover then adds offspring (with age zero) to the population. These two steps are
19+
//! logically coupled — offspring are "age zero" relative to their parents' incremented age — but
20+
//! the framework owns the increment so custom crossover implementations cannot forget it.
2021
//!
2122
//! Normally the crossover adds children to the population, thus increasing the population_size
2223
//! above the target_population_size. Selection will reduce this again in the next generation.
@@ -83,8 +84,7 @@ pub type CrossoverAllele<C> = <<C as Crossover>::Genotype as Genotype>::Allele;
8384
/// let selected_population_size =
8485
/// (existing_population_size as f32 * self.selection_rate).ceil() as usize;
8586
///
86-
/// // Important!!! Increment age, as the old offspring now become parents
87-
/// state.population.increment_age();
87+
/// // Note: increment_age is handled by the evolve loop immediately before this call
8888
/// // Important!!! Append offspring as recycled clones from parents (will reset age and crossover later)
8989
/// // Use population's methods for safe chromosome recycling
9090
/// state.population.extend_from_within(selected_population_size);
@@ -151,17 +151,6 @@ pub trait Crossover: Clone + Send + Sync + std::fmt::Debug {
151151
) {
152152
// state.update_population_cardinality(genotype, config);
153153
}
154-
155-
/// to guard against invalid Crossover strategies which break the internal consistency
156-
/// of the genes, unique genotypes can't simply exchange genes without gene duplication issues
157-
fn require_crossover_indexes(&self) -> bool {
158-
false
159-
}
160-
/// to guard against invalid Crossover strategies which break the internal consistency
161-
/// of the genes, unique genotypes can't simply exchange genes without gene duplication issues
162-
fn require_crossover_points(&self) -> bool {
163-
false
164-
}
165154
}
166155

167156
#[derive(Clone, Debug)]

src/crossover/clone.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ impl<G: EvolveGenotype> Crossover for Clone<G> {
2929
let selected_population_size =
3030
(existing_population_size as f32 * self.selection_rate).ceil() as usize;
3131

32-
state.population.increment_age();
3332
state
3433
.population
3534
.extend_from_within(selected_population_size);

src/crossover/multi_gene.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use super::Crossover;
2-
use crate::genotype::EvolveGenotype;
2+
use crate::genotype::{EvolveGenotype, SupportsGeneCrossover};
33
use crate::strategy::evolve::{EvolveConfig, EvolveState};
44
use crate::strategy::{StrategyAction, StrategyReporter, StrategyState};
55
use itertools::Itertools;
@@ -16,15 +16,15 @@ use std::time::Instant;
1616
/// [MultiUniqueGenotype](crate::genotype::MultiUniqueGenotype) as it would not preserve the gene
1717
/// uniqueness in the children.
1818
#[derive(Clone, Debug)]
19-
pub struct MultiGene<G: EvolveGenotype> {
19+
pub struct MultiGene<G: EvolveGenotype + SupportsGeneCrossover> {
2020
_phantom: PhantomData<G>,
2121
pub selection_rate: f32,
2222
pub crossover_rate: f32,
2323
pub crossover_sampler: Bernoulli,
2424
pub number_of_crossovers: usize,
2525
pub allow_duplicates: bool,
2626
}
27-
impl<G: EvolveGenotype> Crossover for MultiGene<G> {
27+
impl<G: EvolveGenotype + SupportsGeneCrossover> Crossover for MultiGene<G> {
2828
type Genotype = G;
2929

3030
fn call<R: Rng, SR: StrategyReporter<Genotype = G>>(
@@ -39,7 +39,6 @@ impl<G: EvolveGenotype> Crossover for MultiGene<G> {
3939
let existing_population_size = state.population.chromosomes.len();
4040
let selected_population_size =
4141
(existing_population_size as f32 * self.selection_rate).ceil() as usize;
42-
state.population.increment_age();
4342
state
4443
.population
4544
.extend_from_within(selected_population_size);
@@ -69,12 +68,9 @@ impl<G: EvolveGenotype> Crossover for MultiGene<G> {
6968
}
7069
state.add_duration(StrategyAction::Crossover, now.elapsed());
7170
}
72-
fn require_crossover_indexes(&self) -> bool {
73-
true
74-
}
7571
}
7672

77-
impl<G: EvolveGenotype> MultiGene<G> {
73+
impl<G: EvolveGenotype + SupportsGeneCrossover> MultiGene<G> {
7874
/// Create a new MultiGene crossover strategy.
7975
/// * `selection_rate` - fraction of parents selected for reproduction (0.5-0.8 typical)
8076
/// * `crossover_rate` - probability parent pair crosses over vs cloning (0.5-0.9 typical)

src/crossover/multi_point.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use super::Crossover;
2-
use crate::genotype::EvolveGenotype;
2+
use crate::genotype::{EvolveGenotype, SupportsPointCrossover};
33
use crate::strategy::evolve::{EvolveConfig, EvolveState};
44
use crate::strategy::{StrategyAction, StrategyReporter, StrategyState};
55
use itertools::Itertools;
@@ -16,15 +16,15 @@ use std::time::Instant;
1616
/// Not allowed for [UniqueGenotype](crate::genotype::UniqueGenotype) as it would not preserve the gene uniqueness in the children.
1717
/// Allowed for [MultiUniqueGenotype](crate::genotype::MultiUniqueGenotype) as there are valid crossover points between each new set
1818
#[derive(Clone, Debug)]
19-
pub struct MultiPoint<G: EvolveGenotype> {
19+
pub struct MultiPoint<G: EvolveGenotype + SupportsPointCrossover> {
2020
_phantom: PhantomData<G>,
2121
pub selection_rate: f32,
2222
pub crossover_rate: f32,
2323
pub crossover_sampler: Bernoulli,
2424
pub number_of_crossovers: usize,
2525
pub allow_duplicates: bool,
2626
}
27-
impl<G: EvolveGenotype> Crossover for MultiPoint<G> {
27+
impl<G: EvolveGenotype + SupportsPointCrossover> Crossover for MultiPoint<G> {
2828
type Genotype = G;
2929

3030
fn call<R: Rng, SR: StrategyReporter<Genotype = G>>(
@@ -39,7 +39,6 @@ impl<G: EvolveGenotype> Crossover for MultiPoint<G> {
3939
let existing_population_size = state.population.chromosomes.len();
4040
let selected_population_size =
4141
(existing_population_size as f32 * self.selection_rate).ceil() as usize;
42-
state.population.increment_age();
4342
state
4443
.population
4544
.extend_from_within(selected_population_size);
@@ -69,12 +68,9 @@ impl<G: EvolveGenotype> Crossover for MultiPoint<G> {
6968
}
7069
state.add_duration(StrategyAction::Crossover, now.elapsed());
7170
}
72-
fn require_crossover_points(&self) -> bool {
73-
true
74-
}
7571
}
7672

77-
impl<G: EvolveGenotype> MultiPoint<G> {
73+
impl<G: EvolveGenotype + SupportsPointCrossover> MultiPoint<G> {
7874
/// Create a new MultiPoint crossover strategy.
7975
/// * `selection_rate` - fraction of parents selected for reproduction (0.5-0.8 typical)
8076
/// * `crossover_rate` - probability parent pair crosses over vs cloning (0.5-0.9 typical)

src/crossover/rejuvenate.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ impl<G: EvolveGenotype> Crossover for Rejuvenate<G> {
3333
let dropped_population_size = (existing_population_size - selected_population_size).max(0);
3434

3535
state.population.truncate(selected_population_size);
36-
state.population.increment_age();
3736
state.population.extend_from_within(dropped_population_size);
3837

3938
state

src/crossover/single_gene.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use super::Crossover;
2-
use crate::genotype::EvolveGenotype;
2+
use crate::genotype::{EvolveGenotype, SupportsGeneCrossover};
33
use crate::strategy::evolve::{EvolveConfig, EvolveState};
44
use crate::strategy::{StrategyAction, StrategyReporter, StrategyState};
55
use itertools::Itertools;
@@ -15,13 +15,13 @@ use std::time::Instant;
1515
/// [MultiUniqueGenotype](crate::genotype::MultiUniqueGenotype) as it would not preserve the gene
1616
/// uniqueness in the children.
1717
#[derive(Clone, Debug)]
18-
pub struct SingleGene<G: EvolveGenotype> {
18+
pub struct SingleGene<G: EvolveGenotype + SupportsGeneCrossover> {
1919
_phantom: PhantomData<G>,
2020
pub selection_rate: f32,
2121
pub crossover_rate: f32,
2222
pub crossover_sampler: Bernoulli,
2323
}
24-
impl<G: EvolveGenotype> Crossover for SingleGene<G> {
24+
impl<G: EvolveGenotype + SupportsGeneCrossover> Crossover for SingleGene<G> {
2525
type Genotype = G;
2626

2727
fn call<R: Rng, SR: StrategyReporter<Genotype = G>>(
@@ -36,7 +36,6 @@ impl<G: EvolveGenotype> Crossover for SingleGene<G> {
3636
let existing_population_size = state.population.chromosomes.len();
3737
let selected_population_size =
3838
(existing_population_size as f32 * self.selection_rate).ceil() as usize;
39-
state.population.increment_age();
4039
state
4140
.population
4241
.extend_from_within(selected_population_size);
@@ -61,12 +60,9 @@ impl<G: EvolveGenotype> Crossover for SingleGene<G> {
6160

6261
state.add_duration(StrategyAction::Crossover, now.elapsed());
6362
}
64-
fn require_crossover_indexes(&self) -> bool {
65-
true
66-
}
6763
}
6864

69-
impl<G: EvolveGenotype> SingleGene<G> {
65+
impl<G: EvolveGenotype + SupportsGeneCrossover> SingleGene<G> {
7066
/// Create a new SingleGene crossover strategy.
7167
/// * `selection_rate` - fraction of parents selected for reproduction (0.5-0.8 typical)
7268
/// * `crossover_rate` - probability parent pair crosses over vs cloning (0.5-0.9 typical)

src/crossover/single_point.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use super::Crossover;
2-
use crate::genotype::EvolveGenotype;
2+
use crate::genotype::{EvolveGenotype, SupportsPointCrossover};
33
use crate::strategy::evolve::{EvolveConfig, EvolveState};
44
use crate::strategy::{StrategyAction, StrategyReporter, StrategyState};
55
use itertools::Itertools;
@@ -14,13 +14,13 @@ use std::time::Instant;
1414
/// Not allowed for [UniqueGenotype](crate::genotype::UniqueGenotype) as it would not preserve the gene uniqueness in the children.
1515
/// Allowed for [MultiUniqueGenotype](crate::genotype::MultiUniqueGenotype) as there are valid crossover points between each new set
1616
#[derive(Clone, Debug)]
17-
pub struct SinglePoint<G: EvolveGenotype> {
17+
pub struct SinglePoint<G: EvolveGenotype + SupportsPointCrossover> {
1818
_phantom: PhantomData<G>,
1919
pub selection_rate: f32,
2020
pub crossover_rate: f32,
2121
pub crossover_sampler: Bernoulli,
2222
}
23-
impl<G: EvolveGenotype> Crossover for SinglePoint<G> {
23+
impl<G: EvolveGenotype + SupportsPointCrossover> Crossover for SinglePoint<G> {
2424
type Genotype = G;
2525

2626
fn call<R: Rng, SR: StrategyReporter<Genotype = G>>(
@@ -35,7 +35,6 @@ impl<G: EvolveGenotype> Crossover for SinglePoint<G> {
3535
let existing_population_size = state.population.chromosomes.len();
3636
let selected_population_size =
3737
(existing_population_size as f32 * self.selection_rate).ceil() as usize;
38-
state.population.increment_age();
3938
state
4039
.population
4140
.extend_from_within(selected_population_size);
@@ -60,12 +59,9 @@ impl<G: EvolveGenotype> Crossover for SinglePoint<G> {
6059

6160
state.add_duration(StrategyAction::Crossover, now.elapsed());
6261
}
63-
fn require_crossover_points(&self) -> bool {
64-
true
65-
}
6662
}
6763

68-
impl<G: EvolveGenotype> SinglePoint<G> {
64+
impl<G: EvolveGenotype + SupportsPointCrossover> SinglePoint<G> {
6965
/// Create a new SinglePoint crossover strategy.
7066
/// * `selection_rate` - fraction of parents selected for reproduction (0.5-0.8 typical)
7167
/// * `crossover_rate` - probability parent pair crosses over vs cloning (0.5-0.9 typical)

src/crossover/uniform.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use super::Crossover;
2-
use crate::genotype::EvolveGenotype;
2+
use crate::genotype::{EvolveGenotype, SupportsGeneCrossover};
33
use crate::strategy::evolve::{EvolveConfig, EvolveState};
44
use crate::strategy::{StrategyAction, StrategyReporter, StrategyState};
55
use itertools::Itertools;
@@ -15,14 +15,14 @@ use std::time::Instant;
1515
/// [MultiUniqueGenotype](crate::genotype::MultiUniqueGenotype) as it would not preserve the gene
1616
/// uniqueness in the children.
1717
#[derive(Clone, Debug)]
18-
pub struct Uniform<G: EvolveGenotype> {
18+
pub struct Uniform<G: EvolveGenotype + SupportsGeneCrossover> {
1919
_phantom: PhantomData<G>,
2020
pub selection_rate: f32,
2121
pub crossover_rate: f32,
2222
pub crossover_sampler: Bernoulli,
2323
}
2424

25-
impl<G: EvolveGenotype> Crossover for Uniform<G> {
25+
impl<G: EvolveGenotype + SupportsGeneCrossover> Crossover for Uniform<G> {
2626
type Genotype = G;
2727

2828
fn call<R: Rng, SR: StrategyReporter<Genotype = G>>(
@@ -38,7 +38,6 @@ impl<G: EvolveGenotype> Crossover for Uniform<G> {
3838
let existing_population_size = state.population.chromosomes.len();
3939
let selected_population_size =
4040
(existing_population_size as f32 * self.selection_rate).ceil() as usize;
41-
state.population.increment_age();
4241
state
4342
.population
4443
.extend_from_within(selected_population_size);
@@ -69,12 +68,9 @@ impl<G: EvolveGenotype> Crossover for Uniform<G> {
6968

7069
state.add_duration(StrategyAction::Crossover, now.elapsed());
7170
}
72-
fn require_crossover_indexes(&self) -> bool {
73-
true
74-
}
7571
}
7672

77-
impl<G: EvolveGenotype> Uniform<G> {
73+
impl<G: EvolveGenotype + SupportsGeneCrossover> Uniform<G> {
7874
/// Create a new Uniform crossover strategy.
7975
/// * `selection_rate` - fraction of parents selected for reproduction (0.5-0.8 typical)
8076
/// * `crossover_rate` - probability parent pair crosses over vs cloning (0.5-0.9 typical)

0 commit comments

Comments
 (0)