Skip to main content

magi_core/
consensus.rs

1// Author: Julian Bolivar
2// Version: 1.0.0
3// Date: 2026-04-05
4
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8use crate::error::MagiError;
9use crate::schema::{AgentName, AgentOutput, Finding, Severity, Verdict};
10
11/// Configuration for the consensus engine.
12#[non_exhaustive]
13#[derive(Debug, Clone)]
14pub struct ConsensusConfig {
15    /// Minimum number of successful agent outputs required (default: 2).
16    pub min_agents: usize,
17    /// Tolerance for floating-point comparisons (default: 1e-9).
18    pub epsilon: f64,
19}
20
21/// Result of the consensus determination.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ConsensusResult {
24    /// Classification label (e.g., "STRONG GO", "GO WITH CAVEATS").
25    pub consensus: String,
26    /// Final verdict enum.
27    pub consensus_verdict: Verdict,
28    /// Computed confidence, rounded to 2 decimals.
29    pub confidence: f64,
30    /// Raw normalized score.
31    pub score: f64,
32    /// Number of agents that contributed.
33    pub agent_count: usize,
34    /// Per-agent verdicts.
35    pub votes: BTreeMap<AgentName, Verdict>,
36    /// Joined summaries from majority side.
37    pub majority_summary: String,
38    /// Dissenting agent details.
39    pub dissent: Vec<Dissent>,
40    /// Deduplicated findings sorted by severity (Critical first).
41    pub findings: Vec<DedupFinding>,
42    /// Conditions from Conditional agents.
43    pub conditions: Vec<Condition>,
44    /// Per-agent recommendations.
45    pub recommendations: BTreeMap<AgentName, String>,
46}
47
48/// A deduplicated finding aggregated across agents.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub struct DedupFinding {
51    /// Severity level (promoted to highest across duplicates).
52    pub severity: Severity,
53    /// Finding title.
54    pub title: String,
55    /// Finding detail (from highest-severity contributor).
56    pub detail: String,
57    /// Agents that reported this finding.
58    pub sources: Vec<AgentName>,
59}
60
61/// A dissenting agent's summary and reasoning.
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63pub struct Dissent {
64    /// The dissenting agent.
65    pub agent: AgentName,
66    /// The agent's summary.
67    pub summary: String,
68    /// The agent's reasoning.
69    pub reasoning: String,
70}
71
72/// A condition extracted from a Conditional-verdict agent.
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
74pub struct Condition {
75    /// The agent that set the condition.
76    pub agent: AgentName,
77    /// The condition text (from the agent's recommendation).
78    pub condition: String,
79}
80
81impl Default for ConsensusConfig {
82    fn default() -> Self {
83        Self {
84            min_agents: 2,
85            epsilon: 1e-9,
86        }
87    }
88}
89
90/// Stateless consensus engine for synthesizing agent outputs.
91///
92/// The engine takes a slice of [`AgentOutput`] values and produces a
93/// [`ConsensusResult`] containing the consensus label, confidence score,
94/// deduplicated findings, dissent tracking, and condition extraction.
95///
96/// The engine is stateless: each call to [`determine`](ConsensusEngine::determine)
97/// is independent and the engine is safe to share across threads.
98pub struct ConsensusEngine {
99    config: ConsensusConfig,
100}
101
102impl ConsensusEngine {
103    /// Returns the minimum number of agents required by this engine's configuration.
104    pub fn min_agents(&self) -> usize {
105        self.config.min_agents
106    }
107
108    /// Creates a new consensus engine with the given configuration.
109    ///
110    /// If `config.min_agents` is 0, it is clamped to 1.
111    pub fn new(config: ConsensusConfig) -> Self {
112        let min_agents = if config.min_agents == 0 {
113            1
114        } else {
115            config.min_agents
116        };
117        Self {
118            config: ConsensusConfig {
119                min_agents,
120                ..config
121            },
122        }
123    }
124
125    /// Synthesizes agent outputs into a unified consensus result.
126    ///
127    /// # Errors
128    ///
129    /// - [`MagiError::InsufficientAgents`] if fewer than `min_agents` are provided.
130    /// - [`MagiError::Validation`] if duplicate agent names are detected.
131    pub fn determine(&self, agents: &[AgentOutput]) -> Result<ConsensusResult, MagiError> {
132        // 1. Validate input count
133        if agents.len() < self.config.min_agents {
134            return Err(MagiError::InsufficientAgents {
135                succeeded: agents.len(),
136                required: self.config.min_agents,
137            });
138        }
139
140        // 2. Reject duplicates
141        let mut seen = std::collections::HashSet::new();
142        for agent in agents {
143            if !seen.insert(agent.agent) {
144                return Err(MagiError::Validation(format!(
145                    "duplicate agent name: {}",
146                    agent.agent.display_name()
147                )));
148            }
149        }
150
151        let n = agents.len() as f64;
152        let epsilon = self.config.epsilon;
153
154        // 3. Compute normalized score
155        let score: f64 = agents.iter().map(|a| a.verdict.weight()).sum::<f64>() / n;
156
157        // 4. Determine majority verdict
158        let approve_count = agents
159            .iter()
160            .filter(|a| a.effective_verdict() == Verdict::Approve)
161            .count();
162        let reject_count = agents.len() - approve_count;
163
164        let has_conditional = agents.iter().any(|a| a.verdict == Verdict::Conditional);
165
166        let majority_verdict = match approve_count.cmp(&reject_count) {
167            std::cmp::Ordering::Greater => Verdict::Approve,
168            std::cmp::Ordering::Less => Verdict::Reject,
169            std::cmp::Ordering::Equal => {
170                // Tie: break by alphabetically first agent on each side
171                let first_approve = agents
172                    .iter()
173                    .filter(|a| a.effective_verdict() == Verdict::Approve)
174                    .map(|a| a.agent)
175                    .min();
176                let first_reject = agents
177                    .iter()
178                    .filter(|a| a.effective_verdict() == Verdict::Reject)
179                    .map(|a| a.agent)
180                    .min();
181                match (first_approve, first_reject) {
182                    (Some(a), Some(r)) if a < r => Verdict::Approve,
183                    (Some(_), None) => Verdict::Approve,
184                    _ => Verdict::Reject,
185                }
186            }
187        };
188
189        // 5. Classify score to label + consensus verdict
190        let (mut label, consensus_verdict) =
191            self.classify(score, epsilon, approve_count, reject_count, has_conditional);
192
193        // 6. Degraded mode cap
194        if agents.len() < 3 {
195            if label == "STRONG GO" {
196                label = format!("GO ({}-0)", agents.len());
197            } else if label == "STRONG NO-GO" {
198                label = format!("HOLD ({}-0)", agents.len());
199            }
200        }
201
202        // 7. Compute confidence
203        // base_confidence: sum of majority-side confidences divided by TOTAL agent
204        // count (not majority count). This intentionally penalizes non-unanimous
205        // results — a dissenting agent dilutes the overall confidence even though
206        // it is not on the majority side.
207        // weight_factor: maps |score| from [0,1] to [0.5,1.0], so unanimous
208        // verdicts (|score|=1) get full weight while ties (score=0) halve it.
209        let base_confidence: f64 = agents
210            .iter()
211            .filter(|a| a.effective_verdict() == majority_verdict)
212            .map(|a| a.confidence)
213            .sum::<f64>()
214            / n;
215        let weight_factor = (score.abs() + 1.0) / 2.0;
216        let confidence = (base_confidence * weight_factor).clamp(0.0, 1.0);
217        let confidence = (confidence * 100.0).round() / 100.0;
218
219        // 8. Deduplicate findings
220        let findings = self.deduplicate_findings(agents);
221
222        // 9. Identify dissent
223        let dissent: Vec<Dissent> = agents
224            .iter()
225            .filter(|a| a.effective_verdict() != majority_verdict)
226            .map(|a| Dissent {
227                agent: a.agent,
228                summary: a.summary.clone(),
229                reasoning: a.reasoning.clone(),
230            })
231            .collect();
232
233        // 10. Extract conditions
234        let conditions: Vec<Condition> = agents
235            .iter()
236            .filter(|a| a.verdict == Verdict::Conditional)
237            .map(|a| Condition {
238                agent: a.agent,
239                condition: a.recommendation.clone(),
240            })
241            .collect();
242
243        // 11. Build votes map
244        let votes: BTreeMap<AgentName, Verdict> =
245            agents.iter().map(|a| (a.agent, a.verdict)).collect();
246
247        // 12. Build majority summary
248        let majority_summary = agents
249            .iter()
250            .filter(|a| a.effective_verdict() == majority_verdict)
251            .map(|a| a.summary.as_str())
252            .collect::<Vec<_>>()
253            .join(" | ");
254
255        // 13. Build recommendations map
256        let recommendations: BTreeMap<AgentName, String> = agents
257            .iter()
258            .map(|a| (a.agent, a.recommendation.clone()))
259            .collect();
260
261        Ok(ConsensusResult {
262            consensus: label,
263            consensus_verdict,
264            confidence,
265            score,
266            agent_count: agents.len(),
267            votes,
268            majority_summary,
269            dissent,
270            findings,
271            conditions,
272            recommendations,
273        })
274    }
275
276    /// Classifies a score into a consensus label and verdict.
277    fn classify(
278        &self,
279        score: f64,
280        epsilon: f64,
281        approve_count: usize,
282        reject_count: usize,
283        has_conditional: bool,
284    ) -> (String, Verdict) {
285        if (score - 1.0).abs() < epsilon {
286            ("STRONG GO".to_string(), Verdict::Approve)
287        } else if (score - (-1.0)).abs() < epsilon {
288            ("STRONG NO-GO".to_string(), Verdict::Reject)
289        } else if score > epsilon && has_conditional {
290            ("GO WITH CAVEATS".to_string(), Verdict::Approve)
291        } else if score > epsilon {
292            (
293                format!("GO ({}-{})", approve_count, reject_count),
294                Verdict::Approve,
295            )
296        } else if score.abs() < epsilon {
297            ("HOLD -- TIE".to_string(), Verdict::Reject)
298        } else {
299            (
300                format!("HOLD ({}-{})", reject_count, approve_count),
301                Verdict::Reject,
302            )
303        }
304    }
305
306    /// Deduplicates findings across agents by case-insensitive stripped title.
307    fn deduplicate_findings(&self, agents: &[AgentOutput]) -> Vec<DedupFinding> {
308        // Collect (agent_name, finding) pairs sorted by agent name for determinism
309        let mut agent_findings: Vec<(AgentName, &Finding)> = Vec::new();
310        for agent in agents {
311            for finding in &agent.findings {
312                agent_findings.push((agent.agent, finding));
313            }
314        }
315        // Sort by agent name for deterministic tiebreaking
316        agent_findings.sort_by(|a, b| a.0.cmp(&b.0));
317
318        // Group by case-insensitive stripped title
319        let mut groups: std::collections::HashMap<String, Vec<(AgentName, &Finding)>> =
320            std::collections::HashMap::new();
321        let mut order: Vec<String> = Vec::new();
322
323        for (agent_name, finding) in &agent_findings {
324            let key = finding
325                .stripped_title()
326                .split_whitespace()
327                .collect::<Vec<_>>()
328                .join(" ")
329                .to_lowercase();
330            if !groups.contains_key(&key) {
331                order.push(key.clone());
332            }
333            groups.entry(key).or_default().push((*agent_name, finding));
334        }
335
336        let mut result: Vec<DedupFinding> = Vec::new();
337        for key in &order {
338            let entries = &groups[key];
339            // Find highest severity
340            let max_severity = entries.iter().map(|(_, f)| f.severity).max().unwrap();
341            // Find the first entry with that severity (already sorted by agent name)
342            let best = entries
343                .iter()
344                .find(|(_, f)| f.severity == max_severity)
345                .unwrap();
346            let sources: Vec<AgentName> = entries.iter().map(|(name, _)| *name).collect();
347            result.push(DedupFinding {
348                severity: max_severity,
349                title: best.1.title.clone(),
350                detail: best.1.detail.clone(),
351                sources,
352            });
353        }
354
355        // Sort by severity (Critical first = descending)
356        result.sort_by(|a, b| b.severity.cmp(&a.severity));
357        result
358    }
359}
360
361impl Default for ConsensusEngine {
362    fn default() -> Self {
363        Self::new(ConsensusConfig::default())
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::schema::*;
371
372    fn make_output(agent: AgentName, verdict: Verdict, confidence: f64) -> AgentOutput {
373        AgentOutput {
374            agent,
375            verdict,
376            confidence,
377            summary: format!("{} summary", agent.display_name()),
378            reasoning: format!("{} reasoning", agent.display_name()),
379            findings: vec![],
380            recommendation: format!("{} recommendation", agent.display_name()),
381        }
382    }
383
384    // -- BDD Scenario 1: unanimous approve --
385
386    /// Three approve agents produce STRONG GO with score=1.0.
387    #[test]
388    fn test_unanimous_approve_produces_strong_go() {
389        let agents = vec![
390            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
391            make_output(AgentName::Balthasar, Verdict::Approve, 0.9),
392            make_output(AgentName::Caspar, Verdict::Approve, 0.9),
393        ];
394        let engine = ConsensusEngine::new(ConsensusConfig::default());
395        let result = engine.determine(&agents).unwrap();
396        assert_eq!(result.consensus, "STRONG GO");
397        assert_eq!(result.consensus_verdict, Verdict::Approve);
398        assert!((result.score - 1.0).abs() < 1e-9);
399    }
400
401    // -- BDD Scenario 2: mixed 2 approve + 1 reject --
402
403    /// Two approve + one reject produces GO (2-1) with positive score.
404    #[test]
405    fn test_two_approve_one_reject_produces_go_2_1() {
406        let agents = vec![
407            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
408            make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
409            make_output(AgentName::Caspar, Verdict::Reject, 0.7),
410        ];
411        let engine = ConsensusEngine::new(ConsensusConfig::default());
412        let result = engine.determine(&agents).unwrap();
413        assert_eq!(result.consensus, "GO (2-1)");
414        assert_eq!(result.consensus_verdict, Verdict::Approve);
415        assert!(result.score > 0.0);
416        assert_eq!(result.dissent.len(), 1);
417        assert_eq!(result.dissent[0].agent, AgentName::Caspar);
418    }
419
420    // -- BDD Scenario 3: approve + conditional + reject --
421
422    /// Approve + conditional + reject produces GO WITH CAVEATS.
423    #[test]
424    fn test_approve_conditional_reject_produces_go_with_caveats() {
425        let agents = vec![
426            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
427            make_output(AgentName::Balthasar, Verdict::Conditional, 0.8),
428            make_output(AgentName::Caspar, Verdict::Reject, 0.7),
429        ];
430        let engine = ConsensusEngine::new(ConsensusConfig::default());
431        let result = engine.determine(&agents).unwrap();
432        assert_eq!(result.consensus, "GO WITH CAVEATS");
433        assert_eq!(result.consensus_verdict, Verdict::Approve);
434        assert!(!result.conditions.is_empty());
435        assert_eq!(result.conditions[0].agent, AgentName::Balthasar);
436    }
437
438    // -- BDD Scenario 4: unanimous reject --
439
440    /// Three reject agents produce STRONG NO-GO with score=-1.0.
441    #[test]
442    fn test_unanimous_reject_produces_strong_no_go() {
443        let agents = vec![
444            make_output(AgentName::Melchior, Verdict::Reject, 0.9),
445            make_output(AgentName::Balthasar, Verdict::Reject, 0.8),
446            make_output(AgentName::Caspar, Verdict::Reject, 0.7),
447        ];
448        let engine = ConsensusEngine::new(ConsensusConfig::default());
449        let result = engine.determine(&agents).unwrap();
450        assert_eq!(result.consensus, "STRONG NO-GO");
451        assert_eq!(result.consensus_verdict, Verdict::Reject);
452        assert!((result.score - (-1.0)).abs() < 1e-9);
453    }
454
455    // -- BDD Scenario 5: tie with 2 agents --
456
457    /// One approve + one reject (2 agents) produces HOLD -- TIE.
458    #[test]
459    fn test_tie_with_two_agents_produces_hold_tie() {
460        let agents = vec![
461            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
462            make_output(AgentName::Caspar, Verdict::Reject, 0.9),
463        ];
464        let engine = ConsensusEngine::new(ConsensusConfig::default());
465        let result = engine.determine(&agents).unwrap();
466        assert_eq!(result.consensus, "HOLD -- TIE");
467        assert_eq!(result.consensus_verdict, Verdict::Reject);
468    }
469
470    // -- BDD Scenario 13: finding deduplication --
471
472    /// Same title different case merges into single finding with severity promoted.
473    #[test]
474    fn test_duplicate_findings_merged_with_severity_promoted() {
475        let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
476        m.findings.push(Finding {
477            severity: Severity::Warning,
478            title: "Security Issue".to_string(),
479            detail: "detail_warning".to_string(),
480        });
481        let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
482        b.findings.push(Finding {
483            severity: Severity::Critical,
484            title: "security issue".to_string(),
485            detail: "detail_critical".to_string(),
486        });
487        let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
488        let engine = ConsensusEngine::new(ConsensusConfig::default());
489        let result = engine.determine(&[m, b, c]).unwrap();
490        assert_eq!(result.findings.len(), 1);
491        assert_eq!(result.findings[0].severity, Severity::Critical);
492    }
493
494    /// Merged finding sources include both contributing agents.
495    #[test]
496    fn test_merged_finding_sources_include_both_agents() {
497        let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
498        m.findings.push(Finding {
499            severity: Severity::Warning,
500            title: "Security Issue".to_string(),
501            detail: "detail_m".to_string(),
502        });
503        let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
504        b.findings.push(Finding {
505            severity: Severity::Critical,
506            title: "security issue".to_string(),
507            detail: "detail_b".to_string(),
508        });
509        let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
510        let engine = ConsensusEngine::new(ConsensusConfig::default());
511        let result = engine.determine(&[m, b, c]).unwrap();
512        assert_eq!(result.findings[0].sources.len(), 2);
513        assert!(result.findings[0].sources.contains(&AgentName::Melchior));
514        assert!(result.findings[0].sources.contains(&AgentName::Balthasar));
515    }
516
517    /// Detail preserved from highest-severity finding.
518    #[test]
519    fn test_merged_finding_detail_from_highest_severity() {
520        let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
521        m.findings.push(Finding {
522            severity: Severity::Warning,
523            title: "Issue".to_string(),
524            detail: "detail_warning".to_string(),
525        });
526        let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
527        b.findings.push(Finding {
528            severity: Severity::Critical,
529            title: "issue".to_string(),
530            detail: "detail_critical".to_string(),
531        });
532        let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
533        let engine = ConsensusEngine::new(ConsensusConfig::default());
534        let result = engine.determine(&[m, b, c]).unwrap();
535        assert_eq!(result.findings[0].detail, "detail_critical");
536    }
537
538    /// On same severity, detail comes from first agent by AgentName ordering.
539    #[test]
540    fn test_merged_finding_detail_from_first_agent_on_same_severity() {
541        let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
542        b.findings.push(Finding {
543            severity: Severity::Warning,
544            title: "Issue".to_string(),
545            detail: "detail_b".to_string(),
546        });
547        let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
548        m.findings.push(Finding {
549            severity: Severity::Warning,
550            title: "issue".to_string(),
551            detail: "detail_m".to_string(),
552        });
553        let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
554        let engine = ConsensusEngine::new(ConsensusConfig::default());
555        let result = engine.determine(&[b, m, c]).unwrap();
556        // Balthasar < Melchior alphabetically, same severity → Balthasar's detail
557        assert_eq!(result.findings[0].detail, "detail_b");
558    }
559
560    // -- BDD Scenario 33: degraded mode caps STRONG labels --
561
562    /// Two approve agents (degraded) produce GO (2-0) not STRONG GO.
563    #[test]
564    fn test_degraded_mode_caps_strong_go_to_go() {
565        let agents = vec![
566            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
567            make_output(AgentName::Balthasar, Verdict::Approve, 0.9),
568        ];
569        let engine = ConsensusEngine::new(ConsensusConfig::default());
570        let result = engine.determine(&agents).unwrap();
571        assert_eq!(result.consensus, "GO (2-0)");
572        assert_ne!(result.consensus, "STRONG GO");
573    }
574
575    /// Two reject agents (degraded) produce HOLD (2-0) not STRONG NO-GO.
576    #[test]
577    fn test_degraded_mode_caps_strong_no_go_to_hold() {
578        let agents = vec![
579            make_output(AgentName::Melchior, Verdict::Reject, 0.9),
580            make_output(AgentName::Balthasar, Verdict::Reject, 0.9),
581        ];
582        let engine = ConsensusEngine::new(ConsensusConfig::default());
583        let result = engine.determine(&agents).unwrap();
584        assert_eq!(result.consensus, "HOLD (2-0)");
585        assert_ne!(result.consensus, "STRONG NO-GO");
586    }
587
588    // -- Error cases --
589
590    /// Determine rejects fewer than min_agents with InsufficientAgents.
591    #[test]
592    fn test_determine_rejects_fewer_than_min_agents() {
593        let agents = vec![make_output(AgentName::Melchior, Verdict::Approve, 0.9)];
594        let engine = ConsensusEngine::new(ConsensusConfig::default());
595        let result = engine.determine(&agents);
596        assert!(result.is_err());
597        let err = result.unwrap_err();
598        assert!(matches!(
599            err,
600            MagiError::InsufficientAgents {
601                succeeded: 1,
602                required: 2,
603            }
604        ));
605    }
606
607    /// Determine rejects duplicate agent names with Validation error.
608    #[test]
609    fn test_determine_rejects_duplicate_agent_names() {
610        let agents = vec![
611            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
612            make_output(AgentName::Melchior, Verdict::Reject, 0.8),
613        ];
614        let engine = ConsensusEngine::new(ConsensusConfig::default());
615        let result = engine.determine(&agents);
616        assert!(result.is_err());
617        assert!(matches!(result.unwrap_err(), MagiError::Validation(_)));
618    }
619
620    // -- Score and confidence calculations --
621
622    /// Epsilon-aware classification near score boundaries.
623    #[test]
624    fn test_epsilon_aware_classification_near_boundaries() {
625        // Score very close to 0 (within epsilon) should be HOLD -- TIE
626        // Use Conditional(+0.5) + Approve(+1.0) + Reject(-1.0) = +0.5/3 ≈ 0.1667
627        // That's not near zero. Instead, use a direct near-zero scenario:
628        // We need agents whose weights sum to ~0. e.g. 1 approve + 1 reject = 0/2 = 0
629        let agents = vec![
630            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
631            make_output(AgentName::Balthasar, Verdict::Reject, 0.9),
632        ];
633        let engine = ConsensusEngine::new(ConsensusConfig::default());
634        let result = engine.determine(&agents).unwrap();
635        assert_eq!(result.consensus, "HOLD -- TIE");
636    }
637
638    /// Confidence formula: base * weight_factor, clamped [0,1], rounded 2 decimals.
639    #[test]
640    fn test_confidence_formula_clamped_and_rounded() {
641        // 3 approve agents with confidence 0.9 each
642        // score = (1+1+1)/3 = 1.0
643        // majority side = all 3 (approve), base = (0.9+0.9+0.9)/3 = 0.9
644        // weight_factor = (1.0 + 1.0) / 2.0 = 1.0
645        // confidence = 0.9 * 1.0 = 0.9
646        let agents = vec![
647            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
648            make_output(AgentName::Balthasar, Verdict::Approve, 0.9),
649            make_output(AgentName::Caspar, Verdict::Approve, 0.9),
650        ];
651        let engine = ConsensusEngine::new(ConsensusConfig::default());
652        let result = engine.determine(&agents).unwrap();
653        assert!((result.confidence - 0.9).abs() < 1e-9);
654    }
655
656    /// Confidence with mixed verdicts applies weight_factor correctly.
657    #[test]
658    fn test_confidence_with_mixed_verdicts() {
659        // 2 approve (0.9, 0.8) + 1 reject (0.7)
660        // score = (1+1-1)/3 = 1/3 ≈ 0.3333
661        // majority = approve side: Melchior(0.9), Balthasar(0.8)
662        // base = (0.9 + 0.8) / 3 = 0.5667
663        // weight_factor = (0.3333 + 1.0) / 2.0 = 0.6667
664        // confidence = 0.5667 * 0.6667 = 0.3778 → rounded = 0.38
665        let agents = vec![
666            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
667            make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
668            make_output(AgentName::Caspar, Verdict::Reject, 0.7),
669        ];
670        let engine = ConsensusEngine::new(ConsensusConfig::default());
671        let result = engine.determine(&agents).unwrap();
672        assert!((result.confidence - 0.38).abs() < 0.01);
673    }
674
675    /// Majority summary joins majority agent summaries with " | ".
676    #[test]
677    fn test_majority_summary_joins_with_pipe() {
678        let agents = vec![
679            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
680            make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
681            make_output(AgentName::Caspar, Verdict::Reject, 0.7),
682        ];
683        let engine = ConsensusEngine::new(ConsensusConfig::default());
684        let result = engine.determine(&agents).unwrap();
685        assert!(result.majority_summary.contains("Melchior summary"));
686        assert!(result.majority_summary.contains("Balthasar summary"));
687        assert!(result.majority_summary.contains(" | "));
688        assert!(!result.majority_summary.contains("Caspar summary"));
689    }
690
691    /// Conditions extracted from agents with Conditional verdict.
692    #[test]
693    fn test_conditions_extracted_from_conditional_agents() {
694        let agents = vec![
695            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
696            make_output(AgentName::Balthasar, Verdict::Conditional, 0.8),
697            make_output(AgentName::Caspar, Verdict::Reject, 0.7),
698        ];
699        let engine = ConsensusEngine::new(ConsensusConfig::default());
700        let result = engine.determine(&agents).unwrap();
701        assert_eq!(result.conditions.len(), 1);
702        assert_eq!(result.conditions[0].agent, AgentName::Balthasar);
703        assert_eq!(result.conditions[0].condition, "Balthasar recommendation");
704    }
705
706    /// Recommendations map includes all agents.
707    #[test]
708    fn test_recommendations_includes_all_agents() {
709        let agents = vec![
710            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
711            make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
712            make_output(AgentName::Caspar, Verdict::Reject, 0.7),
713        ];
714        let engine = ConsensusEngine::new(ConsensusConfig::default());
715        let result = engine.determine(&agents).unwrap();
716        assert_eq!(result.recommendations.len(), 3);
717        assert!(result.recommendations.contains_key(&AgentName::Melchior));
718        assert!(result.recommendations.contains_key(&AgentName::Balthasar));
719        assert!(result.recommendations.contains_key(&AgentName::Caspar));
720    }
721
722    /// ConsensusConfig enforces min_agents >= 1.
723    #[test]
724    fn test_consensus_config_enforces_min_agents_at_least_one() {
725        let config = ConsensusConfig {
726            min_agents: 0,
727            ..ConsensusConfig::default()
728        };
729        assert_eq!(config.min_agents, 0);
730        // Engine should clamp to 1 internally
731        let engine = ConsensusEngine::new(config);
732        let agents = vec![make_output(AgentName::Melchior, Verdict::Approve, 0.9)];
733        // Should succeed with 1 agent even though min_agents was 0 (clamped to 1)
734        let result = engine.determine(&agents);
735        assert!(result.is_ok());
736    }
737
738    /// ConsensusConfig::default() returns min_agents=2, epsilon=1e-9.
739    #[test]
740    fn test_consensus_config_default_values() {
741        let config = ConsensusConfig::default();
742        assert_eq!(config.min_agents, 2);
743        assert!((config.epsilon - 1e-9).abs() < 1e-15);
744    }
745
746    /// Tiebreak by AgentName::cmp() — alphabetically first agent's side wins.
747    #[test]
748    fn test_tiebreak_by_agent_name_ordering() {
749        // Balthasar=Approve, Melchior=Reject → tie (score=0)
750        // Balthasar < Melchior alphabetically → Balthasar's side (Approve) wins tiebreak
751        // But label should still be HOLD -- TIE
752        let agents = vec![
753            make_output(AgentName::Balthasar, Verdict::Approve, 0.9),
754            make_output(AgentName::Melchior, Verdict::Reject, 0.9),
755        ];
756        let engine = ConsensusEngine::new(ConsensusConfig::default());
757        let result = engine.determine(&agents).unwrap();
758        // Score is 0, so label is HOLD -- TIE, verdict is Reject
759        assert_eq!(result.consensus, "HOLD -- TIE");
760        assert_eq!(result.consensus_verdict, Verdict::Reject);
761    }
762
763    /// Findings sorted by severity (Critical first).
764    #[test]
765    fn test_findings_sorted_by_severity_critical_first() {
766        let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
767        m.findings.push(Finding {
768            severity: Severity::Info,
769            title: "Info issue".to_string(),
770            detail: "info detail".to_string(),
771        });
772        m.findings.push(Finding {
773            severity: Severity::Critical,
774            title: "Critical issue".to_string(),
775            detail: "critical detail".to_string(),
776        });
777        let b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
778        let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
779        let engine = ConsensusEngine::new(ConsensusConfig::default());
780        let result = engine.determine(&[m, b, c]).unwrap();
781        assert_eq!(result.findings.len(), 2);
782        assert_eq!(result.findings[0].severity, Severity::Critical);
783        assert_eq!(result.findings[1].severity, Severity::Info);
784    }
785
786    /// Titles differing only in whitespace (tabs, multiple spaces, NBSP) are deduplicated.
787    #[test]
788    fn test_duplicate_findings_merged_with_whitespace_normalization() {
789        let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
790        m.findings.push(Finding {
791            severity: Severity::Warning,
792            title: "SQL  injection".to_string(),
793            detail: "detail_m".to_string(),
794        });
795        let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
796        b.findings.push(Finding {
797            severity: Severity::Warning,
798            title: "SQL\tinjection".to_string(),
799            detail: "detail_b".to_string(),
800        });
801        let mut c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
802        c.findings.push(Finding {
803            severity: Severity::Critical,
804            title: "sql injection".to_string(),
805            detail: "detail_c".to_string(),
806        });
807        let engine = ConsensusEngine::new(ConsensusConfig::default());
808        let result = engine.determine(&[m, b, c]).unwrap();
809        assert_eq!(result.findings.len(), 1, "should merge all three into one");
810        assert_eq!(result.findings[0].severity, Severity::Critical);
811        assert_eq!(result.findings[0].sources.len(), 3);
812    }
813
814    /// Votes map contains all agent verdicts.
815    #[test]
816    fn test_votes_map_contains_all_agents() {
817        let agents = vec![
818            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
819            make_output(AgentName::Balthasar, Verdict::Reject, 0.8),
820            make_output(AgentName::Caspar, Verdict::Conditional, 0.7),
821        ];
822        let engine = ConsensusEngine::new(ConsensusConfig::default());
823        let result = engine.determine(&agents).unwrap();
824        assert_eq!(result.votes.len(), 3);
825        assert_eq!(result.votes[&AgentName::Melchior], Verdict::Approve);
826        assert_eq!(result.votes[&AgentName::Balthasar], Verdict::Reject);
827        assert_eq!(result.votes[&AgentName::Caspar], Verdict::Conditional);
828    }
829
830    /// Agent count reflects number of inputs.
831    #[test]
832    fn test_agent_count_reflects_input_count() {
833        let agents = vec![
834            make_output(AgentName::Melchior, Verdict::Approve, 0.9),
835            make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
836            make_output(AgentName::Caspar, Verdict::Approve, 0.7),
837        ];
838        let engine = ConsensusEngine::new(ConsensusConfig::default());
839        let result = engine.determine(&agents).unwrap();
840        assert_eq!(result.agent_count, 3);
841    }
842}