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
590use crate::serialize::{
593 hashset_to_vec, vec_to_hashset, DigesterState as SerializedDigesterState,
594 SerializableAgent, SerializedAgent,
595};
596
597impl SerializableAgent for Digester {
598 fn export_state(&self) -> SerializedAgent {
599 SerializedAgent::Digester(SerializedDigesterState {
600 id: self.id,
601 position: self.position,
602 age_ticks: self.age_ticks,
603 idle_ticks: self.idle_ticks,
604 useful_outputs: self.useful_outputs,
605 all_presentations: self.all_presentations.clone(),
606 known_vocabulary: hashset_to_vec(&self.known_vocabulary),
607 has_exported: self.has_exported,
608 boundary_permeability: self.boundary_permeability,
609 max_idle_ticks: self.max_idle_ticks,
610 sense_radius: self.sense_radius,
611 })
612 }
613
614 fn from_state(state: &SerializedAgent) -> Option<Self> {
615 match state {
616 SerializedAgent::Digester(s) => Some(Digester {
617 id: s.id,
618 position: s.position,
619 age_ticks: s.age_ticks,
620 state: DigesterState::Seeking,
621 engulfed: None,
622 current_document: None,
623 fragments: Vec::new(),
624 all_presentations: s.all_presentations.clone(),
625 idle_ticks: s.idle_ticks,
626 useful_outputs: s.useful_outputs,
627 known_vocabulary: vec_to_hashset(&s.known_vocabulary),
628 has_exported: s.has_exported,
629 integrated_from: HashSet::new(),
630 boundary_permeability: s.boundary_permeability,
631 symbionts: Vec::new(),
632 max_idle_ticks: s.max_idle_ticks,
633 sense_radius: s.sense_radius,
634 }),
635 _ => None,
636 }
637 }
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 #[test]
645 fn digest_text_extracts_keywords() {
646 let mut digester = Digester::new(Position::new(0.0, 0.0));
647 let text = "The mitochondria is the powerhouse of the cell. ATP is produced through oxidative phosphorylation in the inner membrane.".to_string();
648
649 let fragments = digester.digest_text(text);
650
651 assert!(!fragments.is_empty());
652 assert!(fragments.contains(&"mitochondria".to_string()));
653 assert!(fragments.contains(&"cell".to_string()));
654 assert!(fragments.contains(&"membrane".to_string()));
655 assert!(!fragments.contains(&"the".to_string()));
657 assert!(!fragments.contains(&"is".to_string()));
658 }
659
660 #[test]
661 fn engulf_rejects_empty_input() {
662 let mut digester = Digester::new(Position::new(0.0, 0.0));
663 assert_eq!(digester.engulf("".to_string()), DigestionResult::Indigestible);
664 assert_eq!(digester.engulf(" ".to_string()), DigestionResult::Indigestible);
665 }
666
667 #[test]
668 fn engulf_rejects_when_busy() {
669 let mut digester = Digester::new(Position::new(0.0, 0.0));
670 assert_eq!(digester.engulf("hello world foo".to_string()), DigestionResult::Engulfed);
671 assert_eq!(digester.engulf("another input".to_string()), DigestionResult::Busy);
672 }
673
674 #[test]
675 fn lyse_consumes_engulfed_material() {
676 let mut digester = Digester::new(Position::new(0.0, 0.0));
677 digester.engulf("cell membrane protein transport".to_string());
678 let fragments = digester.lyse();
679 assert!(!fragments.is_empty());
680
681 let fragments2 = digester.lyse();
683 assert!(fragments2.is_empty());
684 }
685
686 #[test]
687 fn present_returns_last_fragments() {
688 let mut digester = Digester::new(Position::new(0.0, 0.0));
689 digester.engulf("cell membrane protein".to_string());
690 digester.lyse();
691 let presented = digester.present();
692 assert!(!presented.is_empty());
693 assert!(presented.contains(&"cell".to_string()));
694 }
695
696 #[test]
697 fn healthy_when_producing_output() {
698 let mut digester = Digester::new(Position::new(0.0, 0.0));
699 digester.digest_text("cell membrane protein structure biology".to_string());
700 assert_eq!(digester.self_assess(), CellHealth::Healthy);
701 assert!(!digester.should_die());
702 }
703
704 #[test]
705 fn senescent_after_idle_threshold() {
706 let mut digester = Digester::new(Position::new(0.0, 0.0)).with_max_idle(10);
707
708 for _ in 0..10 {
710 digester.increment_idle();
711 }
712
713 assert_eq!(digester.self_assess(), CellHealth::Senescent);
714 assert!(digester.should_die());
715 }
716
717 #[test]
718 fn stressed_at_half_idle_threshold() {
719 let mut digester = Digester::new(Position::new(0.0, 0.0)).with_max_idle(10);
720
721 for _ in 0..5 {
722 digester.increment_idle();
723 }
724
725 assert_eq!(digester.self_assess(), CellHealth::Stressed);
726 assert!(!digester.should_die()); }
728
729 #[test]
730 fn apoptosis_produces_death_signal() {
731 let mut digester = Digester::new(Position::new(0.0, 0.0));
732 let id = digester.id();
733 digester.digest_text("biology cell protein".to_string());
734
735 let signal = digester.prepare_death_signal();
737 assert_eq!(signal.agent_id, id);
738 assert_eq!(signal.useful_outputs, 1);
739 assert!(!signal.final_fragments.is_empty());
740
741 let signal2 = digester.trigger_apoptosis();
743 assert_eq!(signal2.agent_id, id);
744 }
745
746 #[test]
747 fn useful_output_resets_idle_counter() {
748 let mut digester = Digester::new(Position::new(0.0, 0.0)).with_max_idle(10);
749
750 digester.set_idle_ticks(8);
752 assert_eq!(digester.self_assess(), CellHealth::Stressed);
753
754 digester.digest_text("cell membrane biology protein structure".to_string());
756 assert_eq!(digester.idle_ticks(), 0);
757 assert_eq!(digester.self_assess(), CellHealth::Healthy);
758 }
759
760 #[test]
761 fn extract_keywords_handles_varied_text() {
762 let keywords = extract_keywords(
763 "Rust programming language provides memory safety \
764 without garbage collection. Rust achieves memory safety \
765 through its ownership system.",
766 None,
767 );
768 assert!(keywords.contains(&"rust".to_string()));
769 assert!(keywords.contains(&"memory".to_string()));
770 assert!(keywords.contains(&"safety".to_string()));
771 assert!(keywords.iter().position(|w| w == "rust").unwrap() < 5);
773 }
774
775 #[test]
776 fn digest_full_cycle() {
777 let mut digester = Digester::new(Position::new(0.0, 0.0));
778
779 let presentation = digester.digest("biology cell membrane protein structure".to_string());
781 assert!(!presentation.is_empty());
782 assert!(presentation.contains(&"cell".to_string()));
783
784 assert_eq!(digester.useful_outputs, 1);
786 assert_eq!(digester.total_fragments(), presentation.len());
787 }
788}