evidential_protocol/
validate.rs1use crate::types::*;
7use serde::Serialize;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum Severity {
16 Error,
18 Warning,
20}
21
22#[derive(Debug, Clone)]
24pub struct ValidationError {
25 pub rule: String,
27 pub message: String,
29 pub path: Option<String>,
31 pub severity: Severity,
33}
34
35#[derive(Debug)]
37pub struct ValidationResult {
38 pub valid: bool,
40 pub errors: Vec<ValidationError>,
42 pub warnings: Vec<ValidationError>,
44}
45
46pub fn validate_evidence(evidence: &Evidence, path: &str) -> Vec<ValidationError> {
55 let mut findings: Vec<ValidationError> = Vec::new();
56
57 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 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 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 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 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 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
128pub 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
151pub 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 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 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 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 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 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}