Skip to main content

phago_agents/
genome.rs

1//! Agent genome — evolvable parameters for biological agents.
2//!
3//! Each agent carries a genome that encodes its behavioral parameters.
4//! When agents reproduce (via Transfer/Spawn), the genome is inherited
5//! with random mutations. Natural selection occurs through apoptosis:
6//! agents with poor fitness die faster, removing their genomes.
7
8use serde::{Deserialize, Serialize};
9
10/// Evolvable parameters for an agent.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AgentGenome {
13    /// Sensing radius — how far the agent can detect signals.
14    pub sense_radius: f64,
15    /// Maximum idle ticks before apoptosis triggers.
16    pub max_idle: u64,
17    /// Boost factor for known vocabulary terms during digestion.
18    pub keyword_boost: f64,
19    /// Probability of exploring randomly vs following gradients.
20    pub explore_bias: f64,
21    /// Tendency to move toward substrate boundary vs center.
22    pub boundary_bias: f64,
23
24    // Wiring strategy parameters
25    /// Initial weight for tentative edges (first co-occurrence). Range: [0.05, 0.5].
26    pub tentative_weight: f64,
27    /// Weight boost per subsequent co-activation. Range: [0.01, 0.3].
28    pub reinforcement_boost: f64,
29    /// Fraction of concept pairs to wire per document. Range: [0.1, 1.0].
30    /// 1.0 = wire all pairs, 0.5 = wire ~half (probabilistic), etc.
31    pub wiring_selectivity: f64,
32}
33
34impl AgentGenome {
35    /// Default genome with standard parameters.
36    pub fn default_genome() -> Self {
37        Self {
38            sense_radius: 10.0,
39            max_idle: 30,
40            keyword_boost: 3.0,
41            explore_bias: 0.2,
42            boundary_bias: 0.0,
43            tentative_weight: 0.1,
44            reinforcement_boost: 0.1,
45            wiring_selectivity: 1.0,
46        }
47    }
48
49    /// Create a mutated copy of this genome.
50    ///
51    /// Each parameter is perturbed by ±mutation_rate (as a fraction).
52    /// Uses a simple deterministic PRNG seeded by the given value.
53    pub fn mutate(&self, mutation_rate: f64, seed: u64) -> Self {
54        let mut rng = seed;
55        let mut next = || -> f64 {
56            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
57            // Map to [-1.0, 1.0]
58            ((rng >> 33) as f64 / (u32::MAX as f64)) * 2.0 - 1.0
59        };
60
61        Self {
62            sense_radius: (self.sense_radius * (1.0 + next() * mutation_rate)).clamp(2.0, 30.0),
63            max_idle: ((self.max_idle as f64 * (1.0 + next() * mutation_rate)).round() as u64).clamp(5, 100),
64            keyword_boost: (self.keyword_boost * (1.0 + next() * mutation_rate)).clamp(0.5, 10.0),
65            explore_bias: (self.explore_bias + next() * mutation_rate * 0.5).clamp(0.0, 1.0),
66            boundary_bias: (self.boundary_bias + next() * mutation_rate * 0.5).clamp(-1.0, 1.0),
67            tentative_weight: (self.tentative_weight * (1.0 + next() * mutation_rate)).clamp(0.05, 0.5),
68            reinforcement_boost: (self.reinforcement_boost * (1.0 + next() * mutation_rate)).clamp(0.01, 0.3),
69            wiring_selectivity: (self.wiring_selectivity + next() * mutation_rate * 0.3).clamp(0.1, 1.0),
70        }
71    }
72
73    /// Compute the Euclidean distance between two genomes in parameter space.
74    /// Parameters are normalized to [0,1] range before computing distance.
75    pub fn distance(&self, other: &AgentGenome) -> f64 {
76        let dims = [
77            ((self.sense_radius - 2.0) / 28.0, (other.sense_radius - 2.0) / 28.0),
78            (self.max_idle as f64 / 100.0, other.max_idle as f64 / 100.0),
79            ((self.keyword_boost - 0.5) / 9.5, (other.keyword_boost - 0.5) / 9.5),
80            (self.explore_bias, other.explore_bias),
81            ((self.boundary_bias + 1.0) / 2.0, (other.boundary_bias + 1.0) / 2.0),
82            ((self.tentative_weight - 0.05) / 0.45, (other.tentative_weight - 0.05) / 0.45),
83            ((self.reinforcement_boost - 0.01) / 0.29, (other.reinforcement_boost - 0.01) / 0.29),
84            ((self.wiring_selectivity - 0.1) / 0.9, (other.wiring_selectivity - 0.1) / 0.9),
85        ];
86
87        let sum_sq: f64 = dims.iter().map(|(a, b)| (a - b).powi(2)).sum();
88        sum_sq.sqrt()
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn default_genome_is_valid() {
98        let g = AgentGenome::default_genome();
99        assert!(g.sense_radius > 0.0);
100        assert!(g.max_idle > 0);
101    }
102
103    #[test]
104    fn mutation_changes_genome() {
105        let g = AgentGenome::default_genome();
106        let mutated = g.mutate(0.2, 42);
107        // At least one parameter should differ
108        let same = (g.sense_radius - mutated.sense_radius).abs() < 1e-10
109            && g.max_idle == mutated.max_idle
110            && (g.keyword_boost - mutated.keyword_boost).abs() < 1e-10;
111        assert!(!same, "Mutation should change at least one parameter");
112    }
113
114    #[test]
115    fn mutation_stays_in_bounds() {
116        let g = AgentGenome::default_genome();
117        for seed in 0..100 {
118            let m = g.mutate(0.5, seed);
119            assert!(m.sense_radius >= 2.0 && m.sense_radius <= 30.0);
120            assert!(m.max_idle >= 5 && m.max_idle <= 100);
121            assert!(m.explore_bias >= 0.0 && m.explore_bias <= 1.0);
122        }
123    }
124
125    #[test]
126    fn distance_is_zero_for_same_genome() {
127        let g = AgentGenome::default_genome();
128        assert!(g.distance(&g) < 1e-10);
129    }
130
131    #[test]
132    fn distance_increases_with_mutation() {
133        let g = AgentGenome::default_genome();
134        let m1 = g.mutate(0.1, 42);
135        let m2 = g.mutate(0.5, 42);
136        assert!(g.distance(&m2) >= g.distance(&m1) * 0.5,
137            "Larger mutation should generally produce larger distance");
138    }
139}