use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use unicode_normalization::UnicodeNormalization;
use crate::error::MagiError;
use crate::schema::{AgentName, AgentOutput, Severity, Verdict};
use crate::validate::clean_title;
#[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,
}
fn dedup_key(title: &str) -> String {
caseless::default_case_fold_str(&clean_title(title).nfkc().collect::<String>())
}
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.summary.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| format!("{}: {}", a.agent.display_name(), a.summary))
.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 {
(
format!("GO WITH CAVEATS ({}-{})", approve_count, reject_count),
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> {
struct GroupState {
severity: Severity,
title: String,
detail: String,
sources: Vec<AgentName>,
}
let mut groups: Vec<(String, GroupState)> = Vec::new();
for agent in agents {
for finding in &agent.findings {
let key = dedup_key(&finding.title);
if let Some((_, state)) = groups.iter_mut().find(|(k, _)| k == &key) {
if finding.severity > state.severity {
state.severity = finding.severity;
state.detail = finding.detail.clone();
}
state.sources.push(agent.agent);
} else {
groups.push((
key,
GroupState {
severity: finding.severity,
title: finding.title.clone(),
detail: finding.detail.clone(),
sources: vec![agent.agent],
},
));
}
}
}
let mut result: Vec<DedupFinding> = groups
.into_iter()
.map(|(_, state)| DedupFinding {
severity: state.severity,
title: state.title,
detail: state.detail,
sources: state.sources,
})
.collect();
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 (2-1)");
assert_eq!(result.consensus_verdict, Verdict::Approve);
assert!(!result.conditions.is_empty());
assert_eq!(result.conditions[0].agent, AgentName::Balthasar);
}
#[test]
fn test_go_with_caveats_three_conditionals_unanimous() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Conditional, 0.9),
make_output(AgentName::Balthasar, Verdict::Conditional, 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.consensus, "GO WITH CAVEATS (3-0)");
assert_eq!(result.consensus_verdict, Verdict::Approve);
}
#[test]
fn test_go_with_caveats_two_conditionals_one_approve() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Conditional, 0.9),
make_output(AgentName::Balthasar, Verdict::Conditional, 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.consensus, "GO WITH CAVEATS (3-0)");
assert_eq!(result.consensus_verdict, Verdict::Approve);
}
#[test]
fn test_go_with_caveats_two_conditionals_one_reject() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Conditional, 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, "HOLD -- TIE");
assert_eq!(result.consensus_verdict, Verdict::Reject);
}
#[test]
fn test_go_with_caveats_degraded_two_conditionals() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Conditional, 0.9),
make_output(AgentName::Balthasar, Verdict::Conditional, 0.8),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "GO WITH CAVEATS (2-0)");
assert_eq!(result.consensus_verdict, Verdict::Approve);
assert_eq!(result.agent_count, 2);
}
#[test]
fn test_go_with_caveats_degraded_one_conditional_one_approve() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Conditional, 0.9),
make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "GO WITH CAVEATS (2-0)");
assert_eq!(result.consensus_verdict, Verdict::Approve);
assert_eq!(result.agent_count, 2);
}
#[test]
fn test_degraded_one_conditional_one_reject_produces_hold_1_1() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Conditional, 0.9),
make_output(AgentName::Balthasar, Verdict::Reject, 0.8),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "HOLD (1-1)");
assert_eq!(result.consensus_verdict, Verdict::Reject);
}
#[test]
fn test_score_just_above_epsilon_classifies_as_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 {
epsilon: 0.1,
..ConsensusConfig::default()
});
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "GO WITH CAVEATS (2-1)");
assert_eq!(result.consensus_verdict, Verdict::Approve);
}
#[test]
fn test_score_just_below_epsilon_classifies_as_hold() {
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 {
epsilon: 0.2,
..ConsensusConfig::default()
});
let result = engine.determine(&agents).unwrap();
assert_eq!(result.consensus, "HOLD -- TIE");
assert_eq!(result.consensus_verdict, Verdict::Reject);
}
#[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: Melchior summary")
);
assert!(
result
.majority_summary
.contains("Balthasar: Balthasar summary")
);
assert!(result.majority_summary.contains(" | "));
assert!(!result.majority_summary.contains("Caspar summary"));
}
#[test]
fn test_majority_summary_uses_display_name_capitalized() {
let agents = vec![
make_output(AgentName::Melchior, Verdict::Approve, 0.9),
make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
];
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&agents).unwrap();
assert!(result.majority_summary.contains("Melchior:"));
assert!(result.majority_summary.contains("Balthasar:"));
assert!(!result.majority_summary.contains("melchior:"));
assert!(!result.majority_summary.contains("balthasar:"));
}
#[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 summary");
}
#[test]
fn test_conditions_use_summary_field_not_recommendation_field() {
let mut agent = make_output(AgentName::Melchior, Verdict::Conditional, 0.9);
agent.summary = "Melchior condition summary".to_string();
agent.recommendation = "Melchior detailed recommendation".to_string();
let support = make_output(AgentName::Balthasar, Verdict::Approve, 0.8);
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&[agent, support]).unwrap();
assert_eq!(result.conditions.len(), 1);
assert_eq!(result.conditions[0].condition, "Melchior condition summary");
assert_ne!(
result.conditions[0].condition,
"Melchior detailed recommendation"
);
}
#[test]
fn test_conditions_are_distinct_from_recommendations_section() {
let mut agent = make_output(AgentName::Balthasar, Verdict::Conditional, 0.85);
agent.summary = "Short condition summary".to_string();
agent.recommendation = "Long detailed recommendation text".to_string();
let support = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
let engine = ConsensusEngine::new(ConsensusConfig::default());
let result = engine.determine(&[agent, support]).unwrap();
assert_eq!(result.conditions.len(), 1);
assert_eq!(result.conditions[0].condition, "Short condition summary");
assert!(result.recommendations.contains_key(&AgentName::Balthasar));
assert_eq!(
result.recommendations[&AgentName::Balthasar],
"Long detailed recommendation text"
);
assert_ne!(
result.conditions[0].condition,
result.recommendations[&AgentName::Balthasar]
);
}
#[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_dedup_tab_normalizes_to_space_but_double_space_is_distinct() {
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(),
2,
"tab-normalized title merges with single-space; double-space is distinct"
);
assert_eq!(result.findings[0].severity, Severity::Critical);
assert_eq!(result.findings[0].sources.len(), 2);
assert_eq!(result.findings[1].severity, Severity::Warning);
assert_eq!(result.findings[1].sources.len(), 1);
}
#[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);
}
#[test]
fn test_dedup_key_nfkc_collapses_fullwidth_latin() {
let key_fullwidth = dedup_key("\u{FF21}\u{FF22}\u{FF23}"); let key_ascii = dedup_key("abc");
assert_eq!(
key_fullwidth, key_ascii,
"NFKC must collapse fullwidth ABC to abc"
);
}
#[test]
fn test_dedup_key_nfkc_collapses_combining_accents() {
let precomposed = dedup_key("caf\u{E9}"); let combining = dedup_key("cafe\u{301}"); assert_eq!(
precomposed, combining,
"NFKC must collapse combining accents to precomposed form"
);
}
#[test]
fn test_dedup_key_casefold_sharp_s_equals_double_s() {
let sharp_s = dedup_key("\u{DF}"); let double_s = dedup_key("ss");
assert_eq!(
sharp_s, double_s,
"casefold must fold ß to ss (full Unicode fold, not to_lowercase)"
);
}
#[test]
fn test_dedup_key_casefold_greek_sigma_variants() {
let capital = dedup_key("\u{03A3}"); let small = dedup_key("\u{03C3}"); let final_s = dedup_key("\u{03C2}"); assert_eq!(capital, small, "Σ and σ must fold to the same key");
assert_eq!(
small, final_s,
"σ and ς must fold to the same key (both caseless and Python agree)"
);
}
#[test]
fn test_dedup_key_casefold_turkish_dotted_i() {
let input = "\u{0130}";
assert_eq!(dedup_key(input), "i\u{307}");
}
#[test]
fn test_dedup_key_preserves_interior_whitespace() {
let double_space = dedup_key("foo bar");
let single_space = dedup_key("foo bar");
assert_ne!(
double_space, single_space,
"dedup_key must NOT collapse interior whitespace (aligned with Python)"
);
}
#[test]
fn test_dedup_merges_fullwidth_and_ascii_titles() {
let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
m.findings.push(Finding {
severity: Severity::Warning,
title: "\u{FF33}\u{FF31}\u{FF2C} injection".to_string(), detail: "detail_fullwidth".to_string(),
});
let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
b.findings.push(Finding {
severity: Severity::Critical,
title: "sql injection".to_string(),
detail: "detail_ascii".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,
"fullwidth and ASCII titles must merge to one finding via NFKC"
);
assert_eq!(result.findings[0].severity, Severity::Critical);
}
#[test]
fn test_dedup_first_seen_order_preserved_when_melchior_reports_first() {
let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
m.findings.push(Finding {
severity: Severity::Warning,
title: "Issue A".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: "issue a".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.len(), 1);
assert_eq!(
result.findings[0].title, "Issue A",
"title must come from Melchior (first seen)"
);
assert_eq!(result.findings[0].sources.len(), 2);
assert_eq!(result.findings[0].sources[0], AgentName::Melchior);
assert_eq!(result.findings[0].sources[1], AgentName::Balthasar);
}
#[test]
fn test_dedup_first_seen_order_preserved_when_balthasar_reports_first() {
let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
b.findings.push(Finding {
severity: Severity::Warning,
title: "issue a".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 A".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.len(), 1);
assert_eq!(
result.findings[0].title, "issue a",
"title must come from Balthasar (first seen)"
);
assert_eq!(result.findings[0].sources.len(), 2);
assert_eq!(result.findings[0].sources[0], AgentName::Balthasar);
assert_eq!(result.findings[0].sources[1], AgentName::Melchior);
}
#[test]
fn test_dedup_ordering_stable_across_equal_severity() {
let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
m.findings.push(Finding {
severity: Severity::Warning,
title: "Alpha Issue".to_string(),
detail: "detail_alpha".to_string(),
});
let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
b.findings.push(Finding {
severity: Severity::Warning,
title: "Beta Issue".to_string(),
detail: "detail_beta".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(), 2);
assert_eq!(
result.findings[0].title, "Alpha Issue",
"first-seen finding must appear first when severity is equal"
);
assert_eq!(result.findings[1].title, "Beta Issue");
}
}