converge_optimization/packs/
testing.rs1use super::Pack;
4use crate::Result;
5use crate::gate::{GateDecision, ProblemSpec};
6
7#[derive(Debug, Clone)]
9pub struct TestScenario {
10 pub name: String,
12 pub description: String,
14 pub spec: ProblemSpec,
16 pub expected: ExpectedOutcome,
18}
19
20impl TestScenario {
21 pub fn new(
23 name: impl Into<String>,
24 description: impl Into<String>,
25 spec: ProblemSpec,
26 expected: ExpectedOutcome,
27 ) -> Self {
28 Self {
29 name: name.into(),
30 description: description.into(),
31 spec,
32 expected,
33 }
34 }
35
36 pub fn feasible(
38 name: impl Into<String>,
39 description: impl Into<String>,
40 spec: ProblemSpec,
41 min_confidence: f64,
42 ) -> Self {
43 Self::new(
44 name,
45 description,
46 spec,
47 ExpectedOutcome::Feasible {
48 min_confidence,
49 required_invariants: Vec::new(),
50 },
51 )
52 }
53
54 pub fn infeasible(
56 name: impl Into<String>,
57 description: impl Into<String>,
58 spec: ProblemSpec,
59 expected_violations: Vec<String>,
60 ) -> Self {
61 Self::new(
62 name,
63 description,
64 spec,
65 ExpectedOutcome::Infeasible {
66 expected_violations,
67 },
68 )
69 }
70}
71
72#[derive(Debug, Clone)]
74pub enum ExpectedOutcome {
75 Feasible {
77 min_confidence: f64,
79 required_invariants: Vec<String>,
81 },
82 Infeasible {
84 expected_violations: Vec<String>,
86 },
87 GateDecision {
89 decision: GateDecision,
91 },
92 Deterministic {
94 expected_output: serde_json::Value,
96 },
97}
98
99#[derive(Debug)]
101pub struct ScenarioResult {
102 pub name: String,
104 pub passed: bool,
106 pub error: Option<String>,
108 pub actual_confidence: Option<f64>,
110 pub actual_decision: Option<GateDecision>,
112 pub duration_ms: f64,
114}
115
116impl ScenarioResult {
117 pub fn pass(name: impl Into<String>, duration_ms: f64) -> Self {
119 Self {
120 name: name.into(),
121 passed: true,
122 error: None,
123 actual_confidence: None,
124 actual_decision: None,
125 duration_ms,
126 }
127 }
128
129 pub fn fail(name: impl Into<String>, error: impl Into<String>, duration_ms: f64) -> Self {
131 Self {
132 name: name.into(),
133 passed: false,
134 error: Some(error.into()),
135 actual_confidence: None,
136 actual_decision: None,
137 duration_ms,
138 }
139 }
140
141 pub fn with_confidence(mut self, confidence: f64) -> Self {
143 self.actual_confidence = Some(confidence);
144 self
145 }
146
147 pub fn with_decision(mut self, decision: GateDecision) -> Self {
149 self.actual_decision = Some(decision);
150 self
151 }
152}
153
154pub fn run_scenario(pack: &dyn Pack, scenario: &TestScenario) -> ScenarioResult {
156 let start = std::time::Instant::now();
157
158 let solve_result = match pack.solve(&scenario.spec) {
160 Ok(result) => result,
161 Err(e) => {
162 if let ExpectedOutcome::Infeasible { .. } = &scenario.expected {
164 return ScenarioResult::pass(
165 &scenario.name,
166 start.elapsed().as_secs_f64() * 1000.0,
167 );
168 }
169 return ScenarioResult::fail(
170 &scenario.name,
171 format!("Solve failed: {}", e),
172 start.elapsed().as_secs_f64() * 1000.0,
173 );
174 }
175 };
176
177 let invariant_results = match pack.check_invariants(&solve_result.plan) {
179 Ok(results) => results,
180 Err(e) => {
181 return ScenarioResult::fail(
182 &scenario.name,
183 format!("Invariant check failed: {}", e),
184 start.elapsed().as_secs_f64() * 1000.0,
185 );
186 }
187 };
188
189 let gate = pack.evaluate_gate(&solve_result.plan, &invariant_results);
191 let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
192
193 match &scenario.expected {
195 ExpectedOutcome::Feasible {
196 min_confidence,
197 required_invariants,
198 } => {
199 if solve_result.plan.confidence < *min_confidence {
200 return ScenarioResult::fail(
201 &scenario.name,
202 format!(
203 "Confidence too low: {} < {}",
204 solve_result.plan.confidence, min_confidence
205 ),
206 duration_ms,
207 )
208 .with_confidence(solve_result.plan.confidence);
209 }
210
211 for required in required_invariants {
213 let result = invariant_results.iter().find(|r| &r.invariant == required);
214 match result {
215 Some(r) if !r.passed => {
216 return ScenarioResult::fail(
217 &scenario.name,
218 format!("Required invariant '{}' failed", required),
219 duration_ms,
220 );
221 }
222 None => {
223 return ScenarioResult::fail(
224 &scenario.name,
225 format!("Required invariant '{}' not found", required),
226 duration_ms,
227 );
228 }
229 _ => {}
230 }
231 }
232
233 ScenarioResult::pass(&scenario.name, duration_ms)
234 .with_confidence(solve_result.plan.confidence)
235 .with_decision(gate.decision)
236 }
237
238 ExpectedOutcome::Infeasible {
239 expected_violations,
240 } => {
241 if solve_result.is_feasible() && gate.is_promoted() {
243 return ScenarioResult::fail(
244 &scenario.name,
245 "Expected infeasible but found solution",
246 duration_ms,
247 );
248 }
249
250 for expected in expected_violations {
252 let has_violation = invariant_results
253 .iter()
254 .any(|r| !r.passed && r.invariant == *expected);
255 if !has_violation {
256 return ScenarioResult::fail(
257 &scenario.name,
258 format!("Expected violation '{}' not found", expected),
259 duration_ms,
260 );
261 }
262 }
263
264 ScenarioResult::pass(&scenario.name, duration_ms).with_decision(gate.decision)
265 }
266
267 ExpectedOutcome::GateDecision { decision } => {
268 if gate.decision != *decision {
269 return ScenarioResult::fail(
270 &scenario.name,
271 format!("Expected {:?} but got {:?}", decision, gate.decision),
272 duration_ms,
273 )
274 .with_decision(gate.decision);
275 }
276 ScenarioResult::pass(&scenario.name, duration_ms).with_decision(gate.decision)
277 }
278
279 ExpectedOutcome::Deterministic { expected_output } => {
280 if solve_result.plan.plan != *expected_output {
281 return ScenarioResult::fail(
282 &scenario.name,
283 format!(
284 "Output mismatch: expected {:?}, got {:?}",
285 expected_output, solve_result.plan.plan
286 ),
287 duration_ms,
288 );
289 }
290 ScenarioResult::pass(&scenario.name, duration_ms)
291 }
292 }
293}
294
295pub fn run_all_scenarios(pack: &dyn Pack, scenarios: &[TestScenario]) -> Vec<ScenarioResult> {
297 scenarios.iter().map(|s| run_scenario(pack, s)).collect()
298}
299
300#[derive(Debug)]
302pub struct ScenarioSummary {
303 pub total: usize,
305 pub passed: usize,
307 pub failed: usize,
309 pub total_duration_ms: f64,
311}
312
313impl ScenarioSummary {
314 pub fn from_results(results: &[ScenarioResult]) -> Self {
316 let passed = results.iter().filter(|r| r.passed).count();
317 let total_duration_ms: f64 = results.iter().map(|r| r.duration_ms).sum();
318
319 Self {
320 total: results.len(),
321 passed,
322 failed: results.len() - passed,
323 total_duration_ms,
324 }
325 }
326
327 pub fn all_passed(&self) -> bool {
329 self.failed == 0
330 }
331}
332
333pub fn test_problem_spec(_pack_name: &str, inputs: serde_json::Value) -> Result<ProblemSpec> {
335 use crate::gate::ObjectiveSpec;
336
337 ProblemSpec::builder(format!("test-{}", uuid_v4()), "test-tenant")
338 .objective(ObjectiveSpec::maximize("score"))
339 .inputs_raw(inputs)
340 .seed(42) .build()
342}
343
344fn uuid_v4() -> String {
346 use std::time::{SystemTime, UNIX_EPOCH};
347 let now = SystemTime::now()
348 .duration_since(UNIX_EPOCH)
349 .unwrap()
350 .as_nanos();
351 format!("{:032x}", now)
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn test_scenario_result() {
360 let pass = ScenarioResult::pass("test", 10.0);
361 assert!(pass.passed);
362
363 let fail = ScenarioResult::fail("test", "error", 10.0);
364 assert!(!fail.passed);
365 assert_eq!(fail.error, Some("error".to_string()));
366 }
367
368 #[test]
369 fn test_scenario_summary() {
370 let results = vec![
371 ScenarioResult::pass("test1", 10.0),
372 ScenarioResult::pass("test2", 20.0),
373 ScenarioResult::fail("test3", "error", 5.0),
374 ];
375
376 let summary = ScenarioSummary::from_results(&results);
377 assert_eq!(summary.total, 3);
378 assert_eq!(summary.passed, 2);
379 assert_eq!(summary.failed, 1);
380 assert!(!summary.all_passed());
381 }
382
383 #[test]
384 fn test_test_problem_spec() {
385 let spec = test_problem_spec("test-pack", serde_json::json!({"key": "value"})).unwrap();
386
387 assert!(spec.problem_id.starts_with("test-"));
388 assert_eq!(spec.tenant_scope, "test-tenant");
389 assert_eq!(spec.seed(), 42);
390 }
391}