1#![forbid(unsafe_code)]
18
19use std::collections::VecDeque;
20
21use serde::{Deserialize, Serialize};
22
23use crate::HarmonyVector;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum HarmonyDimension {
32 CpuLoad,
34 MemoryPressure,
36 SwapUsage,
38 DiskIoRate,
40 HealthScore,
42 BatteryPercent,
44 Temperature,
46}
47
48impl HarmonyDimension {
49 pub const ALL: [Self; 7] = [
51 Self::CpuLoad,
52 Self::MemoryPressure,
53 Self::SwapUsage,
54 Self::DiskIoRate,
55 Self::HealthScore,
56 Self::BatteryPercent,
57 Self::Temperature,
58 ];
59
60 #[must_use]
62 pub fn extract(&self, hv: &HarmonyVector) -> Option<f32> {
63 match self {
64 Self::CpuLoad => Some(hv.cpu_load),
65 Self::MemoryPressure => Some(hv.memory_pressure),
66 Self::SwapUsage => Some(hv.swap_usage),
67 Self::DiskIoRate => Some(hv.disk_io_rate),
68 Self::HealthScore => Some(hv.health_score()),
69 Self::BatteryPercent => Some(hv.battery_percent),
70 Self::Temperature => hv.temperature_c,
71 }
72 }
73
74 #[must_use]
76 pub const fn as_str(self) -> &'static str {
77 match self {
78 Self::CpuLoad => "cpu_load",
79 Self::MemoryPressure => "memory_pressure",
80 Self::SwapUsage => "swap_usage",
81 Self::DiskIoRate => "disk_io_rate",
82 Self::HealthScore => "health_score",
83 Self::BatteryPercent => "battery_percent",
84 Self::Temperature => "temperature",
85 }
86 }
87
88 #[must_use]
92 const fn is_inverted(self) -> bool {
93 matches!(self, Self::BatteryPercent | Self::HealthScore)
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum AnomalySeverity {
103 Warning,
105 Critical,
107}
108
109impl AnomalySeverity {
110 #[must_use]
112 pub fn from_z_score(z: f32) -> Option<Self> {
113 let z = z.abs();
114 if z > 3.0 {
115 Some(Self::Critical)
116 } else if z > 2.0 {
117 Some(Self::Warning)
118 } else {
119 None
120 }
121 }
122
123 #[must_use]
125 pub const fn as_str(self) -> &'static str {
126 match self {
127 Self::Warning => "warning",
128 Self::Critical => "critical",
129 }
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum AnomalyDirection {
137 Above,
139 Below,
141}
142
143impl AnomalyDirection {
144 #[must_use]
146 pub const fn as_str(self) -> &'static str {
147 match self {
148 Self::Above => "above",
149 Self::Below => "below",
150 }
151 }
152
153 fn from_z_score(z: f32) -> Self {
154 if z > 0.0 { Self::Above } else { Self::Below }
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum AnomalyImpact {
163 Harmful,
165 Beneficial,
167}
168
169impl AnomalyImpact {
170 #[must_use]
172 pub const fn as_str(self) -> &'static str {
173 match self {
174 Self::Harmful => "harmful",
175 Self::Beneficial => "beneficial",
176 }
177 }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct AnomalyAlert {
183 pub dimension: HarmonyDimension,
185 pub z_score: f32,
187 pub direction: AnomalyDirection,
189 pub severity: AnomalySeverity,
191 pub impact: AnomalyImpact,
193 pub current_value: f32,
195 pub baseline_mean: f32,
197 pub baseline_std: f32,
199}
200
201impl AnomalyAlert {
202 #[must_use]
204 pub fn to_json(&self) -> serde_json::Value {
205 serde_json::json!({
206 "dimension": self.dimension.as_str(),
207 "z_score": self.z_score,
208 "direction": self.direction.as_str(),
209 "severity": self.severity.as_str(),
210 "impact": self.impact.as_str(),
211 "current_value": self.current_value,
212 "baseline_mean": self.baseline_mean,
213 "baseline_std": self.baseline_std,
214 })
215 }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct AnomalyConfig {
223 pub window_size: usize,
225 pub warning_threshold: f32,
227 pub critical_threshold: f32,
229 pub min_samples: usize,
232 pub std_epsilon: f32,
234}
235
236impl Default for AnomalyConfig {
237 fn default() -> Self {
238 Self {
239 window_size: 100,
240 warning_threshold: 2.0,
241 critical_threshold: 3.0,
242 min_samples: 10,
243 std_epsilon: 1e-6,
244 }
245 }
246}
247
248#[derive(Debug, Clone)]
250struct DimensionWindow {
251 values: VecDeque<f32>,
252 sum: f64,
254 sum_sq: f64,
256}
257
258impl DimensionWindow {
259 fn new(capacity: usize) -> Self {
260 Self {
261 values: VecDeque::with_capacity(capacity),
262 sum: 0.0,
263 sum_sq: 0.0,
264 }
265 }
266
267 fn push(&mut self, value: f32, capacity: usize) {
268 self.sum += f64::from(value);
269 self.sum_sq = f64::from(value).mul_add(f64::from(value), self.sum_sq);
270 self.values.push_back(value);
271 if self.values.len() > capacity {
272 if let Some(old) = self.values.pop_front() {
273 self.sum -= f64::from(old);
274 self.sum_sq = f64::from(old).mul_add(-f64::from(old), self.sum_sq);
275 }
276 }
277 }
278
279 fn len(&self) -> usize {
280 self.values.len()
281 }
282
283 fn mean(&self) -> f32 {
284 if self.values.is_empty() {
285 0.0
286 } else {
287 (self.sum / self.values.len() as f64) as f32
288 }
289 }
290
291 #[allow(clippy::suboptimal_flops)]
292 fn std_dev(&self, epsilon: f32) -> f32 {
293 let n = self.values.len() as f64;
294 if n < 2.0 {
295 return epsilon;
296 }
297 let mean = self.sum / n;
298 let variance = self.sum_sq / n - mean * mean;
299 let variance = variance.max(0.0);
300 (variance.sqrt() as f32).max(epsilon)
301 }
302}
303
304#[must_use]
309const fn clamp_metric(dim: HarmonyDimension, value: f32) -> f32 {
310 if value.is_nan() || value.is_infinite() {
311 return match dim {
312 HarmonyDimension::Temperature => 0.0,
313 _ => 0.0,
314 };
315 }
316 match dim {
317 HarmonyDimension::CpuLoad
318 | HarmonyDimension::MemoryPressure
319 | HarmonyDimension::SwapUsage
320 | HarmonyDimension::DiskIoRate
321 | HarmonyDimension::HealthScore
322 | HarmonyDimension::BatteryPercent => value.clamp(0.0, 1.0),
323 HarmonyDimension::Temperature => value.clamp(-40.0, 200.0),
324 }
325}
326
327pub struct AnomalyDetector {
350 windows: [DimensionWindow; 7],
351 config: AnomalyConfig,
352 alert_count: u64,
354 sample_count: u64,
356}
357
358impl Default for AnomalyDetector {
359 fn default() -> Self {
360 Self::new(AnomalyConfig::default())
361 }
362}
363
364impl AnomalyDetector {
365 #[must_use]
367 pub fn new(config: AnomalyConfig) -> Self {
368 let cap = config.window_size;
369 Self {
370 windows: [
371 DimensionWindow::new(cap),
372 DimensionWindow::new(cap),
373 DimensionWindow::new(cap),
374 DimensionWindow::new(cap),
375 DimensionWindow::new(cap),
376 DimensionWindow::new(cap),
377 DimensionWindow::new(cap),
378 ],
379 config,
380 alert_count: 0,
381 sample_count: 0,
382 }
383 }
384
385 pub fn check(&mut self, hv: &HarmonyVector) -> Vec<AnomalyAlert> {
400 let mut alerts = Vec::new();
401
402 for (i, dim) in HarmonyDimension::ALL.iter().enumerate() {
403 if let Some(raw_value) = dim.extract(hv) {
404 let value = clamp_metric(*dim, raw_value);
405 self.windows[i].push(value, self.config.window_size);
406
407 if self.windows[i].len() >= self.config.min_samples {
408 let mean = self.windows[i].mean();
409 let std = self.windows[i].std_dev(self.config.std_epsilon);
410 let z = (value - mean) / std;
411
412 if let Some(severity) = AnomalySeverity::from_z_score(z) {
413 let direction = AnomalyDirection::from_z_score(z);
414 let impact = self.classify_impact(*dim, direction);
415 alerts.push(AnomalyAlert {
416 dimension: *dim,
417 z_score: z,
418 direction,
419 severity,
420 impact,
421 current_value: value,
422 baseline_mean: mean,
423 baseline_std: std,
424 });
425 }
426 }
427 }
428 }
429
430 self.sample_count += 1;
431 self.alert_count += alerts.len() as u64;
432 alerts
433 }
434
435 const fn classify_impact(
438 &self,
439 dim: HarmonyDimension,
440 direction: AnomalyDirection,
441 ) -> AnomalyImpact {
442 if dim.is_inverted() {
445 match direction {
446 AnomalyDirection::Below => AnomalyImpact::Harmful,
447 AnomalyDirection::Above => AnomalyImpact::Beneficial,
448 }
449 } else {
450 match direction {
452 AnomalyDirection::Above => AnomalyImpact::Harmful,
453 AnomalyDirection::Below => AnomalyImpact::Beneficial,
454 }
455 }
456 }
457
458 #[must_use]
462 pub fn stats(&self, dim: HarmonyDimension) -> (f32, f32, usize) {
463 let idx = HarmonyDimension::ALL.iter().position(|d| *d == dim);
464 match idx {
465 Some(i) => {
466 let w = &self.windows[i];
467 (w.mean(), w.std_dev(self.config.std_epsilon), w.len())
468 }
469 None => (0.0, 0.0, 0),
470 }
471 }
472
473 #[must_use]
475 pub const fn alert_count(&self) -> u64 {
476 self.alert_count
477 }
478
479 #[must_use]
481 pub const fn sample_count(&self) -> u64 {
482 self.sample_count
483 }
484
485 #[must_use]
487 pub fn window_len(&self, dim: HarmonyDimension) -> usize {
488 HarmonyDimension::ALL
489 .iter()
490 .position(|d| *d == dim)
491 .map_or(0, |i| self.windows[i].len())
492 }
493
494 #[must_use]
496 pub fn summary(&self) -> serde_json::Value {
497 let dims: Vec<serde_json::Value> = HarmonyDimension::ALL
498 .iter()
499 .map(|dim| {
500 let (mean, std, n) = self.stats(*dim);
501 serde_json::json!({
502 "dimension": dim.as_str(),
503 "mean": mean,
504 "std_dev": std,
505 "samples": n,
506 })
507 })
508 .collect();
509
510 serde_json::json!({
511 "dimensions": dims,
512 "total_alerts": self.alert_count,
513 "total_samples": self.sample_count,
514 "window_size": self.config.window_size,
515 "warning_threshold": self.config.warning_threshold,
516 "critical_threshold": self.config.critical_threshold,
517 })
518 }
519}
520
521#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
526#[serde(rename_all = "snake_case")]
527pub enum DispatchNature {
528 Yang,
530 Yin,
532}
533
534impl DispatchNature {
535 #[must_use]
545 pub fn from_tool_name(name: &str) -> Self {
546 let yang_keywords = [
548 "create",
549 "write",
550 "delete",
551 "associate",
552 "consolidate",
553 "decay",
554 "update",
555 "tag",
556 "flush",
557 "end",
558 "distribute",
559 "trigger",
560 "dispatch",
561 "execute",
562 "build",
563 "act",
564 "start",
565 "register",
566 "import",
567 "export",
568 "retire",
569 "clear",
570 "remove",
571 "set",
572 "put",
573 "post",
574 "send",
575 "emit",
576 "activate",
577 "shutdown",
578 "stop",
579 "restart",
580 ];
581
582 let yin_keywords = [
583 "read",
584 "list",
585 "search",
586 "query",
587 "scan",
588 "status",
589 "report",
590 "history",
591 "analyze",
592 "reflect",
593 "observe",
594 "check",
595 "count",
596 "tags",
597 "stats",
598 "health",
599 "config",
600 "show",
601 "get",
602 "surface",
603 "detect",
604 "gnosis",
605 "list",
606 "effectiveness",
607 "heartbeat",
608 "recall",
609 "checkpoint",
610 "help",
611 "doctor",
612 "polyglot",
613 "brain",
614 ];
615
616 let lower = name.to_lowercase();
617
618 for kw in &yang_keywords {
620 if lower.contains(kw) {
621 return Self::Yang;
622 }
623 }
624
625 for kw in &yin_keywords {
627 if lower.contains(kw) {
628 return Self::Yin;
629 }
630 }
631
632 if let Some(action) = lower.rsplit('.').next() {
634 for kw in &yang_keywords {
635 if action.contains(kw) {
636 return Self::Yang;
637 }
638 }
639 for kw in &yin_keywords {
640 if action.contains(kw) {
641 return Self::Yin;
642 }
643 }
644 }
645
646 Self::Yin
648 }
649
650 #[must_use]
652 pub const fn as_str(self) -> &'static str {
653 match self {
654 Self::Yang => "yang",
655 Self::Yin => "yin",
656 }
657 }
658}
659
660#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
662#[serde(rename_all = "snake_case")]
663pub enum BalanceState {
664 YangExcess,
666 YinExcess,
668 Balanced,
670}
671
672impl BalanceState {
673 #[must_use]
675 pub const fn as_str(self) -> &'static str {
676 match self {
677 Self::YangExcess => "yang_excess",
678 Self::YinExcess => "yin_excess",
679 Self::Balanced => "balanced",
680 }
681 }
682
683 fn from_ratio(yang_ratio: f32) -> Self {
685 if yang_ratio > 0.7 {
686 Self::YangExcess
687 } else if yang_ratio < 0.3 {
688 Self::YinExcess
689 } else {
690 Self::Balanced
691 }
692 }
693
694 #[must_use]
696 pub const fn recommendation(self) -> &'static str {
697 match self {
698 Self::YangExcess => {
699 "High action ratio — suggest consolidation, dream cycle, or reflection pause"
700 }
701 Self::YinExcess => {
702 "Low action ratio — suggest exploration, curiosity drive boost, or active task"
703 }
704 Self::Balanced => "Balance is healthy — no action needed",
705 }
706 }
707}
708
709pub struct YinYangTracker {
726 window: VecDeque<DispatchNature>,
728 capacity: usize,
730 yang_count: usize,
732 yin_count: usize,
734 total_yang: u64,
736 total_yin: u64,
738}
739
740impl Default for YinYangTracker {
741 fn default() -> Self {
742 Self::new(100)
743 }
744}
745
746impl YinYangTracker {
747 #[must_use]
749 pub fn new(window_size: usize) -> Self {
750 Self {
751 window: VecDeque::with_capacity(window_size),
752 capacity: window_size,
753 yang_count: 0,
754 yin_count: 0,
755 total_yang: 0,
756 total_yin: 0,
757 }
758 }
759
760 pub fn record(&mut self, tool_name: &str) {
762 let nature = DispatchNature::from_tool_name(tool_name);
763 self.record_nature(nature);
764 }
765
766 pub fn record_nature(&mut self, nature: DispatchNature) {
768 match nature {
769 DispatchNature::Yang => {
770 self.yang_count += 1;
771 self.total_yang += 1;
772 }
773 DispatchNature::Yin => {
774 self.yin_count += 1;
775 self.total_yin += 1;
776 }
777 }
778 self.window.push_back(nature);
779 if self.window.len() > self.capacity {
780 if let Some(old) = self.window.pop_front() {
781 match old {
782 DispatchNature::Yang => self.yang_count -= 1,
783 DispatchNature::Yin => self.yin_count -= 1,
784 }
785 }
786 }
787 }
788
789 #[must_use]
791 pub fn yang_ratio(&self) -> f32 {
792 let total = self.yang_count + self.yin_count;
793 if total == 0 {
794 0.5 } else {
796 self.yang_count as f32 / total as f32
797 }
798 }
799
800 #[must_use]
802 pub fn yin_ratio(&self) -> f32 {
803 1.0 - self.yang_ratio()
804 }
805
806 #[must_use]
808 pub fn state(&self) -> BalanceState {
809 BalanceState::from_ratio(self.yang_ratio())
810 }
811
812 #[must_use]
814 pub fn balance(&self) -> YinYangBalance {
815 YinYangBalance {
816 yang_ratio: self.yang_ratio(),
817 yin_ratio: self.yin_ratio(),
818 yang_count: self.yang_count,
819 yin_count: self.yin_count,
820 state: self.state(),
821 total_yang: self.total_yang,
822 total_yin: self.total_yin,
823 }
824 }
825
826 #[must_use]
828 pub fn window_len(&self) -> usize {
829 self.window.len()
830 }
831
832 #[must_use]
834 pub const fn total_dispatches(&self) -> u64 {
835 self.total_yang + self.total_yin
836 }
837
838 #[must_use]
840 pub fn summary(&self) -> serde_json::Value {
841 let b = self.balance();
842 serde_json::json!({
843 "yang_ratio": b.yang_ratio,
844 "yin_ratio": b.yin_ratio,
845 "yang_count": b.yang_count,
846 "yin_count": b.yin_count,
847 "state": b.state.as_str(),
848 "recommendation": b.state.recommendation(),
849 "total_yang": b.total_yang,
850 "total_yin": b.total_yin,
851 "window_size": self.capacity,
852 })
853 }
854}
855
856#[derive(Debug, Clone, Serialize, Deserialize)]
858pub struct YinYangBalance {
859 pub yang_ratio: f32,
861 pub yin_ratio: f32,
863 pub yang_count: usize,
865 pub yin_count: usize,
867 pub state: BalanceState,
869 pub total_yang: u64,
871 pub total_yin: u64,
873}
874
875impl YinYangBalance {
876 #[must_use]
878 pub fn to_json(&self) -> serde_json::Value {
879 serde_json::json!({
880 "yang_ratio": self.yang_ratio,
881 "yin_ratio": self.yin_ratio,
882 "yang_count": self.yang_count,
883 "yin_count": self.yin_count,
884 "state": self.state.as_str(),
885 "recommendation": self.state.recommendation(),
886 "total_yang": self.total_yang,
887 "total_yin": self.total_yin,
888 })
889 }
890}
891
892#[cfg(test)]
895mod tests {
896 use super::*;
897 use crate::{BatteryState, GunaTag, HarmonyVector, ThermalState};
898 use chrono::Utc;
899
900 fn make_hv(
901 cpu: f32,
902 mem: f32,
903 swap: f32,
904 disk: f32,
905 battery: f32,
906 temp: Option<f32>,
907 ) -> HarmonyVector {
908 HarmonyVector {
909 cpu_load: cpu,
910 memory_pressure: mem,
911 swap_usage: swap,
912 thermal_state: ThermalState::from_celsius(temp.unwrap_or(45.0)),
913 temperature_c: temp,
914 battery_state: BatteryState::Full,
915 battery_percent: battery,
916 disk_io_rate: disk,
917 active: cpu > 0.15,
918 guna: GunaTag::Sattvic,
919 timestamp: Utc::now(),
920 }
921 }
922
923 #[test]
926 fn dimension_extract_cpu_load() {
927 let hv = make_hv(0.5, 0.2, 0.1, 0.0, 1.0, Some(45.0));
928 assert_eq!(HarmonyDimension::CpuLoad.extract(&hv), Some(0.5));
929 }
930
931 #[test]
932 fn dimension_extract_memory_pressure() {
933 let hv = make_hv(0.5, 0.3, 0.1, 0.0, 1.0, Some(45.0));
934 assert_eq!(HarmonyDimension::MemoryPressure.extract(&hv), Some(0.3));
935 }
936
937 #[test]
938 fn dimension_extract_swap_usage() {
939 let hv = make_hv(0.5, 0.2, 0.4, 0.0, 1.0, Some(45.0));
940 assert_eq!(HarmonyDimension::SwapUsage.extract(&hv), Some(0.4));
941 }
942
943 #[test]
944 fn dimension_extract_disk_io() {
945 let hv = make_hv(0.5, 0.2, 0.1, 0.6, 1.0, Some(45.0));
946 assert_eq!(HarmonyDimension::DiskIoRate.extract(&hv), Some(0.6));
947 }
948
949 #[test]
950 fn dimension_extract_health_score() {
951 let hv = make_hv(0.1, 0.1, 0.0, 0.0, 1.0, Some(45.0));
952 let health = HarmonyDimension::HealthScore.extract(&hv);
953 assert!(health.is_some());
954 assert!(health.unwrap() > 0.8);
955 }
956
957 #[test]
958 fn dimension_extract_battery() {
959 let hv = make_hv(0.5, 0.2, 0.1, 0.0, 0.7, Some(45.0));
960 assert_eq!(HarmonyDimension::BatteryPercent.extract(&hv), Some(0.7));
961 }
962
963 #[test]
964 fn dimension_extract_temperature() {
965 let hv = make_hv(0.5, 0.2, 0.1, 0.0, 1.0, Some(72.0));
966 assert_eq!(HarmonyDimension::Temperature.extract(&hv), Some(72.0));
967 }
968
969 #[test]
970 fn dimension_extract_temperature_none() {
971 let hv = make_hv(0.5, 0.2, 0.1, 0.0, 1.0, None);
972 assert_eq!(HarmonyDimension::Temperature.extract(&hv), None);
973 }
974
975 #[test]
976 fn dimension_all_has_seven() {
977 assert_eq!(HarmonyDimension::ALL.len(), 7);
978 }
979
980 #[test]
981 fn dimension_as_str() {
982 assert_eq!(HarmonyDimension::CpuLoad.as_str(), "cpu_load");
983 assert_eq!(HarmonyDimension::MemoryPressure.as_str(), "memory_pressure");
984 assert_eq!(HarmonyDimension::SwapUsage.as_str(), "swap_usage");
985 assert_eq!(HarmonyDimension::DiskIoRate.as_str(), "disk_io_rate");
986 assert_eq!(HarmonyDimension::HealthScore.as_str(), "health_score");
987 assert_eq!(HarmonyDimension::BatteryPercent.as_str(), "battery_percent");
988 assert_eq!(HarmonyDimension::Temperature.as_str(), "temperature");
989 }
990
991 #[test]
992 fn dimension_inverted_flags() {
993 assert!(HarmonyDimension::BatteryPercent.is_inverted());
994 assert!(HarmonyDimension::HealthScore.is_inverted());
995 assert!(!HarmonyDimension::CpuLoad.is_inverted());
996 assert!(!HarmonyDimension::MemoryPressure.is_inverted());
997 assert!(!HarmonyDimension::Temperature.is_inverted());
998 }
999
1000 #[test]
1003 fn severity_from_z_score() {
1004 assert_eq!(AnomalySeverity::from_z_score(1.5), None);
1005 assert_eq!(
1006 AnomalySeverity::from_z_score(2.5),
1007 Some(AnomalySeverity::Warning)
1008 );
1009 assert_eq!(
1010 AnomalySeverity::from_z_score(-2.5),
1011 Some(AnomalySeverity::Warning)
1012 );
1013 assert_eq!(
1014 AnomalySeverity::from_z_score(3.5),
1015 Some(AnomalySeverity::Critical)
1016 );
1017 assert_eq!(
1018 AnomalySeverity::from_z_score(-3.5),
1019 Some(AnomalySeverity::Critical)
1020 );
1021 }
1022
1023 #[test]
1024 fn severity_as_str() {
1025 assert_eq!(AnomalySeverity::Warning.as_str(), "warning");
1026 assert_eq!(AnomalySeverity::Critical.as_str(), "critical");
1027 }
1028
1029 #[test]
1032 fn direction_from_z_score() {
1033 assert_eq!(AnomalyDirection::from_z_score(2.5), AnomalyDirection::Above);
1034 assert_eq!(
1035 AnomalyDirection::from_z_score(-2.5),
1036 AnomalyDirection::Below
1037 );
1038 }
1039
1040 #[test]
1043 fn anomaly_detector_no_alerts_with_few_samples() {
1044 let mut detector = AnomalyDetector::default();
1045 let hv = make_hv(0.5, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1046 let alerts = detector.check(&hv);
1047 assert!(alerts.is_empty(), "Should not alert with < min_samples");
1048 }
1049
1050 #[test]
1051 fn anomaly_detector_stable_no_alerts() {
1052 let mut detector = AnomalyDetector::default();
1053 for _ in 0..20 {
1055 let hv = make_hv(0.3, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1056 detector.check(&hv);
1057 }
1058 let hv = make_hv(0.3, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1060 let alerts = detector.check(&hv);
1061 assert!(alerts.is_empty(), "Stable values should not trigger alerts");
1062 }
1063
1064 #[test]
1065 fn anomaly_detector_detects_spike() {
1066 let mut detector = AnomalyDetector::new(AnomalyConfig {
1067 min_samples: 5,
1068 ..Default::default()
1069 });
1070 for _ in 0..10 {
1072 let hv = make_hv(0.2, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1073 detector.check(&hv);
1074 }
1075 let hv = make_hv(0.95, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1077 let alerts = detector.check(&hv);
1078 assert!(!alerts.is_empty(), "CPU spike should trigger an alert");
1079 let cpu_alert = alerts
1080 .iter()
1081 .find(|a| a.dimension == HarmonyDimension::CpuLoad);
1082 assert!(cpu_alert.is_some(), "Should have a CpuLoad alert");
1083 let alert = cpu_alert.unwrap();
1084 assert!(
1085 alert.z_score > 2.0,
1086 "Z-score should be > 2.0: {}",
1087 alert.z_score
1088 );
1089 assert_eq!(alert.direction, AnomalyDirection::Above);
1090 assert_eq!(alert.impact, AnomalyImpact::Harmful);
1091 }
1092
1093 #[test]
1094 fn anomaly_detector_detects_drop() {
1095 let mut detector = AnomalyDetector::new(AnomalyConfig {
1096 min_samples: 5,
1097 ..Default::default()
1098 });
1099 for _ in 0..10 {
1101 let hv = make_hv(0.2, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1102 detector.check(&hv);
1103 }
1104 let hv = make_hv(0.2, 0.2, 0.1, 0.0, 0.1, Some(45.0));
1106 let alerts = detector.check(&hv);
1107 let bat_alert = alerts
1108 .iter()
1109 .find(|a| a.dimension == HarmonyDimension::BatteryPercent);
1110 assert!(bat_alert.is_some(), "Battery drop should trigger an alert");
1111 let alert = bat_alert.unwrap();
1112 assert_eq!(alert.direction, AnomalyDirection::Below);
1113 assert_eq!(alert.impact, AnomalyImpact::Harmful);
1114 }
1115
1116 #[test]
1117 fn anomaly_detector_beneficial_anomaly() {
1118 let mut detector = AnomalyDetector::new(AnomalyConfig {
1119 min_samples: 5,
1120 ..Default::default()
1121 });
1122 for _ in 0..10 {
1124 let hv = make_hv(0.8, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1125 detector.check(&hv);
1126 }
1127 let hv = make_hv(0.1, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1129 let alerts = detector.check(&hv);
1130 let cpu_alert = alerts
1131 .iter()
1132 .find(|a| a.dimension == HarmonyDimension::CpuLoad);
1133 assert!(cpu_alert.is_some(), "CPU drop should trigger an alert");
1134 let alert = cpu_alert.unwrap();
1135 assert_eq!(alert.direction, AnomalyDirection::Below);
1136 assert_eq!(alert.impact, AnomalyImpact::Beneficial);
1137 }
1138
1139 #[test]
1140 fn anomaly_detector_stats() {
1141 let mut detector = AnomalyDetector::new(AnomalyConfig {
1142 min_samples: 3,
1143 ..Default::default()
1144 });
1145 for v in [0.2, 0.3, 0.4, 0.5] {
1146 let hv = make_hv(v, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1147 detector.check(&hv);
1148 }
1149 let (mean, _std, n) = detector.stats(HarmonyDimension::CpuLoad);
1150 assert!((mean - 0.35).abs() < 0.01, "Mean should be ~0.35: {mean}");
1151 assert_eq!(n, 4);
1152 }
1153
1154 #[test]
1155 fn anomaly_detector_alert_count() {
1156 let mut detector = AnomalyDetector::new(AnomalyConfig {
1157 min_samples: 5,
1158 ..Default::default()
1159 });
1160 for _ in 0..10 {
1161 let hv = make_hv(0.2, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1162 detector.check(&hv);
1163 }
1164 assert_eq!(detector.alert_count(), 0);
1165 assert_eq!(detector.sample_count(), 10);
1166
1167 let hv = make_hv(0.99, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1169 let alerts = detector.check(&hv);
1170 assert!(detector.alert_count() >= 1);
1171 assert!(!alerts.is_empty());
1172 }
1173
1174 #[test]
1175 fn anomaly_detector_window_len() {
1176 let mut detector = AnomalyDetector::new(AnomalyConfig {
1177 window_size: 5,
1178 min_samples: 2,
1179 ..Default::default()
1180 });
1181 for _ in 0..10 {
1182 let hv = make_hv(0.2, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1183 detector.check(&hv);
1184 }
1185 assert_eq!(detector.window_len(HarmonyDimension::CpuLoad), 5);
1187 }
1188
1189 #[test]
1190 fn anomaly_detector_summary() {
1191 let mut detector = AnomalyDetector::default();
1192 let hv = make_hv(0.3, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1193 detector.check(&hv);
1194 let s = detector.summary();
1195 assert_eq!(s["total_samples"], 1);
1196 assert_eq!(s["total_alerts"], 0);
1197 assert!(s["dimensions"].is_array());
1198 }
1199
1200 #[test]
1201 fn anomaly_alert_to_json() {
1202 let alert = AnomalyAlert {
1203 dimension: HarmonyDimension::CpuLoad,
1204 z_score: 3.5,
1205 direction: AnomalyDirection::Above,
1206 severity: AnomalySeverity::Critical,
1207 impact: AnomalyImpact::Harmful,
1208 current_value: 0.95,
1209 baseline_mean: 0.3,
1210 baseline_std: 0.15,
1211 };
1212 let json = alert.to_json();
1213 assert_eq!(json["dimension"], "cpu_load");
1214 assert_eq!(json["severity"], "critical");
1215 assert_eq!(json["direction"], "above");
1216 assert_eq!(json["impact"], "harmful");
1217 }
1218
1219 #[test]
1222 fn dispatch_nature_yang_create() {
1223 assert_eq!(
1224 DispatchNature::from_tool_name("memory.create"),
1225 DispatchNature::Yang
1226 );
1227 }
1228
1229 #[test]
1230 fn dispatch_nature_yang_delete() {
1231 assert_eq!(
1232 DispatchNature::from_tool_name("memory.delete"),
1233 DispatchNature::Yang
1234 );
1235 }
1236
1237 #[test]
1238 fn dispatch_nature_yang_update() {
1239 assert_eq!(
1240 DispatchNature::from_tool_name("memory.update"),
1241 DispatchNature::Yang
1242 );
1243 }
1244
1245 #[test]
1246 fn dispatch_nature_yang_consolidate() {
1247 assert_eq!(
1248 DispatchNature::from_tool_name("memory.consolidate"),
1249 DispatchNature::Yang
1250 );
1251 }
1252
1253 #[test]
1254 fn dispatch_nature_yin_read() {
1255 assert_eq!(
1256 DispatchNature::from_tool_name("memory.read"),
1257 DispatchNature::Yin
1258 );
1259 }
1260
1261 #[test]
1262 fn dispatch_nature_yin_search() {
1263 assert_eq!(
1264 DispatchNature::from_tool_name("memory.search"),
1265 DispatchNature::Yin
1266 );
1267 }
1268
1269 #[test]
1270 fn dispatch_nature_yin_list() {
1271 assert_eq!(
1272 DispatchNature::from_tool_name("memory.list"),
1273 DispatchNature::Yin
1274 );
1275 }
1276
1277 #[test]
1278 fn dispatch_nature_yin_status() {
1279 assert_eq!(
1280 DispatchNature::from_tool_name("citta.status"),
1281 DispatchNature::Yin
1282 );
1283 }
1284
1285 #[test]
1286 fn dispatch_nature_yin_gnosis() {
1287 assert_eq!(
1288 DispatchNature::from_tool_name("gnosis"),
1289 DispatchNature::Yin
1290 );
1291 }
1292
1293 #[test]
1294 fn dispatch_nature_default_yin() {
1295 assert_eq!(
1296 DispatchNature::from_tool_name("unknown.thing"),
1297 DispatchNature::Yin
1298 );
1299 }
1300
1301 #[test]
1302 fn dispatch_nature_as_str() {
1303 assert_eq!(DispatchNature::Yang.as_str(), "yang");
1304 assert_eq!(DispatchNature::Yin.as_str(), "yin");
1305 }
1306
1307 #[test]
1310 fn balance_state_from_ratio() {
1311 assert_eq!(BalanceState::from_ratio(0.8), BalanceState::YangExcess);
1312 assert_eq!(BalanceState::from_ratio(0.2), BalanceState::YinExcess);
1313 assert_eq!(BalanceState::from_ratio(0.5), BalanceState::Balanced);
1314 assert_eq!(BalanceState::from_ratio(0.3), BalanceState::Balanced);
1315 assert_eq!(BalanceState::from_ratio(0.7), BalanceState::Balanced);
1316 }
1317
1318 #[test]
1319 fn balance_state_as_str() {
1320 assert_eq!(BalanceState::YangExcess.as_str(), "yang_excess");
1321 assert_eq!(BalanceState::YinExcess.as_str(), "yin_excess");
1322 assert_eq!(BalanceState::Balanced.as_str(), "balanced");
1323 }
1324
1325 #[test]
1326 fn balance_state_recommendation() {
1327 assert!(!BalanceState::YangExcess.recommendation().is_empty());
1328 assert!(!BalanceState::YinExcess.recommendation().is_empty());
1329 assert!(!BalanceState::Balanced.recommendation().is_empty());
1330 }
1331
1332 #[test]
1335 fn yin_yang_empty_tracker() {
1336 let tracker = YinYangTracker::default();
1337 assert_eq!(tracker.yang_ratio(), 0.5); assert_eq!(tracker.state(), BalanceState::Balanced);
1339 assert_eq!(tracker.window_len(), 0);
1340 assert_eq!(tracker.total_dispatches(), 0);
1341 }
1342
1343 #[test]
1344 fn yin_yang_balanced() {
1345 let mut tracker = YinYangTracker::default();
1346 tracker.record("memory.create"); tracker.record("memory.read"); assert_eq!(tracker.yang_ratio(), 0.5);
1349 assert_eq!(tracker.state(), BalanceState::Balanced);
1350 }
1351
1352 #[test]
1353 fn yin_yang_yang_excess() {
1354 let mut tracker = YinYangTracker::default();
1355 tracker.record("memory.create"); tracker.record("memory.delete"); tracker.record("memory.update"); tracker.record("memory.read"); let balance = tracker.balance();
1360 assert_eq!(balance.state, BalanceState::YangExcess);
1361 assert!(balance.yang_ratio > 0.7);
1362 }
1363
1364 #[test]
1365 fn yin_yang_yin_excess() {
1366 let mut tracker = YinYangTracker::default();
1367 tracker.record("memory.read"); tracker.record("memory.search"); tracker.record("memory.list"); tracker.record("gnosis"); let balance = tracker.balance();
1372 assert_eq!(balance.state, BalanceState::YinExcess);
1373 assert!(balance.yang_ratio < 0.3);
1374 }
1375
1376 #[test]
1377 fn yin_yang_window_eviction() {
1378 let mut tracker = YinYangTracker::new(5);
1379 for _ in 0..5 {
1381 tracker.record("memory.create");
1382 }
1383 assert_eq!(tracker.window_len(), 5);
1384 assert_eq!(tracker.yang_ratio(), 1.0);
1385
1386 for _ in 0..3 {
1388 tracker.record("memory.read");
1389 }
1390 assert_eq!(tracker.window_len(), 5);
1391 let balance = tracker.balance();
1392 assert_eq!(balance.yang_count, 2);
1393 assert_eq!(balance.yin_count, 3);
1394 }
1395
1396 #[test]
1397 fn yin_yang_total_counts() {
1398 let mut tracker = YinYangTracker::new(3);
1399 tracker.record("memory.create"); tracker.record("memory.read"); tracker.record("memory.delete"); tracker.record("memory.search"); tracker.record("memory.update"); let balance = tracker.balance();
1406 assert_eq!(balance.total_yang, 3);
1411 assert_eq!(balance.total_yin, 2);
1412 }
1413
1414 #[test]
1415 fn yin_yang_record_nature_direct() {
1416 let mut tracker = YinYangTracker::default();
1417 tracker.record_nature(DispatchNature::Yang);
1418 tracker.record_nature(DispatchNature::Yin);
1419 assert_eq!(tracker.yang_ratio(), 0.5);
1420 }
1421
1422 #[test]
1423 fn yin_yang_summary() {
1424 let mut tracker = YinYangTracker::default();
1425 tracker.record("memory.create");
1426 tracker.record("memory.read");
1427 let s = tracker.summary();
1428 assert_eq!(s["state"], "balanced");
1429 assert_eq!(s["total_yang"], 1);
1430 assert_eq!(s["total_yin"], 1);
1431 }
1432
1433 #[test]
1434 fn yin_yang_balance_to_json() {
1435 let mut tracker = YinYangTracker::default();
1436 tracker.record("memory.create");
1437 tracker.record("memory.create");
1438 tracker.record("memory.create");
1439 tracker.record("memory.read");
1440 let balance = tracker.balance();
1441 let json = balance.to_json();
1442 assert_eq!(json["state"], "yang_excess");
1443 }
1444
1445 #[test]
1448 fn impossible_metrics_clamped_negative_cpu() {
1449 let mut detector = AnomalyDetector::new(AnomalyConfig {
1450 min_samples: 5,
1451 ..Default::default()
1452 });
1453 for _ in 0..10 {
1455 let hv = make_hv(0.3, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1456 detector.check(&hv);
1457 }
1458 let hv = make_hv(-100.0, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1460 let alerts = detector.check(&hv);
1461 for alert in &alerts {
1464 assert!(
1465 alert.z_score.abs() < 100.0,
1466 "z-score should be bounded, got {}",
1467 alert.z_score
1468 );
1469 assert!(!alert.z_score.is_nan(), "z-score should not be NaN");
1470 assert!(
1471 !alert.z_score.is_infinite(),
1472 "z-score should not be infinite"
1473 );
1474 }
1475 }
1476
1477 #[test]
1478 fn impossible_metrics_clamped_f32_max() {
1479 let mut detector = AnomalyDetector::new(AnomalyConfig {
1480 min_samples: 5,
1481 ..Default::default()
1482 });
1483 for _ in 0..10 {
1484 let hv = make_hv(0.3, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1485 detector.check(&hv);
1486 }
1487 let hv = make_hv(f32::MAX, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1489 let alerts = detector.check(&hv);
1490 for alert in &alerts {
1491 assert!(
1492 alert.z_score.abs() < 100.0,
1493 "z-score should be bounded, got {}",
1494 alert.z_score
1495 );
1496 assert!(!alert.z_score.is_nan());
1497 }
1498 }
1499
1500 #[test]
1501 fn impossible_metrics_clamped_nan() {
1502 let mut detector = AnomalyDetector::new(AnomalyConfig {
1503 min_samples: 5,
1504 ..Default::default()
1505 });
1506 for _ in 0..10 {
1507 let hv = make_hv(0.3, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1508 detector.check(&hv);
1509 }
1510 let hv = make_hv(f32::NAN, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1512 let alerts = detector.check(&hv);
1513 for alert in &alerts {
1514 assert!(!alert.z_score.is_nan(), "z-score should not be NaN");
1515 assert!(alert.z_score.abs() < 100.0);
1516 }
1517 }
1518
1519 #[test]
1520 fn impossible_metrics_clamped_extreme_temperature() {
1521 let mut detector = AnomalyDetector::new(AnomalyConfig {
1522 min_samples: 5,
1523 ..Default::default()
1524 });
1525 for _ in 0..10 {
1526 let hv = make_hv(0.3, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1527 detector.check(&hv);
1528 }
1529 let hv = make_hv(0.3, 0.2, 0.1, 0.0, 1.0, Some(1e10));
1531 let alerts = detector.check(&hv);
1532 for alert in &alerts {
1533 assert!(
1534 alert.z_score.abs() < 100.0,
1535 "z-score should be bounded, got {}",
1536 alert.z_score
1537 );
1538 }
1539 }
1540
1541 #[test]
1542 fn clamp_metric_function_direct() {
1543 assert_eq!(clamp_metric(HarmonyDimension::CpuLoad, -1.0), 0.0);
1544 assert_eq!(clamp_metric(HarmonyDimension::CpuLoad, 2.0), 1.0);
1545 assert_eq!(clamp_metric(HarmonyDimension::CpuLoad, 0.5), 0.5);
1546 assert_eq!(clamp_metric(HarmonyDimension::BatteryPercent, -0.5), 0.0);
1547 assert_eq!(clamp_metric(HarmonyDimension::BatteryPercent, 1.5), 1.0);
1548 assert_eq!(clamp_metric(HarmonyDimension::Temperature, -100.0), -40.0);
1549 assert_eq!(clamp_metric(HarmonyDimension::Temperature, 500.0), 200.0);
1550 assert_eq!(clamp_metric(HarmonyDimension::Temperature, 45.0), 45.0);
1551 assert_eq!(clamp_metric(HarmonyDimension::CpuLoad, f32::NAN), 0.0);
1552 assert_eq!(clamp_metric(HarmonyDimension::CpuLoad, f32::INFINITY), 0.0);
1553 }
1554}