converge_optimization/gate/
report.rs1use serde::{Deserialize, Serialize};
4
5use super::{ReplayEnvelope, Violation};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SolverReport {
10 pub solver_id: String,
12 pub feasible: bool,
14 pub objective_value: Option<f64>,
16 pub constraint_violations: Vec<Violation>,
18 pub diagnostics: Vec<Diagnostic>,
20 pub stop_reason: StopReason,
22 pub replay: ReplayEnvelope,
24}
25
26impl SolverReport {
27 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 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 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 pub fn with_diagnostic(mut self, diag: Diagnostic) -> Self {
82 self.diagnostics.push(diag);
83 self
84 }
85
86 pub fn with_diagnostics(mut self, diags: impl IntoIterator<Item = Diagnostic>) -> Self {
88 self.diagnostics.extend(diags);
89 self
90 }
91
92 pub fn with_violation(mut self, violation: Violation) -> Self {
94 self.constraint_violations.push(violation);
95 self
96 }
97
98 pub fn is_optimal(&self) -> bool {
100 self.feasible && self.stop_reason == StopReason::Optimal
101 }
102
103 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum StopReason {
118 Optimal,
120 Feasible,
122 Infeasible,
124 NoFeasible,
126 TimeBudgetExhausted,
128 IterationBudgetExhausted,
130 CandidateCapReached,
132 UserRequested,
134 SolverError,
136 DataInsufficient,
138 HumanDecisionRequired,
140}
141
142impl StopReason {
143 pub fn is_success(&self) -> bool {
145 matches!(self, Self::Optimal | Self::Feasible)
146 }
147
148 pub fn is_failure(&self) -> bool {
150 matches!(
151 self,
152 Self::Infeasible | Self::NoFeasible | Self::SolverError | Self::DataInsufficient
153 )
154 }
155
156 pub fn is_budget_exhausted(&self) -> bool {
158 matches!(
159 self,
160 Self::TimeBudgetExhausted | Self::IterationBudgetExhausted | Self::CandidateCapReached
161 )
162 }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct Diagnostic {
168 pub kind: DiagnosticKind,
170 pub message: String,
172 pub data: serde_json::Value,
174}
175
176impl Diagnostic {
177 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264pub enum DiagnosticKind {
265 ScoringBreakdown,
267 TieBreakRationale,
269 Pruning,
271 Performance,
273 ConstraintAnalysis,
275 CandidateRejection,
277 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}