1use crate::ai::auditors::AuditorAgent;
5use crate::ai::{AuditContext, AuditorFinding};
6
7use super::ConsensusFinding;
8
9const DEFAULT_MIN_CONFIDENCE: f64 = 0.5;
11
12#[derive(Debug, Clone)]
14pub struct ConsensusConfig {
15 pub min_confidence: f64,
17 pub consensus_boost: f64,
19 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#[derive(Debug, Clone)]
35pub struct ConsensusReport {
36 pub findings: Vec<ConsensusFinding>,
38 pub auditor_count: usize,
40 pub deduplicated_count: usize,
42 pub filtered_count: usize,
44}
45
46pub struct ConsensusEngine {
50 auditors: Vec<Box<dyn AuditorAgent>>,
51 config: ConsensusConfig,
52}
53
54impl ConsensusEngine {
55 pub fn new(config: ConsensusConfig) -> Self {
57 Self {
58 auditors: Vec::new(),
59 config,
60 }
61 }
62
63 pub fn register(&mut self, auditor: Box<dyn AuditorAgent>) {
65 self.auditors.push(auditor);
66 }
67
68 pub fn auditor_count(&self) -> usize {
70 self.auditors.len()
71 }
72
73 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 let (mut merged, dedup_count) = self.deduplicate_and_boost(all_findings);
97
98 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 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 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 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 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 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
192fn 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
210fn 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#[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())); }
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 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 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 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 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}