diff --git a/src/multi_swarm/genome/mutation.py b/src/multi_swarm/genome/mutation.py new file mode 100644 index 0000000..4699f94 --- /dev/null +++ b/src/multi_swarm/genome/mutation.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import random +from typing import Any + +from .hypothesis import HypothesisAgentGenome + +FEATURE_POOL: tuple[str, ...] = ("open", "high", "low", "close", "volume") + +COGNITIVE_STYLES: tuple[str, ...] = ( + "physicist", + "biologist", + "historian", + "meteorologist", + "ecologist", + "engineer", +) + + +def _clone_with(g: HypothesisAgentGenome, **overrides: Any) -> HypothesisAgentGenome: + payload: dict[str, Any] = g.to_dict() + payload.update(overrides) + payload.pop("id", None) + payload["parent_ids"] = [*g.parent_ids, g.id] + payload["generation"] = g.generation + 1 + return HypothesisAgentGenome.from_dict(payload) + + +def mutate_temperature(g: HypothesisAgentGenome, rng: random.Random) -> HypothesisAgentGenome: + delta = rng.choice([-0.1, 0.1]) + new_t = max(0.6, min(1.3, g.temperature + delta)) + return _clone_with(g, temperature=round(new_t, 4)) + + +def mutate_lookback(g: HypothesisAgentGenome, rng: random.Random) -> HypothesisAgentGenome: + delta = rng.choice([-50, 50]) + new_lb = max(50, min(500, g.lookback_window + delta)) + return _clone_with(g, lookback_window=new_lb) + + +def mutate_feature_access(g: HypothesisAgentGenome, rng: random.Random) -> HypothesisAgentGenome: + current = set(g.feature_access) + if len(current) == len(FEATURE_POOL): + op = "remove" + elif len(current) <= 1: + op = "add" + else: + op = rng.choice(["add", "remove"]) + + if op == "add": + candidates = [f for f in FEATURE_POOL if f not in current] + choice = rng.choice(candidates) + new_set = current | {choice} + else: + choice = rng.choice(sorted(current)) + new_set = current - {choice} + + return _clone_with(g, feature_access=sorted(new_set)) + + +def mutate_cognitive_style(g: HypothesisAgentGenome, rng: random.Random) -> HypothesisAgentGenome: + candidates = [s for s in COGNITIVE_STYLES if s != g.cognitive_style] + new_style = rng.choice(candidates) + return _clone_with(g, cognitive_style=new_style) + + +MUTATION_OPS = ( + mutate_temperature, + mutate_lookback, + mutate_feature_access, + mutate_cognitive_style, +) + + +def random_mutate(g: HypothesisAgentGenome, rng: random.Random) -> HypothesisAgentGenome: + op = rng.choice(MUTATION_OPS) + return op(g, rng) diff --git a/tests/unit/test_genome_mutation.py b/tests/unit/test_genome_mutation.py new file mode 100644 index 0000000..41e5159 --- /dev/null +++ b/tests/unit/test_genome_mutation.py @@ -0,0 +1,61 @@ +import random + +import pytest + +from multi_swarm.genome.hypothesis import HypothesisAgentGenome, ModelTier +from multi_swarm.genome.mutation import ( + COGNITIVE_STYLES, + FEATURE_POOL, + mutate_cognitive_style, + mutate_feature_access, + mutate_lookback, + mutate_temperature, +) + + +@pytest.fixture +def base_genome() -> HypothesisAgentGenome: + return HypothesisAgentGenome( + system_prompt="x", + feature_access=["close"], + temperature=0.9, + top_p=0.95, + model_tier=ModelTier.C, + lookback_window=200, + cognitive_style="physicist", + ) + + +def test_mutate_temperature_within_bounds(base_genome: HypothesisAgentGenome) -> None: + rng = random.Random(0) + for _ in range(50): + new = mutate_temperature(base_genome, rng) + assert 0.6 <= new.temperature <= 1.3 + + +def test_mutate_lookback_within_bounds(base_genome: HypothesisAgentGenome) -> None: + rng = random.Random(0) + for _ in range(50): + new = mutate_lookback(base_genome, rng) + assert 50 <= new.lookback_window <= 500 + + +def test_mutate_feature_access_changes_set(base_genome: HypothesisAgentGenome) -> None: + rng = random.Random(0) + new = mutate_feature_access(base_genome, rng) + assert set(new.feature_access) != set(base_genome.feature_access) or len(FEATURE_POOL) == 1 + assert all(f in FEATURE_POOL for f in new.feature_access) + assert len(new.feature_access) >= 1 + + +def test_mutate_cognitive_style_uses_pool(base_genome: HypothesisAgentGenome) -> None: + rng = random.Random(0) + new = mutate_cognitive_style(base_genome, rng) + assert new.cognitive_style in COGNITIVE_STYLES + + +def test_mutation_preserves_lineage(base_genome: HypothesisAgentGenome) -> None: + rng = random.Random(0) + new = mutate_temperature(base_genome, rng) + assert base_genome.id in new.parent_ids + assert new.id != base_genome.id