Skip to main content

phago_agents/
fitness.rs

1//! Fitness tracking for evolutionary agent selection.
2//!
3//! Tracks per-agent graph contributions to compute fitness scores.
4//! Fitness determines which genomes propagate: fitter agents live longer
5//! (higher max_idle effectively) and their genomes seed new agents.
6
7use phago_core::types::AgentId;
8use serde::Serialize;
9use std::collections::HashMap;
10
11/// Per-agent fitness data.
12#[derive(Debug, Clone, Serialize)]
13pub struct AgentFitness {
14    pub agent_id: AgentId,
15    /// Total concepts added to the knowledge graph.
16    pub concepts_added: u64,
17    /// Total edges created or strengthened.
18    pub edges_contributed: u64,
19    /// Total ticks alive.
20    pub ticks_alive: u64,
21    /// Fitness score = weighted multi-objective combination.
22    pub fitness: f64,
23    /// Generation number (0 = original, 1 = first offspring, etc.)
24    pub generation: u32,
25    /// Novel concepts: concepts that didn't exist before this agent created them.
26    pub novel_concepts: u64,
27    /// Bridge edges: edges that connect previously disconnected node clusters.
28    pub bridge_edges: u64,
29    /// Strong edges: edges with co_activations >= 2 (reinforced across documents).
30    pub strong_edges: u64,
31}
32
33/// Tracks fitness across all agents in a colony.
34pub struct FitnessTracker {
35    data: HashMap<AgentId, AgentFitness>,
36    generation_counter: u32,
37}
38
39impl FitnessTracker {
40    pub fn new() -> Self {
41        Self {
42            data: HashMap::new(),
43            generation_counter: 0,
44        }
45    }
46
47    /// Register a new agent with its generation.
48    pub fn register(&mut self, agent_id: AgentId, generation: u32) {
49        self.data.insert(agent_id, AgentFitness {
50            agent_id,
51            concepts_added: 0,
52            edges_contributed: 0,
53            ticks_alive: 0,
54            fitness: 0.0,
55            generation,
56            novel_concepts: 0,
57            bridge_edges: 0,
58            strong_edges: 0,
59        });
60    }
61
62    /// Record that an agent added concepts to the graph.
63    pub fn record_concepts(&mut self, agent_id: &AgentId, count: u64) {
64        if let Some(f) = self.data.get_mut(agent_id) {
65            f.concepts_added += count;
66            Self::recompute_fitness(f);
67        }
68    }
69
70    /// Record that an agent contributed edges.
71    pub fn record_edges(&mut self, agent_id: &AgentId, count: u64) {
72        if let Some(f) = self.data.get_mut(agent_id) {
73            f.edges_contributed += count;
74            Self::recompute_fitness(f);
75        }
76    }
77
78    /// Record novel concepts (concepts that didn't exist in the graph before).
79    pub fn record_novel_concepts(&mut self, agent_id: &AgentId, count: u64) {
80        if let Some(f) = self.data.get_mut(agent_id) {
81            f.novel_concepts += count;
82            Self::recompute_fitness(f);
83        }
84    }
85
86    /// Record bridge edges (edges connecting previously isolated clusters).
87    pub fn record_bridge_edges(&mut self, agent_id: &AgentId, count: u64) {
88        if let Some(f) = self.data.get_mut(agent_id) {
89            f.bridge_edges += count;
90            Self::recompute_fitness(f);
91        }
92    }
93
94    /// Record strong edges (co_activations >= 2).
95    pub fn record_strong_edges(&mut self, agent_id: &AgentId, count: u64) {
96        if let Some(f) = self.data.get_mut(agent_id) {
97            f.strong_edges += count;
98            Self::recompute_fitness(f);
99        }
100    }
101
102    /// Record a tick for all registered agents.
103    pub fn tick_all(&mut self, alive_ids: &[AgentId]) {
104        for id in alive_ids {
105            if let Some(f) = self.data.get_mut(id) {
106                f.ticks_alive += 1;
107                Self::recompute_fitness(f);
108            }
109        }
110    }
111
112    /// Multi-objective fitness function.
113    ///
114    /// Weights:
115    /// - 30% productivity: (concepts + edges) / ticks  (throughput)
116    /// - 30% novelty: novel_concepts / concepts_added  (exploration value)
117    /// - 20% quality: strong_edges / edges_contributed  (reinforcement signal)
118    /// - 20% connectivity: bridge_edges / edges_contributed  (integration value)
119    fn recompute_fitness(f: &mut AgentFitness) {
120        if f.ticks_alive == 0 {
121            return;
122        }
123
124        let productivity = (f.concepts_added as f64 + f.edges_contributed as f64)
125            / f.ticks_alive as f64;
126
127        let novelty = if f.concepts_added > 0 {
128            f.novel_concepts as f64 / f.concepts_added as f64
129        } else {
130            0.0
131        };
132
133        let quality = if f.edges_contributed > 0 {
134            f.strong_edges as f64 / f.edges_contributed as f64
135        } else {
136            0.0
137        };
138
139        let connectivity = if f.edges_contributed > 0 {
140            f.bridge_edges as f64 / f.edges_contributed as f64
141        } else {
142            0.0
143        };
144
145        f.fitness = 0.3 * productivity + 0.3 * novelty + 0.2 * quality + 0.2 * connectivity;
146    }
147
148    /// Get the fittest living agent.
149    pub fn fittest(&self, alive_ids: &[AgentId]) -> Option<&AgentFitness> {
150        alive_ids.iter()
151            .filter_map(|id| self.data.get(id))
152            .max_by(|a, b| a.fitness.partial_cmp(&b.fitness).unwrap_or(std::cmp::Ordering::Equal))
153    }
154
155    /// Get fitness data for an agent.
156    pub fn get(&self, agent_id: &AgentId) -> Option<&AgentFitness> {
157        self.data.get(agent_id)
158    }
159
160    /// Get all fitness data.
161    pub fn all(&self) -> Vec<&AgentFitness> {
162        self.data.values().collect()
163    }
164
165    /// Mean fitness of living agents.
166    pub fn mean_fitness(&self, alive_ids: &[AgentId]) -> f64 {
167        let fitnesses: Vec<f64> = alive_ids.iter()
168            .filter_map(|id| self.data.get(id))
169            .map(|f| f.fitness)
170            .collect();
171        if fitnesses.is_empty() {
172            0.0
173        } else {
174            fitnesses.iter().sum::<f64>() / fitnesses.len() as f64
175        }
176    }
177
178    /// Next generation number.
179    pub fn next_generation(&mut self) -> u32 {
180        self.generation_counter += 1;
181        self.generation_counter
182    }
183
184    /// Current max generation.
185    pub fn max_generation(&self) -> u32 {
186        self.data.values().map(|f| f.generation).max().unwrap_or(0)
187    }
188}
189
190impl Default for FitnessTracker {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn fitness_tracks_contributions() {
202        let mut tracker = FitnessTracker::new();
203        let id = AgentId::new();
204        tracker.register(id, 0);
205        tracker.record_concepts(&id, 5);
206        tracker.tick_all(&[id]);
207
208        let f = tracker.get(&id).unwrap();
209        assert_eq!(f.concepts_added, 5);
210        assert_eq!(f.ticks_alive, 1);
211        assert!(f.fitness > 0.0);
212    }
213
214    #[test]
215    fn fittest_returns_best_agent() {
216        let mut tracker = FitnessTracker::new();
217        let id1 = AgentId::new();
218        let id2 = AgentId::new();
219        tracker.register(id1, 0);
220        tracker.register(id2, 0);
221        tracker.record_concepts(&id1, 10);
222        tracker.record_concepts(&id2, 2);
223        tracker.tick_all(&[id1, id2]);
224
225        let best = tracker.fittest(&[id1, id2]).unwrap();
226        assert_eq!(best.agent_id, id1);
227    }
228}