From 65dda09aff154db64eb41bfcfd56a26d3df12180 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 9 May 2026 20:15:21 +0200 Subject: [PATCH] feat(ga): next_generation step (elitism + tournament + mutate/crossover) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/multi_swarm/ga/loop.py | 41 ++++++++++++++++++++++++++++++++++ tests/unit/test_ga_loop.py | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/multi_swarm/ga/loop.py create mode 100644 tests/unit/test_ga_loop.py diff --git a/src/multi_swarm/ga/loop.py b/src/multi_swarm/ga/loop.py new file mode 100644 index 0000000..cfbbbd9 --- /dev/null +++ b/src/multi_swarm/ga/loop.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass + +from ..genome.crossover import uniform_crossover +from ..genome.hypothesis import HypothesisAgentGenome +from ..genome.mutation import random_mutate +from .selection import elite_select, tournament_select + + +@dataclass(frozen=True) +class GAConfig: + population_size: int + elite_k: int + tournament_k: int + p_crossover: float + + +def next_generation( + population: list[HypothesisAgentGenome], + fitnesses: dict[str, float], + cfg: GAConfig, + rng: random.Random, +) -> list[HypothesisAgentGenome]: + """Costruisce la prossima generazione: elitismo + tournament + crossover/mutate.""" + new_pop: list[HypothesisAgentGenome] = list( + elite_select(population, fitnesses, cfg.elite_k) + ) + + while len(new_pop) < cfg.population_size: + if rng.random() < cfg.p_crossover and len(population) >= 2: + p1 = tournament_select(population, fitnesses, cfg.tournament_k, rng) + p2 = tournament_select(population, fitnesses, cfg.tournament_k, rng) + child = uniform_crossover(p1, p2, rng) + else: + parent = tournament_select(population, fitnesses, cfg.tournament_k, rng) + child = random_mutate(parent, rng) + new_pop.append(child) + + return new_pop[: cfg.population_size] diff --git a/tests/unit/test_ga_loop.py b/tests/unit/test_ga_loop.py new file mode 100644 index 0000000..6e52ebe --- /dev/null +++ b/tests/unit/test_ga_loop.py @@ -0,0 +1,45 @@ +import random + +from multi_swarm.ga.loop import GAConfig, next_generation +from multi_swarm.genome.hypothesis import HypothesisAgentGenome, ModelTier + + +def make(idx: int) -> HypothesisAgentGenome: + return HypothesisAgentGenome( + system_prompt=f"p-{idx}", + feature_access=["close"], + temperature=0.9, + top_p=0.95, + model_tier=ModelTier.C, + lookback_window=100, + cognitive_style="x", + ) + + +def test_next_generation_size_preserved() -> None: + population = [make(i) for i in range(20)] + fitnesses = {g.id: float(i) for i, g in enumerate(population)} + cfg = GAConfig(population_size=20, elite_k=2, tournament_k=3, p_crossover=0.5) + new_pop = next_generation(population, fitnesses, cfg, rng=random.Random(0)) + assert len(new_pop) == 20 + + +def test_next_generation_includes_elites() -> None: + population = [make(i) for i in range(20)] + fitnesses = {g.id: float(i) for i, g in enumerate(population)} + cfg = GAConfig(population_size=20, elite_k=2, tournament_k=3, p_crossover=0.5) + new_pop = next_generation(population, fitnesses, cfg, rng=random.Random(0)) + elite_ids = { + g.id for g in sorted(population, key=lambda g: fitnesses[g.id], reverse=True)[:2] + } + new_ids = {g.id for g in new_pop} + assert elite_ids.issubset(new_ids) + + +def test_next_generation_increments_generation_for_offspring() -> None: + population = [make(i) for i in range(20)] + fitnesses = {g.id: float(i) for i, g in enumerate(population)} + cfg = GAConfig(population_size=20, elite_k=2, tournament_k=3, p_crossover=0.5) + new_pop = next_generation(population, fitnesses, cfg, rng=random.Random(0)) + new_offspring = [g for g in new_pop if g.id not in {p.id for p in population}] + assert all(g.generation > 0 for g in new_offspring)