Skip to main content

wm_substrate/
lib.rs

1//! wm-substrate — hardware awareness for WhiteMagic (Lakshmi / Harmony Vector).
2//!
3//! Reads real system metrics from `/proc` and `/sys` on Linux, providing
4//! a [`HarmonyVector`] that feeds into the governance pipeline via
5//! [`Homeostasis`](wm_governance::Homeostasis). On non-Linux or when
6//! files are unavailable, gracefully degrades to default values.
7//!
8//! This is the "body" of the cognitive system — the nervous system that
9//! gives the mind awareness of its own resource footprint. v2 had no
10//! Lakshmi, no hardware awareness — "a mind without a body". v4's
11//! substrate monitor is the foundation of governed autonomy.
12
13#![forbid(unsafe_code)]
14
15pub mod anomaly;
16pub mod homeostatic;
17pub mod sensorimotor;
18pub mod write_budget;
19
20use std::collections::VecDeque;
21use std::fs;
22use std::sync::RwLock;
23
24use chrono::{DateTime, Utc};
25use serde::{Deserialize, Serialize};
26
27// ── Thermal State ─────────────────────────────────────────────────────
28
29/// Thermal state classification from CPU/package temperature.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum ThermalState {
33    /// Below 60°C — normal operation.
34    Normal,
35    /// 60–75°C — elevated, may throttle.
36    Warm,
37    /// 75–90°C — hot, should reduce load.
38    Hot,
39    /// Above 90°C — critical, must shed load.
40    Critical,
41    /// Temperature could not be read.
42    Unknown,
43}
44
45impl ThermalState {
46    /// Health factor contribution (1.0 = perfect, 0.0 = critical).
47    #[must_use]
48    pub const fn health_factor(self) -> f32 {
49        match self {
50            Self::Normal => 1.0,
51            Self::Warm => 0.8,
52            Self::Hot => 0.5,
53            Self::Critical => 0.2,
54            Self::Unknown => 0.9, // Assume OK if we can't read
55        }
56    }
57
58    /// Classify from temperature in Celsius.
59    fn from_celsius(temp: f32) -> Self {
60        if temp < 60.0 {
61            Self::Normal
62        } else if temp < 75.0 {
63            Self::Warm
64        } else if temp < 90.0 {
65            Self::Hot
66        } else {
67            Self::Critical
68        }
69    }
70
71    /// Human-readable name.
72    #[must_use]
73    pub const fn as_str(self) -> &'static str {
74        match self {
75            Self::Normal => "normal",
76            Self::Warm => "warm",
77            Self::Hot => "hot",
78            Self::Critical => "critical",
79            Self::Unknown => "unknown",
80        }
81    }
82}
83
84// ── Battery State ─────────────────────────────────────────────────────
85
86/// Battery state classification from power supply.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum BatteryState {
90    /// Battery is being charged.
91    Charging,
92    /// Battery is discharging (on battery power).
93    Discharging,
94    /// Battery is fully charged.
95    Full,
96    /// Connected to power but not charging.
97    NotCharging,
98    /// Battery state could not be read or no battery present.
99    Unknown,
100}
101
102impl BatteryState {
103    /// Human-readable name.
104    #[must_use]
105    pub const fn as_str(self) -> &'static str {
106        match self {
107            Self::Charging => "charging",
108            Self::Discharging => "discharging",
109            Self::Full => "full",
110            Self::NotCharging => "not_charging",
111            Self::Unknown => "unknown",
112        }
113    }
114
115    fn from_status(s: &str) -> Self {
116        match s.trim() {
117            "Charging" => Self::Charging,
118            "Discharging" => Self::Discharging,
119            "Full" => Self::Full,
120            "Not charging" => Self::NotCharging,
121            _ => Self::Unknown,
122        }
123    }
124}
125
126// ── Guna Tag ──────────────────────────────────────────────────────────
127
128/// Guna classification of system resource behavior.
129///
130/// Inspired by the three Gunas from Samkhya philosophy, applied to
131/// hardware resource patterns:
132/// - **Sattvic**: Low resource usage, responsive — harmonious.
133/// - **Rajasic**: High CPU or memory, greedy — active but consuming.
134/// - **Tamasic**: Idle or sleeping — dormant.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum GunaTag {
138    /// Low resource usage, responsive — harmonious.
139    Sattvic,
140    /// High CPU or memory, greedy — active but consuming.
141    Rajasic,
142    /// Idle or sleeping — dormant.
143    Tamasic,
144}
145
146impl GunaTag {
147    /// Human-readable name.
148    #[must_use]
149    pub const fn as_str(self) -> &'static str {
150        match self {
151            Self::Sattvic => "sattvic",
152            Self::Rajasic => "rajasic",
153            Self::Tamasic => "tamasic",
154        }
155    }
156}
157
158// ── Harmony Vector ────────────────────────────────────────────────────
159
160/// Harmony Vector — real-time hardware state snapshot.
161///
162/// Superset of [`Homeostasis`](wm_governance::Homeostasis) with additional
163/// signals: swap, thermal, battery, and disk I/O. Feeds into `DharmaGate`
164/// via the `From<HarmonyVector> for Homeostasis` conversion.
165///
166/// This is the Lakshmi (Harmony Monitor) of the Mandala OS — it observes
167/// the Annamaya Kosha (Hardware Layer) and reports the system's physical
168/// state to the governance and consciousness layers.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct HarmonyVector {
171    /// CPU load fraction (0.0 = idle, 1.0 = saturated).
172    pub cpu_load: f32,
173    /// Memory pressure fraction (0.0 = plenty, 1.0 = critical).
174    pub memory_pressure: f32,
175    /// Swap usage fraction (0.0 = none, 1.0 = full).
176    pub swap_usage: f32,
177    /// Thermal state classification.
178    pub thermal_state: ThermalState,
179    /// Temperature in Celsius (if available).
180    pub temperature_c: Option<f32>,
181    /// Battery state classification.
182    pub battery_state: BatteryState,
183    /// Battery charge fraction (0.0 = empty, 1.0 = full).
184    pub battery_percent: f32,
185    /// Disk I/O rate fraction (0.0 = idle, 1.0 = saturated).
186    pub disk_io_rate: f32,
187    /// Whether the system is actively processing.
188    pub active: bool,
189    /// Guna classification of overall resource behavior.
190    pub guna: GunaTag,
191    /// When this sample was taken.
192    pub timestamp: DateTime<Utc>,
193}
194
195impl HarmonyVector {
196    /// Compute a comprehensive health score (0.0 = critical, 1.0 = perfect).
197    ///
198    /// Weighted average of CPU (30%), memory (30%), swap (20%), and
199    /// thermal (20%) health factors.
200    #[must_use]
201    pub fn health_score(&self) -> f32 {
202        // Platform honesty: if all physical metrics are exactly zero, sensors are unavailable
203        // (e.g. non-Linux / missing /proc). Report neutral 0.5 rather than false perfect 1.0.
204        if self.cpu_load == 0.0
205            && self.memory_pressure == 0.0
206            && self.swap_usage == 0.0
207            && self.temperature_c.unwrap_or(0.0) == 0.0
208        {
209            return 0.5;
210        }
211        let cpu_health = 1.0 - self.cpu_load.min(1.0);
212        let mem_health = 1.0 - self.memory_pressure.min(1.0);
213        let swap_health = 1.0 - self.swap_usage.min(1.0);
214        let thermal_health = self.thermal_state.health_factor();
215        let base = cpu_health.mul_add(
216            0.3,
217            mem_health.mul_add(0.3, swap_health.mul_add(0.2, thermal_health * 0.2)),
218        );
219        base.clamp(0.0, 1.0)
220    }
221
222    /// Whether the system is under stress (health < 0.3).
223    #[must_use]
224    pub fn is_stressed(&self) -> bool {
225        self.health_score() < 0.3
226    }
227
228    /// Classify the system's Guna tag from its metrics.
229    fn classify_guna(cpu_load: f32, memory_pressure: f32, active: bool) -> GunaTag {
230        if !active && cpu_load < 0.05 {
231            GunaTag::Tamasic
232        } else if cpu_load > 0.7 || memory_pressure > 0.7 {
233            GunaTag::Rajasic
234        } else {
235            GunaTag::Sattvic
236        }
237    }
238
239    /// Convert to a JSON-serializable map for MCP tool responses.
240    #[must_use]
241    pub fn to_json(&self) -> serde_json::Value {
242        serde_json::json!({
243            "cpu_load": self.cpu_load,
244            "memory_pressure": self.memory_pressure,
245            "swap_usage": self.swap_usage,
246            "thermal_state": self.thermal_state.as_str(),
247            "temperature_c": self.temperature_c,
248            "battery_state": self.battery_state.as_str(),
249            "battery_percent": self.battery_percent,
250            "disk_io_rate": self.disk_io_rate,
251            "active": self.active,
252            "guna": self.guna.as_str(),
253            "health_score": self.health_score(),
254            "stressed": self.is_stressed(),
255            "timestamp": self.timestamp.to_rfc3339(),
256        })
257    }
258
259    /// Sanitize all fraction fields to [0.0, 1.0], replacing NaN/Infinity with 0.0.
260    ///
261    /// This prevents metric poisoning where impossible values (negative CPU,
262    /// f32::MAX, NaN) could skew z-scores and health scores.
263    #[must_use]
264    pub fn sanitized(mut self) -> Self {
265        const fn clamp01(v: f32) -> f32 {
266            if v.is_nan() || v.is_infinite() {
267                0.0
268            } else {
269                v.clamp(0.0, 1.0)
270            }
271        }
272        self.cpu_load = clamp01(self.cpu_load);
273        self.memory_pressure = clamp01(self.memory_pressure);
274        self.swap_usage = clamp01(self.swap_usage);
275        self.battery_percent = clamp01(self.battery_percent);
276        self.disk_io_rate = clamp01(self.disk_io_rate);
277        if let Some(temp) = self.temperature_c {
278            if temp.is_nan() || temp.is_infinite() || !(-100.0..=200.0).contains(&temp) {
279                self.temperature_c = None;
280            }
281        }
282        self
283    }
284}
285
286impl Default for HarmonyVector {
287    fn default() -> Self {
288        Self {
289            cpu_load: 0.0,
290            memory_pressure: 0.0,
291            swap_usage: 0.0,
292            thermal_state: ThermalState::Unknown,
293            temperature_c: None,
294            battery_state: BatteryState::Unknown,
295            battery_percent: 1.0,
296            disk_io_rate: 0.0,
297            active: false,
298            guna: GunaTag::Tamasic,
299            timestamp: Utc::now(),
300        }
301    }
302}
303
304// ── Substrate Monitor ─────────────────────────────────────────────────
305
306/// Substrate monitor — reads real hardware metrics from `/proc` and `/sys`.
307///
308/// On Linux, reads:
309/// - `/proc/loadavg` for CPU load (1-minute average normalized by CPU count)
310/// - `/proc/meminfo` for memory pressure and swap usage
311/// - `/sys/class/thermal/thermal_zone*/temp` for temperature
312/// - `/sys/class/power_supply/BAT*/capacity` and `status` for battery
313///
314/// On non-Linux or when files are unavailable, gracefully degrades to
315/// default values. History is stored as a ring buffer.
316pub struct SubstrateMonitor {
317    history: RwLock<VecDeque<HarmonyVector>>,
318    max_history: usize,
319    cpu_count: f32,
320    /// Whether hardware sensors (`/proc`, `/sys`) are readable on this platform.
321    sensors_available: bool,
322}
323
324impl SubstrateMonitor {
325    /// Create a new substrate monitor with the given history capacity.
326    #[must_use]
327    pub fn new(max_history: usize) -> Self {
328        let cpu_count = std::thread::available_parallelism().map_or(1.0, |n| n.get() as f32);
329        let sensors_available =
330            cfg!(target_os = "linux") && std::path::Path::new("/proc/loadavg").exists();
331        Self {
332            history: RwLock::new(VecDeque::with_capacity(max_history)),
333            max_history,
334            cpu_count,
335            sensors_available,
336        }
337    }
338
339    /// Whether hardware sensors are available on this platform.
340    ///
341    /// When `false`, `sample()` returns neutral defaults and homeostasis
342    /// runs in degraded mode — health is *unknown*, not perfect.
343    #[must_use]
344    pub const fn sensors_available(&self) -> bool {
345        self.sensors_available
346    }
347
348    /// Create with default history capacity (100 samples).
349    #[must_use]
350    #[allow(clippy::should_implement_trait)]
351    pub fn default() -> Self {
352        Self::new(100)
353    }
354
355    /// Sample current hardware state.
356    ///
357    /// Reads `/proc` and `/sys`, constructs a [`HarmonyVector`], stores
358    /// it in history, and returns it.
359    pub fn sample(&self) -> HarmonyVector {
360        let cpu_load = self.read_cpu_load();
361        let (memory_pressure, swap_usage) = self.read_memory();
362        let (thermal_state, temperature_c) = self.read_thermal();
363        let (battery_state, battery_percent) = self.read_battery();
364        let active = cpu_load > 0.15;
365        let guna = HarmonyVector::classify_guna(cpu_load, memory_pressure, active);
366
367        let hv = HarmonyVector {
368            cpu_load,
369            memory_pressure,
370            swap_usage,
371            thermal_state,
372            temperature_c,
373            battery_state,
374            battery_percent,
375            disk_io_rate: 0.0,
376            active,
377            guna,
378            timestamp: Utc::now(),
379        };
380
381        if let Ok(mut hist) = self.history.write() {
382            if hist.len() >= self.max_history {
383                hist.pop_front();
384            }
385            hist.push_back(hv.clone());
386        }
387
388        hv
389    }
390
391    /// Get historical samples (most recent first, up to `limit`).
392    #[must_use]
393    pub fn history(&self, limit: usize) -> Vec<HarmonyVector> {
394        self.history
395            .read()
396            .map(|h| h.iter().rev().take(limit).cloned().collect())
397            .unwrap_or_default()
398    }
399
400    /// Get the most recent sample without taking a new one.
401    #[must_use]
402    pub fn last_sample(&self) -> Option<HarmonyVector> {
403        self.history.read().ok().and_then(|h| h.back().cloned())
404    }
405
406    /// Number of samples in history.
407    #[must_use]
408    pub fn history_len(&self) -> usize {
409        self.history.read().map_or(0, |h| h.len())
410    }
411
412    // ── /proc and /sys readers ───────────────────────────────────────
413
414    fn read_cpu_load(&self) -> f32 {
415        fs::read_to_string("/proc/loadavg")
416            .ok()
417            .and_then(|s| {
418                let parts: Vec<&str> = s.split_whitespace().collect();
419                parts.first().and_then(|p| p.parse::<f32>().ok())
420            })
421            .map_or(0.0, |load| (load / self.cpu_count).min(1.0))
422    }
423
424    fn read_memory(&self) -> (f32, f32) {
425        let meminfo = match fs::read_to_string("/proc/meminfo") {
426            Ok(s) => s,
427            Err(_) => return (0.0, 0.0),
428        };
429
430        let mut mem_total = None;
431        let mut mem_available = None;
432        let mut swap_total = None;
433        let mut swap_free = None;
434
435        for line in meminfo.lines() {
436            if line.starts_with("MemTotal:") {
437                mem_total = parse_kb(line);
438            } else if line.starts_with("MemAvailable:") {
439                mem_available = parse_kb(line);
440            } else if line.starts_with("SwapTotal:") {
441                swap_total = parse_kb(line);
442            } else if line.starts_with("SwapFree:") {
443                swap_free = parse_kb(line);
444            }
445        }
446
447        let memory_pressure = match (mem_total, mem_available) {
448            (Some(total), Some(avail)) if total > 0 => 1.0 - (avail as f32 / total as f32).min(1.0),
449            _ => 0.0,
450        };
451
452        let swap_usage = match (swap_total, swap_free) {
453            (Some(total), Some(free)) if total > 0 => 1.0 - (free as f32 / total as f32).min(1.0),
454            _ => 0.0,
455        };
456
457        (memory_pressure, swap_usage)
458    }
459
460    fn read_thermal(&self) -> (ThermalState, Option<f32>) {
461        for i in 0..10 {
462            let path = format!("/sys/class/thermal/thermal_zone{i}/temp");
463            if let Ok(s) = fs::read_to_string(&path) {
464                if let Ok(millideg) = s.trim().parse::<f32>() {
465                    let temp = millideg / 1000.0;
466                    return (ThermalState::from_celsius(temp), Some(temp));
467                }
468            }
469        }
470        (ThermalState::Unknown, None)
471    }
472
473    fn read_battery(&self) -> (BatteryState, f32) {
474        for name in ["BAT0", "BAT1", "BAT2"] {
475            let base = format!("/sys/class/power_supply/{name}");
476            let capacity = fs::read_to_string(format!("{base}/capacity"))
477                .ok()
478                .and_then(|s| s.trim().parse::<f32>().ok())
479                .map(|v| (v / 100.0).clamp(0.0, 1.0));
480            let status = fs::read_to_string(format!("{base}/status"))
481                .ok()
482                .map_or(BatteryState::Unknown, |s| BatteryState::from_status(&s));
483
484            if capacity.is_some() {
485                return (status, capacity.unwrap_or(1.0));
486            }
487        }
488        (BatteryState::Unknown, 1.0)
489    }
490}
491
492/// Parse a `/proc/meminfo` line like `MemTotal:       16384000 kB` → kB value.
493fn parse_kb(line: &str) -> Option<u64> {
494    line.split(':')
495        .nth(1)
496        .and_then(|s| s.split_whitespace().next())
497        .and_then(|s| s.parse::<u64>().ok())
498}
499
500// ── Tests ─────────────────────────────────────────────────────────────
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    #[test]
507    fn sensors_available_matches_platform() {
508        let monitor = SubstrateMonitor::default();
509        // On Linux with /proc mounted, sensors must be reported available;
510        // on any other platform they must report unavailable (degraded mode).
511        let expect = cfg!(target_os = "linux") && std::path::Path::new("/proc/loadavg").exists();
512        assert_eq!(monitor.sensors_available(), expect);
513    }
514
515    #[test]
516    fn sample_never_panics_regardless_of_platform() {
517        let monitor = SubstrateMonitor::default();
518        let hv = monitor.sample();
519        assert!((0.0..=1.0).contains(&hv.cpu_load));
520        assert!((0.0..=1.0).contains(&hv.memory_pressure));
521    }
522
523    #[test]
524    fn thermal_state_classification() {
525        assert_eq!(ThermalState::from_celsius(45.0), ThermalState::Normal);
526        assert_eq!(ThermalState::from_celsius(65.0), ThermalState::Warm);
527        assert_eq!(ThermalState::from_celsius(80.0), ThermalState::Hot);
528        assert_eq!(ThermalState::from_celsius(95.0), ThermalState::Critical);
529    }
530
531    #[test]
532    fn thermal_health_factor() {
533        assert_eq!(ThermalState::Normal.health_factor(), 1.0);
534        assert_eq!(ThermalState::Warm.health_factor(), 0.8);
535        assert_eq!(ThermalState::Hot.health_factor(), 0.5);
536        assert_eq!(ThermalState::Critical.health_factor(), 0.2);
537        assert_eq!(ThermalState::Unknown.health_factor(), 0.9);
538    }
539
540    #[test]
541    fn thermal_as_str() {
542        assert_eq!(ThermalState::Normal.as_str(), "normal");
543        assert_eq!(ThermalState::Warm.as_str(), "warm");
544        assert_eq!(ThermalState::Hot.as_str(), "hot");
545        assert_eq!(ThermalState::Critical.as_str(), "critical");
546        assert_eq!(ThermalState::Unknown.as_str(), "unknown");
547    }
548
549    #[test]
550    fn battery_state_from_status() {
551        assert_eq!(
552            BatteryState::from_status("Charging"),
553            BatteryState::Charging
554        );
555        assert_eq!(
556            BatteryState::from_status("Discharging"),
557            BatteryState::Discharging
558        );
559        assert_eq!(BatteryState::from_status("Full"), BatteryState::Full);
560        assert_eq!(
561            BatteryState::from_status("Not charging"),
562            BatteryState::NotCharging
563        );
564        assert_eq!(BatteryState::from_status("Unknown"), BatteryState::Unknown);
565    }
566
567    #[test]
568    fn battery_as_str() {
569        assert_eq!(BatteryState::Charging.as_str(), "charging");
570        assert_eq!(BatteryState::Discharging.as_str(), "discharging");
571        assert_eq!(BatteryState::Full.as_str(), "full");
572        assert_eq!(BatteryState::NotCharging.as_str(), "not_charging");
573        assert_eq!(BatteryState::Unknown.as_str(), "unknown");
574    }
575
576    #[test]
577    fn guna_classification() {
578        assert_eq!(
579            HarmonyVector::classify_guna(0.02, 0.1, false),
580            GunaTag::Tamasic
581        );
582        assert_eq!(
583            HarmonyVector::classify_guna(0.8, 0.3, true),
584            GunaTag::Rajasic
585        );
586        assert_eq!(
587            HarmonyVector::classify_guna(0.3, 0.8, true),
588            GunaTag::Rajasic
589        );
590        assert_eq!(
591            HarmonyVector::classify_guna(0.3, 0.3, true),
592            GunaTag::Sattvic
593        );
594    }
595
596    #[test]
597    fn guna_as_str() {
598        assert_eq!(GunaTag::Sattvic.as_str(), "sattvic");
599        assert_eq!(GunaTag::Rajasic.as_str(), "rajasic");
600        assert_eq!(GunaTag::Tamasic.as_str(), "tamasic");
601    }
602
603    #[test]
604    fn harmony_vector_health_score_healthy() {
605        let hv = HarmonyVector {
606            cpu_load: 0.1,
607            memory_pressure: 0.2,
608            swap_usage: 0.1,
609            thermal_state: ThermalState::Normal,
610            temperature_c: Some(45.0),
611            battery_state: BatteryState::Full,
612            battery_percent: 1.0,
613            disk_io_rate: 0.0,
614            active: true,
615            guna: GunaTag::Sattvic,
616            timestamp: Utc::now(),
617        };
618        let score = hv.health_score();
619        assert!(score > 0.8, "Health score should be high: {score}");
620        assert!(!hv.is_stressed());
621    }
622
623    #[test]
624    fn harmony_vector_health_score_stressed() {
625        let hv = HarmonyVector {
626            cpu_load: 0.9,
627            memory_pressure: 0.9,
628            swap_usage: 0.8,
629            thermal_state: ThermalState::Critical,
630            temperature_c: Some(95.0),
631            battery_state: BatteryState::Discharging,
632            battery_percent: 0.1,
633            disk_io_rate: 0.0,
634            active: true,
635            guna: GunaTag::Rajasic,
636            timestamp: Utc::now(),
637        };
638        let score = hv.health_score();
639        assert!(score < 0.3, "Health score should be critical: {score}");
640        assert!(hv.is_stressed());
641    }
642
643    #[test]
644    fn harmony_vector_health_score_moderate() {
645        let hv = HarmonyVector {
646            cpu_load: 0.5,
647            memory_pressure: 0.4,
648            swap_usage: 0.3,
649            thermal_state: ThermalState::Warm,
650            temperature_c: Some(65.0),
651            battery_state: BatteryState::Discharging,
652            battery_percent: 0.5,
653            disk_io_rate: 0.0,
654            active: true,
655            guna: GunaTag::Sattvic,
656            timestamp: Utc::now(),
657        };
658        let score = hv.health_score();
659        assert!(score > 0.4 && score < 0.7, "Moderate health: {score}");
660        assert!(!hv.is_stressed());
661    }
662
663    #[test]
664    fn harmony_vector_default_is_idle() {
665        let hv = HarmonyVector::default();
666        assert_eq!(hv.guna, GunaTag::Tamasic);
667        assert!(!hv.active);
668        assert_eq!(hv.thermal_state, ThermalState::Unknown);
669        assert_eq!(hv.battery_state, BatteryState::Unknown);
670    }
671
672    #[test]
673    fn harmony_vector_serialization() {
674        let hv = HarmonyVector::default();
675        let json = serde_json::to_string(&hv).unwrap();
676        let back: HarmonyVector = serde_json::from_str(&json).unwrap();
677        assert_eq!(back.cpu_load, hv.cpu_load);
678        assert_eq!(back.thermal_state, hv.thermal_state);
679    }
680
681    #[test]
682    fn harmony_vector_to_json() {
683        let hv = HarmonyVector {
684            cpu_load: 0.3,
685            memory_pressure: 0.2,
686            swap_usage: 0.1,
687            thermal_state: ThermalState::Normal,
688            temperature_c: Some(45.0),
689            battery_state: BatteryState::Full,
690            battery_percent: 1.0,
691            disk_io_rate: 0.0,
692            active: true,
693            guna: GunaTag::Sattvic,
694            timestamp: Utc::now(),
695        };
696        let json = hv.to_json();
697        assert_eq!(json["thermal_state"], "normal");
698        assert_eq!(json["battery_state"], "full");
699        assert_eq!(json["guna"], "sattvic");
700        assert!((json["cpu_load"].as_f64().unwrap() - 0.3).abs() < 0.001);
701        assert!(json["health_score"].as_f64().unwrap() > 0.8);
702    }
703
704    #[test]
705    fn substrate_monitor_sample() {
706        let monitor = SubstrateMonitor::new(10);
707        let hv = monitor.sample();
708        assert!(hv.cpu_load >= 0.0 && hv.cpu_load <= 1.0);
709        assert!(hv.memory_pressure >= 0.0 && hv.memory_pressure <= 1.0);
710        assert_eq!(monitor.history_len(), 1);
711    }
712
713    #[test]
714    fn substrate_monitor_history_grows() {
715        let monitor = SubstrateMonitor::new(5);
716        for _ in 0..3 {
717            let _ = monitor.sample();
718        }
719        assert_eq!(monitor.history_len(), 3);
720        let hist = monitor.history(10);
721        assert_eq!(hist.len(), 3);
722        // Most recent first
723        assert!(hist[0].timestamp >= hist[1].timestamp);
724    }
725
726    #[test]
727    fn substrate_monitor_history_capped() {
728        let monitor = SubstrateMonitor::new(3);
729        for _ in 0..5 {
730            let _ = monitor.sample();
731        }
732        assert_eq!(monitor.history_len(), 3);
733    }
734
735    #[test]
736    fn substrate_monitor_last_sample() {
737        let monitor = SubstrateMonitor::new(10);
738        assert!(monitor.last_sample().is_none());
739        let hv = monitor.sample();
740        let last = monitor.last_sample().unwrap();
741        assert!((last.cpu_load - hv.cpu_load).abs() < 0.01);
742    }
743
744    #[test]
745    fn substrate_monitor_history_limit() {
746        let monitor = SubstrateMonitor::new(100);
747        for _ in 0..10 {
748            let _ = monitor.sample();
749        }
750        let hist = monitor.history(3);
751        assert_eq!(hist.len(), 3);
752    }
753
754    #[test]
755    fn parse_kb_extracts_value() {
756        assert_eq!(parse_kb("MemTotal:       16384000 kB"), Some(16_384_000));
757        assert_eq!(parse_kb("MemAvailable:   8192000 kB"), Some(8_192_000));
758        assert_eq!(parse_kb("garbage"), None);
759    }
760
761    #[test]
762    fn harmony_vector_sanitized_clamps_nan() {
763        let hv = HarmonyVector {
764            cpu_load: f32::NAN,
765            memory_pressure: f32::INFINITY,
766            swap_usage: -0.5,
767            battery_percent: 2.0,
768            disk_io_rate: f32::NEG_INFINITY,
769            temperature_c: Some(500.0),
770            ..Default::default()
771        };
772        let s = hv.sanitized();
773        assert_eq!(s.cpu_load, 0.0, "NaN should become 0.0");
774        assert_eq!(s.memory_pressure, 0.0, "Infinity should become 0.0");
775        assert_eq!(s.swap_usage, 0.0, "Negative should become 0.0");
776        assert_eq!(s.battery_percent, 1.0, "2.0 should be clamped to 1.0");
777        assert_eq!(s.disk_io_rate, 0.0, "NegInfinity should become 0.0");
778        assert!(s.temperature_c.is_none(), "Impossible temp should be None");
779    }
780
781    #[test]
782    fn harmony_vector_sanitized_preserves_valid() {
783        let hv = HarmonyVector {
784            cpu_load: 0.5,
785            memory_pressure: 0.3,
786            swap_usage: 0.1,
787            battery_percent: 0.8,
788            disk_io_rate: 0.2,
789            temperature_c: Some(45.0),
790            ..Default::default()
791        };
792        let s = hv.sanitized();
793        assert_eq!(s.cpu_load, 0.5);
794        assert_eq!(s.memory_pressure, 0.3);
795        assert_eq!(s.temperature_c, Some(45.0));
796    }
797}