Skip to main content

evidential_protocol/
validate.rs

1//! Validation for EP/1.0 structures.
2//!
3//! Produces a list of [`ValidationError`]s with severity levels so callers
4//! can distinguish hard failures from advisory warnings.
5
6use crate::types::*;
7use serde::Serialize;
8
9// ---------------------------------------------------------------------------
10// Types
11// ---------------------------------------------------------------------------
12
13/// Severity of a validation finding.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum Severity {
16    /// Must be fixed — the structure is invalid.
17    Error,
18    /// Advisory — the structure is technically valid but sub-optimal.
19    Warning,
20}
21
22/// A single validation finding.
23#[derive(Debug, Clone)]
24pub struct ValidationError {
25    /// Short rule identifier (e.g. "confidence_floor").
26    pub rule: String,
27    /// Human-readable description.
28    pub message: String,
29    /// JSON-path-style location of the problem (e.g. "claims[0].evidence").
30    pub path: Option<String>,
31    /// Whether this is a hard error or advisory warning.
32    pub severity: Severity,
33}
34
35/// Aggregated result of validating a full response.
36#[derive(Debug)]
37pub struct ValidationResult {
38    /// `true` only if there are zero errors (warnings are allowed).
39    pub valid: bool,
40    /// All findings with [`Severity::Error`].
41    pub errors: Vec<ValidationError>,
42    /// All findings with [`Severity::Warning`].
43    pub warnings: Vec<ValidationError>,
44}
45
46// ---------------------------------------------------------------------------
47// Evidence validation
48// ---------------------------------------------------------------------------
49
50/// Validate a single [`Evidence`] value.
51///
52/// Returns a (possibly empty) list of findings. The `path` prefix is used to
53/// build JSON-path-style locations in error messages.
54pub fn validate_evidence(evidence: &Evidence, path: &str) -> Vec<ValidationError> {
55    let mut findings: Vec<ValidationError> = Vec::new();
56
57    // confidence must be in [0.0, 1.0]
58    if evidence.confidence < 0.0 || evidence.confidence > 1.0 {
59        findings.push(ValidationError {
60            rule: "confidence_range".to_string(),
61            message: format!(
62                "Confidence {} is outside the valid range [0.0, 1.0]",
63                evidence.confidence
64            ),
65            path: Some(format!("{path}.confidence")),
66            severity: Severity::Error,
67        });
68    }
69
70    // confidence must meet the class floor
71    if evidence.confidence < evidence.class.confidence_floor() {
72        findings.push(ValidationError {
73            rule: "confidence_floor".to_string(),
74            message: format!(
75                "Confidence {} is below the floor {} for class {}",
76                evidence.confidence,
77                evidence.class.confidence_floor(),
78                evidence.class,
79            ),
80            path: Some(format!("{path}.confidence")),
81            severity: Severity::Error,
82        });
83    }
84
85    // source must not be empty
86    if evidence.source.trim().is_empty() {
87        findings.push(ValidationError {
88            rule: "source_required".to_string(),
89            message: "Evidence source must not be empty".to_string(),
90            path: Some(format!("{path}.source")),
91            severity: Severity::Error,
92        });
93    }
94
95    // timestamp must not be empty
96    if evidence.timestamp.trim().is_empty() {
97        findings.push(ValidationError {
98            rule: "timestamp_required".to_string(),
99            message: "Evidence timestamp must not be empty".to_string(),
100            path: Some(format!("{path}.timestamp")),
101            severity: Severity::Error,
102        });
103    }
104
105    // Conjecture MUST have reasoning
106    if evidence.class == EvidenceClass::Conjecture && evidence.reasoning.is_none() {
107        findings.push(ValidationError {
108            rule: "conjecture_reasoning".to_string(),
109            message: "Conjecture evidence must include reasoning".to_string(),
110            path: Some(format!("{path}.reasoning")),
111            severity: Severity::Error,
112        });
113    }
114
115    // Direct SHOULD have ttl
116    if evidence.class == EvidenceClass::Direct && evidence.ttl.is_none() {
117        findings.push(ValidationError {
118            rule: "direct_ttl".to_string(),
119            message: "Direct evidence should specify a TTL for cache freshness".to_string(),
120            path: Some(format!("{path}.ttl")),
121            severity: Severity::Warning,
122        });
123    }
124
125    findings
126}
127
128// ---------------------------------------------------------------------------
129// Claim validation
130// ---------------------------------------------------------------------------
131
132/// Validate a single [`EvidentialClaim`] at the given index.
133pub fn validate_claim(claim: &EvidentialClaim, index: usize) -> Vec<ValidationError> {
134    let mut findings: Vec<ValidationError> = Vec::new();
135    let path = format!("claims[{index}]");
136
137    if claim.claim.trim().is_empty() {
138        findings.push(ValidationError {
139            rule: "claim_text_required".to_string(),
140            message: "Claim text must not be empty".to_string(),
141            path: Some(path.clone()),
142            severity: Severity::Error,
143        });
144    }
145
146    findings.extend(validate_evidence(&claim.evidence, &format!("{path}.evidence")));
147
148    findings
149}
150
151// ---------------------------------------------------------------------------
152// Response validation
153// ---------------------------------------------------------------------------
154
155/// Validate a complete [`EvidentialResponse`].
156///
157/// Checks envelope-level constraints and delegates to per-claim validation.
158pub fn validate_response<T: Serialize>(response: &EvidentialResponse<T>) -> ValidationResult {
159    let mut errors: Vec<ValidationError> = Vec::new();
160    let mut warnings: Vec<ValidationError> = Vec::new();
161
162    // ep_version must be "1.0"
163    if response.ep_version != "1.0" {
164        errors.push(ValidationError {
165            rule: "ep_version".to_string(),
166            message: format!(
167                "Unsupported EP version \"{}\"; expected \"1.0\"",
168                response.ep_version
169            ),
170            path: Some("ep_version".to_string()),
171            severity: Severity::Error,
172        });
173    }
174
175    // producer must not be empty
176    if response.producer.trim().is_empty() {
177        errors.push(ValidationError {
178            rule: "producer_required".to_string(),
179            message: "Producer must not be empty".to_string(),
180            path: Some("producer".to_string()),
181            severity: Severity::Error,
182        });
183    }
184
185    // must have at least one claim
186    if response.claims.is_empty() {
187        errors.push(ValidationError {
188            rule: "claims_required".to_string(),
189            message: "Response must contain at least one claim".to_string(),
190            path: Some("claims".to_string()),
191            severity: Severity::Error,
192        });
193    }
194
195    // validate each claim
196    for (i, claim) in response.claims.iter().enumerate() {
197        for finding in validate_claim(claim, i) {
198            match finding.severity {
199                Severity::Error => errors.push(finding),
200                Severity::Warning => warnings.push(finding),
201            }
202        }
203    }
204
205    // aggregate_class must be the weakest class among all claims
206    if !response.claims.is_empty() {
207        let weakest = response
208            .claims
209            .iter()
210            .map(|c| c.evidence.class)
211            .min_by_key(|c| c.strength())
212            .unwrap();
213
214        if response.aggregate_class != weakest {
215            errors.push(ValidationError {
216                rule: "aggregate_class_weakest".to_string(),
217                message: format!(
218                    "Aggregate class \"{}\" does not match weakest claim class \"{}\"",
219                    response.aggregate_class, weakest
220                ),
221                path: Some("aggregate_class".to_string()),
222                severity: Severity::Error,
223            });
224        }
225    }
226
227    ValidationResult {
228        valid: errors.is_empty(),
229        errors,
230        warnings,
231    }
232}