Skip to main content

wm_substrate/
anomaly.rs

1//! Anomaly detection and Yin-Yang balance tracking for the Harmony Vector.
2//!
3//! **N20**: Statistical health monitoring via z-score sliding windows on
4//! 7 harmony dimensions, plus action/reflection balance tracking.
5//!
6//! The [`AnomalyDetector`] computes rolling z-scores for each numeric
7//! dimension of the [`HarmonyVector`]. When a dimension deviates
8//! significantly from its recent baseline, an [`AnomalyAlert`] is
9//! emitted. This feeds into the homeostatic loop (N19) and the
10//! Gan Ying Bus (N16).
11//!
12//! The [`YinYangTracker`] classifies each tool dispatch as Yang
13//! (create/write/delete/execute) or Yin (read/search/analyze/reflect)
14//! and maintains a rolling balance ratio. Too much Yang signals
15//! burnout risk; too much Yin signals stagnation.
16
17#![forbid(unsafe_code)]
18
19use std::collections::VecDeque;
20
21use serde::{Deserialize, Serialize};
22
23use crate::HarmonyVector;
24
25// ── Harmony Dimension ─────────────────────────────────────────────────
26
27/// Numeric dimensions of the Harmony Vector that can be monitored
28/// for anomalies.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum HarmonyDimension {
32    /// CPU load fraction (0.0–1.0).
33    CpuLoad,
34    /// Memory pressure fraction (0.0–1.0).
35    MemoryPressure,
36    /// Swap usage fraction (0.0–1.0).
37    SwapUsage,
38    /// Disk I/O rate fraction (0.0–1.0).
39    DiskIoRate,
40    /// Composite health score (0.0–1.0).
41    HealthScore,
42    /// Battery charge fraction (0.0–1.0).
43    BatteryPercent,
44    /// CPU temperature in Celsius (if available).
45    Temperature,
46}
47
48impl HarmonyDimension {
49    /// All 7 dimensions in canonical order.
50    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    /// Extract the numeric value from a HarmonyVector for this dimension.
61    #[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    /// Human-readable name.
75    #[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    /// Whether an increase in this dimension is "bad" (i.e., high values
89    /// indicate problems). For battery_percent and health_score, low
90    /// values are anomalous, so the direction is inverted.
91    #[must_use]
92    const fn is_inverted(self) -> bool {
93        matches!(self, Self::BatteryPercent | Self::HealthScore)
94    }
95}
96
97// ── Anomaly Alert ─────────────────────────────────────────────────────
98
99/// Severity of an anomaly.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum AnomalySeverity {
103    /// |z| > 2.0 — mild deviation, worth logging.
104    Warning,
105    /// |z| > 3.0 — significant deviation, action may be needed.
106    Critical,
107}
108
109impl AnomalySeverity {
110    /// Classify from absolute z-score.
111    #[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    /// Human-readable name.
124    #[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/// Direction of the anomaly relative to the baseline.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum AnomalyDirection {
137    /// Value is above the rolling mean.
138    Above,
139    /// Value is below the rolling mean.
140    Below,
141}
142
143impl AnomalyDirection {
144    /// Human-readable name.
145    #[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/// Whether this anomaly is harmful (i.e., the deviation is in the
159/// "bad" direction for this dimension).
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum AnomalyImpact {
163    /// The deviation is in the harmful direction.
164    Harmful,
165    /// The deviation is in the beneficial direction.
166    Beneficial,
167}
168
169impl AnomalyImpact {
170    /// Human-readable name.
171    #[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/// An anomaly alert for a single harmony dimension.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct AnomalyAlert {
183    /// Which dimension is anomalous.
184    pub dimension: HarmonyDimension,
185    /// The z-score of the current value.
186    pub z_score: f32,
187    /// Whether the value is above or below the baseline.
188    pub direction: AnomalyDirection,
189    /// Severity level (Warning or Critical).
190    pub severity: AnomalySeverity,
191    /// Whether the deviation is harmful or beneficial.
192    pub impact: AnomalyImpact,
193    /// The current value.
194    pub current_value: f32,
195    /// The rolling mean at the time of detection.
196    pub baseline_mean: f32,
197    /// The rolling std dev at the time of detection.
198    pub baseline_std: f32,
199}
200
201impl AnomalyAlert {
202    /// Convert to JSON for MCP tool responses.
203    #[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// ── Anomaly Detector ──────────────────────────────────────────────────
219
220/// Configuration for [`AnomalyDetector`].
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct AnomalyConfig {
223    /// Sliding window size (number of samples to retain per dimension).
224    pub window_size: usize,
225    /// Warning threshold (|z| > this → Warning).
226    pub warning_threshold: f32,
227    /// Critical threshold (|z| > this → Critical).
228    pub critical_threshold: f32,
229    /// Minimum samples before z-score is computed (avoid noise from
230    /// small windows).
231    pub min_samples: usize,
232    /// Epsilon for std dev to avoid division by zero.
233    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/// Sliding window statistics for a single dimension.
249#[derive(Debug, Clone)]
250struct DimensionWindow {
251    values: VecDeque<f32>,
252    /// Cached sum for O(1) mean computation.
253    sum: f64,
254    /// Cached sum of squares for O(1) std dev computation.
255    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/// Clamp a metric value to its valid range for the given dimension.
305///
306/// This prevents poisoned metrics (e.g., negative CPU load, f32::MAX temperature)
307/// from skewing the rolling statistics and generating false anomaly alerts.
308#[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
327/// Anomaly detector using z-score sliding windows on harmony dimensions.
328///
329/// Maintains a rolling window of [`HarmonyVector`] samples and computes
330/// z-scores for each of the 7 numeric dimensions. When a dimension's
331/// z-score exceeds the warning or critical threshold, an [`AnomalyAlert`]
332/// is generated.
333///
334/// # Example
335/// ```no_run
336/// use wm_substrate::{SubstrateMonitor, anomaly::{AnomalyDetector, AnomalySeverity}};
337///
338/// let monitor = SubstrateMonitor::default();
339/// let mut detector = AnomalyDetector::default();
340///
341/// let hv = monitor.sample();
342/// let alerts = detector.check(&hv);
343/// for alert in &alerts {
344///     if alert.severity == AnomalySeverity::Critical {
345///         // Take corrective action
346///     }
347/// }
348/// ```
349pub struct AnomalyDetector {
350    windows: [DimensionWindow; 7],
351    config: AnomalyConfig,
352    /// Total number of alerts detected since creation.
353    alert_count: u64,
354    /// Number of samples processed.
355    sample_count: u64,
356}
357
358impl Default for AnomalyDetector {
359    fn default() -> Self {
360        Self::new(AnomalyConfig::default())
361    }
362}
363
364impl AnomalyDetector {
365    /// Create a new anomaly detector with the given configuration.
366    #[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    /// Process a new HarmonyVector sample and return any anomaly alerts.
386    ///
387    /// The sample is added to the rolling window, then z-scores are
388    /// computed for each dimension. Dimensions with |z| > warning_threshold
389    /// generate alerts.
390    ///
391    /// Metric values are clamped to valid ranges before being added to
392    /// the window, preventing poisoned metrics (e.g., negative CPU, f32::MAX)
393    /// from skewing z-scores.
394    ///
395    /// Note: the current sample is included in the window before computing
396    /// the z-score, which slightly dampens the score. This is intentional —
397    /// it prevents a single spike from generating a false positive when
398    /// the window is large.
399    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    /// Classify whether an anomaly in the given direction is harmful or
436    /// beneficial for this dimension.
437    const fn classify_impact(
438        &self,
439        dim: HarmonyDimension,
440        direction: AnomalyDirection,
441    ) -> AnomalyImpact {
442        // For inverted dimensions (battery, health), low values are bad.
443        // So "Below" is harmful, "Above" is beneficial.
444        if dim.is_inverted() {
445            match direction {
446                AnomalyDirection::Below => AnomalyImpact::Harmful,
447                AnomalyDirection::Above => AnomalyImpact::Beneficial,
448            }
449        } else {
450            // For normal dimensions (cpu, memory, etc.), high values are bad.
451            match direction {
452                AnomalyDirection::Above => AnomalyImpact::Harmful,
453                AnomalyDirection::Below => AnomalyImpact::Beneficial,
454            }
455        }
456    }
457
458    /// Get the current rolling statistics for a dimension.
459    ///
460    /// Returns `(mean, std_dev, sample_count)` for the dimension's window.
461    #[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    /// Total alerts detected since creation.
474    #[must_use]
475    pub const fn alert_count(&self) -> u64 {
476        self.alert_count
477    }
478
479    /// Total samples processed.
480    #[must_use]
481    pub const fn sample_count(&self) -> u64 {
482        self.sample_count
483    }
484
485    /// Number of samples in the window for a specific dimension.
486    #[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    /// Get a JSON summary of the detector's state.
495    #[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// ── Yin-Yang Tracker ──────────────────────────────────────────────────
522
523/// Classification of a dispatch as Yang (active/creative) or Yin
524/// (passive/receptive).
525#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
526#[serde(rename_all = "snake_case")]
527pub enum DispatchNature {
528    /// Yang — create, write, delete, execute, build, act.
529    Yang,
530    /// Yin — read, search, analyze, reflect, observe, query.
531    Yin,
532}
533
534impl DispatchNature {
535    /// Classify a tool name as Yang or Yin based on its action type.
536    ///
537    /// Yang verbs: create, write, delete, associate, consolidate, decay,
538    /// update, tag, flush, end, distribute, trigger, dispatch, execute,
539    /// build, act, start, register, import, export.
540    ///
541    /// Yin verbs: read, list, search, query, scan, status, report, list,
542    /// history, analyze, reflect, observe, check, count, tags, stats,
543    /// health, config, show, get, surface, detect.
544    #[must_use]
545    pub fn from_tool_name(name: &str) -> Self {
546        // Check for Yang (active) keywords first — they're more specific
547        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        // Check if any yang keyword is a substring
619        for kw in &yang_keywords {
620            if lower.contains(kw) {
621                return Self::Yang;
622            }
623        }
624
625        // Check if any yin keyword is a substring
626        for kw in &yin_keywords {
627            if lower.contains(kw) {
628                return Self::Yin;
629            }
630        }
631
632        // Default: if it contains a dot, check the action part
633        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        // Default to Yin (safe — reading/observing)
647        Self::Yin
648    }
649
650    /// Human-readable name.
651    #[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/// Balance state of the Yin-Yang ratio.
661#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
662#[serde(rename_all = "snake_case")]
663pub enum BalanceState {
664    /// Yang ratio > 0.7 — too much action, burnout risk.
665    YangExcess,
666    /// Yin ratio < 0.3 — too much passivity, stagnation risk.
667    YinExcess,
668    /// Balanced — ratio between 0.3 and 0.7.
669    Balanced,
670}
671
672impl BalanceState {
673    /// Human-readable name.
674    #[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    /// Classify from Yang ratio.
684    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    /// Recommended action for this balance state.
695    #[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
709/// Yin-Yang balance tracker.
710///
711/// Maintains a rolling window of dispatch classifications (Yang/Yin)
712/// and computes the balance ratio. When the ratio drifts outside the
713/// ideal range (0.3–0.7), it flags the imbalance.
714///
715/// # Example
716/// ```no_run
717/// use wm_substrate::anomaly::{YinYangTracker, BalanceState};
718///
719/// let mut tracker = YinYangTracker::default();
720/// tracker.record("memory.create"); // Yang
721/// tracker.record("memory.read");   // Yin
722/// let balance = tracker.balance();
723/// assert_eq!(balance.state, BalanceState::Balanced);
724/// ```
725pub struct YinYangTracker {
726    /// Rolling window of dispatch natures.
727    window: VecDeque<DispatchNature>,
728    /// Window capacity.
729    capacity: usize,
730    /// Yang count in current window.
731    yang_count: usize,
732    /// Yin count in current window.
733    yin_count: usize,
734    /// Total Yang dispatches since creation.
735    total_yang: u64,
736    /// Total Yin dispatches since creation.
737    total_yin: u64,
738}
739
740impl Default for YinYangTracker {
741    fn default() -> Self {
742        Self::new(100)
743    }
744}
745
746impl YinYangTracker {
747    /// Create a new tracker with the given window size.
748    #[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    /// Record a tool dispatch by name (auto-classified as Yang or Yin).
761    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    /// Record a dispatch with a pre-determined nature.
767    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    /// Current Yang ratio (0.0 = all Yin, 1.0 = all Yang).
790    #[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 // Neutral default
795        } else {
796            self.yang_count as f32 / total as f32
797        }
798    }
799
800    /// Current Yin ratio (1.0 - yang_ratio).
801    #[must_use]
802    pub fn yin_ratio(&self) -> f32 {
803        1.0 - self.yang_ratio()
804    }
805
806    /// Current balance state.
807    #[must_use]
808    pub fn state(&self) -> BalanceState {
809        BalanceState::from_ratio(self.yang_ratio())
810    }
811
812    /// Get a balance snapshot.
813    #[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    /// Number of dispatches in the current window.
827    #[must_use]
828    pub fn window_len(&self) -> usize {
829        self.window.len()
830    }
831
832    /// Total dispatches (Yang + Yin) since creation.
833    #[must_use]
834    pub const fn total_dispatches(&self) -> u64 {
835        self.total_yang + self.total_yin
836    }
837
838    /// Get a JSON summary.
839    #[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/// A snapshot of the Yin-Yang balance.
857#[derive(Debug, Clone, Serialize, Deserialize)]
858pub struct YinYangBalance {
859    /// Current Yang ratio (0.0–1.0).
860    pub yang_ratio: f32,
861    /// Current Yin ratio (0.0–1.0).
862    pub yin_ratio: f32,
863    /// Yang count in current window.
864    pub yang_count: usize,
865    /// Yin count in current window.
866    pub yin_count: usize,
867    /// Current balance state.
868    pub state: BalanceState,
869    /// Total Yang dispatches since creation.
870    pub total_yang: u64,
871    /// Total Yin dispatches since creation.
872    pub total_yin: u64,
873}
874
875impl YinYangBalance {
876    /// Convert to JSON.
877    #[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// ── Tests ─────────────────────────────────────────────────────────────
893
894#[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    // ── HarmonyDimension tests ──────────────────────────────────────
924
925    #[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    // ── AnomalySeverity tests ───────────────────────────────────────
1001
1002    #[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    // ── AnomalyDirection tests ──────────────────────────────────────
1030
1031    #[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    // ── AnomalyDetector tests ───────────────────────────────────────
1041
1042    #[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        // Feed 20 stable samples
1054        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        // 21st sample — same values, no anomaly
1059        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        // Feed 10 stable samples
1071        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        // Spike CPU to 0.95 — should trigger anomaly
1076        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        // Feed 10 stable samples with full battery
1100        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        // Battery drops to 0.1 — should trigger anomaly (inverted: below is harmful)
1105        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        // Feed 10 samples with high CPU
1123        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        // CPU drops to 0.1 — below baseline, beneficial for CPU dimension
1128        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        // Spike
1168        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        // Window should be capped at 5
1186        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    // ── DispatchNature tests ────────────────────────────────────────
1220
1221    #[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    // ── BalanceState tests ──────────────────────────────────────────
1308
1309    #[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    // ── YinYangTracker tests ────────────────────────────────────────
1333
1334    #[test]
1335    fn yin_yang_empty_tracker() {
1336        let tracker = YinYangTracker::default();
1337        assert_eq!(tracker.yang_ratio(), 0.5); // Neutral default
1338        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"); // Yang
1347        tracker.record("memory.read"); // Yin
1348        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"); // Yang
1356        tracker.record("memory.delete"); // Yang
1357        tracker.record("memory.update"); // Yang
1358        tracker.record("memory.read"); // Yin
1359        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"); // Yin
1368        tracker.record("memory.search"); // Yin
1369        tracker.record("memory.list"); // Yin
1370        tracker.record("gnosis"); // Yin
1371        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        // Fill with Yang
1380        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        // Add 3 Yin — should evict 3 Yang
1387        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"); // Yang
1400        tracker.record("memory.read"); // Yin
1401        tracker.record("memory.delete"); // Yang
1402        tracker.record("memory.search"); // Yin (evicts first Yang from window)
1403        tracker.record("memory.update"); // Yang (evicts first Yin from window)
1404
1405        let balance = tracker.balance();
1406        // Window has: read(Yin), delete(Yang), search(Yin), update(Yang) → wait, capacity 3
1407        // After 5 records with cap 3: window = [search(Yin), update(Yang)] — no, let me think...
1408        // Push create(Y) → [Y], push read(Yin) → [Y, Yin], push delete(Y) → [Y, Yin, Y],
1409        // push search(Yin) → evict Y → [Yin, Y, Yin], push update(Y) → evict Yin → [Y, Yin, Y]
1410        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    // ── Metric clamping tests ───────────────────────────────────────
1446
1447    #[test]
1448    fn impossible_metrics_clamped_negative_cpu() {
1449        let mut detector = AnomalyDetector::new(AnomalyConfig {
1450            min_samples: 5,
1451            ..Default::default()
1452        });
1453        // Feed 10 stable samples
1454        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        // Feed impossible negative CPU — should be clamped to 0.0, not skew z-score
1459        let hv = make_hv(-100.0, 0.2, 0.1, 0.0, 1.0, Some(45.0));
1460        let alerts = detector.check(&hv);
1461        // Clamped to 0.0, which is a deviation from 0.3 but not extreme
1462        // The key assertion: z-score should be bounded (not f32::MAX or NaN)
1463        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        // Feed f32::MAX CPU — should be clamped to 1.0
1488        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        // Feed NaN CPU — should be clamped to 0.0
1511        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        // Feed extreme temperature — should be clamped to 200.0
1530        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}