use crate::audit::AuditLogger;
use crate::gate::VerdictKind;
pub struct RiskScorer;
impl RiskScorer {
#[must_use]
pub async fn score(audit: &AuditLogger, agent_id: &str) -> f64 {
let events = audit.agent_events(agent_id, 100).await;
if events.is_empty() {
return 0.0;
}
let total = events.len() as f64;
let (denials, flags) =
events
.iter()
.fold((0.0_f64, 0.0_f64), |(d, f), e| match e.verdict {
VerdictKind::Deny => (d + 1.0, f),
VerdictKind::Flag => (d, f + 1.0),
_ => (d, f),
});
let raw = (denials * 2.0 + flags) / (total * 2.0);
raw.min(1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gate::{DenyCode, ToolCall, Verdict};
#[tokio::test]
async fn clean_agent_zero_risk() {
let audit = AuditLogger::new();
let call = ToolCall {
agent_id: "good-agent".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
for _ in 0..10 {
audit.log(&call, &Verdict::Allow).await;
}
let score = RiskScorer::score(&audit, "good-agent").await;
assert_eq!(score, 0.0);
}
#[tokio::test]
async fn all_denied_max_risk() {
let audit = AuditLogger::new();
let call = ToolCall {
agent_id: "bad-agent".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
for _ in 0..10 {
audit
.log(
&call,
&Verdict::Deny {
reason: "nope".into(),
code: DenyCode::Unauthorized,
},
)
.await;
}
let score = RiskScorer::score(&audit, "bad-agent").await;
assert_eq!(score, 1.0);
}
#[tokio::test]
async fn unknown_agent_zero_risk() {
let audit = AuditLogger::new();
assert_eq!(RiskScorer::score(&audit, "nobody").await, 0.0);
}
#[tokio::test]
async fn flags_only_half_risk() {
let audit = AuditLogger::new();
let call = ToolCall {
agent_id: "flagged".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
for _ in 0..10 {
audit
.log(
&call,
&Verdict::Flag {
reason: "sus".into(),
},
)
.await;
}
let score = RiskScorer::score(&audit, "flagged").await;
assert!((score - 0.5).abs() < f64::EPSILON);
}
#[tokio::test]
async fn mixed_verdicts_weighted() {
let audit = AuditLogger::new();
let call = ToolCall {
agent_id: "mixed".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
for _ in 0..5 {
audit.log(&call, &Verdict::Allow).await;
}
for _ in 0..3 {
audit
.log(
&call,
&Verdict::Deny {
reason: "x".into(),
code: DenyCode::Unauthorized,
},
)
.await;
}
for _ in 0..2 {
audit
.log(&call, &Verdict::Flag { reason: "x".into() })
.await;
}
let score = RiskScorer::score(&audit, "mixed").await;
assert!((score - 0.4).abs() < f64::EPSILON);
}
#[tokio::test]
async fn single_deny_max_risk() {
let audit = AuditLogger::new();
let call = ToolCall {
agent_id: "one-shot".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
audit
.log(
&call,
&Verdict::Deny {
reason: "x".into(),
code: DenyCode::Unauthorized,
},
)
.await;
assert_eq!(RiskScorer::score(&audit, "one-shot").await, 1.0);
}
#[tokio::test]
async fn single_allow_zero_risk() {
let audit = AuditLogger::new();
let call = ToolCall {
agent_id: "one-good".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
audit.log(&call, &Verdict::Allow).await;
assert_eq!(RiskScorer::score(&audit, "one-good").await, 0.0);
}
}