use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
pub struct RoundSummaryEvent {
pub round: u32,
pub convergence_score: f32,
#[serde(default)]
pub decisiveness: f32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub net_support: Vec<(String, f32)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cesaro_support: Vec<(String, f32)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_distance: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claim_convergence: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_claims: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub leader_claim_convergence: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub leader_total_claims: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub controversy_scores: Vec<ProposalControversyEntry>,
pub proposal_scores: Vec<ProposalScoreEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accumulated_evidence: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_target: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub positive_budget: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub du_dt: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signed_consensus: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub t_opt: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thermo_probability: Option<f32>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub partial_round_coverage: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProposalControversyEntry {
pub agent_id: String,
pub variance: f32,
pub eval_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
pub struct ProposalScoreEntry {
pub agent_id: String,
pub aggregated_score: f32,
#[serde(skip_serializing_if = "Option::is_none")]
pub category_breakdown: Option<CategoryScoreBreakdown>,
#[serde(skip_serializing_if = "Option::is_none")]
pub controversy_score: Option<f32>,
#[serde(default)]
pub real_eval_count: u32,
#[serde(default)]
pub synthetic_eval_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CategoryScoreBreakdown {
pub correctness: f32,
pub completeness: f32,
pub novelty: f32,
pub feasibility: f32,
pub evidence_quality: f32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_round_summary_serde_roundtrip() {
let event = RoundSummaryEvent {
round: 3,
convergence_score: 0.82,
decisiveness: 0.82,
net_support: vec![("alpha".into(), 0.6), ("beta".into(), -0.3)],
cesaro_support: vec![("alpha".into(), 0.5), ("beta".into(), -0.1)],
raw_distance: Some(0.15),
claim_convergence: Some(0.75),
total_claims: Some(12),
leader_claim_convergence: None,
leader_total_claims: None,
controversy_scores: vec![ProposalControversyEntry {
agent_id: "alpha".into(),
variance: 2.5,
eval_count: 3,
}],
proposal_scores: vec![
ProposalScoreEntry {
agent_id: "alpha".into(),
aggregated_score: 7.5,
category_breakdown: Some(CategoryScoreBreakdown {
correctness: 80.0,
completeness: 70.0,
novelty: 60.0,
feasibility: 90.0,
evidence_quality: 75.0,
}),
controversy_score: Some(2.5),
..Default::default()
},
ProposalScoreEntry {
agent_id: "beta".into(),
aggregated_score: 3.2,
category_breakdown: None,
controversy_score: None,
..Default::default()
},
],
accumulated_evidence: None,
evidence_target: None,
positive_budget: None,
du_dt: None,
signed_consensus: None,
t_opt: None,
thermo_probability: None,
..Default::default()
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RoundSummaryEvent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.round, 3);
assert!((parsed.convergence_score - 0.82).abs() < f32::EPSILON);
assert_eq!(parsed.net_support.len(), 2);
assert_eq!(parsed.net_support[0].0, "alpha");
assert!((parsed.net_support[0].1 - 0.6).abs() < f32::EPSILON);
assert_eq!(parsed.cesaro_support.len(), 2);
assert_eq!(parsed.raw_distance, Some(0.15));
assert_eq!(parsed.claim_convergence, Some(0.75));
assert_eq!(parsed.total_claims, Some(12));
assert_eq!(parsed.controversy_scores.len(), 1);
assert_eq!(parsed.proposal_scores.len(), 2);
assert_eq!(parsed.proposal_scores[0].agent_id, "alpha");
assert!(parsed.proposal_scores[0].category_breakdown.is_some());
assert!(parsed.proposal_scores[1].category_breakdown.is_none());
}
#[test]
fn test_round_summary_backward_compat_minimal_json() {
let json = r#"{
"round": 1,
"convergence_score": 0.5,
"proposal_scores": [
{"agent_id": "a", "aggregated_score": 6.0}
]
}"#;
let event: RoundSummaryEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.round, 1);
assert!((event.convergence_score - 0.5).abs() < f32::EPSILON);
assert!(event.decisiveness.abs() < f32::EPSILON);
assert!(event.net_support.is_empty());
assert!(event.cesaro_support.is_empty());
assert_eq!(event.raw_distance, None);
assert_eq!(event.claim_convergence, None);
assert_eq!(event.total_claims, None);
assert!(event.controversy_scores.is_empty());
assert_eq!(event.proposal_scores.len(), 1);
}
#[test]
fn test_skip_serializing_if_omits_none_and_empty() {
let event = RoundSummaryEvent {
round: 1,
convergence_score: 0.0,
decisiveness: 0.0,
net_support: vec![],
cesaro_support: vec![],
raw_distance: None,
claim_convergence: None,
total_claims: None,
leader_claim_convergence: None,
leader_total_claims: None,
controversy_scores: vec![],
proposal_scores: vec![],
accumulated_evidence: None,
evidence_target: None,
positive_budget: None,
du_dt: None,
signed_consensus: None,
t_opt: None,
thermo_probability: None,
..Default::default()
};
let json = serde_json::to_string(&event).unwrap();
assert!(
json.contains("convergence_score"),
"convergence_score is always present (f32, not Option)"
);
assert!(
json.contains("decisiveness"),
"decisiveness is always present (f32)"
);
assert!(
!json.contains("claim_convergence"),
"None should be omitted"
);
assert!(!json.contains("total_claims"), "None should be omitted");
assert!(
!json.contains("controversy_scores"),
"empty vec should be omitted"
);
assert!(!json.contains("net_support"), "empty vec should be omitted");
assert!(
!json.contains("cesaro_support"),
"empty vec should be omitted"
);
assert!(!json.contains("raw_distance"), "None should be omitted");
assert!(
!json.contains("leader_claim_convergence"),
"None should be omitted"
);
assert!(
!json.contains("leader_total_claims"),
"None should be omitted"
);
assert!(
!json.contains("accumulated_evidence"),
"None should be omitted"
);
assert!(!json.contains("evidence_target"), "None should be omitted");
assert!(!json.contains("positive_budget"), "None should be omitted");
assert!(!json.contains("du_dt"), "None should be omitted");
assert!(!json.contains("signed_consensus"), "None should be omitted");
assert!(!json.contains("t_opt"), "None should be omitted");
assert!(
!json.contains("thermo_probability"),
"None should be omitted"
);
}
#[test]
fn test_proposal_score_entry_backward_compat() {
let json = r#"{"agent_id": "x", "aggregated_score": 4.0}"#;
let entry: ProposalScoreEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.agent_id, "x");
assert!((entry.aggregated_score - 4.0).abs() < f32::EPSILON);
assert!(entry.category_breakdown.is_none());
assert!(entry.controversy_score.is_none());
}
#[test]
fn test_proposal_score_entry_skip_serializing_none() {
let entry = ProposalScoreEntry {
agent_id: "y".into(),
aggregated_score: 5.0,
category_breakdown: None,
controversy_score: None,
..Default::default()
};
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("category_breakdown"));
assert!(!json.contains("controversy_score"));
}
#[test]
fn test_category_score_breakdown_roundtrip() {
let bd = CategoryScoreBreakdown {
correctness: 0.0,
completeness: 100.0,
novelty: 50.5,
feasibility: 99.9,
evidence_quality: 33.3,
};
let json = serde_json::to_vec(&bd).unwrap();
let parsed: CategoryScoreBreakdown = serde_json::from_slice(&json).unwrap();
assert!((parsed.correctness - 0.0).abs() < f32::EPSILON);
assert!((parsed.completeness - 100.0).abs() < f32::EPSILON);
assert!((parsed.novelty - 50.5).abs() < f32::EPSILON);
assert!((parsed.feasibility - 99.9).abs() < 0.01);
assert!((parsed.evidence_quality - 33.3).abs() < 0.01);
}
#[test]
fn test_controversy_entry_roundtrip() {
let entry = ProposalControversyEntry {
agent_id: "delta".into(),
variance: 1.234,
eval_count: 5,
};
let json = serde_json::to_vec(&entry).unwrap();
let parsed: ProposalControversyEntry = serde_json::from_slice(&json).unwrap();
assert_eq!(parsed.agent_id, "delta");
assert!((parsed.variance - 1.234).abs() < 0.001);
assert_eq!(parsed.eval_count, 5);
}
#[test]
fn test_round_summary_empty_proposal_scores() {
let event = RoundSummaryEvent {
round: 2,
convergence_score: 0.0,
decisiveness: 0.0,
net_support: vec![],
cesaro_support: vec![],
raw_distance: None,
claim_convergence: None,
total_claims: None,
leader_claim_convergence: None,
leader_total_claims: None,
controversy_scores: vec![],
proposal_scores: vec![],
accumulated_evidence: None,
evidence_target: None,
positive_budget: None,
du_dt: None,
signed_consensus: None,
t_opt: None,
thermo_probability: None,
..Default::default()
};
let json = serde_json::to_vec(&event).unwrap();
let parsed: RoundSummaryEvent = serde_json::from_slice(&json).unwrap();
assert!(parsed.proposal_scores.is_empty());
}
}