1use 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
21const MATURATION_TICKS: u64 = 10;
23const ANOMALY_THRESHOLD: f64 = 0.5;
25const MAX_ANOMALIES_PER_SCAN: usize = 10;
27
28#[derive(Debug, Clone)]
30pub struct ConceptSelfModel {
31 concept_freq: HashMap<String, f64>,
33 observation_count: u64,
35 mean_edge_weight: f64,
37 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 fn concepts(&self) -> Vec<&String> {
53 self.concept_freq.keys().collect()
54 }
55
56 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#[derive(Debug, Clone, PartialEq)]
65enum SentinelState {
66 Maturing(u64), Scanning,
70 Alerting(String), }
73
74pub struct Sentinel {
76 id: AgentId,
77 position: Position,
78 age_ticks: Tick,
79 state: SentinelState,
80
81 self_model: ConceptSelfModel,
83
84 anomalies_detected: u64,
86 last_scan_tick: Tick,
87
88 engulfed: Option<String>,
90 fragments: Vec<String>,
91
92 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, idle_ticks: 0,
114 scan_interval: 5,
115 }
116 }
117
118 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 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 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 *existing = (*existing * self.self_model.observation_count as f64 + freq)
162 / (self.self_model.observation_count + 1) as f64;
163 }
164 }
165
166 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 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 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 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 for (label, count) in ¤t_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 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 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 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
263impl 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() }
321
322 fn orient(&self, _gradients: &[Gradient]) -> Orientation {
323 Orientation::Stay }
325}
326
327impl Negate for Sentinel {
328 type Observation = Vec<(String, u64)>; 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 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 let current_state = self.state.clone();
413
414 match ¤t_state {
415 SentinelState::Maturing(remaining) => {
416 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; AgentAction::Idle
426 }
427
428 SentinelState::Scanning => {
429 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 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 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 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
503use 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 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}