use serde::{Deserialize, Serialize};
pub type ExpertId = String;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CouncilStartedSummary {
pub experts: u32,
pub synthesizer: ExpertId,
pub min_rounds: u32,
pub max_rounds: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "code", rename_all = "snake_case")]
pub enum CouncilFailure {
QuorumLost {
round: u32,
required: u32,
actual: u32,
},
SynthesizerFailed {
message: String,
},
EmbedderFailed {
round: u32,
message: String,
},
Cancelled,
ConfigError {
message: String,
},
Internal {
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CouncilEvent {
CouncilStarted {
config_summary: CouncilStartedSummary,
},
RoundStarted {
round: u32,
},
ExpertStarted {
round: u32,
expert_id: ExpertId,
model: String,
},
ExpertToken {
round: u32,
expert_id: ExpertId,
delta: String,
},
ExpertCompleted {
round: u32,
expert_id: ExpertId,
tokens: u32,
},
ExpertFailed {
round: u32,
expert_id: ExpertId,
error: String,
},
RoundCompleted {
round: u32,
responded: Vec<ExpertId>,
failed: Vec<ExpertId>,
},
ConvergenceCheck {
round: u32,
min_cosine: f32,
threshold: f32,
converged: bool,
},
SynthesisStarted {
synthesizer_id: ExpertId,
},
FinalToken {
delta: String,
},
CouncilCompleted {
rounds: u32,
final_answer_length: u32,
},
CouncilFailed {
error: CouncilFailure,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_started_serializes_with_type_tag() {
let ev = CouncilEvent::RoundStarted { round: 0 };
let json = serde_json::to_string(&ev).unwrap();
assert_eq!(json, r#"{"type":"round_started","round":0}"#);
}
#[test]
fn council_started_serializes_summary() {
let ev = CouncilEvent::CouncilStarted {
config_summary: CouncilStartedSummary {
experts: 3,
synthesizer: "S".into(),
min_rounds: 2,
max_rounds: 4,
},
};
let json = serde_json::to_string(&ev).unwrap();
assert!(json.contains(r#""type":"council_started""#));
assert!(json.contains(r#""experts":3"#));
assert!(json.contains(r#""synthesizer":"S""#));
}
#[test]
fn expert_token_round_trips() {
let ev = CouncilEvent::ExpertToken {
round: 1,
expert_id: "A".into(),
delta: "hello".into(),
};
let json = serde_json::to_string(&ev).unwrap();
let back: CouncilEvent = serde_json::from_str(&json).unwrap();
assert_eq!(ev, back);
}
#[test]
fn expert_failed_serializes_error() {
let ev = CouncilEvent::ExpertFailed {
round: 0,
expert_id: "B".into(),
error: "timeout after 30s".into(),
};
let json = serde_json::to_string(&ev).unwrap();
assert!(json.contains(r#""type":"expert_failed""#));
assert!(json.contains(r#""error":"timeout after 30s""#));
}
#[test]
fn round_completed_lists_responded_and_failed() {
let ev = CouncilEvent::RoundCompleted {
round: 0,
responded: vec!["A".into(), "C".into()],
failed: vec!["B".into()],
};
let json = serde_json::to_string(&ev).unwrap();
assert!(json.contains(r#""responded":["A","C"]"#));
assert!(json.contains(r#""failed":["B"]"#));
}
#[test]
fn convergence_check_includes_threshold_and_decision() {
let ev = CouncilEvent::ConvergenceCheck {
round: 1,
min_cosine: 0.84,
threshold: 0.92,
converged: false,
};
let json = serde_json::to_string(&ev).unwrap();
assert!(json.contains(r#""converged":false"#));
assert!(json.contains(r#""threshold":0.92"#));
}
#[test]
fn final_token_minimal_shape() {
let ev = CouncilEvent::FinalToken { delta: "Therefore".into() };
let json = serde_json::to_string(&ev).unwrap();
assert_eq!(json, r#"{"type":"final_token","delta":"Therefore"}"#);
}
#[test]
fn council_completed_round_trips() {
let ev = CouncilEvent::CouncilCompleted {
rounds: 2,
final_answer_length: 1842,
};
let json = serde_json::to_string(&ev).unwrap();
let back: CouncilEvent = serde_json::from_str(&json).unwrap();
assert_eq!(ev, back);
}
#[test]
fn council_failed_quorum_lost_has_nested_code() {
let ev = CouncilEvent::CouncilFailed {
error: CouncilFailure::QuorumLost {
round: 1,
required: 2,
actual: 1,
},
};
let json = serde_json::to_string(&ev).unwrap();
assert!(json.contains(r#""type":"council_failed""#));
assert!(json.contains(r#""error":{"code":"quorum_lost""#));
assert!(json.contains(r#""round":1"#));
assert!(json.contains(r#""required":2"#));
assert!(json.contains(r#""actual":1"#));
}
#[test]
fn council_failed_round_trips_for_each_failure_kind() {
let cases = [
CouncilFailure::QuorumLost { round: 0, required: 2, actual: 1 },
CouncilFailure::SynthesizerFailed { message: "boom".into() },
CouncilFailure::EmbedderFailed { round: 1, message: "oom".into() },
CouncilFailure::Cancelled,
CouncilFailure::ConfigError { message: "bad threshold".into() },
CouncilFailure::Internal { message: "bug".into() },
];
for failure in cases {
let ev = CouncilEvent::CouncilFailed { error: failure.clone() };
let json = serde_json::to_string(&ev).unwrap();
let back: CouncilEvent = serde_json::from_str(&json).unwrap();
assert_eq!(ev, back, "round-trip failed for {:?}", failure);
}
}
}