Skip to main content

organism_simulation/
types.rs

1//! Typed vocabulary for simulation agents.
2//!
3//! Replaces string-based likelihood parsing and untyped JSON payloads
4//! with compile-time checked types.
5
6use converge_pack::FactId;
7use serde::{Deserialize, Serialize};
8
9use crate::SimulationDimension;
10
11// ── Risk Likelihood ───────────────────────────────────────────────
12
13/// Five-level risk likelihood scale with associated probabilities.
14/// Replaces string matching like `"very_likely" | "VeryLikely" => 0.9`.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum RiskLikelihood {
18    VeryLikely,
19    Likely,
20    Possible,
21    Unlikely,
22    Rare,
23}
24
25impl RiskLikelihood {
26    /// Convert to probability for simulation.
27    #[must_use]
28    pub fn probability(&self) -> f64 {
29        match self {
30            Self::VeryLikely => 0.9,
31            Self::Likely => 0.7,
32            Self::Possible => 0.4,
33            Self::Unlikely => 0.15,
34            Self::Rare => 0.05,
35        }
36    }
37
38    /// Parse from string, returning None for unknown values.
39    #[must_use]
40    pub fn from_str_lossy(s: &str) -> Option<Self> {
41        match s {
42            "very_likely" | "VeryLikely" => Some(Self::VeryLikely),
43            "likely" | "Likely" => Some(Self::Likely),
44            "possible" | "Possible" => Some(Self::Possible),
45            "unlikely" | "Unlikely" => Some(Self::Unlikely),
46            "rare" | "Rare" => Some(Self::Rare),
47            _ => None,
48        }
49    }
50}
51
52// ── Simulation Verdict ────────────────────────────────────────────
53
54/// Typed payload for simulation agent evaluations/constraints.
55/// Replaces `serde_json::json!({...})` with compile-time structure.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SimulationVerdict {
58    pub strategy_id: FactId,
59    pub dimension: SimulationDimension,
60    pub passed: bool,
61    pub confidence: f64,
62    pub findings: Vec<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub recommendation: Option<SimulationRecommendation>,
65}
66
67/// Recommendation from a simulation verdict.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum SimulationRecommendation {
71    Proceed,
72    ProceedWithCaution,
73    DoNotProceed,
74}
75
76impl SimulationVerdict {
77    pub fn to_json(&self) -> String {
78        serde_json::to_string(self).expect("SimulationVerdict is always serializable")
79    }
80
81    /// Create a fact ID from the verdict.
82    pub fn fact_id(&self) -> String {
83        let dim = match self.dimension {
84            SimulationDimension::Outcome => "sim",
85            SimulationDimension::Cost => "cost",
86            SimulationDimension::Policy => "policy",
87            SimulationDimension::Causal => "causal",
88            SimulationDimension::Operational => "ops",
89        };
90        let result = if self.passed { "pass" } else { "fail" };
91        format!("{dim}-{result}-{}", self.strategy_id)
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn risk_likelihood_serde() {
101        let json = serde_json::to_string(&RiskLikelihood::VeryLikely).unwrap();
102        assert_eq!(json, "\"very_likely\"");
103
104        let back: RiskLikelihood = serde_json::from_str("\"unlikely\"").unwrap();
105        assert_eq!(back, RiskLikelihood::Unlikely);
106    }
107
108    #[test]
109    fn risk_likelihood_probabilities() {
110        assert!((RiskLikelihood::VeryLikely.probability() - 0.9).abs() < f64::EPSILON);
111        assert!((RiskLikelihood::Rare.probability() - 0.05).abs() < f64::EPSILON);
112    }
113
114    #[test]
115    fn risk_likelihood_from_str_lossy() {
116        assert_eq!(
117            RiskLikelihood::from_str_lossy("very_likely"),
118            Some(RiskLikelihood::VeryLikely)
119        );
120        assert_eq!(
121            RiskLikelihood::from_str_lossy("Likely"),
122            Some(RiskLikelihood::Likely)
123        );
124        assert_eq!(RiskLikelihood::from_str_lossy("unknown"), None);
125    }
126
127    #[test]
128    fn simulation_verdict_serde_roundtrip() {
129        let verdict = SimulationVerdict {
130            strategy_id: "strat-1".into(),
131            dimension: SimulationDimension::Cost,
132            passed: true,
133            confidence: 0.85,
134            findings: vec!["within budget".into()],
135            recommendation: None,
136        };
137
138        let json = verdict.to_json();
139        let back: SimulationVerdict = serde_json::from_str(&json).unwrap();
140        assert_eq!(back.dimension, SimulationDimension::Cost);
141        assert!(back.passed);
142        assert!((back.confidence - 0.85).abs() < f64::EPSILON);
143    }
144
145    #[test]
146    fn simulation_verdict_with_recommendation() {
147        let verdict = SimulationVerdict {
148            strategy_id: "s1".into(),
149            dimension: SimulationDimension::Outcome,
150            passed: false,
151            confidence: 0.3,
152            findings: vec![],
153            recommendation: Some(SimulationRecommendation::DoNotProceed),
154        };
155
156        let json = verdict.to_json();
157        assert!(json.contains("do_not_proceed"));
158    }
159
160    #[test]
161    fn simulation_verdict_fact_id() {
162        let verdict = SimulationVerdict {
163            strategy_id: "abc".into(),
164            dimension: SimulationDimension::Cost,
165            passed: true,
166            confidence: 0.9,
167            findings: vec![],
168            recommendation: None,
169        };
170        assert_eq!(verdict.fact_id(), "cost-pass-abc");
171
172        let fail_verdict = SimulationVerdict {
173            strategy_id: "xyz".into(),
174            dimension: SimulationDimension::Operational,
175            passed: false,
176            confidence: 0.2,
177            findings: vec![],
178            recommendation: Some(SimulationRecommendation::DoNotProceed),
179        };
180        assert_eq!(fail_verdict.fact_id(), "ops-fail-xyz");
181    }
182}