Skip to main content

organism_simulation/
policy.rs

1//! Policy simulator.
2//!
3//! Evaluates candidate plans against organizational policy constraints:
4//! authority levels, compliance requirements, data classification rules,
5//! and approval gates.
6
7use crate::{DimensionResult, Sample, SimulationDimension};
8
9#[derive(Debug, Clone)]
10pub struct PolicySimulatorConfig {
11    pub required_authority_level: u32,
12    pub require_compliance_tags: Vec<String>,
13    pub block_on_missing_authority: bool,
14}
15
16impl Default for PolicySimulatorConfig {
17    fn default() -> Self {
18        Self {
19            required_authority_level: 1,
20            require_compliance_tags: Vec::new(),
21            block_on_missing_authority: true,
22        }
23    }
24}
25
26pub struct PolicySimulator {
27    config: PolicySimulatorConfig,
28}
29
30impl PolicySimulator {
31    #[must_use]
32    pub fn new(config: PolicySimulatorConfig) -> Self {
33        Self { config }
34    }
35
36    fn extract_authority_level(plan: &serde_json::Value) -> Option<u32> {
37        plan.get("annotation")
38            .and_then(|a| a.get("authority_level"))
39            .and_then(serde_json::Value::as_u64)
40            .map(|v| u32::try_from(v).unwrap_or(0))
41    }
42
43    fn extract_compliance_tags(plan: &serde_json::Value) -> Vec<String> {
44        plan.get("annotation")
45            .and_then(|a| a.get("compliance_tags"))
46            .and_then(|t| t.as_array())
47            .map(|arr| {
48                arr.iter()
49                    .filter_map(|v| v.as_str().map(String::from))
50                    .collect()
51            })
52            .unwrap_or_default()
53    }
54
55    fn extract_approval_gates(plan: &serde_json::Value) -> Vec<String> {
56        plan.get("annotation")
57            .and_then(|a| a.get("approval_gates"))
58            .and_then(|g| g.as_array())
59            .map(|arr| {
60                arr.iter()
61                    .filter_map(|v| v.as_str().map(String::from))
62                    .collect()
63            })
64            .unwrap_or_default()
65    }
66
67    fn sample(confidence: f64) -> Vec<Sample> {
68        let buckets = 5;
69        let mut samples = Vec::with_capacity(buckets);
70
71        for i in 0..buckets {
72            let bucket_center = (f64::from(u32::try_from(i).unwrap_or(0)) + 0.5)
73                / f64::from(u32::try_from(buckets).unwrap_or(5));
74            let distance = (bucket_center - confidence).abs();
75            let weight = (-distance * 4.0).exp();
76            samples.push(Sample {
77                value: bucket_center,
78                probability: weight,
79            });
80        }
81
82        let total: f64 = samples.iter().map(|s| s.probability).sum();
83        if total > 0.0 {
84            for s in &mut samples {
85                s.probability /= total;
86            }
87        }
88
89        samples
90    }
91
92    pub fn simulate(&self, plan: &serde_json::Value) -> DimensionResult {
93        let authority = Self::extract_authority_level(plan);
94        let tags = Self::extract_compliance_tags(plan);
95        let gates = Self::extract_approval_gates(plan);
96
97        let mut findings = Vec::new();
98        let mut violations = 0u32;
99
100        // Authority check
101        match authority {
102            Some(level) if level >= self.config.required_authority_level => {
103                findings.push(format!("authority level {level} meets requirement"));
104            }
105            Some(level) => {
106                findings.push(format!(
107                    "authority level {level} below required {}",
108                    self.config.required_authority_level,
109                ));
110                violations += 1;
111            }
112            None if self.config.block_on_missing_authority => {
113                findings.push("no authority level declared — blocked by policy".into());
114                violations += 1;
115            }
116            None => {
117                findings.push("no authority level declared — not required".into());
118            }
119        }
120
121        // Compliance tag check
122        for required in &self.config.require_compliance_tags {
123            if tags.iter().any(|t| t == required) {
124                findings.push(format!("compliance: {required} satisfied"));
125            } else {
126                findings.push(format!("compliance: {required} missing"));
127                violations += 1;
128            }
129        }
130
131        // Note approval gates (informational)
132        if !gates.is_empty() {
133            findings.push(format!(
134                "{} approval gate(s) required: {}",
135                gates.len(),
136                gates.join(", ")
137            ));
138        }
139
140        let passed = violations == 0;
141        let total_checks =
142            1 + u32::try_from(self.config.require_compliance_tags.len()).unwrap_or(0);
143        let confidence = if total_checks == 0 {
144            1.0
145        } else {
146            f64::from(total_checks - violations.min(total_checks)) / f64::from(total_checks)
147        };
148
149        let samples = Self::sample(confidence);
150
151        DimensionResult {
152            dimension: SimulationDimension::Policy,
153            passed,
154            confidence,
155            findings,
156            samples,
157        }
158    }
159}
160
161// ── Suggestor Implementation ──────────────────────────────────────
162
163use crate::types::{SimulationRecommendation, SimulationVerdict};
164use converge_pack::{AgentEffect, Context, ContextKey, ProposedFact, Suggestor};
165
166pub struct PolicySimulationAgent {
167    simulator: PolicySimulator,
168}
169
170impl PolicySimulationAgent {
171    #[must_use]
172    pub fn new(config: PolicySimulatorConfig) -> Self {
173        Self {
174            simulator: PolicySimulator::new(config),
175        }
176    }
177
178    #[must_use]
179    pub fn default_config() -> Self {
180        Self {
181            simulator: PolicySimulator::new(PolicySimulatorConfig::default()),
182        }
183    }
184}
185
186#[async_trait::async_trait]
187#[allow(clippy::unnecessary_literal_bound)]
188impl Suggestor for PolicySimulationAgent {
189    fn name(&self) -> &'static str {
190        "policy-simulation"
191    }
192
193    fn dependencies(&self) -> &[ContextKey] {
194        &[ContextKey::Strategies]
195    }
196
197    fn accepts(&self, ctx: &dyn Context) -> bool {
198        ctx.has(ContextKey::Strategies) && !ctx.has(ContextKey::Evaluations)
199    }
200
201    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
202        let strategies = ctx.get(ContextKey::Strategies);
203        let mut proposals = Vec::new();
204
205        for fact in strategies {
206            let plan_json: serde_json::Value = serde_json::from_str(&fact.content)
207                .unwrap_or_else(|_| serde_json::json!({"description": fact.content}));
208
209            let result = self.simulator.simulate(&plan_json);
210
211            let verdict = SimulationVerdict {
212                strategy_id: fact.id.clone(),
213                dimension: crate::SimulationDimension::Policy,
214                passed: result.passed,
215                confidence: result.confidence,
216                findings: result.findings,
217                recommendation: if result.passed {
218                    None
219                } else {
220                    Some(SimulationRecommendation::DoNotProceed)
221                },
222            };
223
224            let key = if result.passed {
225                ContextKey::Evaluations
226            } else {
227                ContextKey::Constraints
228            };
229
230            proposals.push(ProposedFact::new(
231                key,
232                verdict.fact_id(),
233                verdict.to_json(),
234                "policy-simulation",
235            ));
236        }
237
238        AgentEffect::with_proposals(proposals)
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use serde_json::json;
246
247    fn default_simulator() -> PolicySimulator {
248        PolicySimulator::new(PolicySimulatorConfig::default())
249    }
250
251    #[test]
252    fn sufficient_authority_passes() {
253        let sim = default_simulator();
254        let plan = json!({
255            "annotation": {
256                "authority_level": 2
257            }
258        });
259        let result = sim.simulate(&plan);
260        assert_eq!(result.dimension, SimulationDimension::Policy);
261        assert!(result.passed);
262    }
263
264    #[test]
265    fn insufficient_authority_fails() {
266        let sim = PolicySimulator::new(PolicySimulatorConfig {
267            required_authority_level: 3,
268            ..PolicySimulatorConfig::default()
269        });
270        let plan = json!({
271            "annotation": {
272                "authority_level": 1
273            }
274        });
275        let result = sim.simulate(&plan);
276        assert!(!result.passed);
277        assert!(result.findings.iter().any(|f| f.contains("below required")));
278    }
279
280    #[test]
281    fn missing_authority_blocks_by_default() {
282        let sim = default_simulator();
283        let plan = json!({});
284        let result = sim.simulate(&plan);
285        assert!(!result.passed);
286        assert!(
287            result
288                .findings
289                .iter()
290                .any(|f| f.contains("blocked by policy"))
291        );
292    }
293
294    #[test]
295    fn missing_authority_allowed_when_configured() {
296        let sim = PolicySimulator::new(PolicySimulatorConfig {
297            block_on_missing_authority: false,
298            ..PolicySimulatorConfig::default()
299        });
300        let plan = json!({});
301        let result = sim.simulate(&plan);
302        assert!(result.passed);
303    }
304
305    #[test]
306    fn compliance_tags_checked() {
307        let sim = PolicySimulator::new(PolicySimulatorConfig {
308            require_compliance_tags: vec!["gdpr".into(), "soc2".into()],
309            ..PolicySimulatorConfig::default()
310        });
311        let plan = json!({
312            "annotation": {
313                "authority_level": 1,
314                "compliance_tags": ["gdpr"]
315            }
316        });
317        let result = sim.simulate(&plan);
318        assert!(!result.passed); // missing soc2
319        assert!(result.findings.iter().any(|f| f.contains("soc2 missing")));
320    }
321
322    #[test]
323    fn all_compliance_satisfied() {
324        let sim = PolicySimulator::new(PolicySimulatorConfig {
325            require_compliance_tags: vec!["gdpr".into()],
326            ..PolicySimulatorConfig::default()
327        });
328        let plan = json!({
329            "annotation": {
330                "authority_level": 1,
331                "compliance_tags": ["gdpr", "hipaa"]
332            }
333        });
334        let result = sim.simulate(&plan);
335        assert!(result.passed);
336    }
337
338    #[test]
339    fn approval_gates_noted() {
340        let sim = default_simulator();
341        let plan = json!({
342            "annotation": {
343                "authority_level": 1,
344                "approval_gates": ["legal-review", "cfo-sign-off"]
345            }
346        });
347        let result = sim.simulate(&plan);
348        assert!(result.passed);
349        assert!(result.findings.iter().any(|f| f.contains("approval gate")));
350    }
351}