Skip to main content

converge_optimization/packs/
testing.rs

1//! Test harness for pack scenarios
2
3use super::Pack;
4use crate::Result;
5use crate::gate::{GateDecision, ProblemSpec};
6
7/// Test scenario for a pack
8#[derive(Debug, Clone)]
9pub struct TestScenario {
10    /// Scenario name
11    pub name: String,
12    /// Description of what's being tested
13    pub description: String,
14    /// Problem specification for this scenario
15    pub spec: ProblemSpec,
16    /// Expected outcome
17    pub expected: ExpectedOutcome,
18}
19
20impl TestScenario {
21    /// Create a new test scenario
22    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    /// Create a feasibility test (expects solution)
37    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    /// Create an infeasibility test (expects no solution)
55    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/// Expected outcome of a test scenario
73#[derive(Debug, Clone)]
74pub enum ExpectedOutcome {
75    /// Should find a feasible solution
76    Feasible {
77        /// Minimum confidence score expected
78        min_confidence: f64,
79        /// Invariants that must pass
80        required_invariants: Vec<String>,
81    },
82    /// Should report infeasible
83    Infeasible {
84        /// Expected constraint violations
85        expected_violations: Vec<String>,
86    },
87    /// Should match specific gate decision
88    GateDecision {
89        /// Expected decision
90        decision: GateDecision,
91    },
92    /// Should produce deterministic output
93    Deterministic {
94        /// Expected output hash or value
95        expected_output: serde_json::Value,
96    },
97}
98
99/// Result of running a test scenario
100#[derive(Debug)]
101pub struct ScenarioResult {
102    /// Scenario name
103    pub name: String,
104    /// Whether the test passed
105    pub passed: bool,
106    /// Error message if failed
107    pub error: Option<String>,
108    /// Actual confidence (if applicable)
109    pub actual_confidence: Option<f64>,
110    /// Actual gate decision (if applicable)
111    pub actual_decision: Option<GateDecision>,
112    /// Duration in milliseconds
113    pub duration_ms: f64,
114}
115
116impl ScenarioResult {
117    /// Create a passing result
118    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    /// Create a failing result
130    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    /// Add actual confidence to result
142    pub fn with_confidence(mut self, confidence: f64) -> Self {
143        self.actual_confidence = Some(confidence);
144        self
145    }
146
147    /// Add actual decision to result
148    pub fn with_decision(mut self, decision: GateDecision) -> Self {
149        self.actual_decision = Some(decision);
150        self
151    }
152}
153
154/// Run a single test scenario against a pack
155pub fn run_scenario(pack: &dyn Pack, scenario: &TestScenario) -> ScenarioResult {
156    let start = std::time::Instant::now();
157
158    // Solve the problem
159    let solve_result = match pack.solve(&scenario.spec) {
160        Ok(result) => result,
161        Err(e) => {
162            // Check if we expected infeasibility
163            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    // Check invariants
178    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    // Evaluate gate
190    let gate = pack.evaluate_gate(&solve_result.plan, &invariant_results);
191    let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
192
193    // Check against expected outcome
194    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            // Check required invariants passed
212            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            // Should not have found a feasible solution
242            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            // Check for expected violations
251            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
295/// Run all scenarios for a pack
296pub fn run_all_scenarios(pack: &dyn Pack, scenarios: &[TestScenario]) -> Vec<ScenarioResult> {
297    scenarios.iter().map(|s| run_scenario(pack, s)).collect()
298}
299
300/// Summary of scenario results
301#[derive(Debug)]
302pub struct ScenarioSummary {
303    /// Total scenarios run
304    pub total: usize,
305    /// Scenarios passed
306    pub passed: usize,
307    /// Scenarios failed
308    pub failed: usize,
309    /// Total duration in milliseconds
310    pub total_duration_ms: f64,
311}
312
313impl ScenarioSummary {
314    /// Create from results
315    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    /// Check if all scenarios passed
328    pub fn all_passed(&self) -> bool {
329        self.failed == 0
330    }
331}
332
333/// Helper to create a minimal problem spec for testing
334pub 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) // Fixed seed for determinism
341        .build()
342}
343
344/// Generate a simple UUID v4 for testing
345fn 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}