Skip to main content

prosaic_core/
faithfulness.rs

1//! PARENT-style reference-free faithfulness scoring.
2//!
3//! Given a rendered output, the Context that produced it, and the
4//! template's literal tokens, score how faithful the output is to its
5//! inputs. Catches two failure modes:
6//!
7//! 1. **Hallucinated content** — tokens in the output that have no
8//!    source in the Context or template literals. Surfaced as a low
9//!    `precision` score and a list of unentailed tokens.
10//! 2. **Polarity drift** — the hypothesis has a different count of
11//!    negation tokens than the source. Surfaced as `polarity_match =
12//!    false` and a list of drifted tokens.
13//!
14//! Morphological tolerance: singular/plural forms match via the
15//! Language trait's `singularize` method. Beyond that, matching is
16//! exact (after lowercasing and edge-punctuation stripping).
17//!
18//! **Note:** Polarity tokens (`not`, `never`, `no`, etc.) are NOT
19//! treated as stopwords. They are handled separately via a dedicated
20//! multiset comparison. This is intentional and differs from the
21//! `discourse.rs` stopwords list which includes `no` and `not`.
22//!
23//! **Note:** This module maintains its own stopwords list, separate
24//! from `discourse.rs`. The two serve different purposes and a future
25//! refactor can unify them if appropriate.
26//!
27//! **Limitation — partials:** Template literals retrieved via
28//! `Template::literal_tokens()` do not include text inside
29//! `{>partial_name}` expansions, since partials are opaque at parse
30//! time. Templates that rely heavily on partials for prose may score
31//! lower precision than expected. Callers can pre-expand partials or
32//! disable the gate for those templates.
33//!
34//! **Limitation — cross-linguistic polarity:** The polarity token list
35//! is English-only. A future extension point would be a
36//! `Language::polarity_tokens()` method; for v1 this is hardcoded.
37//!
38//! See `docs/plans/parent-faithfulness.md` for design rationale.
39
40#[cfg(not(feature = "std"))]
41use alloc::string::{String, ToString};
42#[cfg(not(feature = "std"))]
43use alloc::vec::Vec;
44
45use crate::collections::{HashSet, new_set};
46use crate::context::{Context, Value};
47use crate::language::Language;
48
49/// Polarity (negation) tokens treated separately from content tokens.
50/// These are NOT in STOPWORDS — they are scored as a distinct multiset gate.
51const POLARITY_TOKENS: &[&str] = &[
52    "not", "never", "no", "none", "cannot", "won't", "neither", "nor",
53];
54
55/// Stopwords excluded from content scoring. Polarity tokens are deliberately
56/// absent from this list — they are handled by the polarity gate instead.
57const STOPWORDS: &[&str] = &[
58    "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
59    "from", "is", "was", "are", "were", "be", "been", "being", "have", "has", "had", "do", "does",
60    "did", "will", "would", "could", "should", "may", "might", "shall", "can", "it", "its", "this",
61    "that", "these", "those", "which", "who", "what", "where", "when", "how", "if", "then", "than",
62    "so", "as", "up", "out", "into", "also", "just", "more", "most",
63];
64
65/// A faithfulness score for a rendered hypothesis against its source.
66#[derive(Debug, Clone, PartialEq)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub struct FaithfulnessScore {
69    /// Fraction of hypothesis content tokens entailed by source. `[0.0, 1.0]`.
70    pub precision: f32,
71    /// True iff polarity-token multisets match exactly between source and hypothesis.
72    pub polarity_match: bool,
73    /// Hypothesis content tokens not entailed by source (hallucinated words).
74    pub unentailed: Vec<String>,
75    /// Polarity tokens whose counts differ between hypothesis and source.
76    /// Empty iff `polarity_match` is true.
77    pub polarity_drift: Vec<PolarityDrift>,
78}
79
80/// A single polarity token whose count differs between source and hypothesis.
81#[derive(Debug, Clone, PartialEq)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83pub struct PolarityDrift {
84    /// The polarity token (e.g. `"not"`, `"never"`).
85    pub token: String,
86    /// How many times it appears in the source (context + template literals).
87    pub in_source: usize,
88    /// How many times it appears in the hypothesis (rendered output).
89    pub in_hypothesis: usize,
90}
91
92impl FaithfulnessScore {
93    /// True iff `precision == 1.0` AND `polarity_match`. Use this for strict
94    /// template conformance tests where zero hallucination is required.
95    pub fn is_faithful(&self) -> bool {
96        self.precision >= 1.0 && self.polarity_match
97    }
98
99    /// True iff `precision >= threshold` AND `polarity_match`. Use for
100    /// runtime gates that tolerate a small fraction of unentailed tokens —
101    /// e.g. hedged phrasings that legitimately introduce words not in the
102    /// input context.
103    pub fn passes(&self, threshold: f32) -> bool {
104        self.precision >= threshold && self.polarity_match
105    }
106}
107
108/// Score the faithfulness of a rendered `output` against the [`Context`]
109/// that produced it and the `template_literals` that anchored it.
110///
111/// The `language` parameter is used for morphological tolerance:
112/// singular/plural pairs are treated as equivalent via
113/// [`Language::singularize`].
114///
115/// # Scoring model
116///
117/// - **Source set:** union of tokens from every Context value plus every
118///   template literal token.
119/// - **Content token:** lowercase, edge-punctuation-stripped, length ≥ 3,
120///   not a stopword, not a polarity token, not purely numeric.
121/// - **Entailment:** exact match on the source set, OR the hypothesis token's
122///   singular form matches a source token, OR any source token's singular form
123///   matches the hypothesis token (bidirectional singularization).
124/// - **Precision:** entailed content tokens / total content tokens.
125///   Vacuously 1.0 when the hypothesis has no content tokens.
126/// - **Polarity gate:** checked separately; counts each polarity token in
127///   source and hypothesis and flags mismatches.
128pub fn score_faithfulness(
129    output: &str,
130    context: &Context,
131    template_literals: &[&str],
132    language: &dyn Language,
133) -> FaithfulnessScore {
134    let hyp_tokens = tokenize(output);
135    let src_tokens: Vec<String> = {
136        let mut v = tokens_from_context(context);
137        v.extend(tokens_from_literals(template_literals));
138        v
139    };
140
141    // ── Polarity gate ──────────────────────────────────────────────────
142    // Separate from content scoring. Each polarity token's count must
143    // match exactly between source and hypothesis.
144    let mut polarity_drift = Vec::new();
145    let mut polarity_match = true;
146    for &tok in POLARITY_TOKENS {
147        let s = src_tokens
148            .iter()
149            .filter(|t| t.as_ref() as &str == tok)
150            .count();
151        let h = hyp_tokens
152            .iter()
153            .filter(|t| t.as_ref() as &str == tok)
154            .count();
155        if s != h {
156            polarity_match = false;
157            polarity_drift.push(PolarityDrift {
158                token: tok.to_string(),
159                in_source: s,
160                in_hypothesis: h,
161            });
162        }
163    }
164
165    // ── Content precision ──────────────────────────────────────────────
166    let hyp_content: Vec<&String> = hyp_tokens.iter().filter(|t| is_content_token(t)).collect();
167
168    if hyp_content.is_empty() {
169        return FaithfulnessScore {
170            precision: 1.0,
171            polarity_match,
172            unentailed: Vec::new(),
173            polarity_drift,
174        };
175    }
176
177    // Build a normalised source set: each source token contributes its
178    // own form AND its singularized form for bidirectional tolerance.
179    let mut src_set: HashSet<String> = new_set();
180    for t in &src_tokens {
181        src_set.insert(t.clone());
182        src_set.insert(language.singularize(t));
183    }
184
185    let mut entailed: usize = 0;
186    let mut unentailed: Vec<String> = Vec::new();
187
188    for t in &hyp_content {
189        let t_sing = language.singularize(t);
190        if src_set.contains(t.as_ref() as &str) || src_set.contains(&t_sing) {
191            entailed += 1;
192        } else {
193            unentailed.push((*t).clone());
194        }
195    }
196
197    let precision = entailed as f32 / hyp_content.len() as f32;
198
199    FaithfulnessScore {
200        precision,
201        polarity_match,
202        unentailed,
203        polarity_drift,
204    }
205}
206
207// ── Tokenization ────────────────────────────────────────────────────────
208
209/// Lowercase, split on whitespace, trim edge punctuation.
210/// Inner hyphens and apostrophes (e.g. `user-facing`, `won't`) are preserved.
211fn tokenize(text: &str) -> Vec<String> {
212    text.split_whitespace()
213        .map(|raw| {
214            raw.trim_matches(|c: char| {
215                matches!(
216                    c,
217                    ',' | '.' | ':' | ';' | '!' | '?' | '"' | '\''
218                    | '(' | ')' | '[' | ']' | '\u{2014}' // em dash
219                    | '\u{2013}' // en dash
220                    | '-'
221                )
222            })
223            .to_lowercase()
224        })
225        .filter(|s| !s.is_empty())
226        .collect()
227}
228
229// ── Classification predicates ────────────────────────────────────────────
230
231fn is_stopword(tok: &str) -> bool {
232    STOPWORDS.contains(&tok)
233}
234
235fn is_polarity(tok: &str) -> bool {
236    POLARITY_TOKENS.contains(&tok)
237}
238
239fn is_numeric(tok: &str) -> bool {
240    !tok.is_empty() && tok.chars().all(|c| c.is_ascii_digit())
241}
242
243fn is_content_token(tok: &str) -> bool {
244    tok.len() >= 3 && !is_stopword(tok) && !is_polarity(tok) && !is_numeric(tok)
245}
246
247// ── Source-set extraction ────────────────────────────────────────────────
248
249fn tokens_from_context(ctx: &Context) -> Vec<String> {
250    let mut out = Vec::new();
251    for (_key, value) in ctx.iter() {
252        match value {
253            Value::String(s) => out.extend(tokenize(s)),
254            Value::Number(n) => out.push(n.to_string()),
255            Value::List(items) => {
256                for item in items {
257                    out.extend(tokenize(item));
258                }
259            }
260            // Entity renders as its name — same token contribution as String.
261            Value::Entity { name, .. } => out.extend(tokenize(name)),
262        }
263    }
264    out
265}
266
267fn tokens_from_literals(literals: &[&str]) -> Vec<String> {
268    let mut out = Vec::new();
269    for lit in literals {
270        out.extend(tokenize(lit));
271    }
272    out
273}
274
275// ── Test-harness macro ───────────────────────────────────────────────────
276
277/// Assert that a rendered output is faithful to its context and template.
278///
279/// Panics with a detailed diagnostic if the output has unentailed content
280/// tokens or a polarity mismatch. Use this in vocab-module tests to
281/// ensure templates only produce words entailed by their inputs.
282///
283/// # Example
284///
285/// ```
286/// use prosaic_core::{assert_faithful, ctx};
287/// use prosaic_grammar_en::English;
288///
289/// let ctx = ctx! { name: "UserService", action: "renamed" };
290/// let lits: &[&str] = &["The class ", " was ", "."];
291/// assert_faithful!("The class UserService was renamed.", ctx, lits, &English::new());
292/// ```
293#[macro_export]
294macro_rules! assert_faithful {
295    ($output:expr, $context:expr, $template_literals:expr, $language:expr $(,)?) => {{
296        let score = $crate::score_faithfulness(
297            &$output,
298            &$context,
299            &$template_literals,
300            $language,
301        );
302        if !score.is_faithful() {
303            panic!(
304                "faithfulness violation:\n  precision: {:.3}\n  polarity_match: {}\n  unentailed: {:?}\n  polarity_drift: {:?}\n  output: {}\n",
305                score.precision,
306                score.polarity_match,
307                score.unentailed,
308                score.polarity_drift,
309                $output,
310            );
311        }
312    }};
313}
314
315// ── Unit tests ───────────────────────────────────────────────────────────
316// Tests that require a Language impl (English) live in
317// prosaic-core/tests/faithfulness_scorer.rs to avoid the two-crate identity
318// issue with dev-dependencies.
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    // ── Tokenization ─────────────────────────────────────────────────────
325
326    #[test]
327    fn tokenize_strips_edge_punctuation() {
328        let toks = tokenize("hello, world! (foo)");
329        assert_eq!(toks, vec!["hello", "world", "foo"]);
330    }
331
332    #[test]
333    fn tokenize_lowercases() {
334        let toks = tokenize("UserService AccountService");
335        assert_eq!(toks, vec!["userservice", "accountservice"]);
336    }
337
338    #[test]
339    fn tokenize_preserves_inner_hyphens_and_apostrophes() {
340        let toks = tokenize("user-facing won't");
341        assert_eq!(toks, vec!["user-facing", "won't"]);
342    }
343
344    #[test]
345    fn tokenize_empty_string_produces_no_tokens() {
346        assert!(tokenize("").is_empty());
347    }
348
349    #[test]
350    fn tokenize_punctuation_only_produces_no_tokens() {
351        assert!(tokenize("... ,,, ???").is_empty());
352    }
353
354    // ── Classification predicates ─────────────────────────────────────────
355
356    #[test]
357    fn content_token_respects_length_threshold() {
358        // Length 2 excluded, length 3 included (if not stopword/polarity/numeric)
359        assert!(!is_content_token("it"));
360        assert!(is_content_token("foo")); // length 3, not excluded
361    }
362
363    #[test]
364    fn content_token_excludes_stopwords() {
365        assert!(!is_content_token("the"));
366        assert!(!is_content_token("and"));
367        assert!(!is_content_token("was"));
368    }
369
370    #[test]
371    fn content_token_excludes_polarity() {
372        // Polarity tokens are NOT treated as stopwords for scoring
373        assert!(!is_content_token("not"));
374        assert!(!is_content_token("never"));
375        assert!(!is_content_token("nor"));
376    }
377
378    #[test]
379    fn content_token_excludes_pure_digits() {
380        assert!(!is_content_token("123"));
381        assert!(!is_content_token("42"));
382    }
383
384    #[test]
385    fn content_token_admits_alphanumeric_mixed() {
386        // "a123" is not pure digits — should pass other checks
387        assert!(is_content_token("a123")); // len 4, not stopword, not polarity, not pure numeric
388    }
389
390    // ── FaithfulnessScore struct methods ──────────────────────────────────
391
392    #[test]
393    fn is_faithful_requires_both_precision_and_polarity() {
394        // Precision 1.0 but polarity mismatch → not faithful
395        let score_bad_polarity = FaithfulnessScore {
396            precision: 1.0,
397            polarity_match: false,
398            unentailed: vec![],
399            polarity_drift: vec![PolarityDrift {
400                token: "not".into(),
401                in_source: 0,
402                in_hypothesis: 1,
403            }],
404        };
405        assert!(!score_bad_polarity.is_faithful());
406
407        // polarity_match true but precision < 1.0 → not faithful
408        let score_bad_precision = FaithfulnessScore {
409            precision: 0.8,
410            polarity_match: true,
411            unentailed: vec!["extra".into()],
412            polarity_drift: vec![],
413        };
414        assert!(!score_bad_precision.is_faithful());
415
416        // Both good → faithful
417        let score_good = FaithfulnessScore {
418            precision: 1.0,
419            polarity_match: true,
420            unentailed: vec![],
421            polarity_drift: vec![],
422        };
423        assert!(score_good.is_faithful());
424    }
425
426    #[test]
427    fn passes_threshold_semantics() {
428        let score = FaithfulnessScore {
429            precision: 0.75,
430            polarity_match: true,
431            unentailed: vec!["extra".into()],
432            polarity_drift: vec![],
433        };
434        assert!(score.passes(0.5), "0.75 >= 0.5 should pass");
435        assert!(score.passes(0.75), "0.75 >= 0.75 should pass (boundary)");
436        assert!(!score.passes(0.76), "0.75 < 0.76 should fail");
437
438        // Polarity mismatch always fails regardless of threshold
439        let score_polarity_bad = FaithfulnessScore {
440            precision: 1.0,
441            polarity_match: false,
442            unentailed: vec![],
443            polarity_drift: vec![PolarityDrift {
444                token: "not".into(),
445                in_source: 0,
446                in_hypothesis: 1,
447            }],
448        };
449        assert!(
450            !score_polarity_bad.passes(0.0),
451            "polarity mismatch always fails"
452        );
453    }
454}