Skip to main content

phago_agents/
sentinel.rs

1//! Sentinel Agent — anomaly detection through negative selection.
2//!
3//! During "maturation", the Sentinel observes normal concept distributions
4//! in the knowledge graph and builds a self-model. After maturation,
5//! it classifies new inputs as Self (normal) or NonSelf (anomalous).
6//!
7//! Biological analog: T-cell maturation in the thymus. Developing T-cells
8//! are shown self-antigens. Those that react to self are destroyed. Only
9//! cells that ignore self and react to non-self survive.
10//!
11//! The Sentinel learns what "normal" looks like (finite, learnable) and
12//! flags everything that deviates — without needing to enumerate threats.
13
14use phago_core::agent::Agent;
15use phago_core::primitives::{Apoptose, Digest, Negate, Sense};
16use phago_core::primitives::symbiose::AgentProfile;
17use phago_core::substrate::Substrate;
18use phago_core::types::*;
19use std::collections::HashMap;
20
21/// How many ticks of observation before the self-model is considered mature.
22const MATURATION_TICKS: u64 = 10;
23/// Deviation threshold for NonSelf classification (0.0-1.0).
24const ANOMALY_THRESHOLD: f64 = 0.5;
25/// Maximum anomalies to report per scan cycle.
26const MAX_ANOMALIES_PER_SCAN: usize = 10;
27
28/// Statistical self-model: distribution of concept frequencies.
29#[derive(Debug, Clone)]
30pub struct ConceptSelfModel {
31    /// Expected concept frequency distribution.
32    concept_freq: HashMap<String, f64>,
33    /// Total observations used to build the model.
34    observation_count: u64,
35    /// Mean edge weight observed.
36    mean_edge_weight: f64,
37    /// Standard deviation of edge weights.
38    edge_weight_std: f64,
39}
40
41impl ConceptSelfModel {
42    fn new() -> Self {
43        Self {
44            concept_freq: HashMap::new(),
45            observation_count: 0,
46            mean_edge_weight: 0.0,
47            edge_weight_std: 0.0,
48        }
49    }
50
51    /// Get all concepts in the self-model.
52    fn concepts(&self) -> Vec<&String> {
53        self.concept_freq.keys().collect()
54    }
55
56    /// Observe a concept with a given frequency.
57    fn observe(&mut self, concept: &str, freq: f64) {
58        *self.concept_freq.entry(concept.to_string()).or_insert(0.0) += freq;
59        self.observation_count += 1;
60    }
61}
62
63/// State machine for the Sentinel.
64#[derive(Debug, Clone, PartialEq)]
65enum SentinelState {
66    /// Building the self-model from graph observations.
67    Maturing(u64), // ticks remaining
68    /// Mature — actively scanning for anomalies.
69    Scanning,
70    /// Detected an anomaly — emitting signal.
71    Alerting(String), // anomaly description
72}
73
74/// The Sentinel agent — the immune system's anomaly detector.
75pub struct Sentinel {
76    id: AgentId,
77    position: Position,
78    age_ticks: Tick,
79    state: SentinelState,
80
81    // Self-model
82    self_model: ConceptSelfModel,
83
84    // Anomaly tracking
85    anomalies_detected: u64,
86    last_scan_tick: Tick,
87
88    // Digestion (minimal — Sentinel doesn't digest documents)
89    engulfed: Option<String>,
90    fragments: Vec<String>,
91
92    // Configuration
93    sense_radius: f64,
94    max_idle_ticks: u64,
95    idle_ticks: u64,
96    scan_interval: u64,
97}
98
99impl Sentinel {
100    pub fn new(position: Position) -> Self {
101        Self {
102            id: AgentId::new(),
103            position,
104            age_ticks: 0,
105            state: SentinelState::Maturing(MATURATION_TICKS),
106            self_model: ConceptSelfModel::new(),
107            anomalies_detected: 0,
108            last_scan_tick: 0,
109            engulfed: None,
110            fragments: Vec::new(),
111            sense_radius: 50.0,
112            max_idle_ticks: 200, // Very patient
113            idle_ticks: 0,
114            scan_interval: 5,
115        }
116    }
117
118    /// Create a sentinel with a deterministic ID (for testing).
119    pub fn with_seed(position: Position, seed: u64) -> Self {
120        Self {
121            id: AgentId::from_seed(seed),
122            position,
123            age_ticks: 0,
124            state: SentinelState::Maturing(MATURATION_TICKS),
125            self_model: ConceptSelfModel::new(),
126            anomalies_detected: 0,
127            last_scan_tick: 0,
128            engulfed: None,
129            fragments: Vec::new(),
130            sense_radius: 50.0,
131            max_idle_ticks: 200,
132            idle_ticks: 0,
133            scan_interval: 5,
134        }
135    }
136
137    pub fn anomalies_detected(&self) -> u64 {
138        self.anomalies_detected
139    }
140
141    /// Build the self-model by observing the current graph state.
142    fn observe_graph(&mut self, substrate: &dyn Substrate) {
143        let all_nodes = substrate.all_nodes();
144        let mut concept_counts: HashMap<String, u64> = HashMap::new();
145
146        for node_id in &all_nodes {
147            if let Some(node) = substrate.get_node(node_id) {
148                if node.node_type == NodeType::Concept {
149                    *concept_counts.entry(node.label.clone()).or_insert(0) += node.access_count;
150                }
151            }
152        }
153
154        // Update frequency distribution
155        let total: u64 = concept_counts.values().sum();
156        if total > 0 {
157            for (label, count) in &concept_counts {
158                let freq = *count as f64 / total as f64;
159                let existing = self.self_model.concept_freq.entry(label.clone()).or_insert(0.0);
160                // Running average
161                *existing = (*existing * self.self_model.observation_count as f64 + freq)
162                    / (self.self_model.observation_count + 1) as f64;
163            }
164        }
165
166        // Compute edge weight statistics
167        let all_edges = substrate.all_edges();
168        if !all_edges.is_empty() {
169            let weights: Vec<f64> = all_edges.iter().map(|(_, _, e)| e.weight).collect();
170            let mean = weights.iter().sum::<f64>() / weights.len() as f64;
171            let variance = weights.iter().map(|w| (w - mean).powi(2)).sum::<f64>() / weights.len() as f64;
172            let std = variance.sqrt();
173
174            // Running average
175            let n = self.self_model.observation_count as f64;
176            self.self_model.mean_edge_weight =
177                (self.self_model.mean_edge_weight * n + mean) / (n + 1.0);
178            self.self_model.edge_weight_std =
179                (self.self_model.edge_weight_std * n + std) / (n + 1.0);
180        }
181
182        self.self_model.observation_count += 1;
183    }
184
185    /// Scan the graph for anomalies by comparing current state to self-model.
186    fn scan_for_anomalies(&self, substrate: &dyn Substrate) -> Vec<String> {
187        let mut anomalies = Vec::new();
188
189        if self.self_model.observation_count == 0 {
190            return anomalies;
191        }
192
193        // Check for concept nodes that deviate from expected distribution
194        let all_nodes = substrate.all_nodes();
195        let mut current_counts: HashMap<String, u64> = HashMap::new();
196        let mut total_count: u64 = 0;
197
198        for node_id in &all_nodes {
199            if let Some(node) = substrate.get_node(node_id) {
200                if node.node_type == NodeType::Concept {
201                    *current_counts.entry(node.label.clone()).or_insert(0) += node.access_count;
202                    total_count += node.access_count;
203                }
204            }
205        }
206
207        if total_count == 0 {
208            return anomalies;
209        }
210
211        // Find concepts that are new (not in self-model) or have unusual frequency
212        for (label, count) in &current_counts {
213            let current_freq = *count as f64 / total_count as f64;
214
215            match self.self_model.concept_freq.get(label) {
216                None => {
217                    // Concept not in self-model — it's novel
218                    if current_freq > 0.01 {
219                        anomalies.push(format!(
220                            "Novel concept '{}' not in self-model (freq: {:.3})",
221                            label, current_freq
222                        ));
223                    }
224                }
225                Some(&expected_freq) => {
226                    // Check deviation from expected frequency
227                    if expected_freq > 0.0 {
228                        let deviation = (current_freq - expected_freq).abs() / expected_freq;
229                        if deviation > ANOMALY_THRESHOLD {
230                            anomalies.push(format!(
231                                "Concept '{}' deviates from self-model: expected {:.3}, got {:.3} (deviation: {:.1}%)",
232                                label, expected_freq, current_freq, deviation * 100.0
233                            ));
234                        }
235                    }
236                }
237            }
238        }
239
240        // Check for edge weight anomalies (only if we have enough observations)
241        if self.self_model.observation_count >= 5 {
242            let all_edges = substrate.all_edges();
243            let mean = self.self_model.mean_edge_weight;
244            let std = self.self_model.edge_weight_std.max(0.05);
245
246            for (from_id, to_id, edge) in &all_edges {
247                let z_score = (edge.weight - mean).abs() / std;
248                if z_score > 3.0 {
249                    let from_label = substrate.get_node(from_id).map(|n| n.label.as_str()).unwrap_or("?");
250                    let to_label = substrate.get_node(to_id).map(|n| n.label.as_str()).unwrap_or("?");
251                    anomalies.push(format!(
252                        "Edge '{}'-'{}' has anomalous weight {:.3} (z-score: {:.1})",
253                        from_label, to_label, edge.weight, z_score
254                    ));
255                }
256            }
257        }
258
259        anomalies
260    }
261}
262
263// --- Trait Implementations ---
264
265impl Digest for Sentinel {
266    type Input = String;
267    type Fragment = String;
268    type Presentation = Vec<String>;
269
270    fn engulf(&mut self, input: String) -> DigestionResult {
271        if input.trim().is_empty() {
272            return DigestionResult::Indigestible;
273        }
274        self.engulfed = Some(input);
275        DigestionResult::Engulfed
276    }
277
278    fn lyse(&mut self) -> Vec<String> {
279        self.engulfed.take().map(|s| vec![s]).unwrap_or_default()
280    }
281
282    fn present(&self) -> Vec<String> {
283        self.fragments.clone()
284    }
285}
286
287impl Apoptose for Sentinel {
288    fn self_assess(&self) -> CellHealth {
289        if self.idle_ticks >= self.max_idle_ticks {
290            CellHealth::Senescent
291        } else if self.idle_ticks >= self.max_idle_ticks / 2 {
292            CellHealth::Stressed
293        } else {
294            CellHealth::Healthy
295        }
296    }
297
298    fn prepare_death_signal(&self) -> DeathSignal {
299        DeathSignal {
300            agent_id: self.id,
301            total_ticks: self.age_ticks,
302            useful_outputs: self.anomalies_detected,
303            final_fragments: Vec::new(),
304            cause: DeathCause::SelfAssessed(self.self_assess()),
305        }
306    }
307}
308
309impl Sense for Sentinel {
310    fn sense_radius(&self) -> f64 {
311        self.sense_radius
312    }
313
314    fn sense_position(&self) -> Position {
315        self.position
316    }
317
318    fn gradient(&self, _substrate: &dyn Substrate) -> Vec<Gradient> {
319        Vec::new() // Sentinels don't chase signals
320    }
321
322    fn orient(&self, _gradients: &[Gradient]) -> Orientation {
323        Orientation::Stay // Sentinels are stationary
324    }
325}
326
327impl Negate for Sentinel {
328    type Observation = Vec<(String, u64)>; // (concept_label, access_count)
329    type SelfModel = ConceptSelfModel;
330
331    fn learn_self(&mut self, observations: &[Self::Observation]) {
332        for obs in observations {
333            let total: u64 = obs.iter().map(|(_, c)| c).sum();
334            if total == 0 {
335                continue;
336            }
337            for (label, count) in obs {
338                let freq = *count as f64 / total as f64;
339                let existing = self.self_model.concept_freq.entry(label.clone()).or_insert(0.0);
340                let n = self.self_model.observation_count as f64;
341                *existing = (*existing * n + freq) / (n + 1.0);
342            }
343            self.self_model.observation_count += 1;
344        }
345    }
346
347    fn self_model(&self) -> &ConceptSelfModel {
348        &self.self_model
349    }
350
351    fn is_mature(&self) -> bool {
352        !matches!(self.state, SentinelState::Maturing(_))
353    }
354
355    fn classify(&self, observation: &Self::Observation) -> Classification {
356        if !self.is_mature() {
357            return Classification::Unknown;
358        }
359
360        let total: u64 = observation.iter().map(|(_, c)| c).sum();
361        if total == 0 {
362            return Classification::Unknown;
363        }
364
365        let mut max_deviation = 0.0f64;
366        for (label, count) in observation {
367            let freq = *count as f64 / total as f64;
368            if let Some(&expected) = self.self_model.concept_freq.get(label) {
369                if expected > 0.0 {
370                    let deviation = (freq - expected).abs() / expected;
371                    max_deviation = max_deviation.max(deviation);
372                }
373            } else {
374                // Novel concept — high deviation
375                max_deviation = max_deviation.max(1.0);
376            }
377        }
378
379        if max_deviation > ANOMALY_THRESHOLD {
380            Classification::NonSelf(max_deviation.min(1.0))
381        } else {
382            Classification::IsSelf
383        }
384    }
385}
386
387impl Agent for Sentinel {
388    fn id(&self) -> AgentId {
389        self.id
390    }
391
392    fn position(&self) -> Position {
393        self.position
394    }
395
396    fn set_position(&mut self, position: Position) {
397        self.position = position;
398    }
399
400    fn agent_type(&self) -> &str {
401        "sentinel"
402    }
403
404    fn tick(&mut self, substrate: &dyn Substrate) -> AgentAction {
405        self.age_ticks += 1;
406
407        if self.should_die() {
408            return AgentAction::Apoptose;
409        }
410
411        // Clone state to avoid borrow conflict with observe_graph
412        let current_state = self.state.clone();
413
414        match &current_state {
415            SentinelState::Maturing(remaining) => {
416                // Observe the graph to build self-model
417                self.observe_graph(substrate);
418
419                if *remaining <= 1 {
420                    self.state = SentinelState::Scanning;
421                } else {
422                    self.state = SentinelState::Maturing(remaining - 1);
423                }
424                self.idle_ticks = 0; // Maturation counts as activity
425                AgentAction::Idle
426            }
427
428            SentinelState::Scanning => {
429                // Periodically scan for anomalies
430                if self.age_ticks - self.last_scan_tick >= self.scan_interval {
431                    self.last_scan_tick = self.age_ticks;
432
433                    let mut anomalies = self.scan_for_anomalies(substrate);
434                    // Cap to most significant anomalies per cycle
435                    anomalies.truncate(MAX_ANOMALIES_PER_SCAN);
436
437                    if !anomalies.is_empty() {
438                        let description = anomalies.join("; ");
439                        self.anomalies_detected += anomalies.len() as u64;
440                        self.state = SentinelState::Alerting(description.clone());
441                        self.idle_ticks = 0;
442
443                        // Present anomalies as insight fragments
444                        let presentations: Vec<FragmentPresentation> = anomalies
445                            .iter()
446                            .map(|a| FragmentPresentation {
447                                label: format!("[ANOMALY] {}", a),
448                                source_document: DocumentId::new(),
449                                position: self.position,
450                                node_type: NodeType::Anomaly,
451                            })
452                            .collect();
453
454                        return AgentAction::PresentFragments(presentations);
455                    }
456                }
457
458                self.idle_ticks += 1;
459                AgentAction::Idle
460            }
461
462            SentinelState::Alerting(_description) => {
463                // Emit anomaly signal to attract synthesizers
464                self.state = SentinelState::Scanning;
465                AgentAction::Emit(Signal::new(
466                    SignalType::Anomaly,
467                    1.0,
468                    self.position,
469                    self.id,
470                    self.age_ticks,
471                ))
472            }
473        }
474    }
475
476    fn age(&self) -> Tick {
477        self.age_ticks
478    }
479
480    fn export_vocabulary(&self) -> Option<Vec<u8>> {
481        if self.self_model.concept_freq.is_empty() {
482            return None;
483        }
484        let terms: Vec<String> = self.self_model.concept_freq.keys().cloned().collect();
485        let cap = VocabularyCapability {
486            terms,
487            origin: self.id,
488            document_count: self.self_model.observation_count,
489        };
490        serde_json::to_vec(&cap).ok()
491    }
492
493    fn profile(&self) -> AgentProfile {
494        AgentProfile {
495            id: self.id,
496            agent_type: "sentinel".to_string(),
497            capabilities: Vec::new(),
498            health: self.self_assess(),
499        }
500    }
501}
502
503// --- Serialization ---
504
505use crate::serialize::{
506    SerializableAgent, SerializedAgent,
507    SentinelState as SerializedSentinelState,
508};
509
510impl SerializableAgent for Sentinel {
511    fn export_state(&self) -> SerializedAgent {
512        SerializedAgent::Sentinel(SerializedSentinelState {
513            id: self.id,
514            position: self.position,
515            age_ticks: self.age_ticks,
516            idle_ticks: self.idle_ticks,
517            anomalies_detected: self.anomalies_detected,
518            last_scan_tick: self.last_scan_tick,
519            self_model_concepts: self.self_model.concepts().into_iter().cloned().collect(),
520            sense_radius: self.sense_radius,
521            max_idle_ticks: self.max_idle_ticks,
522            scan_interval: self.scan_interval,
523        })
524    }
525
526    fn from_state(state: &SerializedAgent) -> Option<Self> {
527        match state {
528            SerializedAgent::Sentinel(s) => {
529                let mut sentinel = Sentinel {
530                    id: s.id,
531                    position: s.position,
532                    age_ticks: s.age_ticks,
533                    state: SentinelState::Scanning,
534                    self_model: ConceptSelfModel::new(),
535                    anomalies_detected: s.anomalies_detected,
536                    last_scan_tick: s.last_scan_tick,
537                    engulfed: None,
538                    fragments: Vec::new(),
539                    sense_radius: s.sense_radius,
540                    max_idle_ticks: s.max_idle_ticks,
541                    idle_ticks: s.idle_ticks,
542                    scan_interval: s.scan_interval,
543                };
544                // Restore self-model concepts with default frequency
545                for concept in &s.self_model_concepts {
546                    sentinel.self_model.observe(concept, 1.0);
547                }
548                Some(sentinel)
549            }
550            _ => None,
551        }
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[test]
560    fn sentinel_starts_maturing() {
561        let sentinel = Sentinel::new(Position::new(0.0, 0.0));
562        assert!(!sentinel.is_mature());
563        assert_eq!(sentinel.agent_type(), "sentinel");
564    }
565
566    #[test]
567    fn classify_unknown_when_immature() {
568        let sentinel = Sentinel::new(Position::new(0.0, 0.0));
569        let obs = vec![("cell".to_string(), 5)];
570        assert_eq!(sentinel.classify(&obs), Classification::Unknown);
571    }
572
573    #[test]
574    fn self_model_learns_from_observations() {
575        let mut sentinel = Sentinel::new(Position::new(0.0, 0.0));
576        let obs = vec![
577            vec![("cell".to_string(), 10u64), ("membrane".to_string(), 8)],
578            vec![("cell".to_string(), 12), ("membrane".to_string(), 7)],
579        ];
580        sentinel.learn_self(&obs);
581
582        assert!(sentinel.self_model().concept_freq.contains_key("cell"));
583        assert!(sentinel.self_model().concept_freq.contains_key("membrane"));
584        assert_eq!(sentinel.self_model().observation_count, 2);
585    }
586}