Skip to main content

everruns_core/capabilities/
citation_verification.rs

1//! `citation_verification` capability — stamps faithfulness verdicts on
2//! citations produced by any feed.
3//!
4//! A standalone guardrail capability, decoupled from the citation feeds: it
5//! consumes the [`TextAnnotation`]s collected during a turn and, for each,
6//! decides whether the cited source actually supports the claim span, writing a
7//! [`VerificationVerdict`]. Because it is separate from the feeds, any feed
8//! (`citation_retrieval`, a native provider feed, …) can be paired with it, and
9//! evals can hold the feed fixed while varying the verifier. See
10//! `specs/citations.md`.
11//!
12//! Two modes:
13//! * `heuristic` (default) — lexical entailment (token overlap between the claim
14//!   span and the source snippet). Deterministic, free, model-agnostic; a weak
15//!   but honest NLI baseline, strongest on verbatim/native citations.
16//! * `llm` — a utility-model judgement per claim/source pair. More accurate;
17//!   falls back to heuristic when no utility model is configured.
18
19use std::sync::Arc;
20
21use async_trait::async_trait;
22use serde::{Deserialize, Serialize};
23use serde_json::json;
24
25use crate::annotation_hook::{
26    CitationVerifier, VerificationContext, citation_tokens, span_text, token_overlap_ratio,
27};
28use crate::capabilities::Capability;
29use crate::capability_types::CapabilityStatus;
30use crate::message::{TextAnnotation, VerificationStatus, VerificationVerdict};
31use crate::utility_llm::UtilityLlmRequest;
32
33/// Canonical capability id.
34pub const CITATION_VERIFICATION_CAPABILITY_ID: &str = "citation_verification";
35
36/// Default entailment threshold: a claim whose distinctive tokens are at least
37/// half-covered by the source is treated as entailed.
38const DEFAULT_THRESHOLD: f32 = 0.5;
39
40/// Verification strategy.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
42#[serde(rename_all = "snake_case")]
43pub enum VerificationMode {
44    /// Deterministic lexical entailment. No model call.
45    #[default]
46    Heuristic,
47    /// Utility-model judgement, with heuristic fallback.
48    Llm,
49}
50
51/// Per-agent config for `citation_verification`.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct CitationVerificationConfig {
54    #[serde(default)]
55    pub mode: VerificationMode,
56    #[serde(default = "default_threshold")]
57    pub threshold: f32,
58}
59
60fn default_threshold() -> f32 {
61    DEFAULT_THRESHOLD
62}
63
64impl Default for CitationVerificationConfig {
65    fn default() -> Self {
66        Self {
67            mode: VerificationMode::default(),
68            threshold: DEFAULT_THRESHOLD,
69        }
70    }
71}
72
73impl CitationVerificationConfig {
74    fn from_value(config: &serde_json::Value) -> Self {
75        if config.is_null() {
76            return Self::default();
77        }
78        serde_json::from_value(config.clone()).unwrap_or_default()
79    }
80}
81
82/// The verification guardrail capability.
83pub struct CitationVerificationCapability;
84
85impl Capability for CitationVerificationCapability {
86    fn id(&self) -> &str {
87        CITATION_VERIFICATION_CAPABILITY_ID
88    }
89
90    fn name(&self) -> &str {
91        "Citation verification"
92    }
93
94    fn description(&self) -> &str {
95        "Verify that each cited source actually supports the claim it is \
96         attached to, stamping a faithfulness verdict on every citation."
97    }
98
99    fn status(&self) -> CapabilityStatus {
100        CapabilityStatus::Available
101    }
102
103    fn icon(&self) -> Option<&str> {
104        Some("shield-check")
105    }
106
107    fn category(&self) -> Option<&str> {
108        Some("Knowledge")
109    }
110
111    /// A constraint on citations rather than a new ability.
112    fn is_guardrail(&self) -> bool {
113        true
114    }
115
116    fn features(&self) -> Vec<&'static str> {
117        vec!["citations"]
118    }
119
120    fn config_schema(&self) -> Option<serde_json::Value> {
121        Some(json!({
122            "type": "object",
123            "additionalProperties": false,
124            "properties": {
125                "mode": {
126                    "type": "string",
127                    "enum": ["heuristic", "llm"],
128                    "default": "heuristic",
129                    "description": "heuristic = lexical overlap (no model call); llm = utility-model judgement with heuristic fallback."
130                },
131                "threshold": {
132                    "type": "number",
133                    "minimum": 0.0,
134                    "maximum": 1.0,
135                    "default": DEFAULT_THRESHOLD,
136                    "description": "Entailment threshold for the heuristic verdict."
137                }
138            }
139        }))
140    }
141
142    fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
143        if config.is_null() {
144            return Ok(());
145        }
146        let cfg: CitationVerificationConfig = serde_json::from_value(config.clone())
147            .map_err(|e| format!("invalid citation_verification config: {e}"))?;
148        if !(0.0..=1.0).contains(&cfg.threshold) {
149            return Err("threshold must be between 0.0 and 1.0".to_string());
150        }
151        Ok(())
152    }
153
154    fn citation_verifier_with_config(
155        &self,
156        config: &serde_json::Value,
157    ) -> Option<Arc<dyn CitationVerifier>> {
158        let cfg = CitationVerificationConfig::from_value(config);
159        Some(Arc::new(CitationVerificationVerifier {
160            mode: cfg.mode,
161            threshold: cfg.threshold,
162        }))
163    }
164}
165
166struct CitationVerificationVerifier {
167    mode: VerificationMode,
168    threshold: f32,
169}
170
171#[async_trait]
172impl CitationVerifier for CitationVerificationVerifier {
173    fn id(&self) -> &str {
174        CITATION_VERIFICATION_CAPABILITY_ID
175    }
176
177    async fn verify(
178        &self,
179        ctx: &VerificationContext<'_>,
180        mut annotations: Vec<TextAnnotation>,
181    ) -> Vec<TextAnnotation> {
182        if annotations.is_empty() {
183            return annotations;
184        }
185
186        // Try the LLM path first when requested and available; on any failure
187        // fall through to the deterministic heuristic (fail-open).
188        if self.mode == VerificationMode::Llm
189            && let Some(svc) = ctx.utility_llm_service
190            && let Some(verdicts) = llm_verdicts(svc, ctx.message_text, &annotations).await
191            && verdicts.len() == annotations.len()
192        {
193            for (ann, verdict) in annotations.iter_mut().zip(verdicts) {
194                ann.verified = Some(verdict);
195            }
196            return annotations;
197        }
198
199        for ann in annotations.iter_mut() {
200            ann.verified = Some(heuristic_verdict(ctx.message_text, ann, self.threshold));
201        }
202        annotations
203    }
204}
205
206/// Lexical entailment: fraction of the claim span's distinctive tokens present
207/// in the cited source snippet.
208fn heuristic_verdict(text: &str, ann: &TextAnnotation, threshold: f32) -> VerificationVerdict {
209    let claim = span_text(text, ann.start, ann.end);
210    let snippet = ann.source.snippet.as_deref().unwrap_or("");
211    let claim_tokens = citation_tokens(&claim);
212    let snippet_tokens = citation_tokens(snippet);
213    let ratio = token_overlap_ratio(&claim_tokens, &snippet_tokens);
214    let status = if ratio >= threshold {
215        VerificationStatus::Entailed
216    } else if ratio >= threshold * 0.5 {
217        VerificationStatus::Uncertain
218    } else {
219        VerificationStatus::Unsupported
220    };
221    VerificationVerdict {
222        status,
223        score: Some(ratio),
224    }
225}
226
227/// Ask the utility model to judge every claim/source pair in one call. Returns
228/// `None` on any transport/parse failure so the caller can fall back.
229async fn llm_verdicts(
230    svc: &Arc<dyn crate::UtilityLlmService>,
231    text: &str,
232    annotations: &[TextAnnotation],
233) -> Option<Vec<VerificationVerdict>> {
234    let prompt = build_verification_prompt(text, annotations);
235    let request = UtilityLlmRequest::user_text(prompt)
236        .with_temperature(0.0)
237        .with_max_tokens(700)
238        .with_metadata("purpose", "citation_verification");
239    let response = svc.chat_completion(request).await.ok()?;
240    parse_verification_response(&response.text, annotations.len())
241}
242
243/// Build a compact JSON-in/JSON-out verification prompt.
244fn build_verification_prompt(text: &str, annotations: &[TextAnnotation]) -> String {
245    let mut pairs = String::new();
246    for (i, ann) in annotations.iter().enumerate() {
247        let claim = span_text(text, ann.start, ann.end);
248        let snippet = ann.source.snippet.as_deref().unwrap_or("");
249        pairs.push_str(&format!(
250            "[{i}]\nCLAIM: {}\nSOURCE: {}\n\n",
251            claim.trim(),
252            snippet.trim()
253        ));
254    }
255    format!(
256        "You verify citations. For each numbered pair, decide whether the SOURCE \
257         supports the CLAIM.\n\
258         Reply with ONLY a JSON array; one object per pair, in order:\n\
259         [{{\"index\": 0, \"status\": \"entailed|unsupported|uncertain\", \"score\": 0.0}}]\n\
260         `entailed` = the source clearly supports the claim; `unsupported` = it does \
261         not; `uncertain` = cannot tell. `score` is your confidence in [0,1].\n\n\
262         {pairs}"
263    )
264}
265
266/// Parse the model's JSON array back into per-annotation verdicts, tolerating
267/// code fences and surrounding prose. Returns `None` unless exactly `expected`
268/// verdicts are recovered in index order.
269fn parse_verification_response(raw: &str, expected: usize) -> Option<Vec<VerificationVerdict>> {
270    let json = extract_json_array(raw)?;
271    let array = json.as_array()?;
272    if array.len() != expected {
273        return None;
274    }
275    let mut out = Vec::with_capacity(expected);
276    for entry in array {
277        let status = match entry.get("status").and_then(|s| s.as_str())? {
278            "entailed" => VerificationStatus::Entailed,
279            "unsupported" => VerificationStatus::Unsupported,
280            _ => VerificationStatus::Uncertain,
281        };
282        let score = entry
283            .get("score")
284            .and_then(|s| s.as_f64())
285            .map(|s| s.clamp(0.0, 1.0) as f32);
286        out.push(VerificationVerdict { status, score });
287    }
288    Some(out)
289}
290
291/// Extract the first top-level JSON array from a model response.
292fn extract_json_array(raw: &str) -> Option<serde_json::Value> {
293    let start = raw.find('[')?;
294    let end = raw.rfind(']')?;
295    if end <= start {
296        return None;
297    }
298    serde_json::from_str(&raw[start..=end]).ok()
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::message::AnnotationSource;
305
306    fn annotation(start: usize, end: usize, snippet: &str) -> TextAnnotation {
307        TextAnnotation {
308            start,
309            end,
310            origin: "citation_retrieval".to_string(),
311            source: AnnotationSource {
312                uri: "u".to_string(),
313                title: None,
314                snippet: Some(snippet.to_string()),
315                location: None,
316            },
317            external_id: None,
318            verified: None,
319        }
320    }
321
322    #[test]
323    fn heuristic_marks_supported_claim_entailed() {
324        let text = "Photosynthesis converts sunlight into chemical energy.";
325        let ann = annotation(
326            0,
327            text.chars().count(),
328            "Photosynthesis converts sunlight into chemical energy in plants.",
329        );
330        let verdict = heuristic_verdict(text, &ann, 0.5);
331        assert_eq!(verdict.status, VerificationStatus::Entailed);
332    }
333
334    #[test]
335    fn heuristic_marks_unrelated_claim_unsupported() {
336        let text = "The stock market rallied on strong earnings today.";
337        let ann = annotation(
338            0,
339            text.chars().count(),
340            "Mitochondria produce ATP through cellular respiration.",
341        );
342        let verdict = heuristic_verdict(text, &ann, 0.5);
343        assert_eq!(verdict.status, VerificationStatus::Unsupported);
344    }
345
346    #[test]
347    fn parses_verdict_array_with_code_fence() {
348        let raw = "```json\n[{\"index\":0,\"status\":\"entailed\",\"score\":0.9},\
349                   {\"index\":1,\"status\":\"unsupported\",\"score\":0.1}]\n```";
350        let verdicts = parse_verification_response(raw, 2).expect("parsed");
351        assert_eq!(verdicts[0].status, VerificationStatus::Entailed);
352        assert_eq!(verdicts[1].status, VerificationStatus::Unsupported);
353        assert!((verdicts[0].score.unwrap() - 0.9).abs() < 1e-6);
354    }
355
356    #[test]
357    fn parse_rejects_wrong_count() {
358        let raw = "[{\"index\":0,\"status\":\"entailed\"}]";
359        assert!(parse_verification_response(raw, 2).is_none());
360    }
361
362    #[test]
363    fn config_validation_rejects_bad_threshold() {
364        let cap = CitationVerificationCapability;
365        assert!(cap.validate_config(&json!({"threshold": 2.0})).is_err());
366        assert!(
367            cap.validate_config(&json!({"mode": "llm", "threshold": 0.4}))
368                .is_ok()
369        );
370    }
371
372    #[tokio::test]
373    async fn verifier_stamps_all_annotations_heuristically() {
374        let cap = CitationVerificationCapability;
375        let verifier = cap
376            .citation_verifier_with_config(&json!({"mode": "heuristic"}))
377            .expect("verifier");
378        let text = "Water boils at 100 degrees Celsius at sea level.";
379        let anns = vec![annotation(
380            0,
381            text.chars().count(),
382            "At sea level water boils at 100 degrees Celsius.",
383        )];
384        let ctx = VerificationContext {
385            message_text: text,
386            utility_llm_service: None,
387        };
388        let out = verifier.verify(&ctx, anns).await;
389        assert_eq!(out.len(), 1);
390        assert_eq!(
391            out[0].verified.as_ref().unwrap().status,
392            VerificationStatus::Entailed
393        );
394    }
395}