Skip to main content

wm_substrate/
homeostatic.rs

1//! Homeostatic Loop — harmony-driven self-regulation.
2//!
3//! **N19**: Reads the [`HarmonyVector`] and [`AnomalyDetector`] output to
4//! take graduated corrective actions across 4 levels:
5//!
6//! 1. **OBSERVE** — log only, no action taken
7//! 2. **ADVISE** — emit a recommendation, log the advice
8//! 3. **CORRECT** — take gentle action (shed load, cool down tool)
9//! 4. **INTERVENE** — strong action (circuit breaker, force dream)
10//!
11//! The loop runs at the planning timescale (every 1s) and checks each
12//! harmony dimension against configurable thresholds. Actions are
13//! recorded for feedback loop analysis by the SelfModel.
14//!
15//! Ported from v2's `harmony/homeostatic_loop.py`.
16
17#![forbid(unsafe_code)]
18
19use std::collections::VecDeque;
20
21use serde::{Deserialize, Serialize};
22
23use crate::HarmonyVector;
24use crate::anomaly::{AnomalyDetector, AnomalySeverity, HarmonyDimension};
25
26// ── Action Level ──────────────────────────────────────────────────────
27
28/// Graduated response level for homeostatic correction.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31#[repr(u8)]
32pub enum ActionLevel {
33    /// Log only, no action taken.
34    Observe = 0,
35    /// Emit a recommendation, log the advice.
36    Advise = 1,
37    /// Take gentle action (shed load, cool down tool, tighten dharma).
38    Correct = 2,
39    /// Strong action (circuit breaker, force Theta/dream, refuse writes).
40    Intervene = 3,
41}
42
43impl ActionLevel {
44    /// Human-readable name.
45    #[must_use]
46    pub const fn as_str(self) -> &'static str {
47        match self {
48            Self::Observe => "observe",
49            Self::Advise => "advise",
50            Self::Correct => "correct",
51            Self::Intervene => "intervene",
52        }
53    }
54
55    /// Whether this level takes active corrective action.
56    #[must_use]
57    pub const fn is_active(self) -> bool {
58        matches!(self, Self::Correct | Self::Intervene)
59    }
60}
61
62impl std::fmt::Display for ActionLevel {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(f, "{}", self.as_str())
65    }
66}
67
68// ── Homeostatic Action ────────────────────────────────────────────────
69
70/// The type of corrective action to take.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum ActionType {
74    /// No action needed.
75    None,
76    /// Log the observation.
77    Log,
78    /// Emit a recommendation event.
79    Recommend,
80    /// Shed load — reduce concurrent operations.
81    ShedLoad,
82    /// Cool down a specific tool (rate limit).
83    ToolCooldown,
84    /// Tighten dharma profile (more restrictive governance).
85    TightenDharma,
86    /// Trigger memory lifecycle sweep (mindful forgetting).
87    MemorySweep,
88    /// Open circuit breaker for a tool.
89    CircuitBreaker,
90    /// Force Theta brain-wave state (dream/consolidation).
91    ForceTheta,
92    /// Refuse write operations (read-only mode).
93    RefuseWrites,
94    /// Increase monitoring frequency.
95    IncreaseMonitoring,
96}
97
98impl ActionType {
99    /// Human-readable name.
100    #[must_use]
101    pub const fn as_str(self) -> &'static str {
102        match self {
103            Self::None => "none",
104            Self::Log => "log",
105            Self::Recommend => "recommend",
106            Self::ShedLoad => "shed_load",
107            Self::ToolCooldown => "tool_cooldown",
108            Self::TightenDharma => "tighten_dharma",
109            Self::MemorySweep => "memory_sweep",
110            Self::CircuitBreaker => "circuit_breaker",
111            Self::ForceTheta => "force_theta",
112            Self::RefuseWrites => "refuse_writes",
113            Self::IncreaseMonitoring => "increase_monitoring",
114        }
115    }
116}
117
118impl std::fmt::Display for ActionType {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        write!(f, "{}", self.as_str())
121    }
122}
123
124// ── Homeostatic Action Event ──────────────────────────────────────────
125
126/// A homeostatic action — the result of a loop cycle.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct HomeostaticAction {
129    /// The dimension that triggered this action.
130    pub dimension: HarmonyDimension,
131    /// The action level (Observe/Advise/Correct/Intervene).
132    pub level: ActionLevel,
133    /// The specific action type.
134    pub action: ActionType,
135    /// The current value of the dimension.
136    pub current_value: f32,
137    /// The threshold that was crossed.
138    pub threshold: f32,
139    /// Human-readable description of the action.
140    pub description: String,
141    /// Whether this action was actually taken (false = advisory only).
142    pub executed: bool,
143}
144
145impl HomeostaticAction {
146    /// Convert to JSON.
147    #[must_use]
148    pub fn to_json(&self) -> serde_json::Value {
149        serde_json::json!({
150            "dimension": self.dimension.as_str(),
151            "level": self.level.as_str(),
152            "action": self.action.as_str(),
153            "current_value": self.current_value,
154            "threshold": self.threshold,
155            "description": self.description,
156            "executed": self.executed,
157        })
158    }
159}
160
161// ── Dimension Thresholds ──────────────────────────────────────────────
162
163/// Threshold configuration for a single harmony dimension.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct DimensionThreshold {
166    /// The dimension this threshold applies to.
167    pub dimension: HarmonyDimension,
168    /// Advise when value crosses this.
169    pub advise_threshold: f32,
170    /// Correct when value crosses this.
171    pub correct_threshold: f32,
172    /// Intervene when value crosses this.
173    pub intervene_threshold: f32,
174    /// Whether the threshold is for high values (true) or low values (false).
175    pub high_is_bad: bool,
176}
177
178impl DimensionThreshold {
179    /// Create a threshold for a "high is bad" dimension (e.g., CPU load).
180    #[must_use]
181    pub const fn high_is_bad(
182        dimension: HarmonyDimension,
183        advise: f32,
184        correct: f32,
185        intervene: f32,
186    ) -> Self {
187        Self {
188            dimension,
189            advise_threshold: advise,
190            correct_threshold: correct,
191            intervene_threshold: intervene,
192            high_is_bad: true,
193        }
194    }
195
196    /// Create a threshold for a "low is bad" dimension (e.g., battery).
197    #[must_use]
198    pub const fn low_is_bad(
199        dimension: HarmonyDimension,
200        advise: f32,
201        correct: f32,
202        intervene: f32,
203    ) -> Self {
204        Self {
205            dimension,
206            advise_threshold: advise,
207            correct_threshold: correct,
208            intervene_threshold: intervene,
209            high_is_bad: false,
210        }
211    }
212
213    /// Determine the action level for a given value.
214    #[must_use]
215    pub fn evaluate(&self, value: f32) -> ActionLevel {
216        if self.high_is_bad {
217            if value >= self.intervene_threshold {
218                ActionLevel::Intervene
219            } else if value >= self.correct_threshold {
220                ActionLevel::Correct
221            } else if value >= self.advise_threshold {
222                ActionLevel::Advise
223            } else {
224                ActionLevel::Observe
225            }
226        } else {
227            // Low is bad — invert comparisons
228            if value <= self.intervene_threshold {
229                ActionLevel::Intervene
230            } else if value <= self.correct_threshold {
231                ActionLevel::Correct
232            } else if value <= self.advise_threshold {
233                ActionLevel::Advise
234            } else {
235                ActionLevel::Observe
236            }
237        }
238    }
239}
240
241/// Default thresholds for all 7 harmony dimensions.
242#[must_use]
243pub fn default_thresholds() -> Vec<DimensionThreshold> {
244    vec![
245        DimensionThreshold::high_is_bad(HarmonyDimension::CpuLoad, 0.7, 0.85, 0.95),
246        DimensionThreshold::high_is_bad(HarmonyDimension::MemoryPressure, 0.7, 0.85, 0.95),
247        DimensionThreshold::high_is_bad(HarmonyDimension::SwapUsage, 0.3, 0.5, 0.8),
248        DimensionThreshold::high_is_bad(HarmonyDimension::DiskIoRate, 0.7, 0.85, 0.95),
249        DimensionThreshold::low_is_bad(HarmonyDimension::HealthScore, 0.6, 0.4, 0.2),
250        DimensionThreshold::low_is_bad(HarmonyDimension::BatteryPercent, 0.3, 0.15, 0.05),
251        DimensionThreshold::high_is_bad(HarmonyDimension::Temperature, 70.0, 80.0, 90.0),
252    ]
253}
254
255// ── Homeostatic Loop Config ───────────────────────────────────────────
256
257/// Configuration for the [`HomeostaticLoop`].
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct HomeostaticConfig {
260    /// Thresholds per dimension.
261    pub thresholds: Vec<DimensionThreshold>,
262    /// Whether to actually execute corrective actions (false = dry run).
263    pub execute_actions: bool,
264    /// Maximum actions to retain in history.
265    pub max_history: usize,
266    /// Whether to consider anomaly alerts in addition to thresholds.
267    pub use_anomaly_detector: bool,
268}
269
270impl Default for HomeostaticConfig {
271    fn default() -> Self {
272        Self {
273            thresholds: default_thresholds(),
274            execute_actions: true,
275            max_history: 100,
276            use_anomaly_detector: true,
277        }
278    }
279}
280
281// ── Homeostatic Loop ──────────────────────────────────────────────────
282
283/// Statistics for the homeostatic loop.
284#[derive(Debug, Clone, Default, Serialize, Deserialize)]
285pub struct LoopStats {
286    /// Total cycles run.
287    pub cycles: u64,
288    /// Total actions taken (all levels).
289    pub total_actions: u64,
290    /// Actions per level.
291    pub actions_per_level: [u64; 4],
292    /// Actions per type (as string → count).
293    pub actions_per_type: std::collections::HashMap<String, u64>,
294    /// Last cycle timestamp (Unix seconds).
295    pub last_cycle: i64,
296}
297
298/// The Homeostatic Loop — graduated self-regulation based on harmony state.
299///
300/// Runs a sample cycle that reads the [`HarmonyVector`] and optional
301/// [`AnomalyDetector`] alerts, evaluates thresholds, and produces
302/// [`HomeostaticAction`]s. Actions can be advisory (Observe/Advise) or
303/// active (Correct/Intervene).
304///
305/// # Example
306/// ```no_run
307/// use wm_substrate::{HarmonyVector, SubstrateMonitor};
308/// use wm_substrate::anomaly::{AnomalyDetector, AnomalyConfig};
309/// use wm_substrate::homeostatic::{HomeostaticLoop, HomeostaticConfig};
310///
311/// let mut loop_ = HomeostaticLoop::new(HomeostaticConfig::default());
312/// let mut monitor = SubstrateMonitor::new(100);
313/// let mut detector = AnomalyDetector::new(AnomalyConfig::default());
314///
315/// let hv = monitor.sample();
316/// detector.check(&hv);
317/// let actions = loop_.sample_cycle(&hv, &detector);
318/// for action in &actions {
319///     println!("{:?}: {}", action.level, action.description);
320/// }
321/// ```
322pub struct HomeostaticLoop {
323    config: HomeostaticConfig,
324    history: VecDeque<HomeostaticAction>,
325    stats: LoopStats,
326}
327
328impl Default for HomeostaticLoop {
329    fn default() -> Self {
330        Self::new(HomeostaticConfig::default())
331    }
332}
333
334impl std::fmt::Debug for HomeostaticLoop {
335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        f.debug_struct("HomeostaticLoop")
337            .field("cycles", &self.stats.cycles)
338            .field("total_actions", &self.stats.total_actions)
339            .field("history_len", &self.history.len())
340            .finish_non_exhaustive()
341    }
342}
343
344impl HomeostaticLoop {
345    /// Create a new homeostatic loop with the given configuration.
346    #[must_use]
347    pub fn new(config: HomeostaticConfig) -> Self {
348        Self {
349            config,
350            history: VecDeque::new(),
351            stats: LoopStats::default(),
352        }
353    }
354
355    /// Run a sample cycle — evaluates the harmony vector and anomaly alerts,
356    /// returns the list of actions taken.
357    pub fn sample_cycle(
358        &mut self,
359        hv: &HarmonyVector,
360        detector: &AnomalyDetector,
361    ) -> Vec<HomeostaticAction> {
362        self.stats.cycles += 1;
363        self.stats.last_cycle = chrono::Utc::now().timestamp();
364
365        let mut new_actions = Vec::new();
366
367        // 1. Evaluate thresholds for each dimension
368        for threshold in &self.config.thresholds {
369            if let Some(value) = threshold.dimension.extract(hv) {
370                let level = threshold.evaluate(value);
371
372                if level != ActionLevel::Observe {
373                    let action_type = self.select_action(threshold.dimension, level);
374                    let description =
375                        self.describe_action(threshold.dimension, level, value, threshold);
376                    let crossed_threshold = match level {
377                        ActionLevel::Intervene => threshold.intervene_threshold,
378                        ActionLevel::Correct => threshold.correct_threshold,
379                        ActionLevel::Advise => threshold.advise_threshold,
380                        ActionLevel::Observe => threshold.advise_threshold,
381                    };
382
383                    new_actions.push(HomeostaticAction {
384                        dimension: threshold.dimension,
385                        level,
386                        action: action_type,
387                        current_value: value,
388                        threshold: crossed_threshold,
389                        description,
390                        executed: self.config.execute_actions && level.is_active(),
391                    });
392                }
393            }
394        }
395
396        // 2. Check anomaly detector alerts
397        if self.config.use_anomaly_detector {
398            for dim in HarmonyDimension::ALL {
399                let (mean, std, n) = detector.stats(dim);
400                if n < 5 {
401                    continue;
402                }
403
404                if let Some(current) = dim.extract(hv) {
405                    if std > 0.0 {
406                        let z = (current - mean) / std;
407                        if let Some(severity) = AnomalySeverity::from_z_score(z) {
408                            let level = match severity {
409                                AnomalySeverity::Critical => ActionLevel::Intervene,
410                                AnomalySeverity::Warning => ActionLevel::Advise,
411                            };
412
413                            // Only add if we haven't already flagged this dimension
414                            if !new_actions.iter().any(|a| a.dimension == dim) {
415                                let action_type = if level == ActionLevel::Intervene {
416                                    ActionType::IncreaseMonitoring
417                                } else {
418                                    ActionType::Log
419                                };
420
421                                new_actions.push(HomeostaticAction {
422                                    dimension: dim,
423                                    level,
424                                    action: action_type,
425                                    current_value: current,
426                                    threshold: mean,
427                                    description: format!(
428                                        "Anomaly detected: {} z-score {:.2} (mean={:.2}, std={:.2})",
429                                        dim.as_str(), z, mean, std
430                                    ),
431                                    executed: false,
432                                });
433                            }
434                        }
435                    }
436                }
437            }
438        }
439
440        // Record all actions in history and stats
441        for action in &new_actions {
442            self.record_action(action);
443        }
444
445        new_actions
446    }
447
448    /// Select the appropriate action type for a dimension + level.
449    const fn select_action(&self, dim: HarmonyDimension, level: ActionLevel) -> ActionType {
450        match (dim, level) {
451            // CPU load
452            (HarmonyDimension::CpuLoad, ActionLevel::Advise) => ActionType::Log,
453            (HarmonyDimension::CpuLoad, ActionLevel::Correct) => ActionType::ShedLoad,
454            (HarmonyDimension::CpuLoad, ActionLevel::Intervene) => ActionType::ForceTheta,
455
456            // Memory pressure
457            (HarmonyDimension::MemoryPressure, ActionLevel::Advise) => ActionType::Log,
458            (HarmonyDimension::MemoryPressure, ActionLevel::Correct) => ActionType::MemorySweep,
459            (HarmonyDimension::MemoryPressure, ActionLevel::Intervene) => ActionType::RefuseWrites,
460
461            // Swap usage
462            (HarmonyDimension::SwapUsage, ActionLevel::Advise) => ActionType::Log,
463            (HarmonyDimension::SwapUsage, ActionLevel::Correct) => ActionType::MemorySweep,
464            (HarmonyDimension::SwapUsage, ActionLevel::Intervene) => ActionType::RefuseWrites,
465
466            // Disk I/O
467            (HarmonyDimension::DiskIoRate, ActionLevel::Advise) => ActionType::Log,
468            (HarmonyDimension::DiskIoRate, ActionLevel::Correct) => ActionType::ShedLoad,
469            (HarmonyDimension::DiskIoRate, ActionLevel::Intervene) => ActionType::CircuitBreaker,
470
471            // Health score (low is bad)
472            (HarmonyDimension::HealthScore, ActionLevel::Advise) => ActionType::Recommend,
473            (HarmonyDimension::HealthScore, ActionLevel::Correct) => ActionType::TightenDharma,
474            (HarmonyDimension::HealthScore, ActionLevel::Intervene) => ActionType::ForceTheta,
475
476            // Battery (low is bad)
477            (HarmonyDimension::BatteryPercent, ActionLevel::Advise) => ActionType::Recommend,
478            (HarmonyDimension::BatteryPercent, ActionLevel::Correct) => ActionType::ShedLoad,
479            (HarmonyDimension::BatteryPercent, ActionLevel::Intervene) => ActionType::ForceTheta,
480
481            // Temperature (high is bad)
482            (HarmonyDimension::Temperature, ActionLevel::Advise) => ActionType::Log,
483            (HarmonyDimension::Temperature, ActionLevel::Correct) => ActionType::ShedLoad,
484            (HarmonyDimension::Temperature, ActionLevel::Intervene) => ActionType::ForceTheta,
485
486            // Default
487            (_, ActionLevel::Observe) => ActionType::None,
488        }
489    }
490
491    /// Generate a human-readable description for an action.
492    fn describe_action(
493        &self,
494        dim: HarmonyDimension,
495        level: ActionLevel,
496        value: f32,
497        threshold: &DimensionThreshold,
498    ) -> String {
499        let direction = if threshold.high_is_bad { "high" } else { "low" };
500        format!(
501            "{} {} ({:.2} {} threshold {:.2}) → {}",
502            dim.as_str(),
503            direction,
504            value,
505            if threshold.high_is_bad { ">=" } else { "<=" },
506            match level {
507                ActionLevel::Intervene => threshold.intervene_threshold,
508                ActionLevel::Correct => threshold.correct_threshold,
509                ActionLevel::Advise => threshold.advise_threshold,
510                ActionLevel::Observe => threshold.advise_threshold,
511            },
512            level,
513        )
514    }
515
516    /// Record an action in history and stats.
517    fn record_action(&mut self, action: &HomeostaticAction) {
518        self.stats.total_actions += 1;
519        self.stats.actions_per_level[action.level as usize] += 1;
520        *self
521            .stats
522            .actions_per_type
523            .entry(action.action.as_str().to_string())
524            .or_insert(0) += 1;
525
526        if self.history.len() >= self.config.max_history {
527            self.history.pop_front();
528        }
529        self.history.push_back(action.clone());
530    }
531
532    /// Get the action history (newest first, up to `limit`).
533    #[must_use]
534    pub fn history(&self, limit: usize) -> Vec<&HomeostaticAction> {
535        self.history.iter().rev().take(limit).collect()
536    }
537
538    /// Total cycles run.
539    #[must_use]
540    pub const fn cycles(&self) -> u64 {
541        self.stats.cycles
542    }
543
544    /// Total actions taken.
545    #[must_use]
546    pub const fn total_actions(&self) -> u64 {
547        self.stats.total_actions
548    }
549
550    /// Actions at a specific level.
551    #[must_use]
552    pub const fn actions_at_level(&self, level: ActionLevel) -> u64 {
553        self.stats.actions_per_level[level as usize]
554    }
555
556    /// Get loop statistics.
557    #[must_use]
558    pub const fn stats(&self) -> &LoopStats {
559        &self.stats
560    }
561
562    /// Get a JSON summary.
563    #[must_use]
564    pub fn summary(&self) -> serde_json::Value {
565        serde_json::json!({
566            "cycles": self.stats.cycles,
567            "total_actions": self.stats.total_actions,
568            "actions_per_level": {
569                "observe": self.stats.actions_per_level[0],
570                "advise": self.stats.actions_per_level[1],
571                "correct": self.stats.actions_per_level[2],
572                "intervene": self.stats.actions_per_level[3],
573            },
574            "actions_per_type": self.stats.actions_per_type,
575            "last_cycle": self.stats.last_cycle,
576            "history_len": self.history.len(),
577            "execute_actions": self.config.execute_actions,
578        })
579    }
580
581    /// Clear history and stats.
582    pub fn clear(&mut self) {
583        self.history.clear();
584        self.stats = LoopStats::default();
585    }
586}
587
588// ── Tests ─────────────────────────────────────────────────────────────
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use crate::anomaly::AnomalyConfig;
594
595    fn make_hv(
596        cpu: f32,
597        mem: f32,
598        swap: f32,
599        disk: f32,
600        health: f32,
601        battery: f32,
602        temp: Option<f32>,
603    ) -> HarmonyVector {
604        let _ = health; // health is computed, not a field
605        HarmonyVector {
606            cpu_load: cpu,
607            memory_pressure: mem,
608            swap_usage: swap,
609            thermal_state: crate::ThermalState::Normal,
610            temperature_c: temp,
611            battery_state: crate::BatteryState::Discharging,
612            battery_percent: battery,
613            disk_io_rate: disk,
614            active: true,
615            guna: crate::GunaTag::Sattvic,
616            timestamp: chrono::Utc::now(),
617        }
618    }
619
620    #[test]
621    fn action_level_as_str() {
622        assert_eq!(ActionLevel::Observe.as_str(), "observe");
623        assert_eq!(ActionLevel::Advise.as_str(), "advise");
624        assert_eq!(ActionLevel::Correct.as_str(), "correct");
625        assert_eq!(ActionLevel::Intervene.as_str(), "intervene");
626    }
627
628    #[test]
629    fn action_level_is_active() {
630        assert!(!ActionLevel::Observe.is_active());
631        assert!(!ActionLevel::Advise.is_active());
632        assert!(ActionLevel::Correct.is_active());
633        assert!(ActionLevel::Intervene.is_active());
634    }
635
636    #[test]
637    fn action_type_as_str() {
638        assert_eq!(ActionType::None.as_str(), "none");
639        assert_eq!(ActionType::ShedLoad.as_str(), "shed_load");
640        assert_eq!(ActionType::ForceTheta.as_str(), "force_theta");
641    }
642
643    #[test]
644    fn threshold_high_is_bad_evaluate() {
645        let t = DimensionThreshold::high_is_bad(HarmonyDimension::CpuLoad, 0.7, 0.85, 0.95);
646        assert_eq!(t.evaluate(0.5), ActionLevel::Observe);
647        assert_eq!(t.evaluate(0.7), ActionLevel::Advise);
648        assert_eq!(t.evaluate(0.85), ActionLevel::Correct);
649        assert_eq!(t.evaluate(0.95), ActionLevel::Intervene);
650    }
651
652    #[test]
653    fn threshold_low_is_bad_evaluate() {
654        let t = DimensionThreshold::low_is_bad(HarmonyDimension::BatteryPercent, 0.3, 0.15, 0.05);
655        assert_eq!(t.evaluate(0.8), ActionLevel::Observe);
656        assert_eq!(t.evaluate(0.3), ActionLevel::Advise);
657        assert_eq!(t.evaluate(0.15), ActionLevel::Correct);
658        assert_eq!(t.evaluate(0.05), ActionLevel::Intervene);
659    }
660
661    #[test]
662    fn default_thresholds_cover_all_dimensions() {
663        let thresholds = default_thresholds();
664        assert_eq!(thresholds.len(), 7);
665        for dim in HarmonyDimension::ALL {
666            assert!(
667                thresholds.iter().any(|t| t.dimension == dim),
668                "Missing threshold for {dim:?}"
669            );
670        }
671    }
672
673    #[test]
674    fn loop_no_action_on_healthy_state() {
675        let mut loop_ = HomeostaticLoop::default();
676        let hv = make_hv(0.3, 0.3, 0.05, 0.2, 0.9, 0.8, Some(45.0));
677        let detector = AnomalyDetector::new(AnomalyConfig::default());
678
679        let actions = loop_.sample_cycle(&hv, &detector);
680        assert!(actions.is_empty());
681        assert_eq!(loop_.cycles(), 1);
682    }
683
684    #[test]
685    fn loop_advise_on_high_cpu() {
686        let mut loop_ = HomeostaticLoop::default();
687        let hv = make_hv(0.75, 0.3, 0.05, 0.2, 0.9, 0.8, Some(45.0));
688        let detector = AnomalyDetector::new(AnomalyConfig::default());
689
690        let actions = loop_.sample_cycle(&hv, &detector);
691        assert!(actions.iter().any(|a| a.dimension == HarmonyDimension::CpuLoad && a.level == ActionLevel::Advise));
692    }
693
694    #[test]
695    fn loop_correct_on_high_memory() {
696        let mut loop_ = HomeostaticLoop::default();
697        let hv = make_hv(0.3, 0.88, 0.05, 0.2, 0.9, 0.8, Some(45.0));
698        let detector = AnomalyDetector::new(AnomalyConfig::default());
699
700        let actions = loop_.sample_cycle(&hv, &detector);
701        assert!(
702            actions
703                .iter()
704                .any(|a| a.dimension == HarmonyDimension::MemoryPressure
705                    && a.level == ActionLevel::Correct)
706        );
707    }
708
709    #[test]
710    fn loop_intervene_on_critical_battery() {
711        let mut loop_ = HomeostaticLoop::default();
712        let hv = make_hv(0.3, 0.3, 0.05, 0.2, 0.9, 0.03, Some(45.0));
713        let detector = AnomalyDetector::new(AnomalyConfig::default());
714
715        let actions = loop_.sample_cycle(&hv, &detector);
716        assert!(
717            actions
718                .iter()
719                .any(|a| a.dimension == HarmonyDimension::BatteryPercent
720                    && a.level == ActionLevel::Intervene)
721        );
722    }
723
724    #[test]
725    fn loop_intervene_on_high_temp() {
726        let mut loop_ = HomeostaticLoop::default();
727        let hv = make_hv(0.3, 0.3, 0.05, 0.2, 0.9, 0.8, Some(92.0));
728        let detector = AnomalyDetector::new(AnomalyConfig::default());
729
730        let actions = loop_.sample_cycle(&hv, &detector);
731        assert!(
732            actions
733                .iter()
734                .any(|a| a.dimension == HarmonyDimension::Temperature
735                    && a.level == ActionLevel::Intervene)
736        );
737    }
738
739    #[test]
740    fn loop_actions_recorded_in_history() {
741        let mut loop_ = HomeostaticLoop::default();
742        let hv = make_hv(0.9, 0.9, 0.6, 0.2, 0.3, 0.1, Some(85.0));
743        let detector = AnomalyDetector::new(AnomalyConfig::default());
744
745        let actions = loop_.sample_cycle(&hv, &detector);
746        assert!(!actions.is_empty());
747
748        let history = loop_.history(10);
749        assert!(!history.is_empty());
750    }
751
752    #[test]
753    fn loop_stats_tracked() {
754        let mut loop_ = HomeostaticLoop::default();
755        let hv = make_hv(0.75, 0.3, 0.05, 0.2, 0.9, 0.8, Some(45.0));
756        let detector = AnomalyDetector::new(AnomalyConfig::default());
757
758        loop_.sample_cycle(&hv, &detector);
759        loop_.sample_cycle(&hv, &detector);
760
761        assert_eq!(loop_.cycles(), 2);
762        assert!(loop_.total_actions() >= 2);
763        assert!(loop_.actions_at_level(ActionLevel::Advise) >= 2);
764    }
765
766    #[test]
767    fn loop_dry_run_does_not_execute() {
768        let config = HomeostaticConfig {
769            execute_actions: false,
770            ..Default::default()
771        };
772        let mut loop_ = HomeostaticLoop::new(config);
773        let hv = make_hv(0.9, 0.3, 0.05, 0.2, 0.9, 0.8, Some(45.0));
774        let detector = AnomalyDetector::new(AnomalyConfig::default());
775
776        let actions = loop_.sample_cycle(&hv, &detector);
777        assert!(actions.iter().all(|a| !a.executed));
778    }
779
780    #[test]
781    fn loop_execute_actions_flag() {
782        let mut loop_ = HomeostaticLoop::default();
783        let hv = make_hv(0.9, 0.3, 0.05, 0.2, 0.9, 0.8, Some(45.0));
784        let detector = AnomalyDetector::new(AnomalyConfig::default());
785
786        let actions = loop_.sample_cycle(&hv, &detector);
787        // Correct level should be executed
788        assert!(actions.iter().any(|a| a.level.is_active() && a.executed));
789    }
790
791    #[test]
792    fn loop_clear_resets() {
793        let mut loop_ = HomeostaticLoop::default();
794        let hv = make_hv(0.9, 0.3, 0.05, 0.2, 0.9, 0.8, Some(45.0));
795        let detector = AnomalyDetector::new(AnomalyConfig::default());
796
797        loop_.sample_cycle(&hv, &detector);
798        assert!(loop_.total_actions() > 0);
799
800        loop_.clear();
801        assert_eq!(loop_.total_actions(), 0);
802        assert_eq!(loop_.cycles(), 0);
803    }
804
805    #[test]
806    fn loop_summary_json() {
807        let mut loop_ = HomeostaticLoop::default();
808        let hv = make_hv(0.75, 0.3, 0.05, 0.2, 0.9, 0.8, Some(45.0));
809        let detector = AnomalyDetector::new(AnomalyConfig::default());
810
811        loop_.sample_cycle(&hv, &detector);
812        let summary = loop_.summary();
813        assert_eq!(summary["cycles"], 1);
814        assert!(summary["total_actions"].as_u64().unwrap() > 0);
815    }
816
817    #[test]
818    fn action_to_json() {
819        let action = HomeostaticAction {
820            dimension: HarmonyDimension::CpuLoad,
821            level: ActionLevel::Correct,
822            action: ActionType::ShedLoad,
823            current_value: 0.88,
824            threshold: 0.85,
825            description: "test".to_string(),
826            executed: true,
827        };
828        let json = action.to_json();
829        assert_eq!(json["dimension"], "cpu_load");
830        assert_eq!(json["level"], "correct");
831        assert_eq!(json["action"], "shed_load");
832        assert_eq!(json["executed"], true);
833    }
834
835    #[test]
836    fn multiple_dimensions_flagged() {
837        let mut loop_ = HomeostaticLoop::default();
838        let hv = make_hv(0.9, 0.9, 0.6, 0.9, 0.3, 0.1, Some(85.0));
839        let detector = AnomalyDetector::new(AnomalyConfig::default());
840
841        let actions = loop_.sample_cycle(&hv, &detector);
842        // Multiple dimensions should be flagged
843        assert!(actions.len() >= 3);
844    }
845
846    #[test]
847    fn select_action_mapping() {
848        let loop_ = HomeostaticLoop::default();
849        assert_eq!(
850            loop_.select_action(HarmonyDimension::CpuLoad, ActionLevel::Correct),
851            ActionType::ShedLoad
852        );
853        assert_eq!(
854            loop_.select_action(HarmonyDimension::CpuLoad, ActionLevel::Intervene),
855            ActionType::ForceTheta
856        );
857        assert_eq!(
858            loop_.select_action(HarmonyDimension::MemoryPressure, ActionLevel::Correct),
859            ActionType::MemorySweep
860        );
861        assert_eq!(
862            loop_.select_action(HarmonyDimension::MemoryPressure, ActionLevel::Intervene),
863            ActionType::RefuseWrites
864        );
865        assert_eq!(
866            loop_.select_action(HarmonyDimension::BatteryPercent, ActionLevel::Advise),
867            ActionType::Recommend
868        );
869        assert_eq!(
870            loop_.select_action(HarmonyDimension::Temperature, ActionLevel::Intervene),
871            ActionType::ForceTheta
872        );
873    }
874}