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