1use phago_core::agent::Agent;
12use phago_core::primitives::{Apoptose, Digest, Sense};
13use phago_core::primitives::symbiose::AgentProfile;
14use phago_core::signal::compute_gradient;
15use phago_core::substrate::Substrate;
16use phago_core::types::*;
17use std::collections::{HashMap, HashSet};
18
19#[derive(Debug, Clone, PartialEq)]
21enum DigesterState {
22 Seeking,
24 FoundTarget(DocumentId),
26 Digesting,
28 Presenting,
30}
31
32pub struct Digester {
34 id: AgentId,
35 position: Position,
36 age_ticks: Tick,
37 state: DigesterState,
38
39 engulfed: Option<String>,
42 current_document: Option<DocumentId>,
44 fragments: Vec<String>,
46 all_presentations: Vec<String>,
48
49 idle_ticks: u64,
52 useful_outputs: u64,
54
55 known_vocabulary: HashSet<String>,
58 has_exported: bool,
60 integrated_from: HashSet<AgentId>,
62 boundary_permeability: f64,
64 symbionts: Vec<SymbiontInfo>,
66
67 max_idle_ticks: u64,
70 sense_radius: f64,
72}
73
74impl Digester {
75 pub fn new(position: Position) -> Self {
76 Self {
77 id: AgentId::new(),
78 position,
79 age_ticks: 0,
80 state: DigesterState::Seeking,
81 engulfed: None,
82 current_document: None,
83 fragments: Vec::new(),
84 all_presentations: Vec::new(),
85 idle_ticks: 0,
86 useful_outputs: 0,
87 known_vocabulary: HashSet::new(),
88 has_exported: false,
89 integrated_from: HashSet::new(),
90 boundary_permeability: 0.0,
91 symbionts: Vec::new(),
92 max_idle_ticks: 30,
93 sense_radius: 10.0,
94 }
95 }
96
97 pub fn with_seed(position: Position, seed: u64) -> Self {
99 Self {
100 id: AgentId::from_seed(seed),
101 position,
102 age_ticks: 0,
103 state: DigesterState::Seeking,
104 engulfed: None,
105 current_document: None,
106 fragments: Vec::new(),
107 all_presentations: Vec::new(),
108 idle_ticks: 0,
109 useful_outputs: 0,
110 known_vocabulary: HashSet::new(),
111 has_exported: false,
112 integrated_from: HashSet::new(),
113 boundary_permeability: 0.0,
114 symbionts: Vec::new(),
115 max_idle_ticks: 30,
116 sense_radius: 10.0,
117 }
118 }
119
120 pub fn with_max_idle(mut self, max_idle: u64) -> Self {
122 self.max_idle_ticks = max_idle;
123 self
124 }
125
126 pub fn total_fragments(&self) -> usize {
128 self.all_presentations.len()
129 }
130
131 pub fn increment_idle(&mut self) {
133 self.idle_ticks += 1;
134 }
135
136 pub fn idle_ticks(&self) -> u64 {
138 self.idle_ticks
139 }
140
141 pub fn set_idle_ticks(&mut self, ticks: u64) {
143 self.idle_ticks = ticks;
144 }
145
146 pub fn digest_text(&mut self, text: String) -> Vec<String> {
150 self.engulf(text);
151 self.lyse()
152 }
153
154 pub fn feed_document(&mut self, doc_id: DocumentId, content: String) {
157 self.current_document = Some(doc_id);
158 self.engulf(content);
159 }
160}
161
162fn extract_keywords(text: &str, known_vocabulary: Option<&HashSet<String>>) -> Vec<String> {
168 let stopwords: std::collections::HashSet<&str> = [
169 "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
170 "have", "has", "had", "do", "does", "did", "will", "would", "shall",
171 "should", "may", "might", "must", "can", "could", "of", "in", "to",
172 "for", "with", "on", "at", "from", "by", "about", "as", "into",
173 "through", "during", "before", "after", "above", "below", "between",
174 "out", "off", "over", "under", "again", "further", "then", "once",
175 "here", "there", "when", "where", "why", "how", "all", "each",
176 "every", "both", "few", "more", "most", "other", "some", "such",
177 "no", "nor", "not", "only", "own", "same", "so", "than", "too",
178 "very", "just", "because", "but", "and", "or", "if", "while",
179 "that", "this", "these", "those", "it", "its", "they", "them",
180 "their", "we", "our", "you", "your", "he", "she", "his", "her",
181 "which", "what", "who", "whom",
182 ]
183 .into_iter()
184 .collect();
185
186 let mut freq: HashMap<String, usize> = HashMap::new();
188 for word in text.split(|c: char| !c.is_alphanumeric()) {
189 let word = word.to_lowercase();
190 if word.len() >= 3 && !stopwords.contains(word.as_str()) {
191 *freq.entry(word).or_insert(0) += 1;
192 }
193 }
194
195 if let Some(vocab) = known_vocabulary {
197 for (word, count) in freq.iter_mut() {
198 if vocab.contains(word) {
199 *count += 3;
200 }
201 }
202 }
203
204 let mut words: Vec<(String, usize)> = freq.into_iter().collect();
206 words.sort_by(|a, b| b.1.cmp(&a.1));
207
208 words.into_iter().map(|(word, _)| word).collect()
209}
210
211impl Digest for Digester {
214 type Input = String;
215 type Fragment = String;
216 type Presentation = Vec<String>;
217
218 fn engulf(&mut self, input: String) -> DigestionResult {
219 if self.engulfed.is_some() {
220 return DigestionResult::Busy;
221 }
222 if input.trim().is_empty() {
223 return DigestionResult::Indigestible;
224 }
225 self.engulfed = Some(input);
226 DigestionResult::Engulfed
227 }
228
229 fn lyse(&mut self) -> Vec<String> {
230 let Some(text) = self.engulfed.take() else {
231 return Vec::new();
232 };
233
234 let vocab = if self.known_vocabulary.is_empty() {
235 None
236 } else {
237 Some(&self.known_vocabulary)
238 };
239 let keywords = extract_keywords(&text, vocab);
240 self.fragments = keywords.clone();
241
242 if !self.fragments.is_empty() {
243 self.useful_outputs += 1;
244 self.idle_ticks = 0;
245 self.all_presentations.extend(self.fragments.clone());
246 }
247
248 keywords
249 }
250
251 fn present(&self) -> Vec<String> {
252 self.fragments.clone()
253 }
254}
255
256impl Apoptose for Digester {
257 fn self_assess(&self) -> CellHealth {
258 if self.idle_ticks >= self.max_idle_ticks {
259 CellHealth::Senescent
260 } else if self.idle_ticks >= self.max_idle_ticks / 2 {
261 CellHealth::Stressed
262 } else {
263 CellHealth::Healthy
264 }
265 }
266
267 fn prepare_death_signal(&self) -> DeathSignal {
268 DeathSignal {
269 agent_id: self.id,
270 total_ticks: self.age_ticks,
271 useful_outputs: self.useful_outputs,
272 final_fragments: self
273 .all_presentations
274 .iter()
275 .map(|s| s.as_bytes().to_vec())
276 .collect(),
277 cause: DeathCause::SelfAssessed(self.self_assess()),
278 }
279 }
280}
281
282impl Sense for Digester {
283 fn sense_radius(&self) -> f64 {
284 self.sense_radius
285 }
286
287 fn sense_position(&self) -> Position {
288 self.position
289 }
290
291 fn gradient(&self, substrate: &dyn Substrate) -> Vec<Gradient> {
292 let signals = substrate.signals_near(&self.position, self.sense_radius);
293
294 let mut by_type: HashMap<String, Vec<&Signal>> = HashMap::new();
296 for signal in &signals {
297 let key = format!("{:?}", signal.signal_type);
298 by_type.entry(key).or_default().push(signal);
299 }
300
301 by_type
302 .values()
303 .filter_map(|sigs| compute_gradient(sigs, &self.position))
304 .collect()
305 }
306
307 fn orient(&self, gradients: &[Gradient]) -> Orientation {
308 let strongest = gradients
310 .iter()
311 .filter(|g| matches!(g.signal_type, SignalType::Input))
312 .max_by(|a, b| a.magnitude.partial_cmp(&b.magnitude).unwrap_or(std::cmp::Ordering::Equal));
313
314 match strongest {
315 Some(g) => Orientation::Toward(Position::new(
316 self.position.x + g.direction.x,
317 self.position.y + g.direction.y,
318 )),
319 None => {
320 if gradients.is_empty() {
321 Orientation::Explore
322 } else {
323 Orientation::Stay
324 }
325 }
326 }
327 }
328}
329
330impl Agent for Digester {
331 fn id(&self) -> AgentId {
332 self.id
333 }
334
335 fn position(&self) -> Position {
336 self.position
337 }
338
339 fn set_position(&mut self, position: Position) {
340 self.position = position;
341 }
342
343 fn agent_type(&self) -> &str {
344 "digester"
345 }
346
347 fn tick(&mut self, substrate: &dyn Substrate) -> AgentAction {
348 self.age_ticks += 1;
349
350 if self.should_die() {
352 return AgentAction::Apoptose;
353 }
354
355 match self.state.clone() {
356 DigesterState::Seeking => {
357 if self.useful_outputs >= 3 && self.symbionts.is_empty() {
360 let nearby_signals = substrate.signals_near(&self.position, self.sense_radius);
361 let symbiosis_target = nearby_signals.iter().find(|s| {
363 matches!(s.signal_type, SignalType::Anomaly | SignalType::Insight)
364 && s.emitter != self.id
365 });
366 if let Some(signal) = symbiosis_target {
367 let target_id = signal.emitter;
368 return AgentAction::SymbioseWith(target_id);
369 }
370 }
371
372 let docs = substrate.undigested_documents();
374 let nearby_doc = docs.iter().find(|d| {
375 d.position.distance_to(&self.position) <= self.sense_radius
376 });
377
378 if let Some(doc) = nearby_doc {
379 let doc_id = doc.id;
381 let doc_pos = doc.position;
382
383 if doc_pos.distance_to(&self.position) < 1.0 {
384 self.state = DigesterState::FoundTarget(doc_id);
386 return AgentAction::EngulfDocument(doc_id);
387 } else {
388 self.idle_ticks += 1;
390 return AgentAction::Move(doc_pos);
391 }
392 }
393
394 let gradients = self.gradient(substrate);
396 let orientation = self.orient(&gradients);
397
398 self.idle_ticks += 1;
399 match orientation {
400 Orientation::Toward(pos) => AgentAction::Move(pos),
401 Orientation::Stay => AgentAction::Idle,
402 Orientation::Explore => {
403 let angle = (self.age_ticks as f64) * 0.7
404 + (self.id.0.as_u128() % 100) as f64 * 0.1;
405 let dx = angle.cos() * 2.0;
406 let dy = angle.sin() * 2.0;
407 AgentAction::Move(Position::new(
408 self.position.x + dx,
409 self.position.y + dy,
410 ))
411 }
412 }
413 }
414
415 DigesterState::FoundTarget(_doc_id) => {
416 if self.engulfed.is_some() {
419 self.state = DigesterState::Digesting;
420 AgentAction::Idle } else {
422 self.state = DigesterState::Seeking;
424 self.idle_ticks += 1;
425 AgentAction::Idle
426 }
427 }
428
429 DigesterState::Digesting => {
430 let fragments = self.lyse();
432 if fragments.is_empty() {
433 self.state = DigesterState::Seeking;
434 self.idle_ticks += 1;
435 AgentAction::Idle
436 } else {
437 self.state = DigesterState::Presenting;
438 let doc_id = self.current_document.unwrap_or(DocumentId::new());
439 let presentations: Vec<FragmentPresentation> = fragments
440 .iter()
441 .map(|label| FragmentPresentation {
442 label: label.clone(),
443 source_document: doc_id,
444 position: self.position,
445 node_type: NodeType::Concept,
446 })
447 .collect();
448 AgentAction::PresentFragments(presentations)
449 }
450 }
451
452 DigesterState::Presenting => {
453 if self.useful_outputs >= 2 && !self.has_exported {
455 self.has_exported = true;
456 self.state = DigesterState::Seeking;
457 self.current_document = None;
458 return AgentAction::ExportCapability(CapabilityId(
459 format!("vocab-{}", self.id.0),
460 ));
461 }
462
463 self.state = DigesterState::Seeking;
465 self.current_document = None;
466 let trace = Trace {
467 agent_id: self.id,
468 trace_type: TraceType::Digestion,
469 intensity: 1.0,
470 tick: self.age_ticks,
471 payload: Vec::new(),
472 };
473 AgentAction::Deposit(
474 SubstrateLocation::Spatial(self.position),
475 trace,
476 )
477 }
478 }
479 }
480
481 fn age(&self) -> Tick {
482 self.age_ticks
483 }
484
485 fn export_vocabulary(&self) -> Option<Vec<u8>> {
488 if self.all_presentations.is_empty() {
489 return None;
490 }
491 let cap = VocabularyCapability {
492 terms: self.all_presentations.clone(),
493 origin: self.id,
494 document_count: self.useful_outputs,
495 };
496 serde_json::to_vec(&cap).ok()
497 }
498
499 fn integrate_vocabulary(&mut self, data: &[u8]) -> bool {
500 if let Ok(cap) = serde_json::from_slice::<VocabularyCapability>(data) {
501 if self.integrated_from.contains(&cap.origin) {
503 return false;
504 }
505 self.integrated_from.insert(cap.origin);
506 for term in cap.terms {
507 self.known_vocabulary.insert(term);
508 }
509 true
510 } else {
511 false
512 }
513 }
514
515 fn profile(&self) -> AgentProfile {
518 AgentProfile {
519 id: self.id,
520 agent_type: "digester".to_string(),
521 capabilities: Vec::new(),
522 health: self.self_assess(),
523 }
524 }
525
526 fn evaluate_symbiosis(&self, other: &AgentProfile) -> Option<SymbiosisEval> {
527 if other.agent_type == "digester" {
529 return Some(SymbiosisEval::Coexist);
530 }
531 if other.health.should_die() {
532 return Some(SymbiosisEval::Coexist);
533 }
534 Some(SymbiosisEval::Integrate)
536 }
537
538 fn absorb_symbiont(&mut self, profile: AgentProfile, data: Vec<u8>) -> bool {
539 if let Ok(cap) = serde_json::from_slice::<VocabularyCapability>(&data) {
541 for term in &cap.terms {
542 self.known_vocabulary.insert(term.clone());
543 }
544 }
545 self.symbionts.push(SymbiontInfo {
546 id: profile.id,
547 name: profile.agent_type.clone(),
548 capabilities: profile.capabilities,
549 });
550 true
551 }
552
553 fn permeability(&self) -> f64 {
556 self.boundary_permeability
557 }
558
559 fn modulate_boundary(&mut self, context: &BoundaryContext) {
560 let reinforcement_factor = (context.reinforcement_count as f64 / 10.0).min(1.0);
562 let age_factor = (context.age as f64 / 100.0).min(1.0);
563 let trust_factor = context.trust;
564
565 self.boundary_permeability =
566 (0.3 * reinforcement_factor + 0.3 * age_factor + 0.4 * trust_factor).clamp(0.0, 1.0);
567 }
568
569 fn externalize_vocabulary(&self) -> Vec<String> {
570 let mut terms: Vec<String> = self.all_presentations.clone();
571 for term in &self.known_vocabulary {
572 if !terms.contains(term) {
573 terms.push(term.clone());
574 }
575 }
576 terms
577 }
578
579 fn internalize_vocabulary(&mut self, terms: &[String]) {
580 for term in terms {
581 self.known_vocabulary.insert(term.clone());
582 }
583 }
584
585 fn vocabulary_size(&self) -> usize {
586 self.known_vocabulary.len() + self.all_presentations.len()
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn digest_text_extracts_keywords() {
596 let mut digester = Digester::new(Position::new(0.0, 0.0));
597 let text = "The mitochondria is the powerhouse of the cell. ATP is produced through oxidative phosphorylation in the inner membrane.".to_string();
598
599 let fragments = digester.digest_text(text);
600
601 assert!(!fragments.is_empty());
602 assert!(fragments.contains(&"mitochondria".to_string()));
603 assert!(fragments.contains(&"cell".to_string()));
604 assert!(fragments.contains(&"membrane".to_string()));
605 assert!(!fragments.contains(&"the".to_string()));
607 assert!(!fragments.contains(&"is".to_string()));
608 }
609
610 #[test]
611 fn engulf_rejects_empty_input() {
612 let mut digester = Digester::new(Position::new(0.0, 0.0));
613 assert_eq!(digester.engulf("".to_string()), DigestionResult::Indigestible);
614 assert_eq!(digester.engulf(" ".to_string()), DigestionResult::Indigestible);
615 }
616
617 #[test]
618 fn engulf_rejects_when_busy() {
619 let mut digester = Digester::new(Position::new(0.0, 0.0));
620 assert_eq!(digester.engulf("hello world foo".to_string()), DigestionResult::Engulfed);
621 assert_eq!(digester.engulf("another input".to_string()), DigestionResult::Busy);
622 }
623
624 #[test]
625 fn lyse_consumes_engulfed_material() {
626 let mut digester = Digester::new(Position::new(0.0, 0.0));
627 digester.engulf("cell membrane protein transport".to_string());
628 let fragments = digester.lyse();
629 assert!(!fragments.is_empty());
630
631 let fragments2 = digester.lyse();
633 assert!(fragments2.is_empty());
634 }
635
636 #[test]
637 fn present_returns_last_fragments() {
638 let mut digester = Digester::new(Position::new(0.0, 0.0));
639 digester.engulf("cell membrane protein".to_string());
640 digester.lyse();
641 let presented = digester.present();
642 assert!(!presented.is_empty());
643 assert!(presented.contains(&"cell".to_string()));
644 }
645
646 #[test]
647 fn healthy_when_producing_output() {
648 let mut digester = Digester::new(Position::new(0.0, 0.0));
649 digester.digest_text("cell membrane protein structure biology".to_string());
650 assert_eq!(digester.self_assess(), CellHealth::Healthy);
651 assert!(!digester.should_die());
652 }
653
654 #[test]
655 fn senescent_after_idle_threshold() {
656 let mut digester = Digester::new(Position::new(0.0, 0.0)).with_max_idle(10);
657
658 for _ in 0..10 {
660 digester.increment_idle();
661 }
662
663 assert_eq!(digester.self_assess(), CellHealth::Senescent);
664 assert!(digester.should_die());
665 }
666
667 #[test]
668 fn stressed_at_half_idle_threshold() {
669 let mut digester = Digester::new(Position::new(0.0, 0.0)).with_max_idle(10);
670
671 for _ in 0..5 {
672 digester.increment_idle();
673 }
674
675 assert_eq!(digester.self_assess(), CellHealth::Stressed);
676 assert!(!digester.should_die()); }
678
679 #[test]
680 fn apoptosis_produces_death_signal() {
681 let mut digester = Digester::new(Position::new(0.0, 0.0));
682 let id = digester.id();
683 digester.digest_text("biology cell protein".to_string());
684
685 let signal = digester.prepare_death_signal();
687 assert_eq!(signal.agent_id, id);
688 assert_eq!(signal.useful_outputs, 1);
689 assert!(!signal.final_fragments.is_empty());
690
691 let signal2 = digester.trigger_apoptosis();
693 assert_eq!(signal2.agent_id, id);
694 }
695
696 #[test]
697 fn useful_output_resets_idle_counter() {
698 let mut digester = Digester::new(Position::new(0.0, 0.0)).with_max_idle(10);
699
700 digester.set_idle_ticks(8);
702 assert_eq!(digester.self_assess(), CellHealth::Stressed);
703
704 digester.digest_text("cell membrane biology protein structure".to_string());
706 assert_eq!(digester.idle_ticks(), 0);
707 assert_eq!(digester.self_assess(), CellHealth::Healthy);
708 }
709
710 #[test]
711 fn extract_keywords_handles_varied_text() {
712 let keywords = extract_keywords(
713 "Rust programming language provides memory safety \
714 without garbage collection. Rust achieves memory safety \
715 through its ownership system.",
716 None,
717 );
718 assert!(keywords.contains(&"rust".to_string()));
719 assert!(keywords.contains(&"memory".to_string()));
720 assert!(keywords.contains(&"safety".to_string()));
721 assert!(keywords.iter().position(|w| w == "rust").unwrap() < 5);
723 }
724
725 #[test]
726 fn digest_full_cycle() {
727 let mut digester = Digester::new(Position::new(0.0, 0.0));
728
729 let presentation = digester.digest("biology cell membrane protein structure".to_string());
731 assert!(!presentation.is_empty());
732 assert!(presentation.contains(&"cell".to_string()));
733
734 assert_eq!(digester.useful_outputs, 1);
736 assert_eq!(digester.total_fragments(), presentation.len());
737 }
738}