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}