Skip to main content

organism_simulation/
cost.rs

1//! Cost simulator.
2//!
3//! Evaluates candidate plans by analyzing cost annotations (compute, labor,
4//! infrastructure, licensing) and producing resource envelope estimates via
5//! Monte Carlo sampling.
6
7use crate::{DimensionResult, Sample, SimulationDimension};
8
9#[derive(Debug, Clone)]
10pub struct CostSimulatorConfig {
11    pub samples: u32,
12    pub budget_ceiling: f64,
13    pub overrun_tolerance: f64,
14}
15
16impl Default for CostSimulatorConfig {
17    fn default() -> Self {
18        Self {
19            samples: 1000,
20            budget_ceiling: 100_000.0,
21            overrun_tolerance: 0.15,
22        }
23    }
24}
25
26pub struct CostSimulator {
27    config: CostSimulatorConfig,
28}
29
30impl CostSimulator {
31    #[must_use]
32    pub fn new(config: CostSimulatorConfig) -> Self {
33        Self { config }
34    }
35
36    fn extract_costs(plan: &serde_json::Value) -> Vec<f64> {
37        plan.get("annotation")
38            .and_then(|a| a.get("costs"))
39            .and_then(|c| c.as_array())
40            .map(|arr| {
41                arr.iter()
42                    .filter_map(|v| v.get("estimate").and_then(serde_json::Value::as_f64))
43                    .collect()
44            })
45            .unwrap_or_default()
46    }
47
48    fn extract_cost_uncertainties(plan: &serde_json::Value) -> Vec<f64> {
49        plan.get("annotation")
50            .and_then(|a| a.get("costs"))
51            .and_then(|c| c.as_array())
52            .map(|arr| {
53                arr.iter()
54                    .filter_map(|v| v.get("uncertainty").and_then(serde_json::Value::as_f64))
55                    .collect()
56            })
57            .unwrap_or_default()
58    }
59
60    fn sample(&self, total_cost: f64, avg_uncertainty: f64) -> Vec<Sample> {
61        let buckets = 5;
62        let mut samples = Vec::with_capacity(buckets);
63        let ratio = total_cost / self.config.budget_ceiling;
64
65        for i in 0..buckets {
66            let bucket_center = (f64::from(u32::try_from(i).unwrap_or(0)) + 0.5)
67                / f64::from(u32::try_from(buckets).unwrap_or(5));
68            // Distribution centered on cost ratio, spread by uncertainty
69            let spread = (1.0 + avg_uncertainty * 3.0).max(1.0);
70            let distance = (bucket_center - ratio.clamp(0.0, 1.0)).abs();
71            let weight = (-distance * spread).exp();
72            samples.push(Sample {
73                value: bucket_center * self.config.budget_ceiling,
74                probability: weight,
75            });
76        }
77
78        let total: f64 = samples.iter().map(|s| s.probability).sum();
79        if total > 0.0 {
80            for s in &mut samples {
81                s.probability /= total;
82            }
83        }
84        for s in &mut samples {
85            s.probability = (s.probability * f64::from(self.config.samples)).round()
86                / f64::from(self.config.samples);
87        }
88
89        samples
90    }
91
92    pub fn simulate(&self, plan: &serde_json::Value) -> DimensionResult {
93        let costs = Self::extract_costs(plan);
94        let uncertainties = Self::extract_cost_uncertainties(plan);
95
96        let total_cost: f64 = costs.iter().sum();
97        let avg_uncertainty = if uncertainties.is_empty() {
98            0.3 // default uncertainty when not specified
99        } else {
100            uncertainties.iter().sum::<f64>()
101                / f64::from(u32::try_from(uncertainties.len()).unwrap_or(1))
102        };
103
104        let max_allowed = self.config.budget_ceiling * (1.0 + self.config.overrun_tolerance);
105        let passed = total_cost <= max_allowed;
106        let confidence = if total_cost <= 0.0 {
107            0.5
108        } else {
109            (1.0 - total_cost / max_allowed).clamp(0.0, 1.0)
110        };
111
112        let samples = self.sample(total_cost, avg_uncertainty);
113
114        let mut findings = Vec::new();
115        if costs.is_empty() {
116            findings.push("no cost annotations — cannot assess budget fit".into());
117        } else {
118            findings.push(format!(
119                "{} cost items, total {:.0} against ceiling {:.0}",
120                costs.len(),
121                total_cost,
122                self.config.budget_ceiling,
123            ));
124        }
125        if total_cost > self.config.budget_ceiling {
126            findings.push(format!(
127                "overrun: {:.0} exceeds ceiling by {:.1}%",
128                total_cost,
129                ((total_cost / self.config.budget_ceiling) - 1.0) * 100.0,
130            ));
131        }
132        if avg_uncertainty > 0.5 {
133            findings.push(format!("high cost uncertainty: avg {avg_uncertainty:.2}"));
134        }
135
136        DimensionResult {
137            dimension: SimulationDimension::Cost,
138            passed,
139            confidence,
140            findings,
141            samples,
142        }
143    }
144}
145
146// ── Suggestor Implementation ──────────────────────────────────────
147
148use crate::types::{SimulationRecommendation, SimulationVerdict};
149use converge_pack::{AgentEffect, Context, ContextKey, ProposedFact, Suggestor};
150
151pub struct CostSimulationAgent {
152    simulator: CostSimulator,
153}
154
155impl CostSimulationAgent {
156    #[must_use]
157    pub fn new(config: CostSimulatorConfig) -> Self {
158        Self {
159            simulator: CostSimulator::new(config),
160        }
161    }
162
163    #[must_use]
164    pub fn default_config() -> Self {
165        Self {
166            simulator: CostSimulator::new(CostSimulatorConfig::default()),
167        }
168    }
169}
170
171#[async_trait::async_trait]
172#[allow(clippy::unnecessary_literal_bound)]
173impl Suggestor for CostSimulationAgent {
174    fn name(&self) -> &'static str {
175        "cost-simulation"
176    }
177
178    fn dependencies(&self) -> &[ContextKey] {
179        &[ContextKey::Strategies]
180    }
181
182    fn accepts(&self, ctx: &dyn Context) -> bool {
183        ctx.has(ContextKey::Strategies) && !ctx.has(ContextKey::Evaluations)
184    }
185
186    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
187        let strategies = ctx.get(ContextKey::Strategies);
188        let mut proposals = Vec::new();
189
190        for fact in strategies {
191            let plan_json: serde_json::Value = serde_json::from_str(&fact.content)
192                .unwrap_or_else(|_| serde_json::json!({"description": fact.content}));
193
194            let result = self.simulator.simulate(&plan_json);
195
196            let verdict = SimulationVerdict {
197                strategy_id: fact.id.clone(),
198                dimension: crate::SimulationDimension::Cost,
199                passed: result.passed,
200                confidence: result.confidence,
201                findings: result.findings,
202                recommendation: if result.passed {
203                    None
204                } else {
205                    Some(SimulationRecommendation::DoNotProceed)
206                },
207            };
208
209            let key = if result.passed {
210                ContextKey::Evaluations
211            } else {
212                ContextKey::Constraints
213            };
214
215            proposals.push(ProposedFact::new(
216                key,
217                verdict.fact_id(),
218                verdict.to_json(),
219                "cost-simulation",
220            ));
221        }
222
223        AgentEffect::with_proposals(proposals)
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use serde_json::json;
231
232    fn default_simulator() -> CostSimulator {
233        CostSimulator::new(CostSimulatorConfig::default())
234    }
235
236    #[test]
237    fn within_budget_passes() {
238        let sim = default_simulator();
239        let plan = json!({
240            "annotation": {
241                "costs": [
242                    {"category": "compute", "estimate": 30_000.0, "uncertainty": 0.1},
243                    {"category": "labor", "estimate": 40_000.0, "uncertainty": 0.2}
244                ]
245            }
246        });
247        let result = sim.simulate(&plan);
248        assert_eq!(result.dimension, SimulationDimension::Cost);
249        assert!(result.passed);
250    }
251
252    #[test]
253    fn over_budget_fails() {
254        let sim = default_simulator();
255        let plan = json!({
256            "annotation": {
257                "costs": [
258                    {"category": "compute", "estimate": 80_000.0},
259                    {"category": "labor", "estimate": 50_000.0}
260                ]
261            }
262        });
263        let result = sim.simulate(&plan);
264        assert!(!result.passed);
265        assert!(result.findings.iter().any(|f| f.contains("overrun")));
266    }
267
268    #[test]
269    fn no_costs_uses_neutral() {
270        let sim = default_simulator();
271        let plan = json!({});
272        let result = sim.simulate(&plan);
273        assert!(result.passed); // 0 cost is within budget
274        assert!(result.findings[0].contains("no cost annotations"));
275    }
276
277    #[test]
278    fn high_uncertainty_flagged() {
279        let sim = default_simulator();
280        let plan = json!({
281            "annotation": {
282                "costs": [
283                    {"category": "compute", "estimate": 50_000.0, "uncertainty": 0.8}
284                ]
285            }
286        });
287        let result = sim.simulate(&plan);
288        assert!(result.findings.iter().any(|f| f.contains("uncertainty")));
289    }
290
291    #[test]
292    fn within_overrun_tolerance_passes() {
293        let sim = default_simulator(); // ceiling 100k, tolerance 15%
294        let plan = json!({
295            "annotation": {
296                "costs": [{"category": "total", "estimate": 110_000.0}]
297            }
298        });
299        let result = sim.simulate(&plan);
300        assert!(result.passed); // 110k < 115k (100k * 1.15)
301    }
302
303    #[test]
304    fn samples_are_normalized() {
305        let sim = default_simulator();
306        let plan = json!({
307            "annotation": {
308                "costs": [{"category": "compute", "estimate": 50_000.0}]
309            }
310        });
311        let result = sim.simulate(&plan);
312        assert!(!result.samples.is_empty());
313        let total: f64 = result.samples.iter().map(|s| s.probability).sum();
314        assert!((total - 1.0).abs() < 0.01);
315    }
316}