Skip to main content

forge_guard/ai/
consensus.rs

1//! Consensus engine — aggregates findings from multiple AI auditors with
2//! cross-validation confidence scoring and deduplication.
3
4use crate::ai::auditors::AuditorAgent;
5use crate::ai::{AuditContext, AuditorFinding};
6
7use super::ConsensusFinding;
8
9/// Minimum confidence threshold for a finding to be included in the final report.
10const DEFAULT_MIN_CONFIDENCE: f64 = 0.5;
11
12/// Consensus configuration.
13#[derive(Debug, Clone)]
14pub struct ConsensusConfig {
15    /// Minimum confidence (0.0–1.0) required for a finding to be included.
16    pub min_confidence: f64,
17    /// Boost confidence when multiple auditors flag the same issue.
18    pub consensus_boost: f64,
19    /// Whether to merge findings that match across domains.
20    pub cross_domain_merge: bool,
21}
22
23impl Default for ConsensusConfig {
24    fn default() -> Self {
25        Self {
26            min_confidence: DEFAULT_MIN_CONFIDENCE,
27            consensus_boost: 0.2,
28            cross_domain_merge: true,
29        }
30    }
31}
32
33/// A report produced by the consensus engine.
34#[derive(Debug, Clone)]
35pub struct ConsensusReport {
36    /// All findings that passed the consensus threshold.
37    pub findings: Vec<ConsensusFinding>,
38    /// Number of auditors that participated.
39    pub auditor_count: usize,
40    /// How many findings were deduplicated / merged.
41    pub deduplicated_count: usize,
42    /// How many findings were discarded due to low confidence.
43    pub filtered_count: usize,
44}
45
46/// The consensus engine runs multiple auditors over the same source code,
47/// deduplicates overlapping findings, and boosts confidence when multiple
48/// auditors independently flag the same issue.
49pub struct ConsensusEngine {
50    auditors: Vec<Box<dyn AuditorAgent>>,
51    config: ConsensusConfig,
52}
53
54impl ConsensusEngine {
55    /// Create a new consensus engine with the given configuration.
56    pub fn new(config: ConsensusConfig) -> Self {
57        Self {
58            auditors: Vec::new(),
59            config,
60        }
61    }
62
63    /// Register an auditor agent.
64    pub fn register(&mut self, auditor: Box<dyn AuditorAgent>) {
65        self.auditors.push(auditor);
66    }
67
68    /// Number of registered auditors.
69    pub fn auditor_count(&self) -> usize {
70        self.auditors.len()
71    }
72
73    /// Run all auditors and aggregate findings through the consensus pipeline.
74    pub fn analyze(&self, context: &AuditContext) -> ConsensusReport {
75        let mut all_findings: Vec<ConsensusFinding> = Vec::new();
76
77        for auditor in &self.auditors {
78            match auditor.analyze(context) {
79                Ok(findings) => {
80                    for finding in findings {
81                        all_findings.push(ConsensusFinding {
82                            auditor: auditor.name().to_owned(),
83                            domain: auditor.domain().to_owned(),
84                            finding,
85                            cross_validated: false,
86                        });
87                    }
88                }
89                Err(e) => {
90                    eprintln!("  ⚠️  [ai:{}] Error: {e}", auditor.name());
91                }
92            }
93        }
94
95        // Step 1: Cross-validate — boost confidence for duplicates across auditors
96        let (mut merged, dedup_count) = self.deduplicate_and_boost(all_findings);
97
98        // Step 2: Filter by minimum confidence
99        let before_filter = merged.len();
100        merged.retain(|cf| cf.finding.confidence >= self.config.min_confidence);
101        let filtered = before_filter - merged.len();
102
103        ConsensusReport {
104            findings: merged,
105            auditor_count: self.auditors.len(),
106            deduplicated_count: dedup_count,
107            filtered_count: filtered,
108        }
109    }
110
111    /// Deduplicate findings across auditors and boost confidence for consensus.
112    ///
113    /// Two findings are considered "the same issue" when their normalized titles
114    /// share significant keyword overlap. When multiple auditors flag the same
115    /// issue, confidence is boosted by `consensus_boost` per additional auditor.
116    fn deduplicate_and_boost(
117        &self,
118        findings: Vec<ConsensusFinding>,
119    ) -> (Vec<ConsensusFinding>, usize) {
120        if findings.is_empty() {
121            return (Vec::new(), 0);
122        }
123
124        let mut dedup_count = 0;
125        let mut groups: Vec<Vec<ConsensusFinding>> = Vec::new();
126
127        for finding in findings {
128            // Try to match into an existing group
129            let matched = if self.config.cross_domain_merge {
130                groups.iter_mut().find(|group| {
131                    group
132                        .first()
133                        .map(|first| same_issue(&first.finding, &finding.finding))
134                        .unwrap_or(false)
135                })
136            } else {
137                // Only match within the same domain
138                groups.iter_mut().find(|group| {
139                    group
140                        .first()
141                        .map(|first| {
142                            first.domain == finding.domain
143                                && same_issue(&first.finding, &finding.finding)
144                        })
145                        .unwrap_or(false)
146                })
147            };
148
149            match matched {
150                Some(group) => {
151                    group.push(finding);
152                    dedup_count += 1;
153                }
154                None => {
155                    groups.push(vec![finding]);
156                }
157            }
158        }
159
160        // Apply consensus boost and keep the highest-confidence finding per group
161        let mut result = Vec::with_capacity(groups.len());
162        for group in groups {
163            let count = group.len();
164            let boost = if count > 1 {
165                (count as f64 - 1.0) * self.config.consensus_boost
166            } else {
167                0.0
168            };
169
170            // Find the finding with highest original confidence
171            let mut best = group.into_iter().max_by(|a, b| {
172                a.finding
173                    .confidence
174                    .partial_cmp(&b.finding.confidence)
175                    .unwrap_or(std::cmp::Ordering::Equal)
176            });
177
178            if let Some(ref mut best) = best {
179                best.finding.confidence = (best.finding.confidence + boost).min(1.0);
180                best.cross_validated = count > 1;
181            }
182
183            if let Some(best) = best {
184                result.push(best);
185            }
186        }
187
188        (result, dedup_count)
189    }
190}
191
192/// Determine whether two findings describe the same underlying issue.
193///
194/// Uses title keyword overlap as a heuristic:
195/// - Tokenize both titles into lowercase keywords
196/// - If intersection / min(|a|, |b|) >= 0.5, they're the same issue
197fn same_issue(a: &AuditorFinding, b: &AuditorFinding) -> bool {
198    let tokens_a = tokenize(&a.title);
199    let tokens_b = tokenize(&b.title);
200
201    if tokens_a.is_empty() || tokens_b.is_empty() {
202        return false;
203    }
204
205    let intersection: Vec<&String> = tokens_a.iter().filter(|t| tokens_b.contains(t)).collect();
206    let min_len = tokens_a.len().min(tokens_b.len());
207    (intersection.len() as f64 / min_len as f64) >= 0.5
208}
209
210/// Normalize a string into lowercase keyword tokens.
211fn tokenize(s: &str) -> Vec<String> {
212    s.to_lowercase()
213        .split(|c: char| !c.is_alphanumeric())
214        .filter(|t| !t.is_empty() && t.len() > 2)
215        .map(String::from)
216        .collect()
217}
218
219// ── Tests ────────────────────────────────────────────────────
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::core::Severity;
224
225    fn make_finding(
226        title: &str,
227        confidence: f64,
228        severity: Severity,
229        category: &str,
230    ) -> AuditorFinding {
231        AuditorFinding {
232            title: title.to_owned(),
233            description: "description".into(),
234            confidence,
235            severity,
236            suggestion: "recommendation".into(),
237            line_numbers: vec![1],
238            category: category.to_owned(),
239        }
240    }
241
242    fn make_consensus(
243        auditor: &str,
244        domain: &str,
245        title: &str,
246        confidence: f64,
247    ) -> ConsensusFinding {
248        ConsensusFinding {
249            auditor: auditor.to_owned(),
250            domain: domain.to_owned(),
251            finding: make_finding(title, confidence, Severity::High, "Test"),
252            cross_validated: false,
253        }
254    }
255
256    #[test]
257    fn test_empty_engine() {
258        let engine = ConsensusEngine::new(ConsensusConfig::default());
259        let report = engine.analyze(&AuditContext {
260            source_code: "".into(),
261            file_name: "test.sol".into(),
262            compiler_version: "0.8.20".into(),
263            additional: Default::default(),
264        });
265        assert_eq!(report.auditor_count, 0);
266        assert!(report.findings.is_empty());
267    }
268
269    #[test]
270    fn test_tokenize() {
271        let tokens = tokenize("Reentrancy vulnerability in withdraw()");
272        assert!(tokens.contains(&"reentrancy".to_string()));
273        assert!(tokens.contains(&"vulnerability".to_string()));
274        assert!(tokens.contains(&"withdraw".to_string()));
275        assert!(!tokens.contains(&"in".to_string())); // too short
276    }
277
278    #[test]
279    fn test_same_issue_identical() {
280        let a = make_finding("Reentrancy in withdraw", 0.9, Severity::High, "Reentrancy");
281        let b = make_finding("Reentrancy in withdraw", 0.8, Severity::High, "Reentrancy");
282        assert!(same_issue(&a, &b));
283    }
284
285    #[test]
286    fn test_same_issue_similar() {
287        let a = make_finding(
288            "Reentrancy vulnerability in withdraw function",
289            0.9,
290            Severity::High,
291            "Reentrancy",
292        );
293        let b = make_finding(
294            "Reentrancy bug in withdraw",
295            0.8,
296            Severity::High,
297            "Reentrancy",
298        );
299        assert!(same_issue(&a, &b));
300    }
301
302    #[test]
303    fn test_different_issues() {
304        let a = make_finding("Reentrancy in withdraw", 0.9, Severity::High, "Reentrancy");
305        let b = make_finding(
306            "Missing access control on mint",
307            0.8,
308            Severity::High,
309            "AccessControl",
310        );
311        assert!(!same_issue(&a, &b));
312    }
313
314    #[test]
315    fn test_deduplicate_and_boost() {
316        let engine = ConsensusEngine::new(ConsensusConfig::default());
317        let findings = vec![
318            make_consensus("auditor-a", "Security", "Reentrancy in withdraw", 0.8),
319            make_consensus("auditor-b", "Security", "Reentrancy in withdraw", 0.7),
320            make_consensus("auditor-a", "Security", "Access control on mint", 0.9),
321        ];
322
323        let (result, dedup) = engine.deduplicate_and_boost(findings);
324        assert_eq!(
325            result.len(),
326            2,
327            "should merge two reentrancy findings into one"
328        );
329        assert_eq!(dedup, 1, "one finding should be deduplicated");
330
331        // The surviving reentrancy finding should have boosted confidence
332        let reentrancy = result
333            .iter()
334            .find(|cf| cf.finding.title.contains("Reentrancy"))
335            .unwrap();
336        assert!(
337            reentrancy.finding.confidence > 0.9,
338            "confidence should be boosted: {}",
339            reentrancy.finding.confidence
340        );
341        assert!(reentrancy.cross_validated);
342    }
343
344    #[test]
345    fn test_no_deduplication_when_different_titles() {
346        let engine = ConsensusEngine::new(ConsensusConfig::default());
347        let findings = vec![
348            make_consensus("auditor-a", "Security", "Reentrancy in withdraw", 0.8),
349            make_consensus("auditor-b", "Security", "Access control on mint", 0.7),
350            make_consensus("auditor-c", "Gas", "Loop gas waste", 0.6),
351        ];
352
353        let (result, dedup) = engine.deduplicate_and_boost(findings);
354        assert_eq!(result.len(), 3);
355        assert_eq!(dedup, 0);
356    }
357
358    #[test]
359    fn test_filter_low_confidence() {
360        let engine = ConsensusEngine::new(ConsensusConfig {
361            min_confidence: 0.7,
362            ..Default::default()
363        });
364
365        // We need at least one auditor registered for the report
366        // Just test the filtering manually
367        let findings = vec![
368            make_consensus("auditor-a", "Security", "Critical reentrancy", 0.95),
369            make_consensus("auditor-b", "Security", "Low confidence issue", 0.3),
370        ];
371
372        // Simulate what analyze() does
373        let (mut merged, _) = engine.deduplicate_and_boost(findings);
374        let before = merged.len();
375        merged.retain(|cf| cf.finding.confidence >= 0.7);
376        let filtered = before - merged.len();
377
378        assert_eq!(merged.len(), 1);
379        assert_eq!(filtered, 1);
380        assert!(merged[0].finding.title.contains("reentrancy"));
381    }
382
383    #[test]
384    fn test_consensus_report_structure() {
385        // Test with no auditors registered
386        let engine = ConsensusEngine::new(ConsensusConfig::default());
387        let ctx = AuditContext {
388            source_code: "contract C {}".into(),
389            file_name: "c.sol".into(),
390            compiler_version: "0.8.20".into(),
391            additional: Default::default(),
392        };
393        let report = engine.analyze(&ctx);
394        assert_eq!(report.auditor_count, 0);
395        assert_eq!(report.deduplicated_count, 0);
396        assert_eq!(report.filtered_count, 0);
397    }
398}