Skip to main content

phago_agents/
digester.rs

1//! Digester Agent — the first living cell.
2//!
3//! A Digester consumes text input, breaks it into keyword fragments,
4//! and presents them for other agents to read. It senses signals in the
5//! substrate to find unprocessed input, and self-terminates (apoptosis)
6//! when it has spent too many cycles without producing useful output.
7//!
8//! Biological analog: a macrophage that patrols tissue, engulfs foreign
9//! material, and presents antigen fragments on its surface.
10
11use 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/// Internal state machine for the digester's lifecycle.
20#[derive(Debug, Clone, PartialEq)]
21enum DigesterState {
22    /// Searching for work — sensing signals and navigating.
23    Seeking,
24    /// Found a document, requesting to engulf it next tick.
25    FoundTarget(DocumentId),
26    /// Currently digesting — will produce fragments next tick.
27    Digesting,
28    /// Has fragments ready to present to the knowledge graph.
29    Presenting,
30}
31
32/// A text-digesting agent — the computational macrophage.
33pub struct Digester {
34    id: AgentId,
35    position: Position,
36    age_ticks: Tick,
37    state: DigesterState,
38
39    // Digestion state
40    /// Raw text currently being digested (if any).
41    engulfed: Option<String>,
42    /// The document currently being digested.
43    current_document: Option<DocumentId>,
44    /// Fragments extracted from the last digestion.
45    fragments: Vec<String>,
46    /// Cumulative presentation: all fragments this agent has ever produced.
47    all_presentations: Vec<String>,
48
49    // Health tracking for apoptosis
50    /// Number of consecutive ticks with no useful output.
51    idle_ticks: u64,
52    /// Total useful outputs produced in lifetime.
53    useful_outputs: u64,
54
55    // Transfer / Symbiose / Dissolve state
56    /// Vocabulary learned from integrated capabilities and digestion.
57    known_vocabulary: HashSet<String>,
58    /// Whether this agent has exported its vocabulary at least once.
59    has_exported: bool,
60    /// Agent IDs from which we've already integrated vocabulary (avoid re-integration).
61    integrated_from: HashSet<AgentId>,
62    /// Boundary permeability (0.0 = rigid, 1.0 = fully dissolved).
63    boundary_permeability: f64,
64    /// Symbionts absorbed by this agent.
65    symbionts: Vec<SymbiontInfo>,
66
67    // Configuration
68    /// Max consecutive idle ticks before triggering apoptosis.
69    max_idle_ticks: u64,
70    /// Sensing radius.
71    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    /// Create a digester with a deterministic ID (for testing).
98    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    /// Create a digester with custom idle threshold.
121    pub fn with_max_idle(mut self, max_idle: u64) -> Self {
122        self.max_idle_ticks = max_idle;
123        self
124    }
125
126    /// Total fragments produced in lifetime.
127    pub fn total_fragments(&self) -> usize {
128        self.all_presentations.len()
129    }
130
131    /// Simulate an idle tick (for testing/demo purposes).
132    pub fn increment_idle(&mut self) {
133        self.idle_ticks += 1;
134    }
135
136    /// Current idle tick count (for testing/inspection).
137    pub fn idle_ticks(&self) -> u64 {
138        self.idle_ticks
139    }
140
141    /// Set idle ticks directly (for testing).
142    pub fn set_idle_ticks(&mut self, ticks: u64) {
143        self.idle_ticks = ticks;
144    }
145
146    /// Direct digestion: feed text and get fragments back immediately.
147    /// This is a convenience for testing — in a colony, agents get input
148    /// via SENSE + ENGULF from the substrate.
149    pub fn digest_text(&mut self, text: String) -> Vec<String> {
150        self.engulf(text);
151        self.lyse()
152    }
153
154    /// Feed document content to this agent (called by colony after EngulfDocument).
155    /// Sets internal state so the next tick processes the content.
156    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
162/// Extract keywords from text using a simple frequency-based approach.
163///
164/// This is deterministic — no LLMs in v0.1. We extract meaningful words
165/// by filtering stopwords, short words, and ranking by frequency.
166/// Words in `known_vocabulary` receive a +3 frequency boost (Transfer effect).
167fn 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    // Tokenize: lowercase, split on non-alphanumeric, filter short and stopwords
187    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    // Boost words that are in the known vocabulary (Transfer effect)
196    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    // Sort by frequency (descending), take top keywords
205    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
211// --- Trait Implementations ---
212
213impl 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        // Group signals by type and compute gradient for each
295        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        // Move toward the strongest Input signal gradient
309        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        // Check apoptosis first — always
351        if self.should_die() {
352            return AgentAction::Apoptose;
353        }
354
355        match self.state.clone() {
356            DigesterState::Seeking => {
357                // Check for symbiosis opportunity: if we have 3+ useful outputs
358                // and sense a non-self signal emitter nearby, attempt symbiosis
359                if self.useful_outputs >= 3 && self.symbionts.is_empty() {
360                    let nearby_signals = substrate.signals_near(&self.position, self.sense_radius);
361                    // Look for Anomaly or Capability signals from other agents
362                    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                // Look for nearby undigested documents
373                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                    // Found a document — move toward it and request engulf
380                    let doc_id = doc.id;
381                    let doc_pos = doc.position;
382
383                    if doc_pos.distance_to(&self.position) < 1.0 {
384                        // Close enough — engulf next tick
385                        self.state = DigesterState::FoundTarget(doc_id);
386                        return AgentAction::EngulfDocument(doc_id);
387                    } else {
388                        // Move toward document
389                        self.idle_ticks += 1;
390                        return AgentAction::Move(doc_pos);
391                    }
392                }
393
394                // No documents nearby — follow signal gradients
395                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                // Colony should have fed us the document content.
417                // If we have engulfed content, start digesting.
418                if self.engulfed.is_some() {
419                    self.state = DigesterState::Digesting;
420                    AgentAction::Idle // Digesting takes one tick
421                } else {
422                    // Colony didn't feed us (maybe doc was already taken)
423                    self.state = DigesterState::Seeking;
424                    self.idle_ticks += 1;
425                    AgentAction::Idle
426                }
427            }
428
429            DigesterState::Digesting => {
430                // Break down the engulfed material
431                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                // After presenting, check if we should export vocabulary
454                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                // Deposit a trace at our location marking successful digestion
464                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    // --- Transfer overrides ---
486
487    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            // Only integrate once per source agent
502            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    // --- Symbiose overrides ---
516
517    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        // Only consider symbiosis with non-digester agents that are healthy
528        if other.agent_type == "digester" {
529            return Some(SymbiosisEval::Coexist);
530        }
531        if other.health.should_die() {
532            return Some(SymbiosisEval::Coexist);
533        }
534        // Integrate agents of different types — this models endosymbiosis
535        Some(SymbiosisEval::Integrate)
536    }
537
538    fn absorb_symbiont(&mut self, profile: AgentProfile, data: Vec<u8>) -> bool {
539        // Merge target's vocabulary into our known_vocabulary
540        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    // --- Dissolve overrides ---
554
555    fn permeability(&self) -> f64 {
556        self.boundary_permeability
557    }
558
559    fn modulate_boundary(&mut self, context: &BoundaryContext) {
560        // permeability = 0.3*reinforcement + 0.3*age + 0.4*trust, clamped [0,1]
561        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// --- Serialization ---
591
592use 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        // Stopwords should be excluded
656        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        // Second lyse returns nothing — material was consumed
682        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        // Simulate idle ticks
709        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()); // Stressed but not dead yet
727    }
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        // prepare_death_signal works with &self (trait object compatible)
736        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        // trigger_apoptosis consumes self (works with concrete types)
742        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        // Build up idle ticks
751        digester.set_idle_ticks(8);
752        assert_eq!(digester.self_assess(), CellHealth::Stressed);
753
754        // Produce useful output — should reset
755        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        // "rust" and "memory" should rank high (appear twice)
772        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        // Full cycle: engulf → lyse → present
780        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        // Agent tracked the output
785        assert_eq!(digester.useful_outputs, 1);
786        assert_eq!(digester.total_fragments(), presentation.len());
787    }
788}