use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::error::MagiError;
use crate::schema::{AgentName, AgentOutput, Finding, Severity, Verdict};
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct ConsensusConfig {
pub min_agents: usize,
pub epsilon: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsensusResult {
pub consensus: String,
pub consensus_verdict: Verdict,
pub confidence: f64,
pub score: f64,
pub agent_count: usize,
pub votes: BTreeMap<AgentName, Verdict>,
pub majority_summary: String,
pub dissent: Vec<Dissent>,
pub findings: Vec<DedupFinding>,
pub conditions: Vec<Condition>,
pub recommendations: BTreeMap<AgentName, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DedupFinding {
pub severity: Severity,
pub title: String,
pub detail: String,
pub sources: Vec<AgentName>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Dissent {
pub agent: AgentName,
pub summary: String,
pub reasoning: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Condition {
pub agent: AgentName,
pub condition: String,
}
impl Default for ConsensusConfig {
fn default() -> Self {
Self {
min_agents: 2,
epsilon: 1e-9,
}
}
}
pub struct ConsensusEngine {
config: ConsensusConfig,
}
impl ConsensusEngine {
pub fn min_agents(&self) -> usize {
self.config.min_agents
}
pub fn new(config: ConsensusConfig) -> Self {
let min_agents = if config.min_agents == 0 {
1
} else {
config.min_agents
};
Self {
config: ConsensusConfig {
min_agents,
..config
},
}
}
pub fn determine(&self, agents: &[AgentOutput]) -> Result<ConsensusResult, MagiError> {
if agents.len() < self.config.min_agents {
return Err(MagiError::InsufficientAgents {
succeeded: agents.len(),
required: self.config.min_agents,
});
}
let mut seen = std::collections::HashSet::new();
for agent in agents {
if !seen.insert(agent.agent) {
return Err(MagiError::Validation(format!(
"duplicate agent name: {}",
agent.agent.display_name()
)));
}
}
let n = agents.len() as f64;
let epsilon = self.config.epsilon;
let score: f64 = agents.iter().map(|a| a.verdict.weight()).sum::<f64>() / n;
let approve_count = agents
.iter()
.filter(|a| a.effective_verdict() == Verdict::Approve)
.count();
let reject_count = agents.len() - approve_count;
let has_conditional = agents.iter().any(|a| a.verdict == Verdict::Conditional);
let majority_verdict = match approve_count.cmp(&reject_count) {
std::cmp::Ordering::Greater => Verdict::Approve,
std::cmp::Ordering::Less => Verdict::Reject,
std::cmp::Ordering::Equal => {
let first_approve = agents
.iter()
.filter(|a| a.effective_verdict() == Verdict::Approve)
.map(|a| a.agent)
.min();
let first_reject = agents
.iter()
.filter(|a| a.effective_verdict() == Verdict::Reject)
.map(|a| a.agent)
.min();
match (first_approve, first_reject) {
(Some(a), Some(r)) if a < r => Verdict::Approve,
(Some(_), None) => Verdict::Approve,
_ => Verdict::Reject,
}
}
};
let (mut label, consensus_verdict) =
self.classify(score, epsilon, approve_count, reject_count, has_conditional);
if agents.len() < 3 {
if label == "STRONG GO" {
label = format!("GO ({}-0)", agents.len());
} else if label == "STRONG NO-GO" {
label = format!("HOLD ({}-0)", agents.len());
}
}
let base_confidence: f64 = agents
.iter()
.filter(|a| a.effective_verdict() == majority_verdict)
.map(|a| a.confidence)
.sum::<f64>()
/ n;
let weight_factor = (score.abs() + 1.0) / 2.0;
let confidence = (base_confidence * weight_factor).clamp(0.0, 1.0);
let confidence = (confidence * 100.0).round() / 100.0;
let findings = self.deduplicate_findings(agents);
let dissent: Vec<Dissent> = agents
.iter()
.filter(|a| a.effective_verdict() != majority_verdict)
.map(|a| Dissent {
agent: a.agent,
summary: a.summary.clone(),
reasoning: a.reasoning.clone(),
})
.collect();
let conditions: Vec<Condition> = agents
.iter()
.filter(|a| a.verdict == Verdict::Conditional)
.map(|a| Condition {
agent: a.agent,
condition: a.recommendation.clone(),
})
.collect();
let votes: BTreeMap<AgentName, Verdict> =
agents.iter().map(|a| (a.agent, a.verdict)).collect();
let majority_summary = agents
.iter()
.filter(|a| a.effective_verdict() == majority_verdict)
.map(|a| a.summary.as_str())
.collect::<Vec<_>>()
.join(" | ");
let recommendations: BTreeMap<AgentName, String> = agents
.iter()
.map(|a| (a.agent, a.recommendation.clone()))
.collect();
Ok(ConsensusResult {
consensus: label,
consensus_verdict,
confidence,
score,
agent_count: agents.len(),
votes,
majority_summary,
dissent,
findings,
conditions,
recommendations,
})
}
fn classify(
&self,
score: f64,
epsilon: f64,
approve_count: usize,
reject_count: usize,
has_conditional: bool,
) -> (String, Verdict) {
if (score - 1.0).abs() < epsilon {
("STRONG GO".to_string(), Verdict::Approve)
} else if (score - (-1.0)).abs() < epsilon {
("STRONG NO-GO".to_string(), Verdict::Reject)
} else if score > epsilon && has_conditional {
("GO WITH CAVEATS".to_string(), Verdict::Approve)
} else if score > epsilon {
(
format!("GO ({}-{})", approve_count, reject_count),
Verdict::Approve,
)
} else if score.abs() < epsilon {
("HOLD -- TIE".to_string(), Verdict::Reject)
} else {
(
format!("HOLD ({}-{})", reject_count, approve_count),
Verdict::Reject,
)
}
}
fn deduplicate_findings(&self, agents: &[AgentOutput]) -> Vec<DedupFinding> {
let mut agent_findings: Vec<(AgentName, &Finding)> = Vec::new();
for agent in agents {
for finding in &agent.findings {
agent_findings.push((agent.agent, finding));
}
}
agent_findings.sort_by(|a, b| a.0.cmp(&b.0));
let mut groups: std::collections::HashMap<String, Vec<(AgentName, &Finding)>> =
std::collections::HashMap::new();
let mut order: Vec<String> = Vec::new();
for (agent_name, finding) in &agent_findings {
let key = finding
.stripped_title()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase();
if !groups.contains_key(&key) {
order.push(key.clone());
}
groups.entry(key).or_default().push((*agent_name, finding));
}
let mut result: Vec<DedupFinding> = Vec::new();
for key in &order {
let entries = &groups[key];
let max_severity = entries.iter().map(|(_, f)| f.severity).max().unwrap();
let best = entries
.iter()
.find(|(_, f)| f.severity == max_severity)
.unwrap();
let sources: Vec<AgentName> = entries.iter().map(|(name, _)| *name).collect();
result.push(DedupFinding {
severity: max_severity,
title: best.1.title.clone(),
detail: best.1.detail.clone(),
sources,
});
}
result.sort_by(|a, b| b.severity.cmp(&a.severity));
result
}
}
impl Default for ConsensusEngine {
fn default() -> Self {
Self::new(ConsensusConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::*;
fn make_output(agent: AgentName, verdict: Verdict, confidence: f64) -> AgentOutput {
AgentOutput {
agent,
verdict,
confidence,
summary: format!("{} summary", agent.display_name()),
reasoning: format!("{} reasoning", agent.display_name()),
findings: vec![],
recommendation: format!("{} recommendation", agent.display_name()),
}
}
#[test]
fn test_unanimous_approve_produces_strong_go() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Balthasar, Verdict::Approve, 0.9),
make_output(AgentName::Caspar, Verdict::Approve, 0.9),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "STRONG GO");
assert_eq!(result.consensus_verdict, Verdict::Approve);
assert!((result.score - 1.0).abs() < 1e-9);
}
#[test]
fn test_two_approve_one_reject_produces_go_2_1() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
make_output(AgentName::Caspar, Verdict::Reject, 0.7),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "GO (2-1)");
assert_eq!(result.consensus_verdict, Verdict::Approve);
assert!(result.score > 0.0);
assert_eq!(result.dissent.len(), 1);
assert_eq!(result.dissent[0].agent, AgentName::Caspar);
}
#[test]
fn test_approve_conditional_reject_produces_go_with_caveats() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Balthasar, Verdict::Conditional, 0.8),
make_output(AgentName::Caspar, Verdict::Reject, 0.7),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "GO WITH CAVEATS");
assert_eq!(result.consensus_verdict, Verdict::Approve);
assert!(!result.conditions.is_empty());
assert_eq!(result.conditions[0].agent, AgentName::Balthasar);
}
#[test]
fn test_unanimous_reject_produces_strong_no_go() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Reject, 0.9),
make_output(AgentName::Balthasar, Verdict::Reject, 0.8),
make_output(AgentName::Caspar, Verdict::Reject, 0.7),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "STRONG NO-GO");
assert_eq!(result.consensus_verdict, Verdict::Reject);
assert!((result.score - (-1.0)).abs() < 1e-9);
}
#[test]
fn test_tie_with_two_agents_produces_hold_tie() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Caspar, Verdict::Reject, 0.9),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "HOLD -- TIE");
assert_eq!(result.consensus_verdict, Verdict::Reject);
}
#[test]
fn test_duplicate_findings_merged_with_severity_promoted() {
let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
m.findings.push(Finding {
severity: Severity::Warning,
title: "Security Issue".to_string(),
detail: "detail_warning".to_string(),
});
let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
b.findings.push(Finding {
severity: Severity::Critical,
title: "security issue".to_string(),
detail: "detail_critical".to_string(),
});
let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&[m, b, c]).unwrap();
assert_eq!(result.findings.len(), 1);
assert_eq!(result.findings[0].severity, Severity::Critical);
}
#[test]
fn test_merged_finding_sources_include_both_agents() {
let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
m.findings.push(Finding {
severity: Severity::Warning,
title: "Security Issue".to_string(),
detail: "detail_m".to_string(),
});
let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
b.findings.push(Finding {
severity: Severity::Critical,
title: "security issue".to_string(),
detail: "detail_b".to_string(),
});
let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&[m, b, c]).unwrap();
assert_eq!(result.findings[0].sources.len(), 2);
assert!(result.findings[0].sources.contains(&AgentName::Melchior));
assert!(result.findings[0].sources.contains(&AgentName::Balthasar));
}
#[test]
fn test_merged_finding_detail_from_highest_severity() {
let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
m.findings.push(Finding {
severity: Severity::Warning,
title: "Issue".to_string(),
detail: "detail_warning".to_string(),
});
let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
b.findings.push(Finding {
severity: Severity::Critical,
title: "issue".to_string(),
detail: "detail_critical".to_string(),
});
let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&[m, b, c]).unwrap();
assert_eq!(result.findings[0].detail, "detail_critical");
}
#[test]
fn test_merged_finding_detail_from_first_agent_on_same_severity() {
let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
b.findings.push(Finding {
severity: Severity::Warning,
title: "Issue".to_string(),
detail: "detail_b".to_string(),
});
let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
m.findings.push(Finding {
severity: Severity::Warning,
title: "issue".to_string(),
detail: "detail_m".to_string(),
});
let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&[b, m, c]).unwrap();
assert_eq!(result.findings[0].detail, "detail_b");
}
#[test]
fn test_degraded_mode_caps_strong_go_to_go() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Balthasar, Verdict::Approve, 0.9),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "GO (2-0)");
assert_ne!(result.consensus, "STRONG GO");
}
#[test]
fn test_degraded_mode_caps_strong_no_go_to_hold() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Reject, 0.9),
make_output(AgentName::Balthasar, Verdict::Reject, 0.9),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "HOLD (2-0)");
assert_ne!(result.consensus, "STRONG NO-GO");
}
#[test]
fn test_determine_rejects_fewer_than_min_agents() {
let agents = vec![make_output(AgentName::Melchior, Verdict::Approve, 0.9)];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(
err,
MagiError::InsufficientAgents {
succeeded: 1,
required: 2,
}
));
}
#[test]
fn test_determine_rejects_duplicate_agent_names() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Melchior, Verdict::Reject, 0.8),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), MagiError::Validation(_)));
}
#[test]
fn test_epsilon_aware_classification_near_boundaries() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Balthasar, Verdict::Reject, 0.9),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "HOLD -- TIE");
}
#[test]
fn test_confidence_formula_clamped_and_rounded() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Balthasar, Verdict::Approve, 0.9),
make_output(AgentName::Caspar, Verdict::Approve, 0.9),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert!((result.confidence - 0.9).abs() < 1e-9);
}
#[test]
fn test_confidence_with_mixed_verdicts() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
make_output(AgentName::Caspar, Verdict::Reject, 0.7),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert!((result.confidence - 0.38).abs() < 0.01);
}
#[test]
fn test_majority_summary_joins_with_pipe() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
make_output(AgentName::Caspar, Verdict::Reject, 0.7),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert!(result.majority_summary.contains("Melchior summary"));
assert!(result.majority_summary.contains("Balthasar summary"));
assert!(result.majority_summary.contains(" | "));
assert!(!result.majority_summary.contains("Caspar summary"));
}
#[test]
fn test_conditions_extracted_from_conditional_agents() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Balthasar, Verdict::Conditional, 0.8),
make_output(AgentName::Caspar, Verdict::Reject, 0.7),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.conditions.len(), 1);
assert_eq!(result.conditions[0].agent, AgentName::Balthasar);
assert_eq!(result.conditions[0].condition, "Balthasar recommendation");
}
#[test]
fn test_recommendations_includes_all_agents() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
make_output(AgentName::Caspar, Verdict::Reject, 0.7),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.recommendations.len(), 3);
assert!(result.recommendations.contains_key(&AgentName::Melchior));
assert!(result.recommendations.contains_key(&AgentName::Balthasar));
assert!(result.recommendations.contains_key(&AgentName::Caspar));
}
#[test]
fn test_consensus_config_enforces_min_agents_at_least_one() {
let config = ConsensusConfig {
min_agents: 0,
..ConsensusConfig::default()
};
assert_eq!(config.min_agents, 0);
let engine = ConsensusEngine::new(config);
let agents = vec![make_output(AgentName::Melchior, Verdict::Approve, 0.9)];
let result = engine.determine(&agents);
assert!(result.is_ok());
}
#[test]
fn test_consensus_config_default_values() {
let config = ConsensusConfig::default();
assert_eq!(config.min_agents, 2);
assert!((config.epsilon - 1e-9).abs() < 1e-15);
}
#[test]
fn test_tiebreak_by_agent_name_ordering() {
let agents = vec![
make_output(AgentName::Balthasar, Verdict::Approve, 0.9),
make_output(AgentName::Melchior, Verdict::Reject, 0.9),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "HOLD -- TIE");
assert_eq!(result.consensus_verdict, Verdict::Reject);
}
#[test]
fn test_findings_sorted_by_severity_critical_first() {
let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
m.findings.push(Finding {
severity: Severity::Info,
title: "Info issue".to_string(),
detail: "info detail".to_string(),
});
m.findings.push(Finding {
severity: Severity::Critical,
title: "Critical issue".to_string(),
detail: "critical detail".to_string(),
});
let b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&[m, b, c]).unwrap();
assert_eq!(result.findings.len(), 2);
assert_eq!(result.findings[0].severity, Severity::Critical);
assert_eq!(result.findings[1].severity, Severity::Info);
}
#[test]
fn test_duplicate_findings_merged_with_whitespace_normalization() {
let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
m.findings.push(Finding {
severity: Severity::Warning,
title: "SQL injection".to_string(),
detail: "detail_m".to_string(),
});
let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
b.findings.push(Finding {
severity: Severity::Warning,
title: "SQL\tinjection".to_string(),
detail: "detail_b".to_string(),
});
let mut c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
c.findings.push(Finding {
severity: Severity::Critical,
title: "sql injection".to_string(),
detail: "detail_c".to_string(),
});
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&[m, b, c]).unwrap();
assert_eq!(result.findings.len(), 1, "should merge all three into one");
assert_eq!(result.findings[0].severity, Severity::Critical);
assert_eq!(result.findings[0].sources.len(), 3);
}
#[test]
fn test_votes_map_contains_all_agents() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Balthasar, Verdict::Reject, 0.8),
make_output(AgentName::Caspar, Verdict::Conditional, 0.7),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.votes.len(), 3);
assert_eq!(result.votes[&AgentName::Melchior], Verdict::Approve);
assert_eq!(result.votes[&AgentName::Balthasar], Verdict::Reject);
assert_eq!(result.votes[&AgentName::Caspar], Verdict::Conditional);
}
#[test]
fn test_agent_count_reflects_input_count() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
make_output(AgentName::Caspar, Verdict::Approve, 0.7),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.agent_count, 3);
}
}