Skip to main content

evidential_protocol/
schema.rs

1//! Builders, utilities, and content-marker helpers for the Evidential Protocol.
2
3use crate::types::*;
4use chrono::Utc;
5use serde::Serialize;
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// Class comparison helpers
10// ---------------------------------------------------------------------------
11
12/// Return the weaker (lower-strength) of two evidence classes.
13pub fn weaker_class(a: &EvidenceClass, b: &EvidenceClass) -> EvidenceClass {
14    if a.strength() <= b.strength() {
15        *a
16    } else {
17        *b
18    }
19}
20
21/// Return the stronger (higher-strength) of two evidence classes.
22pub fn stronger_class(a: &EvidenceClass, b: &EvidenceClass) -> EvidenceClass {
23    if a.strength() >= b.strength() {
24        *a
25    } else {
26        *b
27    }
28}
29
30// ---------------------------------------------------------------------------
31// Expiration / degradation
32// ---------------------------------------------------------------------------
33
34/// Check whether an [`Evidence`] value has exceeded its TTL.
35///
36/// Returns `false` if no TTL is set or the timestamp cannot be parsed.
37pub 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
48/// Compute the degraded class for an evidence value.
49///
50/// Rules:
51/// - If TTL is set and expired → degrade one level.
52/// - If no TTL and older than 24 hours → degrade one level.
53/// - Otherwise return the original class.
54pub 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    // Check TTL expiry
65    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        // No TTL — check 24h staleness
74        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
85// ---------------------------------------------------------------------------
86// Aggregation
87// ---------------------------------------------------------------------------
88
89/// Compute a [`TrustScore`] from a slice of claims.
90pub 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
128/// Return the weakest [`EvidenceClass`] among all claims.
129///
130/// Defaults to [`EvidenceClass::Conjecture`] for an empty slice.
131pub 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
139/// Compute the weighted mean confidence across all claims.
140///
141/// Returns `0.0` for an empty slice.
142pub 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
160/// Format a [`TrustScore`] as a compact display string.
161///
162/// Example output: `[●●●○○] 3/5 verified | Trust: 0.72`
163pub 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
183// ---------------------------------------------------------------------------
184// Builders
185// ---------------------------------------------------------------------------
186
187/// Build an [`Evidence`] value with sensible defaults.
188///
189/// Sets confidence to the class floor, timestamp to now, and leaves optional
190/// fields as `None`.
191pub 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
203/// Build an [`EvidentialClaim`] from text and evidence.
204pub fn claim(text: &str, ev: Evidence) -> EvidentialClaim {
205    EvidentialClaim {
206        claim: text.to_string(),
207        evidence: ev,
208        refs: None,
209    }
210}
211
212/// Build a complete [`EvidentialResponse`] with auto-computed aggregates.
213pub 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
232// ---------------------------------------------------------------------------
233// Content markers
234// ---------------------------------------------------------------------------
235
236/// Parse an inline evidence marker string.
237///
238/// Expected format: `[EP:<class>:<source>:<confidence>]`
239///
240/// Returns `None` if the string does not match.
241pub 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
268/// Format a [`ContentEvidenceMarker`] as an inline string.
269pub fn format_content_marker(m: &ContentEvidenceMarker) -> String {
270    format!("[EP:{}:{}:{}]", m.class, m.source, m.confidence)
271}
272
273/// Extract all `[EP:...]` markers from a content string.
274pub 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}