1use serde::{Deserialize, Serialize};
6use utoipa::ToSchema;
7
8#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
14pub struct JobCompleteEvent {
15 #[serde(default)]
17 pub status: String,
18 #[serde(default)]
20 pub job_id: String,
21 #[serde(default)]
23 pub rounds_completed: u32,
24 #[serde(default)]
26 pub total_rounds: u32,
27 #[serde(default)]
29 pub best_proposal_content: String,
30 #[serde(default)]
32 pub best_proposal_score: f32,
33 #[serde(default)]
35 pub best_proposal_author: String,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub finalized_by_user: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
43pub struct RoundSummaryEvent {
44 pub round: u32,
46 pub convergence_score: f32,
51 #[serde(default)]
55 pub decisiveness: f32,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub net_support: Vec<(String, f32)>,
61 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub cesaro_support: Vec<(String, f32)>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub raw_distance: Option<f32>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub claim_convergence: Option<f32>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub total_claims: Option<u32>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub leader_claim_convergence: Option<f32>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub leader_total_claims: Option<u32>,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub controversy_scores: Vec<ProposalControversyEntry>,
86 pub proposal_scores: Vec<ProposalScoreEntry>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub accumulated_evidence: Option<f32>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub evidence_target: Option<f32>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub positive_budget: Option<f32>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub du_dt: Option<f32>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub signed_consensus: Option<f32>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub t_opt: Option<f32>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub thermo_probability: Option<f32>,
113 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120 pub partial_round_coverage: bool,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
125pub struct ProposalControversyEntry {
126 pub agent_id: String,
128 pub variance: f32,
130 pub eval_count: u32,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
136pub struct ProposalScoreEntry {
137 pub agent_id: String,
139 pub aggregated_score: f32,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub category_breakdown: Option<CategoryScoreBreakdown>,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub controversy_score: Option<f32>,
148 #[serde(default)]
150 pub real_eval_count: u32,
151 #[serde(default)]
154 pub synthetic_eval_count: u32,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
159pub struct CategoryScoreBreakdown {
160 pub correctness: f32,
161 pub completeness: f32,
162 pub novelty: f32,
163 pub feasibility: f32,
164 pub evidence_quality: f32,
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn job_complete_event_deserializes_orchestrator_wire() {
173 let wire = serde_json::json!({
176 "status": "Success",
177 "job_id": "sess1",
178 "rounds_completed": 3,
179 "total_rounds": 5,
180 "best_proposal_content": "final answer",
181 "best_proposal_score": 7.2,
182 "best_proposal_author": "AgentA",
183 "chain_head": "deadbeef"
184 });
185 let e: JobCompleteEvent = serde_json::from_value(wire).unwrap();
186 assert_eq!(e.best_proposal_author, "AgentA");
187 assert_eq!(e.rounds_completed, 3);
188 assert_eq!(e.best_proposal_score, 7.2);
189 assert_eq!(e.finalized_by_user, None);
190 }
191
192 #[test]
193 fn test_round_summary_serde_roundtrip() {
194 let event = RoundSummaryEvent {
195 round: 3,
196 convergence_score: 0.82,
197 decisiveness: 0.82,
198 net_support: vec![("alpha".into(), 0.6), ("beta".into(), -0.3)],
199 cesaro_support: vec![("alpha".into(), 0.5), ("beta".into(), -0.1)],
200 raw_distance: Some(0.15),
201 claim_convergence: Some(0.75),
202 total_claims: Some(12),
203 leader_claim_convergence: None,
204 leader_total_claims: None,
205 controversy_scores: vec![ProposalControversyEntry {
206 agent_id: "alpha".into(),
207 variance: 2.5,
208 eval_count: 3,
209 }],
210 proposal_scores: vec![
211 ProposalScoreEntry {
212 agent_id: "alpha".into(),
213 aggregated_score: 7.5,
214 category_breakdown: Some(CategoryScoreBreakdown {
215 correctness: 80.0,
216 completeness: 70.0,
217 novelty: 60.0,
218 feasibility: 90.0,
219 evidence_quality: 75.0,
220 }),
221 controversy_score: Some(2.5),
222 ..Default::default()
223 },
224 ProposalScoreEntry {
225 agent_id: "beta".into(),
226 aggregated_score: 3.2,
227 category_breakdown: None,
228 controversy_score: None,
229 ..Default::default()
230 },
231 ],
232 accumulated_evidence: None,
233 evidence_target: None,
234 positive_budget: None,
235 du_dt: None,
236 signed_consensus: None,
237 t_opt: None,
238 thermo_probability: None,
239 ..Default::default()
240 };
241
242 let json = serde_json::to_string(&event).unwrap();
243 let parsed: RoundSummaryEvent = serde_json::from_str(&json).unwrap();
244
245 assert_eq!(parsed.round, 3);
246 assert!((parsed.convergence_score - 0.82).abs() < f32::EPSILON);
247 assert_eq!(parsed.net_support.len(), 2);
248 assert_eq!(parsed.net_support[0].0, "alpha");
249 assert!((parsed.net_support[0].1 - 0.6).abs() < f32::EPSILON);
250 assert_eq!(parsed.cesaro_support.len(), 2);
251 assert_eq!(parsed.raw_distance, Some(0.15));
252 assert_eq!(parsed.claim_convergence, Some(0.75));
253 assert_eq!(parsed.total_claims, Some(12));
254 assert_eq!(parsed.controversy_scores.len(), 1);
255 assert_eq!(parsed.proposal_scores.len(), 2);
256 assert_eq!(parsed.proposal_scores[0].agent_id, "alpha");
257 assert!(parsed.proposal_scores[0].category_breakdown.is_some());
258 assert!(parsed.proposal_scores[1].category_breakdown.is_none());
259 }
260
261 #[test]
262 fn test_round_summary_backward_compat_minimal_json() {
263 let json = r#"{
265 "round": 1,
266 "convergence_score": 0.5,
267 "proposal_scores": [
268 {"agent_id": "a", "aggregated_score": 6.0}
269 ]
270 }"#;
271
272 let event: RoundSummaryEvent = serde_json::from_str(json).unwrap();
273 assert_eq!(event.round, 1);
274 assert!((event.convergence_score - 0.5).abs() < f32::EPSILON);
275 assert!(event.decisiveness.abs() < f32::EPSILON);
277 assert!(event.net_support.is_empty());
278 assert!(event.cesaro_support.is_empty());
279 assert_eq!(event.raw_distance, None);
280 assert_eq!(event.claim_convergence, None);
281 assert_eq!(event.total_claims, None);
282 assert!(event.controversy_scores.is_empty());
283 assert_eq!(event.proposal_scores.len(), 1);
284 }
285
286 #[test]
287 fn test_skip_serializing_if_omits_none_and_empty() {
288 let event = RoundSummaryEvent {
289 round: 1,
290 convergence_score: 0.0,
291 decisiveness: 0.0,
292 net_support: vec![],
293 cesaro_support: vec![],
294 raw_distance: None,
295 claim_convergence: None,
296 total_claims: None,
297 leader_claim_convergence: None,
298 leader_total_claims: None,
299 controversy_scores: vec![],
300 proposal_scores: vec![],
301 accumulated_evidence: None,
302 evidence_target: None,
303 positive_budget: None,
304 du_dt: None,
305 signed_consensus: None,
306 t_opt: None,
307 thermo_probability: None,
308 ..Default::default()
309 };
310
311 let json = serde_json::to_string(&event).unwrap();
312 assert!(
314 json.contains("convergence_score"),
315 "convergence_score is always present (f32, not Option)"
316 );
317 assert!(
318 json.contains("decisiveness"),
319 "decisiveness is always present (f32)"
320 );
321 assert!(
322 !json.contains("claim_convergence"),
323 "None should be omitted"
324 );
325 assert!(!json.contains("total_claims"), "None should be omitted");
326 assert!(
327 !json.contains("controversy_scores"),
328 "empty vec should be omitted"
329 );
330 assert!(!json.contains("net_support"), "empty vec should be omitted");
331 assert!(
332 !json.contains("cesaro_support"),
333 "empty vec should be omitted"
334 );
335 assert!(!json.contains("raw_distance"), "None should be omitted");
336 assert!(
337 !json.contains("leader_claim_convergence"),
338 "None should be omitted"
339 );
340 assert!(
341 !json.contains("leader_total_claims"),
342 "None should be omitted"
343 );
344 assert!(
345 !json.contains("accumulated_evidence"),
346 "None should be omitted"
347 );
348 assert!(!json.contains("evidence_target"), "None should be omitted");
349 assert!(!json.contains("positive_budget"), "None should be omitted");
350 assert!(!json.contains("du_dt"), "None should be omitted");
351 assert!(!json.contains("signed_consensus"), "None should be omitted");
352 assert!(!json.contains("t_opt"), "None should be omitted");
353 assert!(
354 !json.contains("thermo_probability"),
355 "None should be omitted"
356 );
357 }
358
359 #[test]
360 fn test_proposal_score_entry_backward_compat() {
361 let json = r#"{"agent_id": "x", "aggregated_score": 4.0}"#;
363 let entry: ProposalScoreEntry = serde_json::from_str(json).unwrap();
364 assert_eq!(entry.agent_id, "x");
365 assert!((entry.aggregated_score - 4.0).abs() < f32::EPSILON);
366 assert!(entry.category_breakdown.is_none());
367 assert!(entry.controversy_score.is_none());
368 }
369
370 #[test]
371 fn test_proposal_score_entry_skip_serializing_none() {
372 let entry = ProposalScoreEntry {
373 agent_id: "y".into(),
374 aggregated_score: 5.0,
375 category_breakdown: None,
376 controversy_score: None,
377 ..Default::default()
378 };
379 let json = serde_json::to_string(&entry).unwrap();
380 assert!(!json.contains("category_breakdown"));
381 assert!(!json.contains("controversy_score"));
382 }
383
384 #[test]
385 fn test_category_score_breakdown_roundtrip() {
386 let bd = CategoryScoreBreakdown {
387 correctness: 0.0,
388 completeness: 100.0,
389 novelty: 50.5,
390 feasibility: 99.9,
391 evidence_quality: 33.3,
392 };
393 let json = serde_json::to_vec(&bd).unwrap();
394 let parsed: CategoryScoreBreakdown = serde_json::from_slice(&json).unwrap();
395 assert!((parsed.correctness - 0.0).abs() < f32::EPSILON);
396 assert!((parsed.completeness - 100.0).abs() < f32::EPSILON);
397 assert!((parsed.novelty - 50.5).abs() < f32::EPSILON);
398 assert!((parsed.feasibility - 99.9).abs() < 0.01);
399 assert!((parsed.evidence_quality - 33.3).abs() < 0.01);
400 }
401
402 #[test]
403 fn test_controversy_entry_roundtrip() {
404 let entry = ProposalControversyEntry {
405 agent_id: "delta".into(),
406 variance: 1.234,
407 eval_count: 5,
408 };
409 let json = serde_json::to_vec(&entry).unwrap();
410 let parsed: ProposalControversyEntry = serde_json::from_slice(&json).unwrap();
411 assert_eq!(parsed.agent_id, "delta");
412 assert!((parsed.variance - 1.234).abs() < 0.001);
413 assert_eq!(parsed.eval_count, 5);
414 }
415
416 #[test]
417 fn test_round_summary_empty_proposal_scores() {
418 let event = RoundSummaryEvent {
420 round: 2,
421 convergence_score: 0.0,
422 decisiveness: 0.0,
423 net_support: vec![],
424 cesaro_support: vec![],
425 raw_distance: None,
426 claim_convergence: None,
427 total_claims: None,
428 leader_claim_convergence: None,
429 leader_total_claims: None,
430 controversy_scores: vec![],
431 proposal_scores: vec![],
432 accumulated_evidence: None,
433 evidence_target: None,
434 positive_budget: None,
435 du_dt: None,
436 signed_consensus: None,
437 t_opt: None,
438 thermo_probability: None,
439 ..Default::default()
440 };
441 let json = serde_json::to_vec(&event).unwrap();
442 let parsed: RoundSummaryEvent = serde_json::from_slice(&json).unwrap();
443 assert!(parsed.proposal_scores.is_empty());
444 }
445}