1use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use unicode_normalization::UnicodeNormalization;
8
9use crate::error::MagiError;
10use crate::schema::{AgentName, AgentOutput, Severity, Verdict};
11use crate::validate::clean_title;
12
13#[non_exhaustive]
15#[derive(Debug, Clone)]
16pub struct ConsensusConfig {
17 pub min_agents: usize,
19 pub epsilon: f64,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ConsensusResult {
26 pub consensus: String,
28 pub consensus_verdict: Verdict,
30 pub confidence: f64,
32 pub score: f64,
34 pub agent_count: usize,
36 pub votes: BTreeMap<AgentName, Verdict>,
38 pub majority_summary: String,
40 pub dissent: Vec<Dissent>,
42 pub findings: Vec<DedupFinding>,
44 pub conditions: Vec<Condition>,
46 pub recommendations: BTreeMap<AgentName, String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52pub struct DedupFinding {
53 pub severity: Severity,
55 pub title: String,
57 pub detail: String,
59 pub sources: Vec<AgentName>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
65pub struct Dissent {
66 pub agent: AgentName,
68 pub summary: String,
70 pub reasoning: String,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct Condition {
77 pub agent: AgentName,
79 pub condition: String,
81}
82
83impl Default for ConsensusConfig {
84 fn default() -> Self {
85 Self {
86 min_agents: 2,
87 epsilon: 1e-9,
88 }
89 }
90}
91
92pub struct ConsensusEngine {
101 config: ConsensusConfig,
102}
103
104fn dedup_key(title: &str) -> String {
126 caseless::default_case_fold_str(&clean_title(title).nfkc().collect::<String>())
127}
128
129impl ConsensusEngine {
130 pub fn min_agents(&self) -> usize {
132 self.config.min_agents
133 }
134
135 pub fn new(config: ConsensusConfig) -> Self {
139 let min_agents = if config.min_agents == 0 {
140 1
141 } else {
142 config.min_agents
143 };
144 Self {
145 config: ConsensusConfig {
146 min_agents,
147 ..config
148 },
149 }
150 }
151
152 pub fn determine(&self, agents: &[AgentOutput]) -> Result<ConsensusResult, MagiError> {
159 if agents.len() < self.config.min_agents {
161 return Err(MagiError::InsufficientAgents {
162 succeeded: agents.len(),
163 required: self.config.min_agents,
164 });
165 }
166
167 let mut seen = std::collections::HashSet::new();
169 for agent in agents {
170 if !seen.insert(agent.agent) {
171 return Err(MagiError::Validation(format!(
172 "duplicate agent name: {}",
173 agent.agent.display_name()
174 )));
175 }
176 }
177
178 let n = agents.len() as f64;
179 let epsilon = self.config.epsilon;
180
181 let score: f64 = agents.iter().map(|a| a.verdict.weight()).sum::<f64>() / n;
183
184 let approve_count = agents
186 .iter()
187 .filter(|a| a.effective_verdict() == Verdict::Approve)
188 .count();
189 let reject_count = agents.len() - approve_count;
190
191 let has_conditional = agents.iter().any(|a| a.verdict == Verdict::Conditional);
192
193 let majority_verdict = match approve_count.cmp(&reject_count) {
194 std::cmp::Ordering::Greater => Verdict::Approve,
195 std::cmp::Ordering::Less => Verdict::Reject,
196 std::cmp::Ordering::Equal => {
197 let first_approve = agents
199 .iter()
200 .filter(|a| a.effective_verdict() == Verdict::Approve)
201 .map(|a| a.agent)
202 .min();
203 let first_reject = agents
204 .iter()
205 .filter(|a| a.effective_verdict() == Verdict::Reject)
206 .map(|a| a.agent)
207 .min();
208 match (first_approve, first_reject) {
209 (Some(a), Some(r)) if a < r => Verdict::Approve,
210 (Some(_), None) => Verdict::Approve,
211 _ => Verdict::Reject,
212 }
213 }
214 };
215
216 let (mut label, consensus_verdict) =
218 self.classify(score, epsilon, approve_count, reject_count, has_conditional);
219
220 if agents.len() < 3 {
222 if label == "STRONG GO" {
223 label = format!("GO ({}-0)", agents.len());
224 } else if label == "STRONG NO-GO" {
225 label = format!("HOLD ({}-0)", agents.len());
226 }
227 }
228
229 let base_confidence: f64 = agents
237 .iter()
238 .filter(|a| a.effective_verdict() == majority_verdict)
239 .map(|a| a.confidence)
240 .sum::<f64>()
241 / n;
242 let weight_factor = (score.abs() + 1.0) / 2.0;
243 let confidence = (base_confidence * weight_factor).clamp(0.0, 1.0);
244 let confidence = (confidence * 100.0).round() / 100.0;
245
246 let findings = self.deduplicate_findings(agents);
248
249 let dissent: Vec<Dissent> = agents
251 .iter()
252 .filter(|a| a.effective_verdict() != majority_verdict)
253 .map(|a| Dissent {
254 agent: a.agent,
255 summary: a.summary.clone(),
256 reasoning: a.reasoning.clone(),
257 })
258 .collect();
259
260 let conditions: Vec<Condition> = agents
262 .iter()
263 .filter(|a| a.verdict == Verdict::Conditional)
264 .map(|a| Condition {
265 agent: a.agent,
266 condition: a.summary.clone(),
267 })
268 .collect();
269
270 let votes: BTreeMap<AgentName, Verdict> =
272 agents.iter().map(|a| (a.agent, a.verdict)).collect();
273
274 let majority_summary = agents
276 .iter()
277 .filter(|a| a.effective_verdict() == majority_verdict)
278 .map(|a| format!("{}: {}", a.agent.display_name(), a.summary))
279 .collect::<Vec<_>>()
280 .join(" | ");
281
282 let recommendations: BTreeMap<AgentName, String> = agents
284 .iter()
285 .map(|a| (a.agent, a.recommendation.clone()))
286 .collect();
287
288 Ok(ConsensusResult {
289 consensus: label,
290 consensus_verdict,
291 confidence,
292 score,
293 agent_count: agents.len(),
294 votes,
295 majority_summary,
296 dissent,
297 findings,
298 conditions,
299 recommendations,
300 })
301 }
302
303 fn classify(
305 &self,
306 score: f64,
307 epsilon: f64,
308 approve_count: usize,
309 reject_count: usize,
310 has_conditional: bool,
311 ) -> (String, Verdict) {
312 if (score - 1.0).abs() < epsilon {
313 ("STRONG GO".to_string(), Verdict::Approve)
314 } else if (score - (-1.0)).abs() < epsilon {
315 ("STRONG NO-GO".to_string(), Verdict::Reject)
316 } else if score > epsilon && has_conditional {
317 (
318 format!("GO WITH CAVEATS ({}-{})", approve_count, reject_count),
319 Verdict::Approve,
320 )
321 } else if score > epsilon {
322 (
323 format!("GO ({}-{})", approve_count, reject_count),
324 Verdict::Approve,
325 )
326 } else if score.abs() < epsilon {
327 ("HOLD -- TIE".to_string(), Verdict::Reject)
328 } else {
329 (
330 format!("HOLD ({}-{})", reject_count, approve_count),
331 Verdict::Reject,
332 )
333 }
334 }
335
336 fn deduplicate_findings(&self, agents: &[AgentOutput]) -> Vec<DedupFinding> {
342 struct GroupState {
344 severity: Severity,
346 title: String,
348 detail: String,
350 sources: Vec<AgentName>,
352 }
353
354 let mut groups: Vec<(String, GroupState)> = Vec::new();
360
361 for agent in agents {
362 for finding in &agent.findings {
363 let key = dedup_key(&finding.title);
364 if let Some((_, state)) = groups.iter_mut().find(|(k, _)| k == &key) {
365 if finding.severity > state.severity {
367 state.severity = finding.severity;
368 state.detail = finding.detail.clone();
369 }
370 state.sources.push(agent.agent);
371 } else {
372 groups.push((
373 key,
374 GroupState {
375 severity: finding.severity,
376 title: finding.title.clone(),
377 detail: finding.detail.clone(),
378 sources: vec![agent.agent],
379 },
380 ));
381 }
382 }
383 }
384
385 let mut result: Vec<DedupFinding> = groups
386 .into_iter()
387 .map(|(_, state)| DedupFinding {
388 severity: state.severity,
389 title: state.title,
390 detail: state.detail,
391 sources: state.sources,
392 })
393 .collect();
394
395 result.sort_by(|a, b| b.severity.cmp(&a.severity));
398 result
399 }
400}
401
402impl Default for ConsensusEngine {
403 fn default() -> Self {
404 Self::new(ConsensusConfig::default())
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::schema::*;
412
413 fn make_output(agent: AgentName, verdict: Verdict, confidence: f64) -> AgentOutput {
414 AgentOutput {
415 agent,
416 verdict,
417 confidence,
418 summary: format!("{} summary", agent.display_name()),
419 reasoning: format!("{} reasoning", agent.display_name()),
420 findings: vec![],
421 recommendation: format!("{} recommendation", agent.display_name()),
422 }
423 }
424
425 #[test]
429 fn test_unanimous_approve_produces_strong_go() {
430 let agents = vec![
431 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
432 make_output(AgentName::Balthasar, Verdict::Approve, 0.9),
433 make_output(AgentName::Caspar, Verdict::Approve, 0.9),
434 ];
435 let engine = ConsensusEngine::new(ConsensusConfig::default());
436 let result = engine.determine(&agents).unwrap();
437 assert_eq!(result.consensus, "STRONG GO");
438 assert_eq!(result.consensus_verdict, Verdict::Approve);
439 assert!((result.score - 1.0).abs() < 1e-9);
440 }
441
442 #[test]
446 fn test_two_approve_one_reject_produces_go_2_1() {
447 let agents = vec![
448 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
449 make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
450 make_output(AgentName::Caspar, Verdict::Reject, 0.7),
451 ];
452 let engine = ConsensusEngine::new(ConsensusConfig::default());
453 let result = engine.determine(&agents).unwrap();
454 assert_eq!(result.consensus, "GO (2-1)");
455 assert_eq!(result.consensus_verdict, Verdict::Approve);
456 assert!(result.score > 0.0);
457 assert_eq!(result.dissent.len(), 1);
458 assert_eq!(result.dissent[0].agent, AgentName::Caspar);
459 }
460
461 #[test]
465 fn test_approve_conditional_reject_produces_go_with_caveats() {
466 let agents = vec![
467 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
468 make_output(AgentName::Balthasar, Verdict::Conditional, 0.8),
469 make_output(AgentName::Caspar, Verdict::Reject, 0.7),
470 ];
471 let engine = ConsensusEngine::new(ConsensusConfig::default());
472 let result = engine.determine(&agents).unwrap();
473 assert_eq!(result.consensus, "GO WITH CAVEATS (2-1)");
474 assert_eq!(result.consensus_verdict, Verdict::Approve);
475 assert!(!result.conditions.is_empty());
476 assert_eq!(result.conditions[0].agent, AgentName::Balthasar);
477 }
478
479 #[test]
483 fn test_go_with_caveats_three_conditionals_unanimous() {
484 let agents = vec![
485 make_output(AgentName::Melchior, Verdict::Conditional, 0.9),
486 make_output(AgentName::Balthasar, Verdict::Conditional, 0.8),
487 make_output(AgentName::Caspar, Verdict::Conditional, 0.7),
488 ];
489 let engine = ConsensusEngine::new(ConsensusConfig::default());
490 let result = engine.determine(&agents).unwrap();
491 assert_eq!(result.consensus, "GO WITH CAVEATS (3-0)");
492 assert_eq!(result.consensus_verdict, Verdict::Approve);
493 }
494
495 #[test]
497 fn test_go_with_caveats_two_conditionals_one_approve() {
498 let agents = vec![
499 make_output(AgentName::Melchior, Verdict::Conditional, 0.9),
500 make_output(AgentName::Balthasar, Verdict::Conditional, 0.8),
501 make_output(AgentName::Caspar, Verdict::Approve, 0.7),
502 ];
503 let engine = ConsensusEngine::new(ConsensusConfig::default());
504 let result = engine.determine(&agents).unwrap();
505 assert_eq!(result.consensus, "GO WITH CAVEATS (3-0)");
506 assert_eq!(result.consensus_verdict, Verdict::Approve);
507 }
508
509 #[test]
513 fn test_go_with_caveats_two_conditionals_one_reject() {
514 let agents = vec![
515 make_output(AgentName::Melchior, Verdict::Conditional, 0.9),
516 make_output(AgentName::Balthasar, Verdict::Conditional, 0.8),
517 make_output(AgentName::Caspar, Verdict::Reject, 0.7),
518 ];
519 let engine = ConsensusEngine::new(ConsensusConfig::default());
520 let result = engine.determine(&agents).unwrap();
521 assert_eq!(result.consensus, "HOLD -- TIE");
523 assert_eq!(result.consensus_verdict, Verdict::Reject);
524 }
525
526 #[test]
529 fn test_go_with_caveats_degraded_two_conditionals() {
530 let agents = vec![
531 make_output(AgentName::Melchior, Verdict::Conditional, 0.9),
532 make_output(AgentName::Balthasar, Verdict::Conditional, 0.8),
533 ];
534 let engine = ConsensusEngine::new(ConsensusConfig::default());
535 let result = engine.determine(&agents).unwrap();
536 assert_eq!(result.consensus, "GO WITH CAVEATS (2-0)");
537 assert_eq!(result.consensus_verdict, Verdict::Approve);
538 assert_eq!(result.agent_count, 2);
539 }
540
541 #[test]
543 fn test_go_with_caveats_degraded_one_conditional_one_approve() {
544 let agents = vec![
545 make_output(AgentName::Melchior, Verdict::Conditional, 0.9),
546 make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
547 ];
548 let engine = ConsensusEngine::new(ConsensusConfig::default());
549 let result = engine.determine(&agents).unwrap();
550 assert_eq!(result.consensus, "GO WITH CAVEATS (2-0)");
551 assert_eq!(result.consensus_verdict, Verdict::Approve);
552 assert_eq!(result.agent_count, 2);
553 }
554
555 #[test]
558 fn test_degraded_one_conditional_one_reject_produces_hold_1_1() {
559 let agents = vec![
560 make_output(AgentName::Melchior, Verdict::Conditional, 0.9),
561 make_output(AgentName::Balthasar, Verdict::Reject, 0.8),
562 ];
563 let engine = ConsensusEngine::new(ConsensusConfig::default());
564 let result = engine.determine(&agents).unwrap();
565 assert_eq!(result.consensus, "HOLD (1-1)");
569 assert_eq!(result.consensus_verdict, Verdict::Reject);
570 }
571
572 #[test]
578 fn test_score_just_above_epsilon_classifies_as_go_with_caveats() {
579 let agents = vec![
582 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
583 make_output(AgentName::Balthasar, Verdict::Conditional, 0.8),
584 make_output(AgentName::Caspar, Verdict::Reject, 0.7),
585 ];
586 let engine = ConsensusEngine::new(ConsensusConfig {
587 epsilon: 0.1,
588 ..ConsensusConfig::default()
589 });
590 let result = engine.determine(&agents).unwrap();
591 assert_eq!(result.consensus, "GO WITH CAVEATS (2-1)");
592 assert_eq!(result.consensus_verdict, Verdict::Approve);
593 }
594
595 #[test]
601 fn test_score_just_below_epsilon_classifies_as_hold() {
602 let agents = vec![
605 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
606 make_output(AgentName::Balthasar, Verdict::Conditional, 0.8),
607 make_output(AgentName::Caspar, Verdict::Reject, 0.7),
608 ];
609 let engine = ConsensusEngine::new(ConsensusConfig {
610 epsilon: 0.2,
611 ..ConsensusConfig::default()
612 });
613 let result = engine.determine(&agents).unwrap();
614 assert_eq!(result.consensus, "HOLD -- TIE");
615 assert_eq!(result.consensus_verdict, Verdict::Reject);
616 }
617
618 #[test]
622 fn test_unanimous_reject_produces_strong_no_go() {
623 let agents = vec![
624 make_output(AgentName::Melchior, Verdict::Reject, 0.9),
625 make_output(AgentName::Balthasar, Verdict::Reject, 0.8),
626 make_output(AgentName::Caspar, Verdict::Reject, 0.7),
627 ];
628 let engine = ConsensusEngine::new(ConsensusConfig::default());
629 let result = engine.determine(&agents).unwrap();
630 assert_eq!(result.consensus, "STRONG NO-GO");
631 assert_eq!(result.consensus_verdict, Verdict::Reject);
632 assert!((result.score - (-1.0)).abs() < 1e-9);
633 }
634
635 #[test]
639 fn test_tie_with_two_agents_produces_hold_tie() {
640 let agents = vec![
641 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
642 make_output(AgentName::Caspar, Verdict::Reject, 0.9),
643 ];
644 let engine = ConsensusEngine::new(ConsensusConfig::default());
645 let result = engine.determine(&agents).unwrap();
646 assert_eq!(result.consensus, "HOLD -- TIE");
647 assert_eq!(result.consensus_verdict, Verdict::Reject);
648 }
649
650 #[test]
654 fn test_duplicate_findings_merged_with_severity_promoted() {
655 let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
656 m.findings.push(Finding {
657 severity: Severity::Warning,
658 title: "Security Issue".to_string(),
659 detail: "detail_warning".to_string(),
660 });
661 let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
662 b.findings.push(Finding {
663 severity: Severity::Critical,
664 title: "security issue".to_string(),
665 detail: "detail_critical".to_string(),
666 });
667 let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
668 let engine = ConsensusEngine::new(ConsensusConfig::default());
669 let result = engine.determine(&[m, b, c]).unwrap();
670 assert_eq!(result.findings.len(), 1);
671 assert_eq!(result.findings[0].severity, Severity::Critical);
672 }
673
674 #[test]
676 fn test_merged_finding_sources_include_both_agents() {
677 let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
678 m.findings.push(Finding {
679 severity: Severity::Warning,
680 title: "Security Issue".to_string(),
681 detail: "detail_m".to_string(),
682 });
683 let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
684 b.findings.push(Finding {
685 severity: Severity::Critical,
686 title: "security issue".to_string(),
687 detail: "detail_b".to_string(),
688 });
689 let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
690 let engine = ConsensusEngine::new(ConsensusConfig::default());
691 let result = engine.determine(&[m, b, c]).unwrap();
692 assert_eq!(result.findings[0].sources.len(), 2);
693 assert!(result.findings[0].sources.contains(&AgentName::Melchior));
694 assert!(result.findings[0].sources.contains(&AgentName::Balthasar));
695 }
696
697 #[test]
699 fn test_merged_finding_detail_from_highest_severity() {
700 let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
701 m.findings.push(Finding {
702 severity: Severity::Warning,
703 title: "Issue".to_string(),
704 detail: "detail_warning".to_string(),
705 });
706 let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
707 b.findings.push(Finding {
708 severity: Severity::Critical,
709 title: "issue".to_string(),
710 detail: "detail_critical".to_string(),
711 });
712 let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
713 let engine = ConsensusEngine::new(ConsensusConfig::default());
714 let result = engine.determine(&[m, b, c]).unwrap();
715 assert_eq!(result.findings[0].detail, "detail_critical");
716 }
717
718 #[test]
720 fn test_merged_finding_detail_from_first_agent_on_same_severity() {
721 let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
722 b.findings.push(Finding {
723 severity: Severity::Warning,
724 title: "Issue".to_string(),
725 detail: "detail_b".to_string(),
726 });
727 let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
728 m.findings.push(Finding {
729 severity: Severity::Warning,
730 title: "issue".to_string(),
731 detail: "detail_m".to_string(),
732 });
733 let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
734 let engine = ConsensusEngine::new(ConsensusConfig::default());
735 let result = engine.determine(&[b, m, c]).unwrap();
736 assert_eq!(result.findings[0].detail, "detail_b");
738 }
739
740 #[test]
744 fn test_degraded_mode_caps_strong_go_to_go() {
745 let agents = vec![
746 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
747 make_output(AgentName::Balthasar, Verdict::Approve, 0.9),
748 ];
749 let engine = ConsensusEngine::new(ConsensusConfig::default());
750 let result = engine.determine(&agents).unwrap();
751 assert_eq!(result.consensus, "GO (2-0)");
752 assert_ne!(result.consensus, "STRONG GO");
753 }
754
755 #[test]
757 fn test_degraded_mode_caps_strong_no_go_to_hold() {
758 let agents = vec![
759 make_output(AgentName::Melchior, Verdict::Reject, 0.9),
760 make_output(AgentName::Balthasar, Verdict::Reject, 0.9),
761 ];
762 let engine = ConsensusEngine::new(ConsensusConfig::default());
763 let result = engine.determine(&agents).unwrap();
764 assert_eq!(result.consensus, "HOLD (2-0)");
765 assert_ne!(result.consensus, "STRONG NO-GO");
766 }
767
768 #[test]
772 fn test_determine_rejects_fewer_than_min_agents() {
773 let agents = vec![make_output(AgentName::Melchior, Verdict::Approve, 0.9)];
774 let engine = ConsensusEngine::new(ConsensusConfig::default());
775 let result = engine.determine(&agents);
776 assert!(result.is_err());
777 let err = result.unwrap_err();
778 assert!(matches!(
779 err,
780 MagiError::InsufficientAgents {
781 succeeded: 1,
782 required: 2,
783 }
784 ));
785 }
786
787 #[test]
789 fn test_determine_rejects_duplicate_agent_names() {
790 let agents = vec![
791 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
792 make_output(AgentName::Melchior, Verdict::Reject, 0.8),
793 ];
794 let engine = ConsensusEngine::new(ConsensusConfig::default());
795 let result = engine.determine(&agents);
796 assert!(result.is_err());
797 assert!(matches!(result.unwrap_err(), MagiError::Validation(_)));
798 }
799
800 #[test]
804 fn test_epsilon_aware_classification_near_boundaries() {
805 let agents = vec![
810 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
811 make_output(AgentName::Balthasar, Verdict::Reject, 0.9),
812 ];
813 let engine = ConsensusEngine::new(ConsensusConfig::default());
814 let result = engine.determine(&agents).unwrap();
815 assert_eq!(result.consensus, "HOLD -- TIE");
816 }
817
818 #[test]
820 fn test_confidence_formula_clamped_and_rounded() {
821 let agents = vec![
827 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
828 make_output(AgentName::Balthasar, Verdict::Approve, 0.9),
829 make_output(AgentName::Caspar, Verdict::Approve, 0.9),
830 ];
831 let engine = ConsensusEngine::new(ConsensusConfig::default());
832 let result = engine.determine(&agents).unwrap();
833 assert!((result.confidence - 0.9).abs() < 1e-9);
834 }
835
836 #[test]
838 fn test_confidence_with_mixed_verdicts() {
839 let agents = vec![
846 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
847 make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
848 make_output(AgentName::Caspar, Verdict::Reject, 0.7),
849 ];
850 let engine = ConsensusEngine::new(ConsensusConfig::default());
851 let result = engine.determine(&agents).unwrap();
852 assert!((result.confidence - 0.38).abs() < 0.01);
853 }
854
855 #[test]
857 fn test_majority_summary_joins_with_pipe() {
858 let agents = vec![
859 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
860 make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
861 make_output(AgentName::Caspar, Verdict::Reject, 0.7),
862 ];
863 let engine = ConsensusEngine::new(ConsensusConfig::default());
864 let result = engine.determine(&agents).unwrap();
865 assert!(
866 result
867 .majority_summary
868 .contains("Melchior: Melchior summary")
869 );
870 assert!(
871 result
872 .majority_summary
873 .contains("Balthasar: Balthasar summary")
874 );
875 assert!(result.majority_summary.contains(" | "));
876 assert!(!result.majority_summary.contains("Caspar summary"));
877 }
878
879 #[test]
881 fn test_majority_summary_uses_display_name_capitalized() {
882 let agents = vec![
883 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
884 make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
885 ];
886 let engine = ConsensusEngine::new(ConsensusConfig::default());
887 let result = engine.determine(&agents).unwrap();
888 assert!(result.majority_summary.contains("Melchior:"));
889 assert!(result.majority_summary.contains("Balthasar:"));
890 assert!(!result.majority_summary.contains("melchior:"));
892 assert!(!result.majority_summary.contains("balthasar:"));
893 }
894
895 #[test]
897 fn test_conditions_extracted_from_conditional_agents() {
898 let agents = vec![
899 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
900 make_output(AgentName::Balthasar, Verdict::Conditional, 0.8),
901 make_output(AgentName::Caspar, Verdict::Reject, 0.7),
902 ];
903 let engine = ConsensusEngine::new(ConsensusConfig::default());
904 let result = engine.determine(&agents).unwrap();
905 assert_eq!(result.conditions.len(), 1);
906 assert_eq!(result.conditions[0].agent, AgentName::Balthasar);
907 assert_eq!(result.conditions[0].condition, "Balthasar summary");
908 }
909
910 #[test]
912 fn test_conditions_use_summary_field_not_recommendation_field() {
913 let mut agent = make_output(AgentName::Melchior, Verdict::Conditional, 0.9);
914 agent.summary = "Melchior condition summary".to_string();
915 agent.recommendation = "Melchior detailed recommendation".to_string();
916 let support = make_output(AgentName::Balthasar, Verdict::Approve, 0.8);
917 let engine = ConsensusEngine::new(ConsensusConfig::default());
918 let result = engine.determine(&[agent, support]).unwrap();
919 assert_eq!(result.conditions.len(), 1);
920 assert_eq!(result.conditions[0].condition, "Melchior condition summary");
921 assert_ne!(
922 result.conditions[0].condition,
923 "Melchior detailed recommendation"
924 );
925 }
926
927 #[test]
929 fn test_conditions_are_distinct_from_recommendations_section() {
930 let mut agent = make_output(AgentName::Balthasar, Verdict::Conditional, 0.85);
931 agent.summary = "Short condition summary".to_string();
932 agent.recommendation = "Long detailed recommendation text".to_string();
933 let support = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
934 let engine = ConsensusEngine::new(ConsensusConfig::default());
935 let result = engine.determine(&[agent, support]).unwrap();
936 assert_eq!(result.conditions.len(), 1);
938 assert_eq!(result.conditions[0].condition, "Short condition summary");
939 assert!(result.recommendations.contains_key(&AgentName::Balthasar));
941 assert_eq!(
942 result.recommendations[&AgentName::Balthasar],
943 "Long detailed recommendation text"
944 );
945 assert_ne!(
947 result.conditions[0].condition,
948 result.recommendations[&AgentName::Balthasar]
949 );
950 }
951
952 #[test]
954 fn test_recommendations_includes_all_agents() {
955 let agents = vec![
956 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
957 make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
958 make_output(AgentName::Caspar, Verdict::Reject, 0.7),
959 ];
960 let engine = ConsensusEngine::new(ConsensusConfig::default());
961 let result = engine.determine(&agents).unwrap();
962 assert_eq!(result.recommendations.len(), 3);
963 assert!(result.recommendations.contains_key(&AgentName::Melchior));
964 assert!(result.recommendations.contains_key(&AgentName::Balthasar));
965 assert!(result.recommendations.contains_key(&AgentName::Caspar));
966 }
967
968 #[test]
970 fn test_consensus_config_enforces_min_agents_at_least_one() {
971 let config = ConsensusConfig {
972 min_agents: 0,
973 ..ConsensusConfig::default()
974 };
975 assert_eq!(config.min_agents, 0);
976 let engine = ConsensusEngine::new(config);
978 let agents = vec![make_output(AgentName::Melchior, Verdict::Approve, 0.9)];
979 let result = engine.determine(&agents);
981 assert!(result.is_ok());
982 }
983
984 #[test]
986 fn test_consensus_config_default_values() {
987 let config = ConsensusConfig::default();
988 assert_eq!(config.min_agents, 2);
989 assert!((config.epsilon - 1e-9).abs() < 1e-15);
990 }
991
992 #[test]
994 fn test_tiebreak_by_agent_name_ordering() {
995 let agents = vec![
999 make_output(AgentName::Balthasar, Verdict::Approve, 0.9),
1000 make_output(AgentName::Melchior, Verdict::Reject, 0.9),
1001 ];
1002 let engine = ConsensusEngine::new(ConsensusConfig::default());
1003 let result = engine.determine(&agents).unwrap();
1004 assert_eq!(result.consensus, "HOLD -- TIE");
1006 assert_eq!(result.consensus_verdict, Verdict::Reject);
1007 }
1008
1009 #[test]
1011 fn test_findings_sorted_by_severity_critical_first() {
1012 let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
1013 m.findings.push(Finding {
1014 severity: Severity::Info,
1015 title: "Info issue".to_string(),
1016 detail: "info detail".to_string(),
1017 });
1018 m.findings.push(Finding {
1019 severity: Severity::Critical,
1020 title: "Critical issue".to_string(),
1021 detail: "critical detail".to_string(),
1022 });
1023 let b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
1024 let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
1025 let engine = ConsensusEngine::new(ConsensusConfig::default());
1026 let result = engine.determine(&[m, b, c]).unwrap();
1027 assert_eq!(result.findings.len(), 2);
1028 assert_eq!(result.findings[0].severity, Severity::Critical);
1029 assert_eq!(result.findings[1].severity, Severity::Info);
1030 }
1031
1032 #[test]
1036 fn test_dedup_tab_normalizes_to_space_but_double_space_is_distinct() {
1037 let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
1040 m.findings.push(Finding {
1041 severity: Severity::Warning,
1042 title: "SQL injection".to_string(), detail: "detail_m".to_string(),
1044 });
1045 let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
1046 b.findings.push(Finding {
1047 severity: Severity::Warning,
1048 title: "SQL\tinjection".to_string(), detail: "detail_b".to_string(),
1050 });
1051 let mut c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
1052 c.findings.push(Finding {
1053 severity: Severity::Critical,
1054 title: "sql injection".to_string(),
1055 detail: "detail_c".to_string(),
1056 });
1057 let engine = ConsensusEngine::new(ConsensusConfig::default());
1058 let result = engine.determine(&[m, b, c]).unwrap();
1059 assert_eq!(
1063 result.findings.len(),
1064 2,
1065 "tab-normalized title merges with single-space; double-space is distinct"
1066 );
1067 assert_eq!(result.findings[0].severity, Severity::Critical);
1069 assert_eq!(result.findings[0].sources.len(), 2);
1070 assert_eq!(result.findings[1].severity, Severity::Warning);
1072 assert_eq!(result.findings[1].sources.len(), 1);
1073 }
1074
1075 #[test]
1077 fn test_votes_map_contains_all_agents() {
1078 let agents = vec![
1079 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
1080 make_output(AgentName::Balthasar, Verdict::Reject, 0.8),
1081 make_output(AgentName::Caspar, Verdict::Conditional, 0.7),
1082 ];
1083 let engine = ConsensusEngine::new(ConsensusConfig::default());
1084 let result = engine.determine(&agents).unwrap();
1085 assert_eq!(result.votes.len(), 3);
1086 assert_eq!(result.votes[&AgentName::Melchior], Verdict::Approve);
1087 assert_eq!(result.votes[&AgentName::Balthasar], Verdict::Reject);
1088 assert_eq!(result.votes[&AgentName::Caspar], Verdict::Conditional);
1089 }
1090
1091 #[test]
1093 fn test_agent_count_reflects_input_count() {
1094 let agents = vec![
1095 make_output(AgentName::Melchior, Verdict::Approve, 0.9),
1096 make_output(AgentName::Balthasar, Verdict::Approve, 0.8),
1097 make_output(AgentName::Caspar, Verdict::Approve, 0.7),
1098 ];
1099 let engine = ConsensusEngine::new(ConsensusConfig::default());
1100 let result = engine.determine(&agents).unwrap();
1101 assert_eq!(result.agent_count, 3);
1102 }
1103
1104 #[test]
1109 fn test_dedup_key_nfkc_collapses_fullwidth_latin() {
1110 let key_fullwidth = dedup_key("\u{FF21}\u{FF22}\u{FF23}"); let key_ascii = dedup_key("abc");
1112 assert_eq!(
1113 key_fullwidth, key_ascii,
1114 "NFKC must collapse fullwidth ABC to abc"
1115 );
1116 }
1117
1118 #[test]
1121 fn test_dedup_key_nfkc_collapses_combining_accents() {
1122 let precomposed = dedup_key("caf\u{E9}"); let combining = dedup_key("cafe\u{301}"); assert_eq!(
1125 precomposed, combining,
1126 "NFKC must collapse combining accents to precomposed form"
1127 );
1128 }
1129
1130 #[test]
1133 fn test_dedup_key_casefold_sharp_s_equals_double_s() {
1134 let sharp_s = dedup_key("\u{DF}"); let double_s = dedup_key("ss");
1136 assert_eq!(
1137 sharp_s, double_s,
1138 "casefold must fold ß to ss (full Unicode fold, not to_lowercase)"
1139 );
1140 }
1141
1142 #[test]
1149 fn test_dedup_key_casefold_greek_sigma_variants() {
1150 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");
1154 assert_eq!(
1155 small, final_s,
1156 "σ and ς must fold to the same key (both caseless and Python agree)"
1157 );
1158 }
1159
1160 #[test]
1164 fn test_dedup_key_casefold_turkish_dotted_i() {
1165 let input = "\u{0130}";
1169 assert_eq!(dedup_key(input), "i\u{307}");
1170 }
1171
1172 #[test]
1176 fn test_dedup_key_preserves_interior_whitespace() {
1177 let double_space = dedup_key("foo bar");
1178 let single_space = dedup_key("foo bar");
1179 assert_ne!(
1180 double_space, single_space,
1181 "dedup_key must NOT collapse interior whitespace (aligned with Python)"
1182 );
1183 }
1184
1185 #[test]
1188 fn test_dedup_merges_fullwidth_and_ascii_titles() {
1189 let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
1191 m.findings.push(Finding {
1192 severity: Severity::Warning,
1193 title: "\u{FF33}\u{FF31}\u{FF2C} injection".to_string(), detail: "detail_fullwidth".to_string(),
1195 });
1196 let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
1197 b.findings.push(Finding {
1198 severity: Severity::Critical,
1199 title: "sql injection".to_string(),
1200 detail: "detail_ascii".to_string(),
1201 });
1202 let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
1203 let engine = ConsensusEngine::new(ConsensusConfig::default());
1204 let result = engine.determine(&[m, b, c]).unwrap();
1205 assert_eq!(
1206 result.findings.len(),
1207 1,
1208 "fullwidth and ASCII titles must merge to one finding via NFKC"
1209 );
1210 assert_eq!(result.findings[0].severity, Severity::Critical);
1211 }
1212
1213 #[test]
1218 fn test_dedup_first_seen_order_preserved_when_melchior_reports_first() {
1219 let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
1220 m.findings.push(Finding {
1221 severity: Severity::Warning,
1222 title: "Issue A".to_string(),
1223 detail: "detail_m".to_string(),
1224 });
1225 let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
1226 b.findings.push(Finding {
1227 severity: Severity::Warning,
1228 title: "issue a".to_string(),
1229 detail: "detail_b".to_string(),
1230 });
1231 let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
1232 let engine = ConsensusEngine::new(ConsensusConfig::default());
1233 let result = engine.determine(&[m, b, c]).unwrap();
1235 assert_eq!(result.findings.len(), 1);
1236 assert_eq!(
1237 result.findings[0].title, "Issue A",
1238 "title must come from Melchior (first seen)"
1239 );
1240 assert_eq!(result.findings[0].sources.len(), 2);
1241 assert_eq!(result.findings[0].sources[0], AgentName::Melchior);
1243 assert_eq!(result.findings[0].sources[1], AgentName::Balthasar);
1244 }
1245
1246 #[test]
1249 fn test_dedup_first_seen_order_preserved_when_balthasar_reports_first() {
1250 let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
1251 b.findings.push(Finding {
1252 severity: Severity::Warning,
1253 title: "issue a".to_string(),
1254 detail: "detail_b".to_string(),
1255 });
1256 let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
1257 m.findings.push(Finding {
1258 severity: Severity::Warning,
1259 title: "Issue A".to_string(),
1260 detail: "detail_m".to_string(),
1261 });
1262 let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
1263 let engine = ConsensusEngine::new(ConsensusConfig::default());
1264 let result = engine.determine(&[b, m, c]).unwrap();
1266 assert_eq!(result.findings.len(), 1);
1267 assert_eq!(
1268 result.findings[0].title, "issue a",
1269 "title must come from Balthasar (first seen)"
1270 );
1271 assert_eq!(result.findings[0].sources.len(), 2);
1272 assert_eq!(result.findings[0].sources[0], AgentName::Balthasar);
1273 assert_eq!(result.findings[0].sources[1], AgentName::Melchior);
1274 }
1275
1276 #[test]
1279 fn test_dedup_ordering_stable_across_equal_severity() {
1280 let mut m = make_output(AgentName::Melchior, Verdict::Approve, 0.9);
1281 m.findings.push(Finding {
1282 severity: Severity::Warning,
1283 title: "Alpha Issue".to_string(),
1284 detail: "detail_alpha".to_string(),
1285 });
1286 let mut b = make_output(AgentName::Balthasar, Verdict::Approve, 0.9);
1287 b.findings.push(Finding {
1288 severity: Severity::Warning,
1289 title: "Beta Issue".to_string(),
1290 detail: "detail_beta".to_string(),
1291 });
1292 let c = make_output(AgentName::Caspar, Verdict::Approve, 0.9);
1293 let engine = ConsensusEngine::new(ConsensusConfig::default());
1294 let result = engine.determine(&[m, b, c]).unwrap();
1296 assert_eq!(result.findings.len(), 2);
1297 assert_eq!(
1298 result.findings[0].title, "Alpha Issue",
1299 "first-seen finding must appear first when severity is equal"
1300 );
1301 assert_eq!(result.findings[1].title, "Beta Issue");
1302 }
1303}