Skip to main content

evidence_chain/
lib.rs

1//! Builder for explainable evidence chains.
2//!
3//! An [`EvidenceChain`] is an ordered list of [`EvidenceLink`]s — each link
4//! records one verifiable observation, an optional metric, and whether that
5//! observation met its threshold. Calling [`EvidenceChain::finalize`] computes
6//! an aggregate [`EvidenceStrength`] (pass ratio) across all links.
7//!
8//! The design is domain-agnostic: the same types work for fraud detection,
9//! trading signals, compliance checks, on-chain analysis, or any system where
10//! decisions must be auditable and explainable.
11//!
12//! # Quick start
13//!
14//! ```
15//! use evidence_chain::{EvidenceChain, EvidenceLink, EvidenceCategory};
16//!
17//! let mut chain = EvidenceChain::new("fraud-detector", "1.0.0");
18//!
19//! chain.add_link(
20//!     EvidenceLink::new(EvidenceCategory::Value, "Transaction above limit", "tx:8f3a")
21//!         .with_metric(15_000.0, "USD")
22//!         .with_threshold(10_000.0, true),
23//! );
24//! chain.add_link(
25//!     EvidenceLink::new(EvidenceCategory::Behavioral, "New device fingerprint", "device:c91b")
26//!         .with_threshold(1.0, true),
27//! );
28//! chain.add_link(
29//!     EvidenceLink::new(EvidenceCategory::Temporal, "Outside normal hours", "2024-03-15T03:42Z")
30//!         .with_threshold(1.0, false), // did not meet threshold
31//! );
32//!
33//! chain.finalize();
34//!
35//! assert_eq!(chain.strength.total_checks, 3);
36//! assert_eq!(chain.strength.passed_checks, 2);
37//! assert!((chain.strength.ratio - 2.0 / 3.0).abs() < 1e-9);
38//! ```
39//!
40//! # Serialization
41//!
42//! All types implement [`serde::Serialize`] and [`serde::Deserialize`], so chains
43//! can be stored as JSON, embedded in database columns, or sent over the wire.
44//!
45//! ```
46//! use evidence_chain::{EvidenceChain, EvidenceLink, EvidenceCategory};
47//!
48//! let mut chain = EvidenceChain::new("signal-v1", "2.0.0");
49//! chain.add_link(EvidenceLink::new(EvidenceCategory::Structural, "Pattern matched", "ref:001"));
50//! chain.finalize();
51//!
52//! let json = serde_json::to_string(&chain).unwrap();
53//! let decoded: EvidenceChain = serde_json::from_str(&json).unwrap();
54//! assert_eq!(decoded.rule_id, "signal-v1");
55//! ```
56
57use serde::{Deserialize, Serialize};
58
59/// Broad category of an evidence observation.
60///
61/// Use whichever category best describes the nature of the observation.
62/// All variants are valid for any domain — the labels are intentionally general.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum EvidenceCategory {
66    /// Shape or composition of the observed entity (counts, ratios, topology).
67    Structural,
68    /// Time-based observations (age, duration, gaps, timestamps).
69    Temporal,
70    /// Numerical or monetary observations (amounts, scores, percentages).
71    Value,
72    /// Pattern or history-based observations (reuse, frequency, sequences).
73    Behavioral,
74}
75
76/// A single verifiable observation in an [`EvidenceChain`].
77///
78/// Each link records:
79/// - *what* was observed (`observation`)
80/// - *where* it can be verified (`reference`)
81/// - *how much* was measured (`metric_value` + `metric_unit`)
82/// - *whether* it cleared the decision threshold (`threshold`, `threshold_met`)
83///
84/// Build links with the fluent API:
85///
86/// ```
87/// use evidence_chain::{EvidenceLink, EvidenceCategory};
88///
89/// let link = EvidenceLink::new(EvidenceCategory::Value, "Score above cutoff", "record:42")
90///     .with_metric(0.87, "probability")
91///     .with_threshold(0.80, true);
92///
93/// assert!(link.threshold_met);
94/// assert_eq!(link.metric_unit.as_deref(), Some("probability"));
95/// ```
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct EvidenceLink {
98    /// Broad category of this observation.
99    pub category: EvidenceCategory,
100    /// Human-readable description of the observed fact.
101    pub observation: String,
102    /// Verifiable pointer to the source: an ID, hash, URL, timestamp, etc.
103    pub reference: String,
104    /// Numerical measurement of the observation (optional).
105    pub metric_value: Option<f64>,
106    /// Unit of `metric_value` (e.g. `"USD"`, `"blocks"`, `"sat/vB"`, `"ratio"`).
107    pub metric_unit: Option<String>,
108    /// Decision threshold for this criterion (optional).
109    pub threshold: Option<f64>,
110    /// `true` if `metric_value` satisfies `threshold`, or if no threshold applies.
111    pub threshold_met: bool,
112}
113
114impl EvidenceLink {
115    /// Creates a new link with the minimum required fields.
116    ///
117    /// `threshold_met` defaults to `true`. Use [`with_threshold`](Self::with_threshold)
118    /// to set it explicitly when a numeric threshold applies.
119    pub fn new(
120        category: EvidenceCategory,
121        observation: impl Into<String>,
122        reference: impl Into<String>,
123    ) -> Self {
124        Self {
125            category,
126            observation: observation.into(),
127            reference: reference.into(),
128            metric_value: None,
129            metric_unit: None,
130            threshold: None,
131            threshold_met: true,
132        }
133    }
134
135    /// Attaches a numeric measurement to this link.
136    pub fn with_metric(mut self, value: f64, unit: impl Into<String>) -> Self {
137        self.metric_value = Some(value);
138        self.metric_unit = Some(unit.into());
139        self
140    }
141
142    /// Records the decision threshold and whether it was met.
143    pub fn with_threshold(mut self, threshold: f64, met: bool) -> Self {
144        self.threshold = Some(threshold);
145        self.threshold_met = met;
146        self
147    }
148}
149
150/// Aggregate pass/fail ratio across all links in an [`EvidenceChain`].
151///
152/// Computed by [`EvidenceChain::finalize`].
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct EvidenceStrength {
155    /// Total number of links in the chain.
156    pub total_checks: usize,
157    /// Number of links where `threshold_met == true`.
158    pub passed_checks: usize,
159    /// `passed_checks / total_checks`; `0.0` when `total_checks == 0`.
160    pub ratio: f64,
161}
162
163/// An ordered chain of evidence links for a single decision.
164///
165/// Build the chain by adding [`EvidenceLink`]s, then call [`finalize`](Self::finalize)
166/// to compute the aggregate [`EvidenceStrength`].
167///
168/// `rule_id` and `rule_version` identify the rule or model that
169/// produced this chain — use any stable identifiers meaningful to your domain.
170///
171/// # Example
172///
173/// ```
174/// use evidence_chain::{EvidenceChain, EvidenceLink, EvidenceCategory};
175///
176/// let mut chain = EvidenceChain::new("velocity-check", "1.2.0");
177///
178/// chain.add_link(
179///     EvidenceLink::new(EvidenceCategory::Value, "5 transactions in 10 minutes", "window:abc")
180///         .with_metric(5.0, "tx/10min")
181///         .with_threshold(3.0, true),
182/// );
183/// chain.add_link(
184///     EvidenceLink::new(EvidenceCategory::Structural, "All transactions to same recipient", "addr:xyz")
185///         .with_threshold(1.0, true),
186/// );
187///
188/// chain.finalize();
189/// assert_eq!(chain.strength.ratio, 1.0);
190/// ```
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct EvidenceChain {
193    /// Identifier of the rule or model that produced this chain.
194    pub rule_id: String,
195    /// Version of the rule or model.
196    pub rule_version: String,
197    /// Ordered list of evidence observations.
198    pub links: Vec<EvidenceLink>,
199    /// Aggregate strength — populated by [`finalize`](Self::finalize).
200    pub strength: EvidenceStrength,
201}
202
203impl EvidenceChain {
204    /// Creates an empty chain for the given rule identifier and version.
205    pub fn new(rule_id: impl Into<String>, version: impl Into<String>) -> Self {
206        Self {
207            rule_id: rule_id.into(),
208            rule_version: version.into(),
209            links: Vec::new(),
210            strength: EvidenceStrength {
211                total_checks: 0,
212                passed_checks: 0,
213                ratio: 0.0,
214            },
215        }
216    }
217
218    /// Appends an evidence link to the chain.
219    pub fn add_link(&mut self, link: EvidenceLink) {
220        self.links.push(link);
221    }
222
223    /// Recomputes [`EvidenceStrength`] from the current links.
224    ///
225    /// Idempotent — safe to call multiple times. Call after all links have
226    /// been added to get an accurate `strength`.
227    pub fn finalize(&mut self) {
228        let total = self.links.len();
229        let passed = self.links.iter().filter(|l| l.threshold_met).count();
230        self.strength = EvidenceStrength {
231            total_checks: total,
232            passed_checks: passed,
233            ratio: if total == 0 {
234                0.0
235            } else {
236                passed as f64 / total as f64
237            },
238        };
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn link(met: bool) -> EvidenceLink {
247        EvidenceLink::new(EvidenceCategory::Structural, "observation", "ref:1")
248            .with_metric(15.0, "units")
249            .with_threshold(10.0, met)
250    }
251
252    #[test]
253    fn empty_chain_has_zero_strength() {
254        let mut chain = EvidenceChain::new("rule-001", "1.0.0");
255        chain.finalize();
256        assert_eq!(chain.strength.total_checks, 0);
257        assert_eq!(chain.strength.passed_checks, 0);
258        assert_eq!(chain.strength.ratio, 0.0);
259    }
260
261    #[test]
262    fn all_pass_gives_ratio_one() {
263        let mut chain = EvidenceChain::new("rule-001", "1.0.0");
264        chain.add_link(link(true));
265        chain.add_link(link(true));
266        chain.add_link(link(true));
267        chain.finalize();
268        assert_eq!(chain.strength.total_checks, 3);
269        assert_eq!(chain.strength.passed_checks, 3);
270        assert_eq!(chain.strength.ratio, 1.0);
271    }
272
273    #[test]
274    fn partial_pass_gives_correct_ratio() {
275        let mut chain = EvidenceChain::new("rule-001", "1.0.0");
276        chain.add_link(link(true));
277        chain.add_link(link(true));
278        chain.add_link(link(false));
279        chain.add_link(link(false));
280        chain.finalize();
281        assert_eq!(chain.strength.total_checks, 4);
282        assert_eq!(chain.strength.passed_checks, 2);
283        assert!((chain.strength.ratio - 0.5).abs() < 1e-9);
284    }
285
286    #[test]
287    fn add_link_increments_count() {
288        let mut chain = EvidenceChain::new("rule-001", "1.0.0");
289        assert_eq!(chain.links.len(), 0);
290        chain.add_link(link(true));
291        assert_eq!(chain.links.len(), 1);
292        chain.add_link(link(false));
293        assert_eq!(chain.links.len(), 2);
294    }
295
296    #[test]
297    fn finalize_is_idempotent() {
298        let mut chain = EvidenceChain::new("rule-001", "1.0.0");
299        chain.add_link(link(true));
300        chain.add_link(link(false));
301        chain.finalize();
302        let ratio = chain.strength.ratio;
303        chain.finalize();
304        assert!((chain.strength.ratio - ratio).abs() < 1e-9);
305    }
306
307    #[test]
308    fn evidence_link_builder_fields() {
309        let link = EvidenceLink::new(
310            EvidenceCategory::Temporal,
311            "Age exceeded limit",
312            "record:42",
313        )
314        .with_metric(60_000.0, "blocks")
315        .with_threshold(157_680.0, true);
316        assert_eq!(link.observation, "Age exceeded limit");
317        assert_eq!(link.metric_value, Some(60_000.0));
318        assert_eq!(link.metric_unit.as_deref(), Some("blocks"));
319        assert_eq!(link.threshold, Some(157_680.0));
320        assert!(link.threshold_met);
321    }
322
323    #[test]
324    fn evidence_link_default_threshold_met_is_true() {
325        let link = EvidenceLink::new(EvidenceCategory::Behavioral, "Pattern observed", "ref:x");
326        assert!(link.threshold_met);
327        assert!(link.threshold.is_none());
328        assert!(link.metric_value.is_none());
329    }
330
331    #[test]
332    fn evidence_link_serialization_roundtrip() {
333        let link = EvidenceLink::new(EvidenceCategory::Value, "Score above cutoff", "record:99")
334            .with_metric(0.91, "probability")
335            .with_threshold(0.80, true);
336        let json = serde_json::to_string(&link).unwrap();
337        let decoded: EvidenceLink = serde_json::from_str(&json).unwrap();
338        assert_eq!(decoded.observation, "Score above cutoff");
339        assert_eq!(decoded.metric_value, Some(0.91));
340        assert!(decoded.threshold_met);
341    }
342
343    #[test]
344    fn evidence_chain_serialization_roundtrip() {
345        let mut chain = EvidenceChain::new("velocity-check", "1.0.0");
346        chain.add_link(link(true));
347        chain.add_link(link(false));
348        chain.finalize();
349        let json = serde_json::to_string(&chain).unwrap();
350        let decoded: EvidenceChain = serde_json::from_str(&json).unwrap();
351        assert_eq!(decoded.rule_id, "velocity-check");
352        assert_eq!(decoded.strength.total_checks, 2);
353        assert!((decoded.strength.ratio - 0.5).abs() < 1e-9);
354    }
355
356    #[test]
357    fn evidence_category_serializes_to_snake_case() {
358        let cases = [
359            (EvidenceCategory::Structural, "structural"),
360            (EvidenceCategory::Temporal, "temporal"),
361            (EvidenceCategory::Value, "value"),
362            (EvidenceCategory::Behavioral, "behavioral"),
363        ];
364        for (variant, expected) in &cases {
365            let json = serde_json::to_string(variant).unwrap();
366            assert_eq!(json, format!("\"{}\"", expected));
367            let decoded: EvidenceCategory = serde_json::from_str(&json).unwrap();
368            assert_eq!(&decoded, variant);
369        }
370    }
371
372    #[test]
373    fn chain_with_no_failed_links_has_full_strength() {
374        let mut chain = EvidenceChain::new("compliance-check", "3.1.0");
375        for _ in 0..10 {
376            chain.add_link(link(true));
377        }
378        chain.finalize();
379        assert_eq!(chain.strength.ratio, 1.0);
380        assert_eq!(chain.strength.passed_checks, 10);
381    }
382}