1use 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
22const 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#[derive(Debug, Clone, PartialEq)]
30enum SynthesizerState {
31 Dormant,
33 Analyzing,
35 Presenting(Vec<InsightData>),
37 Cooldown(u64),
39}
40
41#[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 BridgeConcept { access_count: u64 },
53 TopicCluster { size: usize, avg_weight: f64 },
55}
56
57pub struct Synthesizer {
59 id: AgentId,
60 position: Position,
61 age_ticks: Tick,
62 state: SynthesizerState,
63
64 insights_produced: u64,
66
67 engulfed: Option<String>,
69 fragments: Vec<String>,
70
71 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, cooldown_ticks: 10,
90 max_idle_ticks: 100, idle_ticks: 0,
92 }
93 }
94
95 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 pub fn insights_produced(&self) -> u64 {
114 self.insights_produced
115 }
116
117 fn analyze_graph(&self, substrate: &dyn Substrate) -> Vec<InsightData> {
123 let mut insights = Vec::new();
124
125 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 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 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 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 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 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
238impl 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 let _ = substrate;
299 Vec::new()
300 }
301
302 fn orient(&self, _gradients: &[Gradient]) -> Orientation {
303 Orientation::Stay }
305}
306
307impl Emerge for Synthesizer {
308 type EmergentBehavior = Vec<InsightData>;
309
310 fn signal_density(&self, substrate: &dyn Substrate) -> f64 {
311 let nearby_signals = substrate.signals_near(&self.position, self.sense_radius);
313 let trace_count = nearby_signals.len();
314
315 let node_count = substrate.node_count();
317
318 (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 None }
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 let density = self.signal_density(substrate);
367 if density >= self.quorum_threshold() {
368 self.state = SynthesizerState::Analyzing;
369 self.idle_ticks = 0;
370 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 let mut insights = self.analyze_graph(substrate);
387
388 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 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 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 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
478use 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}