Skip to main content

wm_tools/expansion/
simulation_tools.rs

1//! Simulation tools — sim.mc, sim.forecast, sim.counterfactual, simulation.calibrate.
2//!
3//! Gana::Mound — simulation, forecasting, and causal analysis.
4
5#![forbid(unsafe_code)]
6
7use async_trait::async_trait;
8
9use std::sync::{Arc, Mutex};
10
11use serde_json::{Value, json};
12use wm_core::{Context, EffectRow, Gana, Resource, Tool, ToolStats};
13use wm_selfmodel::{MetricKind, SelfModel};
14use wm_simulation::{
15    CalibrationStore, CounterfactualEstimator, Distribution, ForecastMethod, Forecaster, McConfig,
16    MonteCarloSimulator,
17};
18
19// ── sim.mc ────────────────────────────────────────────────────────────
20
21/// `sim.mc` — Run a Monte Carlo simulation.
22pub struct SimMcTool {
23    stats: ToolStats,
24    effects: EffectRow,
25}
26
27impl SimMcTool {
28    #[must_use]
29    pub fn new() -> Self {
30        Self {
31            stats: ToolStats::default(),
32            effects: EffectRow::read_only(vec![Resource::Galaxy("simulation".into())]),
33        }
34    }
35}
36
37impl Default for SimMcTool {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43#[async_trait]
44impl Tool for SimMcTool {
45    fn name(&self) -> &str {
46        "sim.mc"
47    }
48    fn gana(&self) -> Gana {
49        Gana::Mound
50    }
51    fn effects(&self) -> &EffectRow {
52        &self.effects
53    }
54    fn description(&self) -> &str {
55        "Run a Monte Carlo simulation (args: n_samples, seed, quasi_mc, distributions, model)"
56    }
57    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
58        let n_samples = args
59            .get("n_samples")
60            .and_then(Value::as_u64)
61            .unwrap_or(5000) as usize;
62
63        let seed = args.get("seed").and_then(Value::as_u64).unwrap_or(42);
64
65        let quasi_mc = args
66            .get("quasi_mc")
67            .and_then(Value::as_bool)
68            .unwrap_or(false);
69
70        let dists_json = args
71            .get("distributions")
72            .and_then(Value::as_array)
73            .ok_or_else(|| {
74                wm_core::CoreError::InvalidArgs("distributions array required".into())
75            })?;
76
77        let distributions: Vec<Distribution> = dists_json
78            .iter()
79            .map(parse_distribution)
80            .collect::<Result<_, _>>()?;
81
82        // The model is a simple expression: "sum", "product", "mean", or "identity:index"
83        let model_str = args.get("model").and_then(Value::as_str).unwrap_or("sum");
84
85        let mut sim = MonteCarloSimulator::new(McConfig {
86            n_samples,
87            seed,
88            quasi_mc,
89        });
90
91        let result = sim.simulate(&distributions, |inputs| match model_str {
92            "sum" => inputs.iter().sum(),
93            "product" => inputs.iter().product(),
94            "mean" => inputs.iter().sum::<f64>() / inputs.len() as f64,
95            s if s.starts_with("identity:") => {
96                let idx: usize = s[9..].parse().unwrap_or(0);
97                inputs.get(idx).copied().unwrap_or(0.0)
98            }
99            _ => inputs.iter().sum(),
100        });
101
102        Ok(json!({
103            "status": "success",
104            "result": result.to_json(),
105        }))
106    }
107    fn stats(&self) -> &ToolStats {
108        &self.stats
109    }
110}
111
112// ── sim.forecast ──────────────────────────────────────────────────────
113
114/// `sim.forecast` — Forecast a time series.
115pub struct SimForecastTool {
116    stats: ToolStats,
117    effects: EffectRow,
118}
119
120impl SimForecastTool {
121    #[must_use]
122    pub fn new() -> Self {
123        Self {
124            stats: ToolStats::default(),
125            effects: EffectRow::read_only(vec![Resource::Galaxy("simulation".into())]),
126        }
127    }
128}
129
130impl Default for SimForecastTool {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136#[async_trait]
137impl Tool for SimForecastTool {
138    fn name(&self) -> &str {
139        "sim.forecast"
140    }
141    fn gana(&self) -> Gana {
142        Gana::Mound
143    }
144    fn effects(&self) -> &EffectRow {
145        &self.effects
146    }
147    fn description(&self) -> &str {
148        "Forecast a time series (args: data, horizon, method=moving_average|exponential_smoothing|linear_trend)"
149    }
150    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
151        let data: Vec<f64> = args
152            .get("data")
153            .and_then(Value::as_array)
154            .ok_or_else(|| wm_core::CoreError::InvalidArgs("data array required".into()))?
155            .iter()
156            .map(|v| v.as_f64().unwrap_or(0.0))
157            .collect();
158
159        if data.is_empty() {
160            return Err(wm_core::CoreError::InvalidArgs(
161                "data must not be empty".into(),
162            ));
163        }
164
165        let horizon = args.get("horizon").and_then(Value::as_u64).unwrap_or(5) as usize;
166
167        let method_str = args
168            .get("method")
169            .and_then(Value::as_str)
170            .unwrap_or("exponential_smoothing");
171
172        let method = match method_str {
173            "moving_average" => ForecastMethod::MovingAverage,
174            "exponential_smoothing" => ForecastMethod::ExponentialSmoothing,
175            "linear_trend" => ForecastMethod::LinearTrend,
176            _ => {
177                return Err(wm_core::CoreError::InvalidArgs(format!(
178                    "unknown method: {method_str}"
179                )));
180            }
181        };
182
183        let forecaster = Forecaster::default();
184        let result = forecaster.forecast(&data, horizon, method);
185
186        Ok(json!({
187            "status": "success",
188            "result": result.to_json(),
189        }))
190    }
191    fn stats(&self) -> &ToolStats {
192        &self.stats
193    }
194}
195
196// ── sim.counterfactual ────────────────────────────────────────────────
197
198/// `sim.counterfactual` — Estimate causal impact of an intervention.
199pub struct SimCounterfactualTool {
200    stats: ToolStats,
201    effects: EffectRow,
202}
203
204impl SimCounterfactualTool {
205    #[must_use]
206    pub fn new() -> Self {
207        Self {
208            stats: ToolStats::default(),
209            effects: EffectRow::read_only(vec![Resource::Galaxy("simulation".into())]),
210        }
211    }
212}
213
214impl Default for SimCounterfactualTool {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220#[async_trait]
221impl Tool for SimCounterfactualTool {
222    fn name(&self) -> &str {
223        "sim.counterfactual"
224    }
225    fn gana(&self) -> Gana {
226        Gana::Mound
227    }
228    fn effects(&self) -> &EffectRow {
229        &self.effects
230    }
231    fn description(&self) -> &str {
232        "Estimate causal impact of an intervention (args: pre, post)"
233    }
234    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
235        let pre: Vec<f64> = args
236            .get("pre")
237            .and_then(Value::as_array)
238            .ok_or_else(|| wm_core::CoreError::InvalidArgs("pre array required".into()))?
239            .iter()
240            .map(|v| v.as_f64().unwrap_or(0.0))
241            .collect();
242
243        let post: Vec<f64> = args
244            .get("post")
245            .and_then(Value::as_array)
246            .ok_or_else(|| wm_core::CoreError::InvalidArgs("post array required".into()))?
247            .iter()
248            .map(|v| v.as_f64().unwrap_or(0.0))
249            .collect();
250
251        if pre.is_empty() || post.is_empty() {
252            return Err(wm_core::CoreError::InvalidArgs(
253                "pre and post must not be empty".into(),
254            ));
255        }
256
257        let estimator = CounterfactualEstimator::default();
258        let result = estimator.estimate(&pre, &post);
259
260        Ok(json!({
261            "status": "success",
262            "result": result.to_json(),
263        }))
264    }
265    fn stats(&self) -> &ToolStats {
266        &self.stats
267    }
268}
269
270// ── Helpers ───────────────────────────────────────────────────────────
271
272fn parse_distribution(v: &Value) -> Result<Distribution, wm_core::CoreError> {
273    let kind = v
274        .get("kind")
275        .and_then(Value::as_str)
276        .ok_or_else(|| wm_core::CoreError::InvalidArgs("distribution kind required".into()))?;
277
278    match kind {
279        "uniform" => {
280            let min = v.get("min").and_then(Value::as_f64).unwrap_or(0.0);
281            let max = v.get("max").and_then(Value::as_f64).unwrap_or(1.0);
282            Ok(Distribution::Uniform { min, max })
283        }
284        "normal" => {
285            let mean = v.get("mean").and_then(Value::as_f64).unwrap_or(0.0);
286            let std_dev = v.get("std_dev").and_then(Value::as_f64).unwrap_or(1.0);
287            Ok(Distribution::Normal { mean, std_dev })
288        }
289        "exponential" => {
290            let lambda = v.get("lambda").and_then(Value::as_f64).unwrap_or(1.0);
291            Ok(Distribution::Exponential { lambda })
292        }
293        "triangular" => {
294            let min = v.get("min").and_then(Value::as_f64).unwrap_or(0.0);
295            let mode = v.get("mode").and_then(Value::as_f64).unwrap_or(0.5);
296            let max = v.get("max").and_then(Value::as_f64).unwrap_or(1.0);
297            Ok(Distribution::Triangular { min, mode, max })
298        }
299        "constant" => {
300            let val = v.get("value").and_then(Value::as_f64).unwrap_or(0.0);
301            Ok(Distribution::Constant(val))
302        }
303        _ => Err(wm_core::CoreError::InvalidArgs(format!(
304            "unknown distribution kind: {kind}"
305        ))),
306    }
307}
308
309// ── simulation.calibrate ─────────────────────────────────────────────
310
311/// `simulation.calibrate` — record predictions, resolve them against
312/// reality, and get an honest Brier scorecard with the Murphy
313/// decomposition (reliability / resolution / uncertainty).
314///
315/// Actions:
316/// - `record` — store a prediction with probability + confidence
317/// - `resolve` — resolve a prediction against its outcome
318/// - `scorecard` — full calibration report (default)
319pub struct SimulationCalibrateTool {
320    store: Arc<Mutex<CalibrationStore>>,
321    self_model: Option<Arc<Mutex<SelfModel>>>,
322    stats: ToolStats,
323    effects: EffectRow,
324}
325
326impl SimulationCalibrateTool {
327    #[must_use]
328    pub fn new(
329        store: Arc<Mutex<CalibrationStore>>,
330        self_model: Option<Arc<Mutex<SelfModel>>>,
331    ) -> Self {
332        Self {
333            store,
334            self_model,
335            stats: ToolStats::default(),
336            effects: EffectRow::read_only(vec![Resource::Galaxy("simulation".into())]),
337        }
338    }
339}
340
341impl Default for SimulationCalibrateTool {
342    fn default() -> Self {
343        Self::new(Arc::new(Mutex::new(CalibrationStore::new())), None)
344    }
345}
346
347#[async_trait]
348impl Tool for SimulationCalibrateTool {
349    fn name(&self) -> &str {
350        "simulation.calibrate"
351    }
352    fn gana(&self) -> Gana {
353        Gana::Mound
354    }
355    fn effects(&self) -> &EffectRow {
356        &self.effects
357    }
358    fn stats(&self) -> &ToolStats {
359        &self.stats
360    }
361    fn description(&self) -> &str {
362        "Record, resolve, and scorecard calibrated predictions — Brier score decomposition (reliability, resolution, uncertainty) for probabilistic forecasts (args: action=record|resolve|scorecard, ...)"
363    }
364    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
365        let action = args
366            .get("action")
367            .and_then(Value::as_str)
368            .unwrap_or("scorecard");
369        let mut store = self
370            .store
371            .lock()
372            .map_err(|e| wm_core::CoreError::Tool(format!("calibration store lock: {e}")))?;
373
374        match action {
375            "record" => {
376                let statement = args
377                    .get("statement")
378                    .and_then(Value::as_str)
379                    .ok_or_else(|| {
380                        wm_core::CoreError::InvalidArgs("statement is required for record".into())
381                    })?;
382                let probability = args
383                    .get("probability")
384                    .and_then(Value::as_f64)
385                    .unwrap_or(0.5)
386                    .clamp(0.0, 1.0);
387                let confidence = args
388                    .get("confidence")
389                    .and_then(Value::as_f64)
390                    .unwrap_or(0.5)
391                    .clamp(0.0, 1.0);
392                let scenario = args
393                    .get("scenario")
394                    .and_then(Value::as_str)
395                    .unwrap_or("default");
396                let pred = store.record(statement, probability, confidence, scenario);
397                Ok(json!({
398                    "status": "success",
399                    "prediction_id": pred.id,
400                    "probability": pred.probability,
401                    "adjusted_probability": pred.adjusted_probability,
402                    "calibration_adjustment": store.calibration_gap(),
403                }))
404            }
405            "resolve" => {
406                let pred_id = args
407                    .get("prediction_id")
408                    .and_then(Value::as_str)
409                    .ok_or_else(|| {
410                        wm_core::CoreError::InvalidArgs(
411                            "prediction_id is required for resolve".into(),
412                        )
413                    })?;
414                let outcome = args
415                    .get("outcome")
416                    .and_then(Value::as_bool)
417                    .ok_or_else(|| {
418                        wm_core::CoreError::InvalidArgs(
419                            "outcome (boolean) is required for resolve".into(),
420                        )
421                    })?;
422                match store.resolve(pred_id, outcome) {
423                    Ok((brier, gap)) => Ok(json!({
424                        "status": "success",
425                        "prediction_id": pred_id,
426                        "brier_score": brier,
427                        "calibration_gap": gap,
428                    })),
429                    Err(e) => Err(wm_core::CoreError::InvalidArgs(e)),
430                }
431            }
432            "scorecard" => {
433                let card = store.scorecard();
434                let mut result = json!({
435                    "status": "success",
436                    "total_predictions": card.total_predictions,
437                    "resolved": card.resolved,
438                    "unresolved": card.unresolved,
439                    "avg_brier_score": card.avg_brier_score,
440                    "reliability": card.reliability,
441                    "resolution": card.resolution,
442                    "uncertainty": card.uncertainty,
443                    "skill_score": card.skill_score,
444                    "calibration_gap": card.calibration_gap,
445                    "perfect_calibration": card.perfect_calibration,
446                    "good_calibration": card.good_calibration,
447                    "calibration_bins": card.calibration_bins,
448                });
449                // Feed the Brier score into the self-model for drift alerts
450                if let Some(model) = &self.self_model {
451                    if let Ok(model) = model.lock() {
452                        model.record(MetricKind::BrierScore, card.avg_brier_score as f32);
453                        let alerts = model
454                            .check_alerts()
455                            .into_iter()
456                            .filter(|a| a.metric == MetricKind::BrierScore)
457                            .map(|a| json!({"level": format!("{:?}", a.level), "message": a.message}))
458                            .collect::<Vec<_>>();
459                        result["alerts"] = json!(alerts);
460                    }
461                }
462                Ok(result)
463            }
464            other => Err(wm_core::CoreError::InvalidArgs(format!(
465                "unknown action '{other}' (expected record | resolve | scorecard)"
466            ))),
467        }
468    }
469}
470
471// ── Registration ──────────────────────────────────────────────────────
472
473/// Register all simulation tools into a registry.
474///
475/// `self_model` (optional) enables calibration monitoring: the scorecard
476/// records the average Brier score into the self-model for drift alerts.
477#[must_use]
478pub fn register_simulation(
479    registry: &wm_dispatch::ToolRegistry,
480    calibration_store: Option<Arc<Mutex<CalibrationStore>>>,
481    self_model: Option<Arc<Mutex<SelfModel>>>,
482) -> wm_dispatch::ToolRegistry {
483    let calibrate = match calibration_store {
484        Some(store) => SimulationCalibrateTool::new(store, self_model),
485        None => SimulationCalibrateTool::default(),
486    };
487    registry
488        .register(Arc::new(SimMcTool::new()))
489        .register(Arc::new(SimForecastTool::new()))
490        .register(Arc::new(SimCounterfactualTool::new()))
491        .register(Arc::new(calibrate))
492}
493
494// ── Tests ─────────────────────────────────────────────────────────────
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[tokio::test]
501    async fn sim_mc_runs_simulation() {
502        let tool = SimMcTool::new();
503        let mut ctx = Context::default();
504        let v = tool
505            .call(
506                &mut ctx,
507                json!({
508                    "n_samples": 1000,
509                    "distributions": [{"kind": "uniform", "min": 0.0, "max": 10.0}],
510                    "model": "sum"
511                }),
512            )
513            .await
514            .unwrap();
515        assert_eq!(v["status"], "success");
516        assert!(v["result"]["mean"].is_number());
517    }
518
519    #[tokio::test]
520    async fn sim_mc_missing_distributions_errors() {
521        let tool = SimMcTool::new();
522        let mut ctx = Context::default();
523        let result = tool.call(&mut ctx, json!({})).await;
524        assert!(result.is_err());
525    }
526
527    #[tokio::test]
528    async fn sim_forecast_runs() {
529        let tool = SimForecastTool::new();
530        let mut ctx = Context::default();
531        let v = tool
532            .call(
533                &mut ctx,
534                json!({
535                    "data": [1.0, 2.0, 3.0, 4.0, 5.0],
536                    "horizon": 3,
537                    "method": "linear_trend"
538                }),
539            )
540            .await
541            .unwrap();
542        assert_eq!(v["status"], "success");
543        assert!(v["result"]["forecast"].is_array());
544    }
545
546    #[tokio::test]
547    async fn sim_forecast_empty_data_errors() {
548        let tool = SimForecastTool::new();
549        let mut ctx = Context::default();
550        let result = tool.call(&mut ctx, json!({"data": []})).await;
551        assert!(result.is_err());
552    }
553
554    #[tokio::test]
555    async fn sim_counterfactual_runs() {
556        let tool = SimCounterfactualTool::new();
557        let mut ctx = Context::default();
558        let v = tool
559            .call(
560                &mut ctx,
561                json!({
562                    "pre": [10.0, 10.0, 10.0, 10.0, 10.0],
563                    "post": [15.0, 15.0, 15.0, 15.0, 15.0]
564                }),
565            )
566            .await
567            .unwrap();
568        assert_eq!(v["status"], "success");
569        assert!(v["result"]["impact"].is_number());
570    }
571
572    #[tokio::test]
573    async fn sim_counterfactual_missing_pre_errors() {
574        let tool = SimCounterfactualTool::new();
575        let mut ctx = Context::default();
576        let result = tool.call(&mut ctx, json!({"post": [1.0]})).await;
577        assert!(result.is_err());
578    }
579
580    #[tokio::test]
581    async fn sim_tools_are_mound_gana() {
582        assert_eq!(SimMcTool::new().gana(), Gana::Mound);
583        assert_eq!(SimForecastTool::new().gana(), Gana::Mound);
584        assert_eq!(SimCounterfactualTool::new().gana(), Gana::Mound);
585    }
586
587    #[tokio::test]
588    async fn parse_distribution_uniform() {
589        let d = parse_distribution(&json!({"kind": "uniform", "min": 0.0, "max": 10.0})).unwrap();
590        assert!(matches!(
591            d,
592            Distribution::Uniform {
593                min: 0.0,
594                max: 10.0
595            }
596        ));
597    }
598
599    #[tokio::test]
600    async fn parse_distribution_normal() {
601        let d =
602            parse_distribution(&json!({"kind": "normal", "mean": 5.0, "std_dev": 2.0})).unwrap();
603        assert!(matches!(
604            d,
605            Distribution::Normal {
606                mean: 5.0,
607                std_dev: 2.0
608            }
609        ));
610    }
611
612    #[tokio::test]
613    async fn parse_distribution_unknown_errors() {
614        let result = parse_distribution(&json!({"kind": "unknown"}));
615        assert!(result.is_err());
616    }
617
618    #[tokio::test]
619    async fn calibrate_record_resolve_scorecard_flow() {
620        let store = Arc::new(Mutex::new(CalibrationStore::new()));
621        let tool = SimulationCalibrateTool::new(Arc::clone(&store), None);
622        let mut ctx = Context::default();
623
624        // Record
625        let v = tool
626            .call(
627                &mut ctx,
628                json!({"action": "record", "statement": "It will rain", "probability": 0.8, "confidence": 0.6, "scenario": "weather"}),
629            )
630            .await
631            .unwrap();
632        assert_eq!(v["status"], "success");
633        let pred_id = v["prediction_id"].as_str().unwrap().to_string();
634
635        // Resolve with a good outcome
636        let v = tool
637            .call(
638                &mut ctx,
639                json!({"action": "resolve", "prediction_id": pred_id, "outcome": true}),
640            )
641            .await
642            .unwrap();
643        assert_eq!(v["status"], "success");
644        assert!((v["brier_score"].as_f64().unwrap() - 0.04).abs() < 1e-9);
645
646        // Scorecard
647        let v = tool
648            .call(&mut ctx, json!({"action": "scorecard"}))
649            .await
650            .unwrap();
651        assert_eq!(v["resolved"], 1);
652        assert_eq!(v["unresolved"], 0);
653        assert!((v["avg_brier_score"].as_f64().unwrap() - 0.04).abs() < 1e-9);
654        assert_eq!(v["calibration_bins"].as_array().unwrap().len(), 10);
655    }
656
657    #[tokio::test]
658    async fn calibrate_requires_statement_and_outcome() {
659        let tool = SimulationCalibrateTool::default();
660        let mut ctx = Context::default();
661        // record without statement
662        assert!(
663            tool.call(&mut ctx, json!({"action": "record"}))
664                .await
665                .is_err()
666        );
667        // resolve without id
668        assert!(
669            tool.call(&mut ctx, json!({"action": "resolve"}))
670                .await
671                .is_err()
672        );
673        // resolve missing prediction
674        assert!(
675            tool.call(
676                &mut ctx,
677                json!({"action": "resolve", "prediction_id": "nope", "outcome": true})
678            )
679            .await
680            .is_err()
681        );
682        // unknown action
683        assert!(
684            tool.call(&mut ctx, json!({"action": "bogus"}))
685                .await
686                .is_err()
687        );
688    }
689
690    #[tokio::test]
691    async fn calibrate_scorecard_defaults_to_scorecard_action() {
692        let tool = SimulationCalibrateTool::default();
693        let mut ctx = Context::default();
694        let v = tool.call(&mut ctx, json!({})).await.unwrap();
695        assert_eq!(v["status"], "success");
696        assert_eq!(v["resolved"], 0);
697        assert_eq!(v["total_predictions"], 0);
698    }
699
700    #[tokio::test]
701    async fn calibrate_feeds_brier_into_self_model() {
702        let store = Arc::new(Mutex::new(CalibrationStore::new()));
703        let model = Arc::new(Mutex::new(wm_selfmodel::SelfModel::new()));
704        let tool = SimulationCalibrateTool::new(Arc::clone(&store), Some(Arc::clone(&model)));
705        let mut ctx = Context::default();
706
707        // Resolve many anti-calibrated predictions → poor Brier
708        for i in 0..14 {
709            let v = tool
710                .call(
711                    &mut ctx,
712                    json!({"action": "record", "statement": format!("pred {i}"), "probability": 0.9}),
713                )
714                .await
715                .unwrap();
716            let id = v["prediction_id"].as_str().unwrap().to_string();
717            let _ = tool
718                .call(
719                    &mut ctx,
720                    json!({"action": "resolve", "prediction_id": id, "outcome": false}),
721                )
722                .await
723                .unwrap();
724        }
725        // Repeated scorecards accumulate Brier samples → forecast → alert
726        let mut saw_alert = false;
727        for _ in 0..20 {
728            let v = tool
729                .call(&mut ctx, json!({"action": "scorecard"}))
730                .await
731                .unwrap();
732            assert!(v["avg_brier_score"].as_f64().unwrap() > 0.6);
733            if let Some(alerts) = v["alerts"].as_array() {
734                if alerts
735                    .iter()
736                    .any(|a| a["level"] == "Warning" || a["level"] == "Critical")
737                {
738                    saw_alert = true;
739                    break;
740                }
741            }
742        }
743        assert!(saw_alert, "drift should produce Brier alerts");
744
745        let model = model.lock().unwrap();
746        assert!(model.sample_count(MetricKind::BrierScore) >= 1);
747    }
748}