evidential_protocol/
schema.rs1use crate::types::*;
4use chrono::Utc;
5use serde::Serialize;
6use std::collections::HashMap;
7
8pub fn weaker_class(a: &EvidenceClass, b: &EvidenceClass) -> EvidenceClass {
14 if a.strength() <= b.strength() {
15 *a
16 } else {
17 *b
18 }
19}
20
21pub fn stronger_class(a: &EvidenceClass, b: &EvidenceClass) -> EvidenceClass {
23 if a.strength() >= b.strength() {
24 *a
25 } else {
26 *b
27 }
28}
29
30pub fn is_expired(evidence: &Evidence) -> bool {
38 let Some(ttl) = evidence.ttl else {
39 return false;
40 };
41 let Ok(created) = chrono::DateTime::parse_from_rfc3339(&evidence.timestamp) else {
42 return false;
43 };
44 let expires_at = created + chrono::Duration::seconds(ttl as i64);
45 Utc::now() >= expires_at
46}
47
48pub fn degraded_class(evidence: &Evidence) -> EvidenceClass {
55 let degrade_one = |c: &EvidenceClass| -> EvidenceClass {
56 match c {
57 EvidenceClass::Direct => EvidenceClass::Inferred,
58 EvidenceClass::Inferred => EvidenceClass::Reported,
59 EvidenceClass::Reported => EvidenceClass::Conjecture,
60 EvidenceClass::Conjecture => EvidenceClass::Conjecture,
61 }
62 };
63
64 if let Some(ttl) = evidence.ttl {
66 if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&evidence.timestamp) {
67 let expires_at = created + chrono::Duration::seconds(ttl as i64);
68 if Utc::now() >= expires_at {
69 return degrade_one(&evidence.class);
70 }
71 }
72 } else {
73 if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&evidence.timestamp) {
75 let age = Utc::now() - created.with_timezone(&Utc);
76 if age > chrono::Duration::hours(24) {
77 return degrade_one(&evidence.class);
78 }
79 }
80 }
81
82 evidence.class
83}
84
85pub fn compute_trust_score(claims: &[EvidentialClaim]) -> TrustScore {
91 if claims.is_empty() {
92 return TrustScore {
93 score: 0.0,
94 total_claims: 0,
95 breakdown: HashMap::new(),
96 requires_review: true,
97 };
98 }
99
100 let mut breakdown: HashMap<EvidenceClass, usize> = HashMap::new();
101 let mut weighted_sum = 0.0;
102 let mut weight_total = 0.0;
103
104 for claim in claims {
105 *breakdown.entry(claim.evidence.class).or_insert(0) += 1;
106 let w = claim.evidence.class.weight();
107 weighted_sum += claim.evidence.confidence * w;
108 weight_total += w;
109 }
110
111 let score = if weight_total > 0.0 {
112 weighted_sum / weight_total
113 } else {
114 0.0
115 };
116
117 let has_conjecture = breakdown.contains_key(&EvidenceClass::Conjecture);
118 let requires_review = has_conjecture || score < 0.5;
119
120 TrustScore {
121 score,
122 total_claims: claims.len(),
123 breakdown,
124 requires_review,
125 }
126}
127
128pub fn aggregate_class(claims: &[EvidentialClaim]) -> EvidenceClass {
132 claims
133 .iter()
134 .map(|c| c.evidence.class)
135 .min_by_key(|c| c.strength())
136 .unwrap_or(EvidenceClass::Conjecture)
137}
138
139pub fn aggregate_confidence(claims: &[EvidentialClaim]) -> f64 {
143 if claims.is_empty() {
144 return 0.0;
145 }
146 let mut weighted_sum = 0.0;
147 let mut weight_total = 0.0;
148 for claim in claims {
149 let w = claim.evidence.class.weight();
150 weighted_sum += claim.evidence.confidence * w;
151 weight_total += w;
152 }
153 if weight_total > 0.0 {
154 weighted_sum / weight_total
155 } else {
156 0.0
157 }
158}
159
160pub fn format_trust_display(trust: &TrustScore) -> String {
164 let verified = trust
165 .breakdown
166 .iter()
167 .filter(|(class, _)| class.strength() >= EvidenceClass::Reported.strength())
168 .map(|(_, count)| *count)
169 .sum::<usize>();
170
171 let total = trust.total_claims.max(1);
172 let filled = ((verified as f64 / total as f64) * 5.0).round() as usize;
173 let filled = filled.min(5);
174 let empty = 5 - filled;
175
176 let bar: String = "●".repeat(filled) + &"○".repeat(empty);
177 format!(
178 "[{}] {}/{} verified | Trust: {:.2}",
179 bar, verified, trust.total_claims, trust.score
180 )
181}
182
183pub fn evidence(class: EvidenceClass, source: &str) -> Evidence {
192 Evidence {
193 class,
194 confidence: class.confidence_floor(),
195 source: source.to_string(),
196 reasoning: None,
197 timestamp: Utc::now().to_rfc3339(),
198 ttl: None,
199 sources: None,
200 }
201}
202
203pub fn claim(text: &str, ev: Evidence) -> EvidentialClaim {
205 EvidentialClaim {
206 claim: text.to_string(),
207 evidence: ev,
208 refs: None,
209 }
210}
211
212pub fn response<T: Serialize + Clone>(
214 producer: &str,
215 data: T,
216 claims: Vec<EvidentialClaim>,
217) -> EvidentialResponse<T> {
218 let agg_class = aggregate_class(&claims);
219 let agg_conf = aggregate_confidence(&claims);
220
221 EvidentialResponse {
222 ep_version: "1.0".to_string(),
223 data,
224 claims,
225 aggregate_class: agg_class,
226 aggregate_confidence: agg_conf,
227 producer: producer.to_string(),
228 produced_at: Utc::now().to_rfc3339(),
229 }
230}
231
232pub fn parse_content_marker(marker: &str) -> Option<ContentEvidenceMarker> {
242 let inner = marker.strip_prefix("[EP:")?.strip_suffix(']')?;
243 let parts: Vec<&str> = inner.splitn(3, ':').collect();
244 if parts.len() != 3 {
245 return None;
246 }
247
248 let class = match parts[0].to_lowercase().as_str() {
249 "direct" => EvidenceClass::Direct,
250 "inferred" => EvidenceClass::Inferred,
251 "reported" => EvidenceClass::Reported,
252 "conjecture" => EvidenceClass::Conjecture,
253 _ => return None,
254 };
255
256 let confidence: f64 = parts[2].parse().ok()?;
257 if !(0.0..=1.0).contains(&confidence) {
258 return None;
259 }
260
261 Some(ContentEvidenceMarker {
262 class,
263 source: parts[1].to_string(),
264 confidence,
265 })
266}
267
268pub fn format_content_marker(m: &ContentEvidenceMarker) -> String {
270 format!("[EP:{}:{}:{}]", m.class, m.source, m.confidence)
271}
272
273pub fn extract_markers(content: &str) -> Vec<ContentEvidenceMarker> {
275 let mut markers = Vec::new();
276 let mut search_from = 0;
277
278 while let Some(start) = content[search_from..].find("[EP:") {
279 let abs_start = search_from + start;
280 if let Some(end) = content[abs_start..].find(']') {
281 let candidate = &content[abs_start..=abs_start + end];
282 if let Some(marker) = parse_content_marker(candidate) {
283 markers.push(marker);
284 }
285 search_from = abs_start + end + 1;
286 } else {
287 break;
288 }
289 }
290
291 markers
292}