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;
7use unicode_normalization::UnicodeNormalization;
8
9use crate::error::MagiError;
10use crate::schema::{AgentName, AgentOutput, Severity, Verdict};
11use crate::validate::clean_title;
12
13/// Configuration for the consensus engine.
14#[non_exhaustive]
15#[derive(Debug, Clone)]
16pub struct ConsensusConfig {
17    /// Minimum number of successful agent outputs required (default: 2).
18    pub min_agents: usize,
19    /// Tolerance for floating-point comparisons (default: 1e-9).
20    pub epsilon: f64,
21}
22
23/// Result of the consensus determination.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ConsensusResult {
26    /// Classification label (e.g., "STRONG GO", "GO WITH CAVEATS").
27    pub consensus: String,
28    /// Final verdict enum.
29    pub consensus_verdict: Verdict,
30    /// Computed confidence, rounded to 2 decimals.
31    pub confidence: f64,
32    /// Raw normalized score.
33    pub score: f64,
34    /// Number of agents that contributed.
35    pub agent_count: usize,
36    /// Per-agent verdicts.
37    pub votes: BTreeMap<AgentName, Verdict>,
38    /// Joined summaries from majority side.
39    pub majority_summary: String,
40    /// Dissenting agent details.
41    pub dissent: Vec<Dissent>,
42    /// Deduplicated findings sorted by severity (Critical first).
43    pub findings: Vec<DedupFinding>,
44    /// Conditions from Conditional agents.
45    pub conditions: Vec<Condition>,
46    /// Per-agent recommendations.
47    pub recommendations: BTreeMap<AgentName, String>,
48}
49
50/// A deduplicated finding aggregated across agents.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52pub struct DedupFinding {
53    /// Severity level (promoted to highest across duplicates).
54    pub severity: Severity,
55    /// Finding title.
56    pub title: String,
57    /// Finding detail (from highest-severity contributor).
58    pub detail: String,
59    /// Agents that reported this finding.
60    pub sources: Vec<AgentName>,
61}
62
63/// A dissenting agent's summary and reasoning.
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
65pub struct Dissent {
66    /// The dissenting agent.
67    pub agent: AgentName,
68    /// The agent's summary.
69    pub summary: String,
70    /// The agent's reasoning.
71    pub reasoning: String,
72}
73
74/// A condition extracted from a Conditional-verdict agent.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct Condition {
77    /// The agent that set the condition.
78    pub agent: AgentName,
79    /// The condition text (from the agent's summary).
80    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
92/// Stateless consensus engine for synthesizing agent outputs.
93///
94/// The engine takes a slice of [`AgentOutput`] values and produces a
95/// [`ConsensusResult`] containing the consensus label, confidence score,
96/// deduplicated findings, dissent tracking, and condition extraction.
97///
98/// The engine is stateless: each call to [`determine`](ConsensusEngine::determine)
99/// is independent and the engine is safe to share across threads.
100pub struct ConsensusEngine {
101    config: ConsensusConfig,
102}
103
104/// Computes the deduplication key for a finding title.
105///
106/// Applies a three-step transformation, matching Python's `_dedup_key` behavior:
107///
108/// 1. [`clean_title`] — strips zero-width Unicode characters, normalizes line
109///    endings and control characters to a single space, trims leading/trailing
110///    whitespace.  Interior runs of multiple spaces are **not** collapsed (aligned
111///    with Python: `clean_title` does not coalesce interior whitespace).
112/// 2. NFKC normalization — collapses compatibility variants (e.g., fullwidth Latin
113///    `ABC` → `ABC`, ligatures, circled letters).
114/// 3. Unicode default casefold via `caseless::default_case_fold_str` — handles
115///    characters that `to_lowercase` misses, e.g., `ß` → `"ss"`, `Σ`/`σ`/`ς` → same
116///    folded form.  This is **not** locale-aware (Turkish dotted-I folds per Unicode
117///    default tables, not Turkish locale rules).
118///
119/// ### Divergence from v0.1.x
120///
121/// v0.1.x applied `split_whitespace().join(" ")` before lowercasing, so
122/// `"foo  bar"` and `"foo bar"` were treated as the same finding. This function
123/// does **not** collapse interior whitespace — `"foo  bar"` and `"foo bar"`
124/// produce distinct keys, matching Python behavior.
125fn dedup_key(title: &str) -> String {
126    caseless::default_case_fold_str(&clean_title(title).nfkc().collect::<String>())
127}
128
129impl ConsensusEngine {
130    /// Returns the minimum number of agents required by this engine's configuration.
131    pub fn min_agents(&self) -> usize {
132        self.config.min_agents
133    }
134
135    /// Creates a new consensus engine with the given configuration.
136    ///
137    /// If `config.min_agents` is 0, it is clamped to 1.
138    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    /// Synthesizes agent outputs into a unified consensus result.
153    ///
154    /// # Errors
155    ///
156    /// - [`MagiError::InsufficientAgents`] if fewer than `min_agents` are provided.
157    /// - [`MagiError::Validation`] if duplicate agent names are detected.
158    pub fn determine(&self, agents: &[AgentOutput]) -> Result<ConsensusResult, MagiError> {
159        // 1. Validate input count
160        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        // 2. Reject duplicates
168        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        // 3. Compute normalized score
182        let score: f64 = agents.iter().map(|a| a.verdict.weight()).sum::<f64>() / n;
183
184        // 4. Determine majority verdict
185        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                // Tie: break by alphabetically first agent on each side
198                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        // 5. Classify score to label + consensus verdict
217        let (mut label, consensus_verdict) =
218            self.classify(score, epsilon, approve_count, reject_count, has_conditional);
219
220        // 6. Degraded mode cap
221        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        // 7. Compute confidence
230        // base_confidence: sum of majority-side confidences divided by TOTAL agent
231        // count (not majority count). This intentionally penalizes non-unanimous
232        // results — a dissenting agent dilutes the overall confidence even though
233        // it is not on the majority side.
234        // weight_factor: maps |score| from [0,1] to [0.5,1.0], so unanimous
235        // verdicts (|score|=1) get full weight while ties (score=0) halve it.
236        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        // 8. Deduplicate findings
247        let findings = self.deduplicate_findings(agents);
248
249        // 9. Identify dissent
250        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        // 10. Extract conditions
261        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        // 11. Build votes map
271        let votes: BTreeMap<AgentName, Verdict> =
272            agents.iter().map(|a| (a.agent, a.verdict)).collect();
273
274        // 12. Build majority summary
275        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        // 13. Build recommendations map
283        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    /// Classifies a score into a consensus label and verdict.
304    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    /// Deduplicates findings across agents using NFKC + Unicode casefold keying.
337    ///
338    /// Ordering contract: findings appear in first-seen order (by agent slice position),
339    /// then sorted by severity DESC (Critical → Info). Within equal severity, first-seen
340    /// order is preserved (stable sort).
341    fn deduplicate_findings(&self, agents: &[AgentOutput]) -> Vec<DedupFinding> {
342        /// Accumulates state for a group of findings that share the same dedup key.
343        struct GroupState {
344            /// Severity promoted to the highest seen across all matching findings.
345            severity: Severity,
346            /// Title from the first-seen finding (insertion order preserved).
347            title: String,
348            /// Detail from the highest-severity finding.
349            detail: String,
350            /// Agents that contributed a matching finding, in first-seen order.
351            sources: Vec<AgentName>,
352        }
353
354        // Intentional O(m²) — preserves insertion order without adding indexmap.
355        // m is bounded by `ValidationLimits::max_findings × agent_count`. At the
356        // default max_findings (100) × 3 agents = 300, this is ~90k string
357        // comparisons on short strings, <1ms in practice. Consider switching to
358        // `indexmap` if max_findings is configured above 500.
359        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                    // Promote severity and update detail if this finding is higher
366                    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        // Sort by severity descending (Critical first); stable to preserve first-seen
396        // order within equal severity groups.
397        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    // -- BDD Scenario 1: unanimous approve --
426
427    /// Three approve agents produce STRONG GO with score=1.0.
428    #[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    // -- BDD Scenario 2: mixed 2 approve + 1 reject --
443
444    /// Two approve + one reject produces GO (2-1) with positive score.
445    #[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    // -- BDD Scenario 3: approve + conditional + reject --
462
463    /// Approve + conditional + reject produces GO WITH CAVEATS (2-1).
464    #[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    // -- S05: GO WITH CAVEATS includes split count --
480
481    /// Three conditional agents produce GO WITH CAVEATS (3-0) (unanimous go side).
482    #[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    /// Two conditionals + one approve produce GO WITH CAVEATS (3-0).
496    #[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    /// Two conditionals + one reject.
510    /// score = (0.5 + 0.5 - 1.0) / 3 = 0.0 → HOLD -- TIE (score is exactly zero).
511    /// Reject pulls score to zero despite conditional majority on effective-verdict side.
512    #[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        // score = (0.5 + 0.5 + (-1.0)) / 3 = 0.0 → HOLD -- TIE
522        assert_eq!(result.consensus, "HOLD -- TIE");
523        assert_eq!(result.consensus_verdict, Verdict::Reject);
524    }
525
526    /// Two conditionals (degraded, 2 agents) produce GO WITH CAVEATS (2-0).
527    /// Degraded mode does NOT alter GO WITH CAVEATS — only caps STRONG labels.
528    #[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    /// One conditional + one approve (degraded) produce GO WITH CAVEATS (2-0).
542    #[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    /// One conditional + one reject (degraded) produce HOLD (1-1).
556    /// Score = (0.5 + -1.0) / 2 = -0.25 → negative → HOLD (1-1), not a tie.
557    #[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        // score = (0.5 - 1.0) / 2 = -0.25, negative, so HOLD side wins
566        // approve_count=1 (Conditional maps to Approve), reject_count=1
567        // HOLD label uses (reject_count-approve_count) = (1-1)
568        assert_eq!(result.consensus, "HOLD (1-1)");
569        assert_eq!(result.consensus_verdict, Verdict::Reject);
570    }
571
572    /// Boundary test: score just above epsilon classifies as GO WITH CAVEATS.
573    ///
574    /// Uses custom epsilon (0.2) to straddle the real score of
575    /// Approve(+1) + Conditional(+0.5) + Reject(-1) = 0.5/3 ≈ 0.1667.
576    /// With epsilon=0.1, score 0.1667 > epsilon → GO WITH CAVEATS.
577    #[test]
578    fn test_score_just_above_epsilon_classifies_as_go_with_caveats() {
579        // score = (1.0 + 0.5 - 1.0) / 3 ≈ 0.1667
580        // epsilon = 0.1 → score > epsilon → GO WITH CAVEATS (2-1)
581        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    /// Boundary test: score just below epsilon classifies as HOLD.
596    ///
597    /// Uses custom epsilon (0.2) to straddle the real score of
598    /// Approve(+1) + Conditional(+0.5) + Reject(-1) = 0.5/3 ≈ 0.1667.
599    /// With epsilon=0.2, score 0.1667 < epsilon → HOLD -- TIE.
600    #[test]
601    fn test_score_just_below_epsilon_classifies_as_hold() {
602        // score = (1.0 + 0.5 - 1.0) / 3 ≈ 0.1667
603        // epsilon = 0.2 → score.abs() < epsilon → HOLD -- TIE
604        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    // -- BDD Scenario 4: unanimous reject --
619
620    /// Three reject agents produce STRONG NO-GO with score=-1.0.
621    #[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    // -- BDD Scenario 5: tie with 2 agents --
636
637    /// One approve + one reject (2 agents) produces HOLD -- TIE.
638    #[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    // -- BDD Scenario 13: finding deduplication --
651
652    /// Same title different case merges into single finding with severity promoted.
653    #[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    /// Merged finding sources include both contributing agents.
675    #[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    /// Detail preserved from highest-severity finding.
698    #[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    /// On same severity, detail comes from the first-seen agent (slice order).
719    #[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        // Balthasar is first in the slice → first seen → Balthasar's detail preserved
737        assert_eq!(result.findings[0].detail, "detail_b");
738    }
739
740    // -- BDD Scenario 33: degraded mode caps STRONG labels --
741
742    /// Two approve agents (degraded) produce GO (2-0) not STRONG GO.
743    #[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    /// Two reject agents (degraded) produce HOLD (2-0) not STRONG NO-GO.
756    #[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    // -- Error cases --
769
770    /// Determine rejects fewer than min_agents with InsufficientAgents.
771    #[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    /// Determine rejects duplicate agent names with Validation error.
788    #[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    // -- Score and confidence calculations --
801
802    /// Epsilon-aware classification near score boundaries.
803    #[test]
804    fn test_epsilon_aware_classification_near_boundaries() {
805        // Score very close to 0 (within epsilon) should be HOLD -- TIE
806        // Use Conditional(+0.5) + Approve(+1.0) + Reject(-1.0) = +0.5/3 ≈ 0.1667
807        // That's not near zero. Instead, use a direct near-zero scenario:
808        // We need agents whose weights sum to ~0. e.g. 1 approve + 1 reject = 0/2 = 0
809        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    /// Confidence formula: base * weight_factor, clamped [0,1], rounded 2 decimals.
819    #[test]
820    fn test_confidence_formula_clamped_and_rounded() {
821        // 3 approve agents with confidence 0.9 each
822        // score = (1+1+1)/3 = 1.0
823        // majority side = all 3 (approve), base = (0.9+0.9+0.9)/3 = 0.9
824        // weight_factor = (1.0 + 1.0) / 2.0 = 1.0
825        // confidence = 0.9 * 1.0 = 0.9
826        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    /// Confidence with mixed verdicts applies weight_factor correctly.
837    #[test]
838    fn test_confidence_with_mixed_verdicts() {
839        // 2 approve (0.9, 0.8) + 1 reject (0.7)
840        // score = (1+1-1)/3 = 1/3 ≈ 0.3333
841        // majority = approve side: Melchior(0.9), Balthasar(0.8)
842        // base = (0.9 + 0.8) / 3 = 0.5667
843        // weight_factor = (0.3333 + 1.0) / 2.0 = 0.6667
844        // confidence = 0.5667 * 0.6667 = 0.3778 → rounded = 0.38
845        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    /// Majority summary joins majority agent summaries with " | ".
856    #[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    /// Majority summary uses agent display name capitalized (not lowercase).
880    #[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        // Ensure NOT lowercase
891        assert!(!result.majority_summary.contains("melchior:"));
892        assert!(!result.majority_summary.contains("balthasar:"));
893    }
894
895    /// Conditions extracted from agents with Conditional verdict.
896    #[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    /// Conditions use summary field, not recommendation field.
911    #[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    /// Conditions are distinct from recommendations section.
928    #[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        // Conditions should be sourced from summary
937        assert_eq!(result.conditions.len(), 1);
938        assert_eq!(result.conditions[0].condition, "Short condition summary");
939        // Recommendations should contain the recommendation field
940        assert!(result.recommendations.contains_key(&AgentName::Balthasar));
941        assert_eq!(
942            result.recommendations[&AgentName::Balthasar],
943            "Long detailed recommendation text"
944        );
945        // They must be distinct
946        assert_ne!(
947            result.conditions[0].condition,
948            result.recommendations[&AgentName::Balthasar]
949        );
950    }
951
952    /// Recommendations map includes all agents.
953    #[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    /// ConsensusConfig enforces min_agents >= 1.
969    #[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        // Engine should clamp to 1 internally
977        let engine = ConsensusEngine::new(config);
978        let agents = vec![make_output(AgentName::Melchior, Verdict::Approve, 0.9)];
979        // Should succeed with 1 agent even though min_agents was 0 (clamped to 1)
980        let result = engine.determine(&agents);
981        assert!(result.is_ok());
982    }
983
984    /// ConsensusConfig::default() returns min_agents=2, epsilon=1e-9.
985    #[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    /// Tiebreak by AgentName::cmp() — alphabetically first agent's side wins.
993    #[test]
994    fn test_tiebreak_by_agent_name_ordering() {
995        // Balthasar=Approve, Melchior=Reject → tie (score=0)
996        // Balthasar < Melchior alphabetically → Balthasar's side (Approve) wins tiebreak
997        // But label should still be HOLD -- TIE
998        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        // Score is 0, so label is HOLD -- TIE, verdict is Reject
1005        assert_eq!(result.consensus, "HOLD -- TIE");
1006        assert_eq!(result.consensus_verdict, Verdict::Reject);
1007    }
1008
1009    /// Findings sorted by severity (Critical first).
1010    #[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    /// Tab-separated and space-separated titles merge (clean_title replaces tab with space);
1033    /// but double-space interior is preserved — "SQL  injection" is a DISTINCT finding
1034    /// from "SQL injection". Aligned with Python dedup_key behavior (no split_whitespace).
1035    #[test]
1036    fn test_dedup_tab_normalizes_to_space_but_double_space_is_distinct() {
1037        // "SQL\tinjection" → clean_title → "SQL injection" → same key as "sql injection"
1038        // "SQL  injection" → clean_title → "SQL  injection" → different key (double space)
1039        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(), // double space — distinct key
1043            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(), // tab → space → merges with "sql injection"
1049            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        // "SQL\tinjection" and "sql injection" share the same key → merge into 1
1060        // "SQL  injection" has a distinct key → separate finding
1061        // Total: 2 findings
1062        assert_eq!(
1063            result.findings.len(),
1064            2,
1065            "tab-normalized title merges with single-space; double-space is distinct"
1066        );
1067        // Critical finding (merged tab+space group) sorts first
1068        assert_eq!(result.findings[0].severity, Severity::Critical);
1069        assert_eq!(result.findings[0].sources.len(), 2);
1070        // Warning finding (double-space group) is separate
1071        assert_eq!(result.findings[1].severity, Severity::Warning);
1072        assert_eq!(result.findings[1].sources.len(), 1);
1073    }
1074
1075    /// Votes map contains all agent verdicts.
1076    #[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    /// Agent count reflects number of inputs.
1092    #[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    // -- S03: dedup_key NFKC + casefold tests --
1105
1106    /// dedup_key applies NFKC normalization: fullwidth Latin chars collapse to ASCII.
1107    /// "ABC" (fullwidth) and "abc" must produce the same key after NFKC + casefold.
1108    #[test]
1109    fn test_dedup_key_nfkc_collapses_fullwidth_latin() {
1110        let key_fullwidth = dedup_key("\u{FF21}\u{FF22}\u{FF23}"); // ABC
1111        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    /// dedup_key applies NFKC: precomposed and combining forms of the same character
1119    /// produce the same key ("café" U+00E9 == "cafe\u{301}").
1120    #[test]
1121    fn test_dedup_key_nfkc_collapses_combining_accents() {
1122        let precomposed = dedup_key("caf\u{E9}"); // é precomposed
1123        let combining = dedup_key("cafe\u{301}"); // e + combining acute
1124        assert_eq!(
1125            precomposed, combining,
1126            "NFKC must collapse combining accents to precomposed form"
1127        );
1128    }
1129
1130    /// dedup_key uses full Unicode casefold: sharp-S ß must fold to "ss" (not "ß").
1131    /// Python str.casefold() and caseless crate both produce "ss". No #[ignore] needed.
1132    #[test]
1133    fn test_dedup_key_casefold_sharp_s_equals_double_s() {
1134        let sharp_s = dedup_key("\u{DF}"); // ß
1135        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    /// dedup_key casefolding: all Greek sigma variants (Σ, σ, ς) fold to the same key.
1143    ///
1144    /// Empirically verified (S03 Step 1):
1145    ///   Python: "ς".casefold() == "σ" (U+03C3)
1146    ///   caseless::default_case_fold_str("ς") == "σ" (U+03C3)
1147    /// Both agree, so all three variants are included in this test.
1148    #[test]
1149    fn test_dedup_key_casefold_greek_sigma_variants() {
1150        let capital = dedup_key("\u{03A3}"); // Σ GREEK CAPITAL LETTER SIGMA
1151        let small = dedup_key("\u{03C3}"); // σ GREEK SMALL LETTER SIGMA
1152        let final_s = dedup_key("\u{03C2}"); // ς GREEK SMALL LETTER FINAL SIGMA
1153        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    /// dedup_key does NOT apply locale-aware Turkish dotted-I folding.
1161    /// Unicode default casefold: "İ" (U+0130) folds to "i\u{307}" (i + combining dot above).
1162    /// This test confirms default (non-locale) behavior, matching caseless crate semantics.
1163    #[test]
1164    fn test_dedup_key_casefold_turkish_dotted_i() {
1165        // U+0130 (LATIN CAPITAL LETTER I WITH DOT ABOVE) under default (non-locale) casefold
1166        // maps to 'i' (U+0069) + combining dot above (U+0307). This matches Python's casefold
1167        // behavior (default, not Turkish locale).
1168        let input = "\u{0130}";
1169        assert_eq!(dedup_key(input), "i\u{307}");
1170    }
1171
1172    /// dedup_key preserves interior whitespace — aligning with Python clean_title + NFKC behavior.
1173    /// "foo  bar" (double space) and "foo bar" (single space) produce DIFFERENT keys.
1174    /// This is the correct Python-aligned behavior: no split_whitespace collapsing.
1175    #[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    /// Findings with fullwidth ASCII titles (e.g., "SQL injection") and ASCII titles
1186    /// ("SQL injection") are merged into a single deduplicated finding via NFKC.
1187    #[test]
1188    fn test_dedup_merges_fullwidth_and_ascii_titles() {
1189        // SQL = U+FF33 U+FF31 U+FF2C (fullwidth)
1190        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(), // SQL injection
1194            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    // -- S03: ordering regression tests --
1214
1215    /// When Melchior reports first in the agent slice, the deduplicated finding's
1216    /// title comes from Melchior's form and sources list is [Melchior, Balthasar].
1217    #[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        // Melchior is first in slice
1234        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        // First source should be Melchior (first-seen insertion order)
1242        assert_eq!(result.findings[0].sources[0], AgentName::Melchior);
1243        assert_eq!(result.findings[0].sources[1], AgentName::Balthasar);
1244    }
1245
1246    /// When Balthasar reports first in the agent slice, the deduplicated finding's
1247    /// title comes from Balthasar's form and sources list is [Balthasar, Melchior].
1248    #[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        // Balthasar is first in slice
1265        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    /// When two distinct findings have equal severity, the finding seen first in the
1277    /// agent slice appears first in the output (stable ordering).
1278    #[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        // Melchior is first, so "Alpha Issue" should appear first when severity ties
1295        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}