use crate::models::{Finding, Severity};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use tracing::{debug, info};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum VotingStrategy {
#[default]
Majority,
Weighted,
Threshold,
Unanimous,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ConfidenceMethod {
Average,
#[default]
Weighted,
Bayesian,
Max,
Min,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum SeverityResolution {
#[default]
Highest,
Lowest,
MajorityVote,
WeightedVote,
}
#[derive(Debug, Clone)]
pub struct DetectorWeight {
pub name: String,
pub weight: f64,
pub accuracy: f64,
}
impl DetectorWeight {
pub fn new(name: impl Into<String>, weight: f64, accuracy: f64) -> Self {
Self {
name: name.into(),
weight,
accuracy,
}
}
}
impl Default for DetectorWeight {
fn default() -> Self {
Self {
name: "default".to_string(),
weight: 1.0,
accuracy: 0.80,
}
}
}
#[derive(Debug, Clone)]
pub struct ConsensusResult {
pub has_consensus: bool,
pub confidence: f64,
pub severity: Severity,
pub contributing_detectors: Vec<String>,
pub vote_count: usize,
pub total_detectors: usize,
pub agreement_ratio: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VotingStats {
pub total_input: usize,
pub total_output: usize,
pub groups_analyzed: usize,
pub single_detector_findings: usize,
pub multi_detector_findings: usize,
pub boosted_by_consensus: usize,
pub rejected_low_confidence: usize,
pub strategy: String,
pub confidence_method: String,
pub threshold: f64,
}
fn default_detector_weights() -> HashMap<String, DetectorWeight> {
let weights = vec![
("CircularDependencyDetector", 1.2, 0.95),
("GodClassDetector", 1.1, 0.85),
("FeatureEnvyDetector", 1.0, 0.80),
("ShotgunSurgeryDetector", 1.0, 0.85),
("InappropriateIntimacyDetector", 1.0, 0.80),
("ArchitecturalBottleneckDetector", 1.1, 0.90),
("RuffLintDetector", 1.3, 0.98),
("RuffImportDetector", 1.2, 0.95),
("MypyDetector", 1.3, 0.99),
("BanditDetector", 1.1, 0.85),
("SemgrepDetector", 1.2, 0.90),
("RadonDetector", 1.0, 0.95),
("JscpdDetector", 1.1, 0.90),
("VultureDetector", 0.9, 0.75),
("PylintDetector", 1.0, 0.85),
];
let mut map = HashMap::new();
for (name, weight, accuracy) in weights {
map.insert(
name.to_string(),
DetectorWeight::new(name, weight, accuracy),
);
}
map.insert("default".to_string(), DetectorWeight::default());
map
}
pub struct VotingEngine {
strategy: VotingStrategy,
confidence_method: ConfidenceMethod,
severity_resolution: SeverityResolution,
confidence_threshold: f64,
min_detectors_for_boost: usize,
detector_weights: HashMap<String, DetectorWeight>,
}
impl Default for VotingEngine {
fn default() -> Self {
Self::new()
}
}
impl VotingEngine {
pub fn new() -> Self {
Self {
strategy: VotingStrategy::default(),
confidence_method: ConfidenceMethod::default(),
severity_resolution: SeverityResolution::default(),
confidence_threshold: 0.6,
min_detectors_for_boost: 2,
detector_weights: default_detector_weights(),
}
}
pub fn with_config(
strategy: VotingStrategy,
confidence_method: ConfidenceMethod,
severity_resolution: SeverityResolution,
confidence_threshold: f64,
min_detectors_for_boost: usize,
) -> Self {
Self {
strategy,
confidence_method,
severity_resolution,
confidence_threshold,
min_detectors_for_boost,
detector_weights: default_detector_weights(),
}
}
pub fn vote(&self, findings: Vec<Finding>) -> (Vec<Finding>, VotingStats) {
if findings.is_empty() {
return (
vec![],
VotingStats {
total_input: 0,
total_output: 0,
..Default::default()
},
);
}
let groups = self.group_by_entity(&findings);
let mut consensus_findings = Vec::new();
let mut rejected_count = 0;
let mut boosted_count = 0;
for (_entity_key, group_findings) in &groups {
if group_findings.len() == 1 {
let finding = &group_findings[0];
let confidence = self.get_finding_confidence(finding);
if confidence >= self.confidence_threshold {
consensus_findings.push(finding.clone());
} else {
rejected_count += 1;
}
} else {
let consensus = self.calculate_consensus(group_findings);
if consensus.has_consensus && consensus.confidence >= self.confidence_threshold {
let merged = self.create_consensus_finding(group_findings, &consensus);
consensus_findings.push(merged);
boosted_count += 1;
} else {
rejected_count += 1;
}
}
}
let stats = VotingStats {
total_input: findings.len(),
total_output: consensus_findings.len(),
groups_analyzed: groups.len(),
single_detector_findings: groups.values().filter(|g| g.len() == 1).count(),
multi_detector_findings: groups.values().filter(|g| g.len() > 1).count(),
boosted_by_consensus: boosted_count,
rejected_low_confidence: rejected_count,
strategy: format!("{:?}", self.strategy),
confidence_method: format!("{:?}", self.confidence_method),
threshold: self.confidence_threshold,
};
info!(
"VotingEngine: {} -> {} findings ({} boosted, {} rejected)",
findings.len(),
consensus_findings.len(),
boosted_count,
rejected_count
);
(consensus_findings, stats)
}
fn group_by_entity(&self, findings: &[Finding]) -> HashMap<String, Vec<Finding>> {
let mut groups: HashMap<String, Vec<Finding>> = HashMap::new();
for finding in findings {
let key = self.get_entity_key(finding);
groups.entry(key).or_default().push(finding.clone());
}
groups
}
fn get_entity_key(&self, finding: &Finding) -> String {
let category = self.get_issue_category(finding);
let location = if !finding.affected_files.is_empty() {
let file = finding.affected_files[0].to_string_lossy();
match (finding.line_start, finding.line_end) {
(Some(start), Some(end)) => {
let bucket = start / 5;
format!("{}:{}:{}", file, bucket, end / 5)
}
(Some(start), None) => {
let bucket = start / 5;
format!("{}:{}", file, bucket)
}
_ => file.to_string(),
}
} else {
"unknown".to_string()
};
format!("{}::{}", category, location)
}
fn get_issue_category(&self, finding: &Finding) -> &str {
let detector = finding.detector.to_lowercase();
if detector.contains("circular") || detector.contains("dependency") {
"circular_dependency"
} else if detector.contains("god") || detector.contains("class") {
"god_class"
} else if detector.contains("dead") || detector.contains("vulture") {
"dead_code"
} else if detector.contains("security") || detector.contains("bandit") {
"security"
} else if detector.contains("complexity") || detector.contains("radon") {
"complexity"
} else if detector.contains("duplicate") || detector.contains("clone") {
"duplication"
} else if detector.contains("type") || detector.contains("mypy") {
"type_error"
} else if detector.contains("lint") || detector.contains("ruff") {
"lint"
} else {
"other"
}
}
fn calculate_consensus(&self, findings: &[Finding]) -> ConsensusResult {
let detectors: Vec<&str> = findings.iter().map(|f| f.detector.as_str()).collect();
let unique_detectors: HashSet<&str> = detectors.iter().copied().collect();
let unique_vec: Vec<String> = unique_detectors.iter().map(|s| s.to_string()).collect();
let confidence = self.calculate_confidence(findings);
let severity = self.resolve_severity(findings);
let has_consensus = self.check_consensus(findings, &unique_vec);
let agreement_ratio = unique_detectors.len() as f64 / findings.len().max(1) as f64;
ConsensusResult {
has_consensus,
confidence,
severity,
contributing_detectors: unique_vec,
vote_count: unique_detectors.len(),
total_detectors: findings.len(),
agreement_ratio,
}
}
fn check_consensus(&self, findings: &[Finding], unique_detectors: &[String]) -> bool {
let detector_count = unique_detectors.len();
match self.strategy {
VotingStrategy::Unanimous => {
detector_count >= 2 && detector_count == findings.len()
}
VotingStrategy::Majority => {
detector_count >= 2
}
VotingStrategy::Weighted => {
let total_weight: f64 = findings
.iter()
.map(|f| self.get_detector_weight(&f.detector))
.sum();
total_weight >= 2.0
}
VotingStrategy::Threshold => {
let confidence = self.calculate_confidence(findings);
confidence >= self.confidence_threshold
}
}
}
fn calculate_confidence(&self, findings: &[Finding]) -> f64 {
let mut confidences = Vec::new();
let mut weights = Vec::new();
for finding in findings {
let conf = self.get_finding_confidence(finding);
let weight = self.get_detector_weight(&finding.detector);
confidences.push(conf);
weights.push(weight);
}
if confidences.is_empty() {
return 0.0;
}
let base = match self.confidence_method {
ConfidenceMethod::Average => confidences.iter().sum::<f64>() / confidences.len() as f64,
ConfidenceMethod::Weighted => {
let total_weight: f64 = weights.iter().sum();
if total_weight > 0.0 {
confidences
.iter()
.zip(weights.iter())
.map(|(c, w)| c * w)
.sum::<f64>()
/ total_weight
} else {
confidences.iter().sum::<f64>() / confidences.len() as f64
}
}
ConfidenceMethod::Max => confidences.iter().cloned().fold(0.0, f64::max),
ConfidenceMethod::Min => confidences.iter().cloned().fold(1.0, f64::min),
ConfidenceMethod::Bayesian => {
let mut prior = 0.5;
for &conf in &confidences {
let likelihood = conf;
prior = (prior * likelihood)
/ (prior * likelihood + (1.0 - prior) * (1.0 - likelihood));
}
prior
}
};
let unique_detectors: HashSet<&str> =
findings.iter().map(|f| f.detector.as_str()).collect();
if unique_detectors.len() >= self.min_detectors_for_boost {
let boost = ((unique_detectors.len() - 1) as f64 * 0.05).min(0.20);
(base + boost).min(1.0)
} else {
base
}
}
fn resolve_severity(&self, findings: &[Finding]) -> Severity {
if findings.is_empty() {
return Severity::Medium;
}
match self.severity_resolution {
SeverityResolution::Highest => findings
.iter()
.map(|f| f.severity)
.max()
.unwrap_or(Severity::Medium),
SeverityResolution::Lowest => findings
.iter()
.map(|f| f.severity)
.min()
.unwrap_or(Severity::Medium),
SeverityResolution::MajorityVote => {
let mut counts: HashMap<Severity, usize> = HashMap::new();
for finding in findings {
*counts.entry(finding.severity).or_insert(0) += 1;
}
counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(sev, _)| sev)
.unwrap_or(Severity::Medium)
}
SeverityResolution::WeightedVote => {
let mut severity_scores: HashMap<Severity, f64> = HashMap::new();
for finding in findings {
let conf = self.get_finding_confidence(finding);
let weight = self.get_detector_weight(&finding.detector);
*severity_scores.entry(finding.severity).or_insert(0.0) += conf * weight;
}
severity_scores
.into_iter()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(sev, _)| sev)
.unwrap_or(Severity::Medium)
}
}
}
fn get_finding_confidence(&self, finding: &Finding) -> f64 {
if let Some(conf) = finding.confidence {
return conf.clamp(0.0, 1.0);
}
self.detector_weights
.get(&finding.detector)
.or_else(|| self.detector_weights.get("default"))
.map(|w| w.accuracy)
.unwrap_or(0.7)
}
fn get_detector_weight(&self, detector_name: &str) -> f64 {
self.detector_weights
.get(detector_name)
.or_else(|| self.detector_weights.get("default"))
.map(|w| w.weight)
.unwrap_or(1.0)
}
fn create_consensus_finding(
&self,
findings: &[Finding],
consensus: &ConsensusResult,
) -> Finding {
let mut sorted_findings = findings.to_vec();
sorted_findings.sort_by(|a, b| b.severity.cmp(&a.severity));
let base = &sorted_findings[0];
let detector_names: Vec<&str> = consensus
.contributing_detectors
.iter()
.take(3)
.map(|s| s.as_str())
.collect();
let detector_str = if consensus.contributing_detectors.len() > 3 {
format!(
"Consensus[{}+{}more]",
detector_names.join("+"),
consensus.contributing_detectors.len() - 3
)
} else {
format!("Consensus[{}]", detector_names.join("+"))
};
let consensus_note = format!(
"\n\n**Consensus Analysis**\n\
- {} detectors agree on this issue\n\
- Confidence: {:.0}%\n\
- Detectors: {}",
consensus.vote_count,
consensus.confidence * 100.0,
consensus.contributing_detectors.join(", ")
);
Finding {
id: base.id.clone(),
detector: detector_str,
severity: consensus.severity,
title: format!("{} [{} detectors]", base.title, consensus.vote_count),
description: format!("{}{}", base.description, consensus_note),
affected_files: base.affected_files.clone(),
line_start: base.line_start,
line_end: base.line_end,
suggested_fix: self.merge_suggestions(findings),
estimated_effort: base.estimated_effort.clone(),
category: base.category.clone(),
cwe_id: base.cwe_id.clone(),
why_it_matters: base.why_it_matters.clone(),
confidence: Some(consensus.confidence),
..Default::default()
}
}
fn merge_suggestions(&self, findings: &[Finding]) -> Option<String> {
let mut suggestions = Vec::new();
let mut seen = HashSet::new();
for f in findings {
if let Some(ref fix) = f.suggested_fix {
if !seen.contains(fix) {
suggestions.push(format!("[{}] {}", f.detector, fix));
seen.insert(fix.clone());
}
}
}
if suggestions.is_empty() {
findings.first().and_then(|f| f.suggested_fix.clone())
} else {
Some(suggestions.join("\n\n"))
}
}
}