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
52#[derive(Debug, Clone, PartialEq)]
54enum SentinelState {
55 Maturing(u64), Scanning,
59 Alerting(String), }
62
63pub struct Sentinel {
65 id: AgentId,
66 position: Position,
67 age_ticks: Tick,
68 state: SentinelState,
69
70 self_model: ConceptSelfModel,
72
73 anomalies_detected: u64,
75 last_scan_tick: Tick,
76
77 engulfed: Option<String>,
79 fragments: Vec<String>,
80
81 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, idle_ticks: 0,
103 scan_interval: 5,
104 }
105 }
106
107 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 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 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 *existing = (*existing * self.self_model.observation_count as f64 + freq)
151 / (self.self_model.observation_count + 1) as f64;
152 }
153 }
154
155 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 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 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 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 for (label, count) in ¤t_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 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 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 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
252impl 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() }
310
311 fn orient(&self, _gradients: &[Gradient]) -> Orientation {
312 Orientation::Stay }
314}
315
316impl Negate for Sentinel {
317 type Observation = Vec<(String, u64)>; 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 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 let current_state = self.state.clone();
402
403 match ¤t_state {
404 SentinelState::Maturing(remaining) => {
405 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; AgentAction::Idle
415 }
416
417 SentinelState::Scanning => {
418 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 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 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 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}