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}