1#![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
19pub 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 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
112pub 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
196pub 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
270fn 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
309pub 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 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#[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#[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 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 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 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 assert!(
663 tool.call(&mut ctx, json!({"action": "record"}))
664 .await
665 .is_err()
666 );
667 assert!(
669 tool.call(&mut ctx, json!({"action": "resolve"}))
670 .await
671 .is_err()
672 );
673 assert!(
675 tool.call(
676 &mut ctx,
677 json!({"action": "resolve", "prediction_id": "nope", "outcome": true})
678 )
679 .await
680 .is_err()
681 );
682 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 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 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}