Skip to main content

organism_simulation/
operational.rs

1//! Operational simulator.
2//!
3//! Evaluates candidate plans for operational feasibility: team capacity,
4//! system load, timeline realism, and dependency availability.
5
6use crate::{DimensionResult, Sample, SimulationDimension};
7
8#[derive(Debug, Clone)]
9pub struct OperationalSimulatorConfig {
10    pub max_team_utilization: f64,
11    pub max_system_load: f64,
12    pub confidence_threshold: f64,
13}
14
15impl Default for OperationalSimulatorConfig {
16    fn default() -> Self {
17        Self {
18            max_team_utilization: 0.85,
19            max_system_load: 0.80,
20            confidence_threshold: 0.5,
21        }
22    }
23}
24
25pub struct OperationalSimulator {
26    config: OperationalSimulatorConfig,
27}
28
29impl OperationalSimulator {
30    #[must_use]
31    pub fn new(config: OperationalSimulatorConfig) -> Self {
32        Self { config }
33    }
34
35    fn extract_team_utilization(plan: &serde_json::Value) -> Option<f64> {
36        plan.get("annotation")
37            .and_then(|a| a.get("team_utilization"))
38            .and_then(serde_json::Value::as_f64)
39    }
40
41    fn extract_system_load(plan: &serde_json::Value) -> Option<f64> {
42        plan.get("annotation")
43            .and_then(|a| a.get("system_load"))
44            .and_then(serde_json::Value::as_f64)
45    }
46
47    fn extract_dependencies(plan: &serde_json::Value) -> Vec<Dependency> {
48        plan.get("annotation")
49            .and_then(|a| a.get("dependencies"))
50            .and_then(|d| d.as_array())
51            .map(|arr| {
52                arr.iter()
53                    .filter_map(|v| {
54                        Some(Dependency {
55                            name: v.get("name").and_then(|n| n.as_str())?.to_string(),
56                            available: v
57                                .get("available")
58                                .and_then(serde_json::Value::as_bool)
59                                .unwrap_or(true),
60                            lead_time_days: v
61                                .get("lead_time_days")
62                                .and_then(serde_json::Value::as_u64)
63                                .map(|n| u32::try_from(n).unwrap_or(0)),
64                        })
65                    })
66                    .collect()
67            })
68            .unwrap_or_default()
69    }
70
71    fn extract_timeline_days(plan: &serde_json::Value) -> Option<u32> {
72        plan.get("annotation")
73            .and_then(|a| a.get("timeline_days"))
74            .and_then(serde_json::Value::as_u64)
75            .map(|n| u32::try_from(n).unwrap_or(0))
76    }
77
78    fn sample(confidence: f64) -> Vec<Sample> {
79        let buckets = 5;
80        let mut samples = Vec::with_capacity(buckets);
81
82        for i in 0..buckets {
83            let bucket_center = (f64::from(u32::try_from(i).unwrap_or(0)) + 0.5)
84                / f64::from(u32::try_from(buckets).unwrap_or(5));
85            let distance = (bucket_center - confidence).abs();
86            let weight = (-distance * 4.0).exp();
87            samples.push(Sample {
88                value: bucket_center,
89                probability: weight,
90            });
91        }
92
93        let total: f64 = samples.iter().map(|s| s.probability).sum();
94        if total > 0.0 {
95            for s in &mut samples {
96                s.probability /= total;
97            }
98        }
99
100        samples
101    }
102
103    pub fn simulate(&self, plan: &serde_json::Value) -> DimensionResult {
104        let team_util = Self::extract_team_utilization(plan);
105        let system_load = Self::extract_system_load(plan);
106        let deps = Self::extract_dependencies(plan);
107        let timeline = Self::extract_timeline_days(plan);
108
109        let mut findings = Vec::new();
110        let mut penalties = 0.0_f64;
111
112        // Team utilization check
113        match team_util {
114            Some(util) if util > self.config.max_team_utilization => {
115                findings.push(format!(
116                    "team overloaded: {:.0}% utilization exceeds {:.0}% cap",
117                    util * 100.0,
118                    self.config.max_team_utilization * 100.0,
119                ));
120                penalties += (util - self.config.max_team_utilization) * 2.0;
121            }
122            Some(util) => {
123                findings.push(format!(
124                    "team utilization {:.0}% within capacity",
125                    util * 100.0
126                ));
127            }
128            None => {
129                findings.push("no team utilization declared".into());
130            }
131        }
132
133        // System load check
134        match system_load {
135            Some(load) if load > self.config.max_system_load => {
136                findings.push(format!(
137                    "system overloaded: {:.0}% load exceeds {:.0}% cap",
138                    load * 100.0,
139                    self.config.max_system_load * 100.0,
140                ));
141                penalties += (load - self.config.max_system_load) * 2.0;
142            }
143            Some(load) => {
144                findings.push(format!("system load {:.0}% within capacity", load * 100.0));
145            }
146            None => {
147                findings.push("no system load declared".into());
148            }
149        }
150
151        // Dependency availability
152        let unavailable: Vec<&Dependency> = deps.iter().filter(|d| !d.available).collect();
153        if !unavailable.is_empty() {
154            for dep in &unavailable {
155                findings.push(format!("dependency unavailable: {}", dep.name));
156            }
157            penalties += 0.3 * f64::from(u32::try_from(unavailable.len()).unwrap_or(1));
158        }
159
160        // Timeline vs dependency lead times
161        if let Some(days) = timeline {
162            let max_lead = deps
163                .iter()
164                .filter_map(|d| d.lead_time_days)
165                .max()
166                .unwrap_or(0);
167            if max_lead > days {
168                findings.push(format!(
169                    "timeline {days} days but dependency needs {max_lead} days lead time",
170                ));
171                penalties += 0.2;
172            }
173        }
174
175        let confidence = (1.0 - penalties).clamp(0.0, 1.0);
176        let passed = confidence >= self.config.confidence_threshold;
177        let samples = Self::sample(confidence);
178
179        if !passed {
180            findings.push(format!(
181                "below threshold: {confidence:.2} < {:.2}",
182                self.config.confidence_threshold,
183            ));
184        }
185
186        DimensionResult {
187            dimension: SimulationDimension::Operational,
188            passed,
189            confidence,
190            findings,
191            samples,
192        }
193    }
194}
195
196struct Dependency {
197    name: String,
198    available: bool,
199    lead_time_days: Option<u32>,
200}
201
202// ── Suggestor Implementation ──────────────────────────────────────
203
204use crate::types::{SimulationRecommendation, SimulationVerdict};
205use converge_pack::{AgentEffect, Context, ContextKey, ProposedFact, Suggestor};
206
207pub struct OperationalSimulationAgent {
208    simulator: OperationalSimulator,
209}
210
211impl OperationalSimulationAgent {
212    #[must_use]
213    pub fn new(config: OperationalSimulatorConfig) -> Self {
214        Self {
215            simulator: OperationalSimulator::new(config),
216        }
217    }
218
219    #[must_use]
220    pub fn default_config() -> Self {
221        Self {
222            simulator: OperationalSimulator::new(OperationalSimulatorConfig::default()),
223        }
224    }
225}
226
227#[async_trait::async_trait]
228#[allow(clippy::unnecessary_literal_bound)]
229impl Suggestor for OperationalSimulationAgent {
230    fn name(&self) -> &'static str {
231        "operational-simulation"
232    }
233
234    fn dependencies(&self) -> &[ContextKey] {
235        &[ContextKey::Strategies]
236    }
237
238    fn accepts(&self, ctx: &dyn Context) -> bool {
239        ctx.has(ContextKey::Strategies) && !ctx.has(ContextKey::Evaluations)
240    }
241
242    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
243        let strategies = ctx.get(ContextKey::Strategies);
244        let mut proposals = Vec::new();
245
246        for fact in strategies {
247            let plan_json: serde_json::Value = serde_json::from_str(&fact.content)
248                .unwrap_or_else(|_| serde_json::json!({"description": fact.content}));
249
250            let result = self.simulator.simulate(&plan_json);
251
252            let verdict = SimulationVerdict {
253                strategy_id: fact.id.clone(),
254                dimension: crate::SimulationDimension::Operational,
255                passed: result.passed,
256                confidence: result.confidence,
257                findings: result.findings,
258                recommendation: if result.passed {
259                    None
260                } else {
261                    Some(SimulationRecommendation::DoNotProceed)
262                },
263            };
264
265            let key = if result.passed {
266                ContextKey::Evaluations
267            } else {
268                ContextKey::Constraints
269            };
270
271            proposals.push(ProposedFact::new(
272                key,
273                verdict.fact_id(),
274                verdict.to_json(),
275                "operational-simulation",
276            ));
277        }
278
279        AgentEffect::with_proposals(proposals)
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use serde_json::json;
287
288    fn default_simulator() -> OperationalSimulator {
289        OperationalSimulator::new(OperationalSimulatorConfig::default())
290    }
291
292    #[test]
293    fn within_capacity_passes() {
294        let sim = default_simulator();
295        let plan = json!({
296            "annotation": {
297                "team_utilization": 0.7,
298                "system_load": 0.6
299            }
300        });
301        let result = sim.simulate(&plan);
302        assert_eq!(result.dimension, SimulationDimension::Operational);
303        assert!(result.passed);
304    }
305
306    #[test]
307    fn team_overload_penalized() {
308        let sim = default_simulator();
309        let plan = json!({
310            "annotation": {
311                "team_utilization": 0.95,
312                "system_load": 0.5
313            }
314        });
315        let result = sim.simulate(&plan);
316        assert!(result.findings.iter().any(|f| f.contains("overloaded")));
317        assert!(result.confidence < 1.0);
318    }
319
320    #[test]
321    fn system_overload_penalized() {
322        let sim = default_simulator();
323        let plan = json!({
324            "annotation": {
325                "team_utilization": 0.5,
326                "system_load": 0.95
327            }
328        });
329        let result = sim.simulate(&plan);
330        assert!(
331            result
332                .findings
333                .iter()
334                .any(|f| f.contains("system overloaded"))
335        );
336    }
337
338    #[test]
339    fn unavailable_dependency_penalized() {
340        let sim = default_simulator();
341        let plan = json!({
342            "annotation": {
343                "team_utilization": 0.5,
344                "system_load": 0.5,
345                "dependencies": [
346                    {"name": "payment-api", "available": false},
347                    {"name": "auth-service", "available": true}
348                ]
349            }
350        });
351        let result = sim.simulate(&plan);
352        assert!(result.findings.iter().any(|f| f.contains("unavailable")));
353    }
354
355    #[test]
356    fn timeline_vs_lead_time() {
357        let sim = default_simulator();
358        let plan = json!({
359            "annotation": {
360                "team_utilization": 0.5,
361                "system_load": 0.5,
362                "timeline_days": 14,
363                "dependencies": [
364                    {"name": "vendor-api", "available": true, "lead_time_days": 30}
365                ]
366            }
367        });
368        let result = sim.simulate(&plan);
369        assert!(result.findings.iter().any(|f| f.contains("lead time")));
370    }
371
372    #[test]
373    fn no_annotations_passes() {
374        let sim = default_simulator();
375        let plan = json!({});
376        let result = sim.simulate(&plan);
377        assert!(result.passed);
378    }
379
380    #[test]
381    fn extreme_overload_fails() {
382        let sim = default_simulator();
383        let plan = json!({
384            "annotation": {
385                "team_utilization": 1.0,
386                "system_load": 1.0,
387                "dependencies": [
388                    {"name": "a", "available": false},
389                    {"name": "b", "available": false}
390                ]
391            }
392        });
393        let result = sim.simulate(&plan);
394        assert!(!result.passed);
395    }
396}