Skip to main content

converge_optimization/gate/
report.rs

1//! Solver execution report
2
3use serde::{Deserialize, Serialize};
4
5use super::{ReplayEnvelope, Violation};
6
7/// Detailed solver execution report
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SolverReport {
10    /// Solver identifier
11    pub solver_id: String,
12    /// Whether a feasible solution was found
13    pub feasible: bool,
14    /// Objective value (if solution found)
15    pub objective_value: Option<f64>,
16    /// Constraint violations
17    pub constraint_violations: Vec<Violation>,
18    /// Diagnostic information
19    pub diagnostics: Vec<Diagnostic>,
20    /// Why the solver stopped
21    pub stop_reason: StopReason,
22    /// Replay information
23    pub replay: ReplayEnvelope,
24}
25
26impl SolverReport {
27    /// Create a feasible report with optimal solution
28    pub fn optimal(
29        solver_id: impl Into<String>,
30        objective_value: f64,
31        replay: ReplayEnvelope,
32    ) -> Self {
33        Self {
34            solver_id: solver_id.into(),
35            feasible: true,
36            objective_value: Some(objective_value),
37            constraint_violations: Vec::new(),
38            diagnostics: Vec::new(),
39            stop_reason: StopReason::Optimal,
40            replay,
41        }
42    }
43
44    /// Create a feasible report (may not be optimal)
45    pub fn feasible(
46        solver_id: impl Into<String>,
47        objective_value: f64,
48        stop_reason: StopReason,
49        replay: ReplayEnvelope,
50    ) -> Self {
51        Self {
52            solver_id: solver_id.into(),
53            feasible: true,
54            objective_value: Some(objective_value),
55            constraint_violations: Vec::new(),
56            diagnostics: Vec::new(),
57            stop_reason,
58            replay,
59        }
60    }
61
62    /// Create an infeasible report
63    pub fn infeasible(
64        solver_id: impl Into<String>,
65        violations: Vec<Violation>,
66        stop_reason: StopReason,
67        replay: ReplayEnvelope,
68    ) -> Self {
69        Self {
70            solver_id: solver_id.into(),
71            feasible: false,
72            objective_value: None,
73            constraint_violations: violations,
74            diagnostics: Vec::new(),
75            stop_reason,
76            replay,
77        }
78    }
79
80    /// Add a diagnostic
81    pub fn with_diagnostic(mut self, diag: Diagnostic) -> Self {
82        self.diagnostics.push(diag);
83        self
84    }
85
86    /// Add multiple diagnostics
87    pub fn with_diagnostics(mut self, diags: impl IntoIterator<Item = Diagnostic>) -> Self {
88        self.diagnostics.extend(diags);
89        self
90    }
91
92    /// Add a constraint violation
93    pub fn with_violation(mut self, violation: Violation) -> Self {
94        self.constraint_violations.push(violation);
95        self
96    }
97
98    /// Check if solution is proven optimal
99    pub fn is_optimal(&self) -> bool {
100        self.feasible && self.stop_reason == StopReason::Optimal
101    }
102
103    /// Check if solver hit a budget limit
104    pub fn hit_budget(&self) -> bool {
105        matches!(
106            self.stop_reason,
107            StopReason::TimeBudgetExhausted
108                | StopReason::IterationBudgetExhausted
109                | StopReason::CandidateCapReached
110        )
111    }
112}
113
114/// Why the solver stopped
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum StopReason {
118    /// Proven optimal solution found
119    Optimal,
120    /// Feasible solution found (may not be optimal)
121    Feasible,
122    /// Problem proven infeasible
123    Infeasible,
124    /// No feasible solution found (but not proven infeasible)
125    NoFeasible,
126    /// Time budget exhausted
127    TimeBudgetExhausted,
128    /// Iteration budget exhausted
129    IterationBudgetExhausted,
130    /// Candidate cap reached
131    CandidateCapReached,
132    /// User requested stop
133    UserRequested,
134    /// Solver encountered error
135    SolverError,
136    /// Insufficient data to solve
137    DataInsufficient,
138    /// Human decision required (multiple close options)
139    HumanDecisionRequired,
140}
141
142impl StopReason {
143    /// Check if this represents a successful solve
144    pub fn is_success(&self) -> bool {
145        matches!(self, Self::Optimal | Self::Feasible)
146    }
147
148    /// Check if this represents a failure to find a solution
149    pub fn is_failure(&self) -> bool {
150        matches!(
151            self,
152            Self::Infeasible | Self::NoFeasible | Self::SolverError | Self::DataInsufficient
153        )
154    }
155
156    /// Check if this represents a budget exhaustion
157    pub fn is_budget_exhausted(&self) -> bool {
158        matches!(
159            self,
160            Self::TimeBudgetExhausted | Self::IterationBudgetExhausted | Self::CandidateCapReached
161        )
162    }
163}
164
165/// Diagnostic information from solver
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct Diagnostic {
168    /// Diagnostic type
169    pub kind: DiagnosticKind,
170    /// Human-readable message
171    pub message: String,
172    /// Additional data
173    pub data: serde_json::Value,
174}
175
176impl Diagnostic {
177    /// Create a generic diagnostic
178    pub fn new(kind: DiagnosticKind, message: impl Into<String>) -> Self {
179        Self {
180            kind,
181            message: message.into(),
182            data: serde_json::Value::Null,
183        }
184    }
185
186    /// Create a generic diagnostic with data
187    pub fn with_data(
188        kind: DiagnosticKind,
189        message: impl Into<String>,
190        data: serde_json::Value,
191    ) -> Self {
192        Self {
193            kind,
194            message: message.into(),
195            data,
196        }
197    }
198
199    /// Create a scoring breakdown diagnostic
200    pub fn scoring_breakdown(breakdown: serde_json::Value) -> Self {
201        Self {
202            kind: DiagnosticKind::ScoringBreakdown,
203            message: "Objective scoring breakdown".to_string(),
204            data: breakdown,
205        }
206    }
207
208    /// Create a tie-break rationale diagnostic
209    pub fn tie_break_rationale(message: impl Into<String>, candidates: Vec<String>) -> Self {
210        Self {
211            kind: DiagnosticKind::TieBreakRationale,
212            message: message.into(),
213            data: serde_json::json!({ "candidates": candidates }),
214        }
215    }
216
217    /// Create a pruning diagnostic
218    pub fn pruning(message: impl Into<String>, pruned_count: usize) -> Self {
219        Self {
220            kind: DiagnosticKind::Pruning,
221            message: message.into(),
222            data: serde_json::json!({ "pruned_count": pruned_count }),
223        }
224    }
225
226    /// Create a performance diagnostic
227    pub fn performance(phase: impl Into<String>, elapsed_ms: f64, iterations: usize) -> Self {
228        Self {
229            kind: DiagnosticKind::Performance,
230            message: format!(
231                "{}: {:.2}ms, {} iterations",
232                phase.into(),
233                elapsed_ms,
234                iterations
235            ),
236            data: serde_json::json!({
237                "elapsed_ms": elapsed_ms,
238                "iterations": iterations
239            }),
240        }
241    }
242
243    /// Create a constraint analysis diagnostic
244    pub fn constraint_analysis(constraint: impl Into<String>, slack: f64, binding: bool) -> Self {
245        Self {
246            kind: DiagnosticKind::ConstraintAnalysis,
247            message: format!(
248                "Constraint '{}': slack={:.2}, {}",
249                constraint.into(),
250                slack,
251                if binding { "binding" } else { "non-binding" }
252            ),
253            data: serde_json::json!({
254                "slack": slack,
255                "binding": binding
256            }),
257        }
258    }
259}
260
261/// Types of diagnostics
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264pub enum DiagnosticKind {
265    /// Breakdown of objective scoring
266    ScoringBreakdown,
267    /// Why a particular tie-break decision was made
268    TieBreakRationale,
269    /// Information about pruned branches/candidates
270    Pruning,
271    /// Performance metrics
272    Performance,
273    /// Constraint analysis
274    ConstraintAnalysis,
275    /// Why a candidate was rejected
276    CandidateRejection,
277    /// Custom diagnostic
278    Custom,
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_optimal_report() {
287        let replay = ReplayEnvelope::minimal(42);
288        let report = SolverReport::optimal("hungarian-v1", 100.0, replay);
289
290        assert!(report.feasible);
291        assert!(report.is_optimal());
292        assert!(!report.hit_budget());
293        assert_eq!(report.objective_value, Some(100.0));
294    }
295
296    #[test]
297    fn test_infeasible_report() {
298        let replay = ReplayEnvelope::minimal(42);
299        let violation = Violation::new("capacity", 1.0, "exceeded limit");
300        let report =
301            SolverReport::infeasible("solver-v1", vec![violation], StopReason::Infeasible, replay);
302
303        assert!(!report.feasible);
304        assert!(!report.is_optimal());
305        assert_eq!(report.constraint_violations.len(), 1);
306    }
307
308    #[test]
309    fn test_budget_exhausted() {
310        let replay = ReplayEnvelope::minimal(42);
311        let report =
312            SolverReport::feasible("solver-v1", 150.0, StopReason::TimeBudgetExhausted, replay);
313
314        assert!(report.feasible);
315        assert!(!report.is_optimal());
316        assert!(report.hit_budget());
317    }
318
319    #[test]
320    fn test_stop_reason_classification() {
321        assert!(StopReason::Optimal.is_success());
322        assert!(StopReason::Feasible.is_success());
323        assert!(!StopReason::Infeasible.is_success());
324
325        assert!(StopReason::Infeasible.is_failure());
326        assert!(StopReason::NoFeasible.is_failure());
327        assert!(!StopReason::Optimal.is_failure());
328
329        assert!(StopReason::TimeBudgetExhausted.is_budget_exhausted());
330        assert!(!StopReason::Optimal.is_budget_exhausted());
331    }
332
333    #[test]
334    fn test_diagnostics() {
335        let diag = Diagnostic::scoring_breakdown(serde_json::json!({"cost": 10, "penalty": 5}));
336        assert_eq!(diag.kind, DiagnosticKind::ScoringBreakdown);
337
338        let diag2 =
339            Diagnostic::tie_break_rationale("chose by id", vec!["a".to_string(), "b".to_string()]);
340        assert_eq!(diag2.kind, DiagnosticKind::TieBreakRationale);
341    }
342
343    #[test]
344    fn test_report_with_diagnostics() {
345        let replay = ReplayEnvelope::minimal(42);
346        let report = SolverReport::optimal("solver", 50.0, replay)
347            .with_diagnostic(Diagnostic::performance("phase1", 10.5, 100))
348            .with_diagnostic(Diagnostic::pruning("removed infeasible", 25));
349
350        assert_eq!(report.diagnostics.len(), 2);
351    }
352}