organism_simulation/
operational.rs1use 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 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 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 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 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
202use 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}