Skip to main content

wm_tools/expansion/
selfmodel.rs

1//! Self-model integration tools — predictive introspection MCP tools.
2//!
3//! Tools:
4//! - `selfmodel.forecast` — forecast a metric or all metrics
5//! - `selfmodel.alerts` — check active alerts
6//! - `selfmodel.snapshot` — full self-model state snapshot
7//! - `selfmodel.gnosis` — compact holistic system introspection
8
9#![forbid(unsafe_code)]
10#![allow(clippy::significant_drop_tightening)]
11
12use async_trait::async_trait;
13
14use std::sync::{Arc, Mutex};
15
16use serde_json::{Value, json};
17use wm_core::{Context, EffectRow, Gana, Tool, ToolStats};
18use wm_selfmodel::{AlertLevel, MetricKind, SelfModel};
19
20// ── Helper: parse MetricKind from string ──────────────────────────────
21
22fn parse_metric_kind(s: &str) -> Result<MetricKind, wm_core::CoreError> {
23    match s.to_lowercase().as_str() {
24        "cpu_load" | "cpu" | "load" => Ok(MetricKind::CpuLoad),
25        "memory_pressure" | "memory" | "mem" => Ok(MetricKind::MemoryPressure),
26        "latency" | "lat" => Ok(MetricKind::Latency),
27        "coherence" | "coh" => Ok(MetricKind::Coherence),
28        "error_rate" | "errors" | "error" => Ok(MetricKind::ErrorRate),
29        "disk_io" | "disk" | "io" => Ok(MetricKind::DiskIo),
30        "swap_usage" | "swap" => Ok(MetricKind::SwapUsage),
31        _ => Err(wm_core::CoreError::InvalidArgs(format!(
32            "unknown metric kind: {s}"
33        ))),
34    }
35}
36
37const fn alert_level_as_str(level: AlertLevel) -> &'static str {
38    match level {
39        AlertLevel::Info => "info",
40        AlertLevel::Warning => "warning",
41        AlertLevel::Critical => "critical",
42    }
43}
44
45fn forecast_to_json(forecast: &wm_selfmodel::Forecast) -> Value {
46    json!({
47        "predicted_value": forecast.predicted_value,
48        "slope": forecast.slope,
49        "ewma": forecast.ewma,
50        "confidence": forecast.confidence,
51        "horizon": forecast.horizon,
52    })
53}
54
55fn metric_snapshot_to_json(snap: &wm_selfmodel::MetricSnapshot) -> Value {
56    json!({
57        "kind": snap.kind.as_str(),
58        "current": snap.current,
59        "min": snap.min,
60        "max": snap.max,
61        "avg": snap.avg,
62        "sample_count": snap.sample_count,
63    })
64}
65
66// ── selfmodel.forecast ────────────────────────────────────────────────
67
68/// `selfmodel.forecast` — forecast a metric or all metrics.
69pub struct SelfModelForecastTool {
70    model: Arc<Mutex<SelfModel>>,
71    stats: ToolStats,
72    effects: EffectRow,
73}
74
75impl SelfModelForecastTool {
76    #[must_use]
77    pub fn new(model: Arc<Mutex<SelfModel>>) -> Self {
78        Self {
79            model,
80            stats: ToolStats::default(),
81            effects: EffectRow::pure(),
82        }
83    }
84}
85
86impl Default for SelfModelForecastTool {
87    fn default() -> Self {
88        Self::new(Arc::new(Mutex::new(SelfModel::new())))
89    }
90}
91
92#[async_trait]
93impl Tool for SelfModelForecastTool {
94    fn name(&self) -> &str {
95        "selfmodel.forecast"
96    }
97    fn gana(&self) -> Gana {
98        Gana::Ghost
99    }
100    fn effects(&self) -> &EffectRow {
101        &self.effects
102    }
103    fn stats(&self) -> &ToolStats {
104        &self.stats
105    }
106    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
107        let model = self
108            .model
109            .lock()
110            .map_err(|e| wm_core::CoreError::Tool(format!("self-model lock: {e}")))?;
111
112        let horizon = args.get("horizon").and_then(Value::as_u64).unwrap_or(5) as usize;
113
114        if let Some(metric_str) = args.get("metric").and_then(|m| m.as_str()) {
115            let kind = parse_metric_kind(metric_str)?;
116            match model.forecast(kind, horizon) {
117                Some(forecast) => Ok(json!({
118                    "metric": kind.as_str(),
119                    "forecast": forecast_to_json(&forecast),
120                })),
121                None => Ok(json!({
122                    "metric": kind.as_str(),
123                    "forecast": null,
124                    "message": "insufficient data for forecast (need at least 2 samples)",
125                })),
126            }
127        } else {
128            let forecasts = model.forecast_all(horizon);
129            if forecasts.is_empty() {
130                return Ok(json!({
131                    "forecasts": [],
132                    "message": "no metrics tracked yet",
133                }));
134            }
135            let result: Vec<Value> = forecasts
136                .iter()
137                .map(|(kind, f)| {
138                    json!({
139                        "metric": kind.as_str(),
140                        "forecast": forecast_to_json(f),
141                    })
142                })
143                .collect();
144            Ok(json!({
145                "forecasts": result,
146                "count": result.len(),
147            }))
148        }
149    }
150}
151
152// ── selfmodel.alerts ──────────────────────────────────────────────────
153
154/// `selfmodel.alerts` — check active alerts from forecast threshold crossings.
155pub struct SelfModelAlertsTool {
156    model: Arc<Mutex<SelfModel>>,
157    stats: ToolStats,
158    effects: EffectRow,
159}
160
161impl SelfModelAlertsTool {
162    #[must_use]
163    pub fn new(model: Arc<Mutex<SelfModel>>) -> Self {
164        Self {
165            model,
166            stats: ToolStats::default(),
167            effects: EffectRow::pure(),
168        }
169    }
170}
171
172impl Default for SelfModelAlertsTool {
173    fn default() -> Self {
174        Self::new(Arc::new(Mutex::new(SelfModel::new())))
175    }
176}
177
178#[async_trait]
179impl Tool for SelfModelAlertsTool {
180    fn name(&self) -> &str {
181        "selfmodel.alerts"
182    }
183    fn gana(&self) -> Gana {
184        Gana::Ghost
185    }
186    fn effects(&self) -> &EffectRow {
187        &self.effects
188    }
189    fn stats(&self) -> &ToolStats {
190        &self.stats
191    }
192    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
193        let model = self
194            .model
195            .lock()
196            .map_err(|e| wm_core::CoreError::Tool(format!("self-model lock: {e}")))?;
197
198        let alerts = model.check_alerts();
199        let critical_count = alerts
200            .iter()
201            .filter(|a| a.level == AlertLevel::Critical)
202            .count();
203        let warning_count = alerts
204            .iter()
205            .filter(|a| a.level == AlertLevel::Warning)
206            .count();
207
208        let alerts_json: Vec<Value> = alerts
209            .iter()
210            .map(|a| {
211                json!({
212                    "metric": a.metric.as_str(),
213                    "level": alert_level_as_str(a.level),
214                    "predicted_value": a.predicted_value,
215                    "threshold": a.threshold,
216                    "message": a.message,
217                    "confidence": a.confidence,
218                })
219            })
220            .collect();
221
222        Ok(json!({
223            "alerts": alerts_json,
224            "total": alerts.len(),
225            "critical_count": critical_count,
226            "warning_count": warning_count,
227        }))
228    }
229}
230
231// ── selfmodel.snapshot ────────────────────────────────────────────────
232
233/// `selfmodel.snapshot` — full self-model state snapshot.
234pub struct SelfModelSnapshotTool {
235    model: Arc<Mutex<SelfModel>>,
236    stats: ToolStats,
237    effects: EffectRow,
238}
239
240impl SelfModelSnapshotTool {
241    #[must_use]
242    pub fn new(model: Arc<Mutex<SelfModel>>) -> Self {
243        Self {
244            model,
245            stats: ToolStats::default(),
246            effects: EffectRow::pure(),
247        }
248    }
249}
250
251impl Default for SelfModelSnapshotTool {
252    fn default() -> Self {
253        Self::new(Arc::new(Mutex::new(SelfModel::new())))
254    }
255}
256
257#[async_trait]
258impl Tool for SelfModelSnapshotTool {
259    fn name(&self) -> &str {
260        "selfmodel.snapshot"
261    }
262    fn gana(&self) -> Gana {
263        Gana::Ghost
264    }
265    fn effects(&self) -> &EffectRow {
266        &self.effects
267    }
268    fn stats(&self) -> &ToolStats {
269        &self.stats
270    }
271    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
272        let model = self
273            .model
274            .lock()
275            .map_err(|e| wm_core::CoreError::Tool(format!("self-model lock: {e}")))?;
276
277        let snap = model.snapshot();
278
279        let metrics_json: Vec<Value> = snap.metrics.iter().map(metric_snapshot_to_json).collect();
280
281        let alerts_json: Vec<Value> = snap
282            .alerts
283            .iter()
284            .map(|a| {
285                json!({
286                    "metric": a.metric.as_str(),
287                    "level": alert_level_as_str(a.level),
288                    "predicted_value": a.predicted_value,
289                    "threshold": a.threshold,
290                    "message": a.message,
291                })
292            })
293            .collect();
294
295        let forecasts_json: Vec<Value> = snap
296            .forecasts
297            .iter()
298            .map(|(kind, f)| {
299                json!({
300                    "metric": kind.as_str(),
301                    "forecast": forecast_to_json(f),
302                })
303            })
304            .collect();
305
306        Ok(json!({
307            "timestamp": snap.timestamp.to_rfc3339(),
308            "confidence": snap.confidence,
309            "conservative_mode": snap.confidence < 0.5,
310            "metrics": metrics_json,
311            "metric_count": snap.metrics.len(),
312            "alerts": alerts_json,
313            "alert_count": snap.alerts.len(),
314            "forecasts": forecasts_json,
315            "forecast_count": snap.forecasts.len(),
316        }))
317    }
318}
319
320// ── selfmodel.gnosis ──────────────────────────────────────────────────
321
322/// `selfmodel.gnosis` — compact holistic system introspection.
323///
324/// Mirrors the legacy v26 `gnosis` tool: one call returns the system's
325/// self-knowledge — confidence, tracked metrics, active alerts, and a
326/// per-metric health summary — without flooding the context window.
327pub struct SelfModelGnosisTool {
328    model: Arc<Mutex<SelfModel>>,
329    stats: ToolStats,
330    effects: EffectRow,
331}
332
333impl SelfModelGnosisTool {
334    #[must_use]
335    pub fn new(model: Arc<Mutex<SelfModel>>) -> Self {
336        Self {
337            model,
338            stats: ToolStats::default(),
339            effects: EffectRow::pure(),
340        }
341    }
342}
343
344impl Default for SelfModelGnosisTool {
345    fn default() -> Self {
346        Self::new(Arc::new(Mutex::new(SelfModel::new())))
347    }
348}
349
350#[async_trait]
351impl Tool for SelfModelGnosisTool {
352    fn name(&self) -> &str {
353        "selfmodel.gnosis"
354    }
355    fn gana(&self) -> Gana {
356        Gana::Ghost
357    }
358    fn effects(&self) -> &EffectRow {
359        &self.effects
360    }
361    fn stats(&self) -> &ToolStats {
362        &self.stats
363    }
364    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
365        let model = self
366            .model
367            .lock()
368            .map_err(|e| wm_core::CoreError::Tool(format!("self-model lock: {e}")))?;
369
370        let snap = model.snapshot();
371        let confidence = model.confidence();
372
373        let metrics_json: Vec<Value> = snap
374            .metrics
375            .iter()
376            .map(|m| {
377                json!({
378                    "metric": m.kind.as_str(),
379                    "samples": m.sample_count,
380                    "current": m.current,
381                    "min": m.min,
382                    "max": m.max,
383                    "avg": m.avg,
384                })
385            })
386            .collect();
387
388        let alerts_json: Vec<Value> = snap
389            .alerts
390            .iter()
391            .map(|a| {
392                json!({
393                    "metric": a.metric.as_str(),
394                    "level": alert_level_as_str(a.level),
395                    "message": a.message,
396                })
397            })
398            .collect();
399
400        // Health verdict per metric: healthy if no alert and enough samples.
401        let mut healthy = 0usize;
402        let mut degraded = 0usize;
403        for m in &snap.metrics {
404            let has_alert = snap.alerts.iter().any(|a| a.metric == m.kind);
405            if has_alert {
406                degraded += 1;
407            } else {
408                healthy += 1;
409            }
410        }
411
412        let overall = if snap.alerts.is_empty() {
413            "healthy"
414        } else {
415            "degraded"
416        };
417
418        Ok(json!({
419            "timestamp": snap.timestamp.to_rfc3339(),
420            "overall_health": overall,
421            "confidence": confidence,
422            "tracked_metrics": healthy + degraded,
423            "healthy_metrics": healthy,
424            "degraded_metrics": degraded,
425            "alert_count": snap.alerts.len(),
426            "metrics": metrics_json,
427            "alerts": alerts_json,
428        }))
429    }
430}
431
432// ── Registration ──────────────────────────────────────────────────────
433
434/// Register all self-model tools into a registry.
435pub fn register_selfmodel(
436    registry: &wm_dispatch::ToolRegistry,
437    model: Arc<Mutex<SelfModel>>,
438) -> wm_dispatch::ToolRegistry {
439    registry
440        .register(Arc::new(SelfModelForecastTool::new(Arc::clone(&model))))
441        .register(Arc::new(SelfModelAlertsTool::new(Arc::clone(&model))))
442        .register(Arc::new(SelfModelSnapshotTool::new(Arc::clone(&model))))
443        .register(Arc::new(SelfModelGnosisTool::new(model)))
444}
445
446// ── Tests ─────────────────────────────────────────────────────────────
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn test_model() -> Arc<Mutex<SelfModel>> {
453        let model = SelfModel::new();
454        for v in [0.1, 0.2, 0.3, 0.4, 0.5] {
455            model.record(MetricKind::CpuLoad, v);
456        }
457        model.record(MetricKind::MemoryPressure, 0.2);
458        model.record(MetricKind::MemoryPressure, 0.3);
459        Arc::new(Mutex::new(model))
460    }
461
462    #[tokio::test]
463    async fn parse_metric_kind_all_variants() {
464        assert_eq!(parse_metric_kind("cpu_load").unwrap(), MetricKind::CpuLoad);
465        assert_eq!(parse_metric_kind("CPU").unwrap(), MetricKind::CpuLoad);
466        assert_eq!(
467            parse_metric_kind("memory_pressure").unwrap(),
468            MetricKind::MemoryPressure
469        );
470        assert_eq!(
471            parse_metric_kind("mem").unwrap(),
472            MetricKind::MemoryPressure
473        );
474        assert_eq!(parse_metric_kind("latency").unwrap(), MetricKind::Latency);
475        assert_eq!(
476            parse_metric_kind("coherence").unwrap(),
477            MetricKind::Coherence
478        );
479        assert_eq!(
480            parse_metric_kind("error_rate").unwrap(),
481            MetricKind::ErrorRate
482        );
483        assert_eq!(parse_metric_kind("disk_io").unwrap(), MetricKind::DiskIo);
484        assert_eq!(parse_metric_kind("swap").unwrap(), MetricKind::SwapUsage);
485    }
486
487    #[tokio::test]
488    async fn parse_metric_kind_invalid() {
489        assert!(parse_metric_kind("nonexistent").is_err());
490    }
491
492    #[tokio::test]
493    async fn forecast_single_metric() {
494        let model = test_model();
495        let tool = SelfModelForecastTool::new(Arc::clone(&model));
496        let mut ctx = Context::new(wm_core::BrainWave::Gamma);
497        let result = tool
498            .call(&mut ctx, json!({"metric": "cpu_load", "horizon": 5}))
499            .await
500            .unwrap();
501        assert_eq!(result["metric"], "cpu_load");
502        assert!(result["forecast"]["predicted_value"].is_number());
503        assert!(result["forecast"]["confidence"].is_number());
504    }
505
506    #[tokio::test]
507    async fn forecast_all_metrics() {
508        let model = test_model();
509        let tool = SelfModelForecastTool::new(Arc::clone(&model));
510        let mut ctx = Context::new(wm_core::BrainWave::Gamma);
511        let result = tool.call(&mut ctx, json!({"horizon": 3})).await.unwrap();
512        let forecasts = result["forecasts"].as_array().unwrap();
513        assert_eq!(forecasts.len(), 2);
514        assert_eq!(result["count"], 2);
515    }
516
517    #[tokio::test]
518    async fn forecast_empty_model() {
519        let model = Arc::new(Mutex::new(SelfModel::new()));
520        let tool = SelfModelForecastTool::new(Arc::clone(&model));
521        let mut ctx = Context::new(wm_core::BrainWave::Gamma);
522        let result = tool.call(&mut ctx, json!({})).await.unwrap();
523        assert_eq!(result["forecasts"].as_array().unwrap().len(), 0);
524    }
525
526    #[tokio::test]
527    async fn forecast_insufficient_data() {
528        let model = Arc::new(Mutex::new(SelfModel::new()));
529        {
530            let m = model.lock().unwrap();
531            m.record(MetricKind::CpuLoad, 0.3);
532        }
533        let tool = SelfModelForecastTool::new(Arc::clone(&model));
534        let mut ctx = Context::new(wm_core::BrainWave::Gamma);
535        let result = tool
536            .call(&mut ctx, json!({"metric": "cpu_load"}))
537            .await
538            .unwrap();
539        assert!(result["forecast"].is_null());
540        assert!(result["message"].as_str().unwrap().contains("insufficient"));
541    }
542
543    #[tokio::test]
544    async fn forecast_invalid_metric() {
545        let model = test_model();
546        let tool = SelfModelForecastTool::new(Arc::clone(&model));
547        let mut ctx = Context::new(wm_core::BrainWave::Gamma);
548        let err = tool
549            .call(&mut ctx, json!({"metric": "nonexistent"}))
550            .await
551            .unwrap_err();
552        assert!(err.to_string().contains("unknown metric"));
553    }
554
555    #[tokio::test]
556    async fn alerts_clear() {
557        let model = Arc::new(Mutex::new(SelfModel::new()));
558        {
559            let m = model.lock().unwrap();
560            for v in [0.1, 0.12, 0.11, 0.13] {
561                m.record(MetricKind::CpuLoad, v);
562            }
563        }
564        let tool = SelfModelAlertsTool::new(Arc::clone(&model));
565        let mut ctx = Context::new(wm_core::BrainWave::Gamma);
566        let result = tool.call(&mut ctx, json!({})).await.unwrap();
567        assert_eq!(result["total"], 0);
568        assert_eq!(result["critical_count"], 0);
569    }
570
571    #[tokio::test]
572    async fn alerts_triggered() {
573        let model = Arc::new(Mutex::new(SelfModel::new()));
574        {
575            let m = model.lock().unwrap();
576            for v in [0.5, 0.6, 0.7, 0.8, 0.9, 0.95] {
577                m.record(MetricKind::CpuLoad, v);
578            }
579        }
580        let tool = SelfModelAlertsTool::new(Arc::clone(&model));
581        let mut ctx = Context::new(wm_core::BrainWave::Gamma);
582        let result = tool.call(&mut ctx, json!({})).await.unwrap();
583        assert!(result["total"].as_u64().unwrap() > 0);
584        let alerts = result["alerts"].as_array().unwrap();
585        assert!(alerts.iter().any(|a| a["metric"] == "cpu_load"));
586    }
587
588    #[tokio::test]
589    async fn snapshot_with_data() {
590        let model = test_model();
591        let tool = SelfModelSnapshotTool::new(Arc::clone(&model));
592        let mut ctx = Context::new(wm_core::BrainWave::Gamma);
593        let result = tool.call(&mut ctx, json!({})).await.unwrap();
594        assert!(result["confidence"].is_number());
595        assert!(result["metric_count"].as_u64().unwrap() > 0);
596        assert!(result["forecast_count"].as_u64().unwrap() > 0);
597        assert!(result["timestamp"].is_string());
598        assert!(result["conservative_mode"].is_boolean());
599    }
600
601    #[tokio::test]
602    async fn snapshot_empty_model() {
603        let model = Arc::new(Mutex::new(SelfModel::new()));
604        let tool = SelfModelSnapshotTool::new(Arc::clone(&model));
605        let mut ctx = Context::new(wm_core::BrainWave::Gamma);
606        let result = tool.call(&mut ctx, json!({})).await.unwrap();
607        assert_eq!(result["metric_count"], 0);
608        assert_eq!(result["alert_count"], 0);
609        assert_eq!(result["forecast_count"], 0);
610    }
611
612    #[tokio::test]
613    async fn register_selfmodel_registers_three_tools() {
614        let model = test_model();
615        let registry = wm_dispatch::ToolRegistry::new();
616        let registry = register_selfmodel(&registry, model);
617        assert!(registry.get("selfmodel.forecast").is_some());
618        assert!(registry.get("selfmodel.alerts").is_some());
619        assert!(registry.get("selfmodel.snapshot").is_some());
620    }
621
622    #[tokio::test]
623    async fn forecast_default_horizon() {
624        let model = test_model();
625        let tool = SelfModelForecastTool::new(Arc::clone(&model));
626        let mut ctx = Context::new(wm_core::BrainWave::Gamma);
627        let result = tool
628            .call(&mut ctx, json!({"metric": "cpu_load"}))
629            .await
630            .unwrap();
631        assert_eq!(result["forecast"]["horizon"], 5);
632    }
633
634    #[tokio::test]
635    async fn snapshot_conservative_mode_flag() {
636        let model = Arc::new(Mutex::new(SelfModel::new()));
637        // Empty model → confidence 0.5 → not conservative
638        let tool = SelfModelSnapshotTool::new(Arc::clone(&model));
639        let mut ctx = Context::new(wm_core::BrainWave::Gamma);
640        let result = tool.call(&mut ctx, json!({})).await.unwrap();
641        assert_eq!(result["conservative_mode"], false);
642    }
643
644    #[tokio::test]
645    async fn gnosis_reports_health_and_metrics() {
646        let model = test_model();
647        let tool = SelfModelGnosisTool::new(Arc::clone(&model));
648        let mut ctx = Context::new(wm_core::BrainWave::Gamma);
649        let result = tool.call(&mut ctx, json!({})).await.unwrap();
650
651        // Rising CPU load (0.1 → 0.5) crosses default warning AND critical
652        // thresholds → degraded (2 alerts, one per rule level).
653        assert_eq!(result["overall_health"], "degraded");
654        assert_eq!(result["tracked_metrics"], 2);
655        assert_eq!(result["healthy_metrics"], 1);
656        assert_eq!(result["degraded_metrics"], 1);
657        assert_eq!(result["alert_count"], 2);
658        assert_eq!(result["metrics"].as_array().unwrap().len(), 2);
659        assert!(result["confidence"].as_f64().unwrap() > 0.0);
660    }
661}