organism_simulation/
cost.rs1use 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 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 } 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
146use 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); 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(); 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); }
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}