use serde::{Deserialize, Serialize};
use strum_macros::Display;
use super::Verdict;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProviderVote {
pub provider: String,
pub verdict: Verdict,
#[serde(default)]
pub confidence: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum ConsensusClass {
Agree,
LlmFp,
SingleProviderFlip,
Split,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConsensusDiscrepancy {
pub votes: Vec<ProviderVote>,
pub benign_votes: usize,
pub non_benign_votes: usize,
pub error_votes: usize,
}
impl ConsensusDiscrepancy {
#[must_use]
pub fn from_votes(votes: Vec<ProviderVote>, error_votes: usize) -> Self {
let benign_votes = votes
.iter()
.filter(|v| v.verdict == Verdict::Benign)
.count();
let non_benign_votes = votes.len() - benign_votes;
Self {
votes,
benign_votes,
non_benign_votes,
error_votes,
}
}
#[must_use]
pub fn is_single_provider_benign_flip(&self) -> bool {
self.benign_votes == 1 && self.non_benign_votes >= 2 && self.error_votes == 0
}
#[must_use]
pub fn flipped_provider(&self) -> Option<&str> {
if !self.is_single_provider_benign_flip() {
return None;
}
self.votes
.iter()
.find(|v| v.verdict == Verdict::Benign)
.map(|v| v.provider.as_str())
}
#[must_use]
pub fn classification(&self) -> ConsensusClass {
if self.is_single_provider_benign_flip() {
return ConsensusClass::SingleProviderFlip;
}
let usable = self.benign_votes + self.non_benign_votes;
if usable == 0 {
return ConsensusClass::Split;
}
if self.error_votes == 0 && (self.benign_votes == 0 || self.non_benign_votes == 0) {
return ConsensusClass::Agree;
}
if self.benign_votes >= 2 {
return ConsensusClass::LlmFp;
}
ConsensusClass::Split
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vote(p: &str, v: Verdict) -> ProviderVote {
ProviderVote {
provider: p.to_string(),
verdict: v,
confidence: 0.9,
}
}
#[test]
fn single_provider_benign_flip_detected() {
let d = ConsensusDiscrepancy::from_votes(
vec![
vote("openai", Verdict::Benign),
vote("grok", Verdict::Malicious),
vote("ollama-cloud", Verdict::Malicious),
],
0,
);
assert!(d.is_single_provider_benign_flip());
assert_eq!(d.classification(), ConsensusClass::SingleProviderFlip);
assert_eq!(d.flipped_provider(), Some("openai"));
}
#[test]
fn two_benign_is_not_a_flip() {
let d = ConsensusDiscrepancy::from_votes(
vec![
vote("openai", Verdict::Benign),
vote("grok", Verdict::Benign),
vote("ollama-cloud", Verdict::Malicious),
],
0,
);
assert!(!d.is_single_provider_benign_flip());
assert_eq!(d.classification(), ConsensusClass::LlmFp);
assert_eq!(d.flipped_provider(), None);
}
#[test]
fn error_masked_flip_is_not_flagged() {
let d = ConsensusDiscrepancy::from_votes(
vec![
vote("openai", Verdict::Benign),
vote("grok", Verdict::Malicious),
],
1,
);
assert!(!d.is_single_provider_benign_flip());
}
#[test]
fn unanimous_is_agree() {
let d = ConsensusDiscrepancy::from_votes(
vec![
vote("openai", Verdict::Benign),
vote("grok", Verdict::Benign),
vote("ollama-cloud", Verdict::Benign),
],
0,
);
assert!(!d.is_single_provider_benign_flip());
assert_eq!(d.classification(), ConsensusClass::Agree);
}
}