Skip to main content

phago_agents/
synthesizer.rs

1//! Synthesizer Agent — collective intelligence through emergence.
2//!
3//! The Synthesizer is dormant until quorum is reached — enough agents
4//! have deposited enough traces and concepts in a region. At quorum,
5//! the Synthesizer activates and performs cross-document analysis:
6//!
7//! - Detects concepts that appear across multiple documents (bridge concepts)
8//! - Identifies clusters of highly-connected concepts (topic clusters)
9//! - Generates Insight nodes that represent emergent understanding
10//!
11//! Biological analog: collective bacterial behavior that only activates
12//! when autoinducer concentration exceeds the quorum threshold. Individual
13//! bacteria cannot perform these behaviors — they are emergent properties
14//! of the collective.
15
16use phago_core::agent::Agent;
17use phago_core::primitives::{Apoptose, Digest, Emerge, Sense};
18use phago_core::primitives::symbiose::AgentProfile;
19use phago_core::substrate::Substrate;
20use phago_core::types::*;
21
22/// Configuration for the Synthesizer.
23const QUORUM_THRESHOLD: f64 = 3.0;
24const MIN_BRIDGE_ACCESS: u64 = 2;
25const MIN_CLUSTER_SIZE: usize = 3;
26const MIN_CLUSTER_WEIGHT: f64 = 0.15;
27
28/// State machine for the Synthesizer.
29#[derive(Debug, Clone, PartialEq)]
30enum SynthesizerState {
31    /// Dormant — waiting for quorum.
32    Dormant,
33    /// Quorum reached — analyzing the graph.
34    Analyzing,
35    /// Presenting insights to the graph.
36    Presenting(Vec<InsightData>),
37    /// Cooldown after producing insights.
38    Cooldown(u64),
39}
40
41/// An insight discovered by the Synthesizer.
42#[derive(Debug, Clone, PartialEq)]
43pub struct InsightData {
44    pub label: String,
45    pub insight_type: InsightType,
46    pub related_concepts: Vec<String>,
47}
48
49#[derive(Debug, Clone, PartialEq)]
50pub enum InsightType {
51    /// A concept that bridges multiple document clusters.
52    BridgeConcept { access_count: u64 },
53    /// A tightly connected cluster of concepts.
54    TopicCluster { size: usize, avg_weight: f64 },
55}
56
57/// The Synthesizer agent — emergent collective intelligence.
58pub struct Synthesizer {
59    id: AgentId,
60    position: Position,
61    age_ticks: Tick,
62    state: SynthesizerState,
63
64    // Emerge tracking
65    insights_produced: u64,
66
67    // Digestion (required by Agent trait but Synthesizer digests insights, not documents)
68    engulfed: Option<String>,
69    fragments: Vec<String>,
70
71    // Configuration
72    sense_radius: f64,
73    cooldown_ticks: u64,
74    max_idle_ticks: u64,
75    idle_ticks: u64,
76}
77
78impl Synthesizer {
79    pub fn new(position: Position) -> Self {
80        Self {
81            id: AgentId::new(),
82            position,
83            age_ticks: 0,
84            state: SynthesizerState::Dormant,
85            insights_produced: 0,
86            engulfed: None,
87            fragments: Vec::new(),
88            sense_radius: 50.0, // Large radius — synthesizers survey the whole substrate
89            cooldown_ticks: 10,
90            max_idle_ticks: 100, // Patient — waits longer than digesters
91            idle_ticks: 0,
92        }
93    }
94
95    /// Create a synthesizer with a deterministic ID (for testing).
96    pub fn with_seed(position: Position, seed: u64) -> Self {
97        Self {
98            id: AgentId::from_seed(seed),
99            position,
100            age_ticks: 0,
101            state: SynthesizerState::Dormant,
102            insights_produced: 0,
103            engulfed: None,
104            fragments: Vec::new(),
105            sense_radius: 50.0,
106            cooldown_ticks: 10,
107            max_idle_ticks: 100,
108            idle_ticks: 0,
109        }
110    }
111
112    /// Total insights produced in lifetime.
113    pub fn insights_produced(&self) -> u64 {
114        self.insights_produced
115    }
116
117    /// Analyze the knowledge graph for cross-document patterns.
118    ///
119    /// This is the core emergence logic. It finds patterns that no
120    /// individual digester could detect because they require seeing
121    /// the full graph structure.
122    fn analyze_graph(&self, substrate: &dyn Substrate) -> Vec<InsightData> {
123        let mut insights = Vec::new();
124
125        // --- Bridge Concepts ---
126        // Concepts accessed by multiple documents (access_count > 1)
127        // are "bridges" — they connect different knowledge domains.
128        let all_nodes = substrate.all_nodes();
129        for node_id in &all_nodes {
130            if let Some(node) = substrate.get_node(node_id) {
131                if node.access_count >= MIN_BRIDGE_ACCESS && node.node_type == NodeType::Concept {
132                    // Check if this bridge hasn't been reported yet
133                    let existing_insights = substrate.all_nodes().iter().any(|nid| {
134                        substrate.get_node(nid).map_or(false, |n| {
135                            n.node_type == NodeType::Insight
136                                && n.label.contains(&node.label)
137                        })
138                    });
139
140                    if !existing_insights {
141                        // Find what this concept is connected to
142                        let neighbors = substrate.neighbors(node_id);
143                        let connected: Vec<String> = neighbors
144                            .iter()
145                            .filter_map(|(nid, _)| {
146                                substrate.get_node(nid).map(|n| n.label.clone())
147                            })
148                            .take(5)
149                            .collect();
150
151                        insights.push(InsightData {
152                            label: format!(
153                                "Bridge: '{}' connects {} document contexts",
154                                node.label, node.access_count
155                            ),
156                            insight_type: InsightType::BridgeConcept {
157                                access_count: node.access_count,
158                            },
159                            related_concepts: connected,
160                        });
161                    }
162                }
163            }
164        }
165
166        // --- Topic Clusters ---
167        // Find groups of tightly connected concepts (avg edge weight above threshold).
168        // Use a simple greedy approach: for each high-access node, collect its
169        // strongly-connected neighbors.
170        let mut reported_clusters: Vec<Vec<String>> = Vec::new();
171
172        for node_id in &all_nodes {
173            if let Some(node) = substrate.get_node(node_id) {
174                if node.node_type != NodeType::Concept {
175                    continue;
176                }
177
178                let neighbors = substrate.neighbors(node_id);
179                let strong_neighbors: Vec<(String, f64)> = neighbors
180                    .iter()
181                    .filter_map(|(nid, edge)| {
182                        if edge.weight >= MIN_CLUSTER_WEIGHT {
183                            substrate.get_node(nid).map(|n| (n.label.clone(), edge.weight))
184                        } else {
185                            None
186                        }
187                    })
188                    .collect();
189
190                if strong_neighbors.len() >= MIN_CLUSTER_SIZE {
191                    let mut cluster_labels: Vec<String> = strong_neighbors
192                        .iter()
193                        .map(|(label, _)| label.clone())
194                        .collect();
195                    cluster_labels.sort();
196
197                    // Check if we already reported a similar cluster
198                    let already_reported = reported_clusters.iter().any(|existing| {
199                        let overlap = cluster_labels
200                            .iter()
201                            .filter(|l| existing.contains(l))
202                            .count();
203                        overlap > existing.len() / 2
204                    });
205
206                    if !already_reported {
207                        let avg_weight: f64 = strong_neighbors.iter().map(|(_, w)| w).sum::<f64>()
208                            / strong_neighbors.len() as f64;
209
210                        // Check no existing insight for this cluster
211                        let cluster_key = format!("Cluster: {}", node.label);
212                        let exists = substrate.all_nodes().iter().any(|nid| {
213                            substrate.get_node(nid).map_or(false, |n| {
214                                n.node_type == NodeType::Insight && n.label == cluster_key
215                            })
216                        });
217
218                        if !exists {
219                            insights.push(InsightData {
220                                label: cluster_key,
221                                insight_type: InsightType::TopicCluster {
222                                    size: strong_neighbors.len(),
223                                    avg_weight,
224                                },
225                                related_concepts: cluster_labels.clone(),
226                            });
227                            reported_clusters.push(cluster_labels);
228                        }
229                    }
230                }
231            }
232        }
233
234        insights
235    }
236}
237
238// --- Trait Implementations ---
239
240impl Digest for Synthesizer {
241    type Input = String;
242    type Fragment = String;
243    type Presentation = Vec<String>;
244
245    fn engulf(&mut self, input: String) -> DigestionResult {
246        if input.trim().is_empty() {
247            return DigestionResult::Indigestible;
248        }
249        self.engulfed = Some(input);
250        DigestionResult::Engulfed
251    }
252
253    fn lyse(&mut self) -> Vec<String> {
254        self.engulfed
255            .take()
256            .map(|s| vec![s])
257            .unwrap_or_default()
258    }
259
260    fn present(&self) -> Vec<String> {
261        self.fragments.clone()
262    }
263}
264
265impl Apoptose for Synthesizer {
266    fn self_assess(&self) -> CellHealth {
267        if self.idle_ticks >= self.max_idle_ticks {
268            CellHealth::Senescent
269        } else if self.idle_ticks >= self.max_idle_ticks / 2 {
270            CellHealth::Stressed
271        } else {
272            CellHealth::Healthy
273        }
274    }
275
276    fn prepare_death_signal(&self) -> DeathSignal {
277        DeathSignal {
278            agent_id: self.id,
279            total_ticks: self.age_ticks,
280            useful_outputs: self.insights_produced,
281            final_fragments: Vec::new(),
282            cause: DeathCause::SelfAssessed(self.self_assess()),
283        }
284    }
285}
286
287impl Sense for Synthesizer {
288    fn sense_radius(&self) -> f64 {
289        self.sense_radius
290    }
291
292    fn sense_position(&self) -> Position {
293        self.position
294    }
295
296    fn gradient(&self, substrate: &dyn Substrate) -> Vec<Gradient> {
297        // Synthesizer doesn't chase gradients — it surveys the whole area
298        let _ = substrate;
299        Vec::new()
300    }
301
302    fn orient(&self, _gradients: &[Gradient]) -> Orientation {
303        Orientation::Stay // Synthesizers don't move
304    }
305}
306
307impl Emerge for Synthesizer {
308    type EmergentBehavior = Vec<InsightData>;
309
310    fn signal_density(&self, substrate: &dyn Substrate) -> f64 {
311        // Count digestion traces in sensing radius — this is our quorum signal
312        let nearby_signals = substrate.signals_near(&self.position, self.sense_radius);
313        let trace_count = nearby_signals.len();
314
315        // Also count concept nodes — more concepts = more material to synthesize
316        let node_count = substrate.node_count();
317
318        // Quorum is based on both agent activity (traces) and knowledge density (nodes)
319        (trace_count as f64) * 0.3 + (node_count as f64) * 0.1
320    }
321
322    fn quorum_threshold(&self) -> f64 {
323        QUORUM_THRESHOLD
324    }
325
326    fn emergent_behavior(&self) -> Option<Vec<InsightData>> {
327        // This is called by tick() when quorum is reached
328        None // We compute insights in tick() directly
329    }
330
331    fn contribute(&self) -> Contribution {
332        Contribution {
333            agent_id: self.id,
334            data: format!("insights:{}", self.insights_produced).into_bytes(),
335        }
336    }
337}
338
339impl Agent for Synthesizer {
340    fn id(&self) -> AgentId {
341        self.id
342    }
343
344    fn position(&self) -> Position {
345        self.position
346    }
347
348    fn set_position(&mut self, position: Position) {
349        self.position = position;
350    }
351
352    fn agent_type(&self) -> &str {
353        "synthesizer"
354    }
355
356    fn tick(&mut self, substrate: &dyn Substrate) -> AgentAction {
357        self.age_ticks += 1;
358
359        if self.should_die() {
360            return AgentAction::Apoptose;
361        }
362
363        match &self.state {
364            SynthesizerState::Dormant => {
365                // Check quorum
366                let density = self.signal_density(substrate);
367                if density >= self.quorum_threshold() {
368                    self.state = SynthesizerState::Analyzing;
369                    self.idle_ticks = 0;
370                    // Emit quorum signal to alert other agents
371                    AgentAction::Emit(Signal::new(
372                        SignalType::Quorum,
373                        1.0,
374                        self.position,
375                        self.id,
376                        self.age_ticks,
377                    ))
378                } else {
379                    self.idle_ticks += 1;
380                    AgentAction::Idle
381                }
382            }
383
384            SynthesizerState::Analyzing => {
385                // Perform collective analysis
386                let mut insights = self.analyze_graph(substrate);
387
388                // Cap to top 10 most significant insights per cycle
389                // Sort bridges by access_count (desc), clusters by size (desc)
390                insights.sort_by(|a, b| {
391                    let score_a = match &a.insight_type {
392                        InsightType::BridgeConcept { access_count } => *access_count as f64,
393                        InsightType::TopicCluster { size, avg_weight } => *size as f64 * avg_weight,
394                    };
395                    let score_b = match &b.insight_type {
396                        InsightType::BridgeConcept { access_count } => *access_count as f64,
397                        InsightType::TopicCluster { size, avg_weight } => *size as f64 * avg_weight,
398                    };
399                    score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal)
400                });
401                insights.truncate(10);
402
403                if insights.is_empty() {
404                    // Nothing new to report — go back to dormant
405                    self.state = SynthesizerState::Dormant;
406                    self.idle_ticks += 1;
407                    AgentAction::Idle
408                } else {
409                    self.state = SynthesizerState::Presenting(insights.clone());
410                    self.insights_produced += insights.len() as u64;
411
412                    // Present insights as fragment presentations
413                    let presentations: Vec<FragmentPresentation> = insights
414                        .iter()
415                        .map(|insight| {
416                            let label = match &insight.insight_type {
417                                InsightType::BridgeConcept { access_count } => {
418                                    format!("[BRIDGE:{}] {}", access_count, insight.label)
419                                }
420                                InsightType::TopicCluster { size, avg_weight } => {
421                                    format!(
422                                        "[CLUSTER:{}/w{:.2}] {}",
423                                        size, avg_weight, insight.label
424                                    )
425                                }
426                            };
427                            FragmentPresentation {
428                                label,
429                                source_document: DocumentId::new(),
430                                position: self.position,
431                                node_type: NodeType::Insight,
432                            }
433                        })
434                        .collect();
435
436                    AgentAction::PresentFragments(presentations)
437                }
438            }
439
440            SynthesizerState::Presenting(_insights) => {
441                // Emit insight signal and enter cooldown
442                self.state = SynthesizerState::Cooldown(self.cooldown_ticks);
443                AgentAction::Emit(Signal::new(
444                    SignalType::Insight,
445                    1.0,
446                    self.position,
447                    self.id,
448                    self.age_ticks,
449                ))
450            }
451
452            SynthesizerState::Cooldown(remaining) => {
453                if *remaining == 0 {
454                    self.state = SynthesizerState::Dormant;
455                    AgentAction::Idle
456                } else {
457                    self.state = SynthesizerState::Cooldown(remaining - 1);
458                    AgentAction::Idle
459                }
460            }
461        }
462    }
463
464    fn age(&self) -> Tick {
465        self.age_ticks
466    }
467
468    fn profile(&self) -> AgentProfile {
469        AgentProfile {
470            id: self.id,
471            agent_type: "synthesizer".to_string(),
472            capabilities: Vec::new(),
473            health: self.self_assess(),
474        }
475    }
476}
477
478// --- Serialization ---
479
480use crate::serialize::{
481    SerializableAgent, SerializedAgent,
482    SynthesizerState as SerializedSynthesizerState,
483};
484
485impl SerializableAgent for Synthesizer {
486    fn export_state(&self) -> SerializedAgent {
487        SerializedAgent::Synthesizer(SerializedSynthesizerState {
488            id: self.id,
489            position: self.position,
490            age_ticks: self.age_ticks,
491            idle_ticks: self.idle_ticks,
492            insights_produced: self.insights_produced,
493            sense_radius: self.sense_radius,
494            cooldown_ticks: self.cooldown_ticks,
495            max_idle_ticks: self.max_idle_ticks,
496        })
497    }
498
499    fn from_state(state: &SerializedAgent) -> Option<Self> {
500        match state {
501            SerializedAgent::Synthesizer(s) => Some(Synthesizer {
502                id: s.id,
503                position: s.position,
504                age_ticks: s.age_ticks,
505                state: SynthesizerState::Dormant,
506                insights_produced: s.insights_produced,
507                engulfed: None,
508                fragments: Vec::new(),
509                sense_radius: s.sense_radius,
510                cooldown_ticks: s.cooldown_ticks,
511                max_idle_ticks: s.max_idle_ticks,
512                idle_ticks: s.idle_ticks,
513            }),
514            _ => None,
515        }
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn synthesizer_starts_dormant() {
525        let synth = Synthesizer::new(Position::new(0.0, 0.0));
526        assert_eq!(synth.state, SynthesizerState::Dormant);
527        assert_eq!(synth.insights_produced(), 0);
528    }
529
530    #[test]
531    fn synthesizer_type_name() {
532        let synth = Synthesizer::new(Position::new(0.0, 0.0));
533        assert_eq!(synth.agent_type(), "synthesizer");
534    }
535}