use serde::{Deserialize, Serialize};
use super::{ReplayEnvelope, Violation};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolverReport {
pub solver_id: String,
pub feasible: bool,
pub objective_value: Option<f64>,
pub constraint_violations: Vec<Violation>,
pub diagnostics: Vec<Diagnostic>,
pub stop_reason: StopReason,
pub replay: ReplayEnvelope,
}
impl SolverReport {
pub fn optimal(
solver_id: impl Into<String>,
objective_value: f64,
replay: ReplayEnvelope,
) -> Self {
Self {
solver_id: solver_id.into(),
feasible: true,
objective_value: Some(objective_value),
constraint_violations: Vec::new(),
diagnostics: Vec::new(),
stop_reason: StopReason::Optimal,
replay,
}
}
pub fn feasible(
solver_id: impl Into<String>,
objective_value: f64,
stop_reason: StopReason,
replay: ReplayEnvelope,
) -> Self {
Self {
solver_id: solver_id.into(),
feasible: true,
objective_value: Some(objective_value),
constraint_violations: Vec::new(),
diagnostics: Vec::new(),
stop_reason,
replay,
}
}
pub fn infeasible(
solver_id: impl Into<String>,
violations: Vec<Violation>,
stop_reason: StopReason,
replay: ReplayEnvelope,
) -> Self {
Self {
solver_id: solver_id.into(),
feasible: false,
objective_value: None,
constraint_violations: violations,
diagnostics: Vec::new(),
stop_reason,
replay,
}
}
pub fn with_diagnostic(mut self, diag: Diagnostic) -> Self {
self.diagnostics.push(diag);
self
}
pub fn with_diagnostics(mut self, diags: impl IntoIterator<Item = Diagnostic>) -> Self {
self.diagnostics.extend(diags);
self
}
pub fn with_violation(mut self, violation: Violation) -> Self {
self.constraint_violations.push(violation);
self
}
pub fn is_optimal(&self) -> bool {
self.feasible && self.stop_reason == StopReason::Optimal
}
pub fn hit_budget(&self) -> bool {
matches!(
self.stop_reason,
StopReason::TimeBudgetExhausted
| StopReason::IterationBudgetExhausted
| StopReason::CandidateCapReached
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
Optimal,
Feasible,
Infeasible,
NoFeasible,
TimeBudgetExhausted,
IterationBudgetExhausted,
CandidateCapReached,
UserRequested,
SolverError,
DataInsufficient,
HumanDecisionRequired,
}
impl StopReason {
pub fn is_success(&self) -> bool {
matches!(self, Self::Optimal | Self::Feasible)
}
pub fn is_failure(&self) -> bool {
matches!(
self,
Self::Infeasible | Self::NoFeasible | Self::SolverError | Self::DataInsufficient
)
}
pub fn is_budget_exhausted(&self) -> bool {
matches!(
self,
Self::TimeBudgetExhausted | Self::IterationBudgetExhausted | Self::CandidateCapReached
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Diagnostic {
pub kind: DiagnosticKind,
pub message: String,
pub data: serde_json::Value,
}
impl Diagnostic {
pub fn new(kind: DiagnosticKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
data: serde_json::Value::Null,
}
}
pub fn with_data(kind: DiagnosticKind, message: impl Into<String>, data: serde_json::Value) -> Self {
Self {
kind,
message: message.into(),
data,
}
}
pub fn scoring_breakdown(breakdown: serde_json::Value) -> Self {
Self {
kind: DiagnosticKind::ScoringBreakdown,
message: "Objective scoring breakdown".to_string(),
data: breakdown,
}
}
pub fn tie_break_rationale(message: impl Into<String>, candidates: Vec<String>) -> Self {
Self {
kind: DiagnosticKind::TieBreakRationale,
message: message.into(),
data: serde_json::json!({ "candidates": candidates }),
}
}
pub fn pruning(message: impl Into<String>, pruned_count: usize) -> Self {
Self {
kind: DiagnosticKind::Pruning,
message: message.into(),
data: serde_json::json!({ "pruned_count": pruned_count }),
}
}
pub fn performance(
phase: impl Into<String>,
elapsed_ms: f64,
iterations: usize,
) -> Self {
Self {
kind: DiagnosticKind::Performance,
message: format!("{}: {:.2}ms, {} iterations", phase.into(), elapsed_ms, iterations),
data: serde_json::json!({
"elapsed_ms": elapsed_ms,
"iterations": iterations
}),
}
}
pub fn constraint_analysis(
constraint: impl Into<String>,
slack: f64,
binding: bool,
) -> Self {
Self {
kind: DiagnosticKind::ConstraintAnalysis,
message: format!(
"Constraint '{}': slack={:.2}, {}",
constraint.into(),
slack,
if binding { "binding" } else { "non-binding" }
),
data: serde_json::json!({
"slack": slack,
"binding": binding
}),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticKind {
ScoringBreakdown,
TieBreakRationale,
Pruning,
Performance,
ConstraintAnalysis,
CandidateRejection,
Custom,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_optimal_report() {
let replay = ReplayEnvelope::minimal(42);
let report = SolverReport::optimal("hungarian-v1", 100.0, replay);
assert!(report.feasible);
assert!(report.is_optimal());
assert!(!report.hit_budget());
assert_eq!(report.objective_value, Some(100.0));
}
#[test]
fn test_infeasible_report() {
let replay = ReplayEnvelope::minimal(42);
let violation = Violation::new("capacity", 1.0, "exceeded limit");
let report = SolverReport::infeasible(
"solver-v1",
vec![violation],
StopReason::Infeasible,
replay,
);
assert!(!report.feasible);
assert!(!report.is_optimal());
assert_eq!(report.constraint_violations.len(), 1);
}
#[test]
fn test_budget_exhausted() {
let replay = ReplayEnvelope::minimal(42);
let report = SolverReport::feasible(
"solver-v1",
150.0,
StopReason::TimeBudgetExhausted,
replay,
);
assert!(report.feasible);
assert!(!report.is_optimal());
assert!(report.hit_budget());
}
#[test]
fn test_stop_reason_classification() {
assert!(StopReason::Optimal.is_success());
assert!(StopReason::Feasible.is_success());
assert!(!StopReason::Infeasible.is_success());
assert!(StopReason::Infeasible.is_failure());
assert!(StopReason::NoFeasible.is_failure());
assert!(!StopReason::Optimal.is_failure());
assert!(StopReason::TimeBudgetExhausted.is_budget_exhausted());
assert!(!StopReason::Optimal.is_budget_exhausted());
}
#[test]
fn test_diagnostics() {
let diag = Diagnostic::scoring_breakdown(serde_json::json!({"cost": 10, "penalty": 5}));
assert_eq!(diag.kind, DiagnosticKind::ScoringBreakdown);
let diag2 = Diagnostic::tie_break_rationale("chose by id", vec!["a".to_string(), "b".to_string()]);
assert_eq!(diag2.kind, DiagnosticKind::TieBreakRationale);
}
#[test]
fn test_report_with_diagnostics() {
let replay = ReplayEnvelope::minimal(42);
let report = SolverReport::optimal("solver", 50.0, replay)
.with_diagnostic(Diagnostic::performance("phase1", 10.5, 100))
.with_diagnostic(Diagnostic::pruning("removed infeasible", 25));
assert_eq!(report.diagnostics.len(), 2);
}
}