Skip to main content

quorum_rs/
events.rs

1//! Shared SSE event types used by both the orchestrator (publisher) and
2//! agent workers (consumer).  These live in the SDK so that the dependency
3//! direction is always `orchestrator → sdk`, never the reverse.
4
5use serde::{Deserialize, Serialize};
6use utoipa::ToSchema;
7
8/// SSE event: emitted once when a deliberation halts (terminal). The orchestrator
9/// publishes this under `...result.event.job_complete`; the SDK mirrors the wire
10/// shape so agent workers can deserialize it and fire the `on_job_complete` hook
11/// with the final winner (`best_proposal_author`). Unknown/extra fields on the
12/// wire (e.g. `chain_head`) are ignored.
13#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
14pub struct JobCompleteEvent {
15    /// Status string (e.g. "Success").
16    #[serde(default)]
17    pub status: String,
18    /// The job / session id.
19    #[serde(default)]
20    pub job_id: String,
21    /// Total rounds actually completed.
22    #[serde(default)]
23    pub rounds_completed: u32,
24    /// Rounds planned (0 = unknown on older payloads).
25    #[serde(default)]
26    pub total_rounds: u32,
27    /// Content of the winning proposal.
28    #[serde(default)]
29    pub best_proposal_content: String,
30    /// Score of the winning proposal.
31    #[serde(default)]
32    pub best_proposal_score: f32,
33    /// Winning proposal's author — the final winner.
34    #[serde(default)]
35    pub best_proposal_author: String,
36    /// Set when a user force-finalized a specific agent.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub finalized_by_user: Option<String>,
39}
40
41/// SSE event: emitted after evaluation scoring completes each round.
42#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
43pub struct RoundSummaryEvent {
44    /// Round number
45    pub round: u32,
46    /// Winner conviction *w* ∈ [-1, +1]: the winning proposer's signed eval
47    /// mass normalized by total absolute mass across all proposals.
48    /// +1 = unanimous endorsement, 0 = split, -1 = unanimous rejection.
49    /// Used in evidence accumulation: `Δe = w × u'(t)`.
50    pub convergence_score: f32,
51    /// **Deprecated.** Previously `Σ|net_support[p]|`. Now set to `|w| × √P`
52    /// for backward-compatible display. New consumers should use
53    /// `convergence_score` (= *w*) directly.
54    #[serde(default)]
55    pub decisiveness: f32,
56    /// Signed net support per proposal this round ∈ \[-1, +1\].
57    /// Positive = ensemble believes correct; negative = believes wrong.
58    /// Each entry is `(agent_id, net_support)` sorted descending.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub net_support: Vec<(String, f32)>,
61    /// Running Cesaro mean of net support ∈ \[-1, +1\].
62    /// Smoothed trend for dashboard display.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub cesaro_support: Vec<(String, f32)>,
65    /// Instantaneous distance between consecutive net_support vectors (diagnostics).
66    /// Not used for termination — `convergence_score` is the Cesaro-smoothed version.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub raw_distance: Option<f32>,
69    /// Board-wide claim convergence: fraction of claims (with 2+ evaluators)
70    /// that have unanimous verdict. None if no structured claims were present.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub claim_convergence: Option<f32>,
73    /// Total unique claims assessed by 2+ evaluators across all proposals.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub total_claims: Option<u32>,
76    /// Leader-only claim convergence: fraction of the winning proposal's
77    /// claims (with 2+ evaluators) that have unanimous verdict.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub leader_claim_convergence: Option<f32>,
80    /// Total unique claims assessed for the leader proposal only.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub leader_total_claims: Option<u32>,
83    /// Per-proposal controversy scores (evaluator score variance).
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub controversy_scores: Vec<ProposalControversyEntry>,
86    /// All proposals with their mean evaluator scores
87    pub proposal_scores: Vec<ProposalScoreEntry>,
88    /// Thermodynamic evidence accumulated so far (#218).
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub accumulated_evidence: Option<f32>,
91    /// Halting threshold *T* = `effort × positive_budget`.
92    /// Display progress = `accumulated_evidence / evidence_target`.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub evidence_target: Option<f32>,
95    /// Total extractable signal (normalises the evidence target).
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub positive_budget: Option<f32>,
98    /// Signed marginal utility at this round's time index.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub du_dt: Option<f32>,
101    /// **Deprecated.** Folded into `convergence_score` (= *w*).
102    /// Kept for backward-compatible deserialization of old events.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub signed_consensus: Option<f32>,
105    /// Optimal stopping round computed from thermodynamic parameters.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub t_opt: Option<f32>,
108    /// Normalized utility `U(t)/U(T_opt)` — thermodynamic probability of
109    /// finding the right answer at this round, relative to the peak.
110    /// 1.0 at T_opt, ~0.877 at round 1 (HP defaults).
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub thermo_probability: Option<f32>,
113    /// `true` when any proposal received fewer real evaluations than
114    /// the number of dispatched evaluators minus one (i.e. at least one
115    /// evaluator timed out or returned a partial batch). The threshold
116    /// is computed from the pre-filter evaluator roster, not the
117    /// post-filter proposer count. Dashboards should surface this as a
118    /// data-quality warning alongside the round's metrics.
119    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120    pub partial_round_coverage: bool,
121}
122
123/// Per-proposal evaluator score variance — measures disagreement.
124#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
125pub struct ProposalControversyEntry {
126    /// ID of the proposing agent
127    pub agent_id: String,
128    /// Variance of evaluator scores for this proposal (σ²)
129    pub variance: f32,
130    /// Number of evaluators who scored this proposal
131    pub eval_count: u32,
132}
133
134/// Score entry within a RoundSummaryEvent.
135#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
136pub struct ProposalScoreEntry {
137    /// ID of the proposing agent
138    pub agent_id: String,
139    /// Sum of signed QV contributions (`Σ score_q_s`) across all evaluators.
140    /// Positive = net endorsement, negative = net opposition.
141    pub aggregated_score: f32,
142    /// Mean category scores across all evaluators (if structured evaluations present)
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub category_breakdown: Option<CategoryScoreBreakdown>,
145    /// Evaluator score variance (σ²) for this proposal
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub controversy_score: Option<f32>,
148    /// Number of real (non-synthetic) evaluations received for this proposal.
149    #[serde(default)]
150    pub real_eval_count: u32,
151    /// Number of synthetic (injected max-score) evaluations for this proposal.
152    /// Non-zero means at least one evaluator timed out or returned partial results.
153    #[serde(default)]
154    pub synthetic_eval_count: u32,
155}
156
157/// Aggregated category scores across all evaluators for a single proposal.
158#[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        // Exact shape the orchestrator publishes, including `chain_head` which the
174        // SDK struct omits — must be ignored, not rejected.
175        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        // Old orchestrator JSON without new fields — must deserialize with defaults
264        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        // decisiveness defaults to 0.0 when absent from old JSON
276        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        // convergence_score and decisiveness are non-optional f32 — always serialized
313        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        // Minimal JSON — old format without category_breakdown or controversy_score
362        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        // Edge case: round completes with no proposals (all agents timed out)
419        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}