use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceCategory {
Structural,
Temporal,
Value,
Behavioral,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceLink {
pub category: EvidenceCategory,
pub observation: String,
pub reference: String,
pub metric_value: Option<f64>,
pub metric_unit: Option<String>,
pub threshold: Option<f64>,
pub threshold_met: bool,
}
impl EvidenceLink {
pub fn new(
category: EvidenceCategory,
observation: impl Into<String>,
reference: impl Into<String>,
) -> Self {
Self {
category,
observation: observation.into(),
reference: reference.into(),
metric_value: None,
metric_unit: None,
threshold: None,
threshold_met: true,
}
}
pub fn with_metric(mut self, value: f64, unit: impl Into<String>) -> Self {
self.metric_value = Some(value);
self.metric_unit = Some(unit.into());
self
}
pub fn with_threshold(mut self, threshold: f64, met: bool) -> Self {
self.threshold = Some(threshold);
self.threshold_met = met;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceStrength {
pub total_checks: usize,
pub passed_checks: usize,
pub ratio: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceChain {
pub rule_id: String,
pub rule_version: String,
pub links: Vec<EvidenceLink>,
pub strength: EvidenceStrength,
}
impl EvidenceChain {
pub fn new(rule_id: impl Into<String>, version: impl Into<String>) -> Self {
Self {
rule_id: rule_id.into(),
rule_version: version.into(),
links: Vec::new(),
strength: EvidenceStrength {
total_checks: 0,
passed_checks: 0,
ratio: 0.0,
},
}
}
pub fn add_link(&mut self, link: EvidenceLink) {
self.links.push(link);
}
pub fn finalize(&mut self) {
let total = self.links.len();
let passed = self.links.iter().filter(|l| l.threshold_met).count();
self.strength = EvidenceStrength {
total_checks: total,
passed_checks: passed,
ratio: if total == 0 {
0.0
} else {
passed as f64 / total as f64
},
};
}
}
#[cfg(test)]
mod tests {
use super::*;
fn link(met: bool) -> EvidenceLink {
EvidenceLink::new(EvidenceCategory::Structural, "observation", "ref:1")
.with_metric(15.0, "units")
.with_threshold(10.0, met)
}
#[test]
fn empty_chain_has_zero_strength() {
let mut chain = EvidenceChain::new("rule-001", "1.0.0");
chain.finalize();
assert_eq!(chain.strength.total_checks, 0);
assert_eq!(chain.strength.passed_checks, 0);
assert_eq!(chain.strength.ratio, 0.0);
}
#[test]
fn all_pass_gives_ratio_one() {
let mut chain = EvidenceChain::new("rule-001", "1.0.0");
chain.add_link(link(true));
chain.add_link(link(true));
chain.add_link(link(true));
chain.finalize();
assert_eq!(chain.strength.total_checks, 3);
assert_eq!(chain.strength.passed_checks, 3);
assert_eq!(chain.strength.ratio, 1.0);
}
#[test]
fn partial_pass_gives_correct_ratio() {
let mut chain = EvidenceChain::new("rule-001", "1.0.0");
chain.add_link(link(true));
chain.add_link(link(true));
chain.add_link(link(false));
chain.add_link(link(false));
chain.finalize();
assert_eq!(chain.strength.total_checks, 4);
assert_eq!(chain.strength.passed_checks, 2);
assert!((chain.strength.ratio - 0.5).abs() < 1e-9);
}
#[test]
fn add_link_increments_count() {
let mut chain = EvidenceChain::new("rule-001", "1.0.0");
assert_eq!(chain.links.len(), 0);
chain.add_link(link(true));
assert_eq!(chain.links.len(), 1);
chain.add_link(link(false));
assert_eq!(chain.links.len(), 2);
}
#[test]
fn finalize_is_idempotent() {
let mut chain = EvidenceChain::new("rule-001", "1.0.0");
chain.add_link(link(true));
chain.add_link(link(false));
chain.finalize();
let ratio = chain.strength.ratio;
chain.finalize();
assert!((chain.strength.ratio - ratio).abs() < 1e-9);
}
#[test]
fn evidence_link_builder_fields() {
let link = EvidenceLink::new(
EvidenceCategory::Temporal,
"Age exceeded limit",
"record:42",
)
.with_metric(60_000.0, "blocks")
.with_threshold(157_680.0, true);
assert_eq!(link.observation, "Age exceeded limit");
assert_eq!(link.metric_value, Some(60_000.0));
assert_eq!(link.metric_unit.as_deref(), Some("blocks"));
assert_eq!(link.threshold, Some(157_680.0));
assert!(link.threshold_met);
}
#[test]
fn evidence_link_default_threshold_met_is_true() {
let link = EvidenceLink::new(EvidenceCategory::Behavioral, "Pattern observed", "ref:x");
assert!(link.threshold_met);
assert!(link.threshold.is_none());
assert!(link.metric_value.is_none());
}
#[test]
fn evidence_link_serialization_roundtrip() {
let link = EvidenceLink::new(EvidenceCategory::Value, "Score above cutoff", "record:99")
.with_metric(0.91, "probability")
.with_threshold(0.80, true);
let json = serde_json::to_string(&link).unwrap();
let decoded: EvidenceLink = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.observation, "Score above cutoff");
assert_eq!(decoded.metric_value, Some(0.91));
assert!(decoded.threshold_met);
}
#[test]
fn evidence_chain_serialization_roundtrip() {
let mut chain = EvidenceChain::new("velocity-check", "1.0.0");
chain.add_link(link(true));
chain.add_link(link(false));
chain.finalize();
let json = serde_json::to_string(&chain).unwrap();
let decoded: EvidenceChain = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.rule_id, "velocity-check");
assert_eq!(decoded.strength.total_checks, 2);
assert!((decoded.strength.ratio - 0.5).abs() < 1e-9);
}
#[test]
fn evidence_category_serializes_to_snake_case() {
let cases = [
(EvidenceCategory::Structural, "structural"),
(EvidenceCategory::Temporal, "temporal"),
(EvidenceCategory::Value, "value"),
(EvidenceCategory::Behavioral, "behavioral"),
];
for (variant, expected) in &cases {
let json = serde_json::to_string(variant).unwrap();
assert_eq!(json, format!("\"{}\"", expected));
let decoded: EvidenceCategory = serde_json::from_str(&json).unwrap();
assert_eq!(&decoded, variant);
}
}
#[test]
fn chain_with_no_failed_links_has_full_strength() {
let mut chain = EvidenceChain::new("compliance-check", "3.1.0");
for _ in 0..10 {
chain.add_link(link(true));
}
chain.finalize();
assert_eq!(chain.strength.ratio, 1.0);
assert_eq!(chain.strength.passed_checks, 10);
}
}