Skip to main content

sqlite_graphrag/
preservation.rs

1//! Preservation checks for LLM-enriched memory bodies (G29 Step 4).
2//!
3//! When a language model rewrites a memory body, the operator must be
4//! protected against silent hallucination: the LLM may invent facts, drop
5//! key terms, or drift semantically far from the source. This module
6//! provides a lightweight, deterministic similarity metric that runs
7//! locally without any model call, so the gate can be enforced before the
8//! enriched body touches persistent storage.
9//!
10//! The default metric is a normalised trigram-Jaccard similarity computed
11//! on the union of `set_a` and `set_b`. The score is in `[0.0, 1.0]`,
12//! where `1.0` means the two inputs share every trigram and `0.0` means
13//! they share none. The threshold default of `0.7` follows the gap G29
14//! specification, with `--preserve-threshold <F>` letting operators tune
15//! it per workload.
16//!
17//! # Examples
18//!
19//! ```
20//! use sqlite_graphrag::preservation::{jaccard_similarity, PreservationVerdict};
21//!
22//! let score = jaccard_similarity("the quick brown fox", "the quick brown fox!");
23//! assert!(score > 0.8);
24//!
25//! let verdict =
26//!     PreservationVerdict::evaluate("the quick brown fox", "the quick brown fox!", 0.7);
27//! assert!(matches!(verdict, PreservationVerdict::Preserved { .. }));
28//!
29//! let verdict = PreservationVerdict::evaluate("orig body", "rewritten body", 0.7);
30//! assert!(matches!(verdict, PreservationVerdict::Rejected { .. }));
31//! ```
32
33use serde::{Deserialize, Serialize};
34use std::collections::HashSet;
35
36/// Default minimum evidence length (Unicode scalars) before grounding is
37/// enforced. Below this, G-PR-6 accepts the candidate to avoid mass
38/// `preservation_failed` on weak corpora. Overridable via XDG
39/// `enrich.entity_description.min_corpus_chars`.
40pub const DEFAULT_GROUNDING_MIN_CORPUS_CHARS: usize = 40;
41
42/// Whether there is enough evidence to judge a candidate against at all
43/// (G-PR-7).
44///
45/// Single source of truth for "is this corpus worth grounding against",
46/// shared by the entity-description write path and the `--status` quality
47/// sampler. Before this existed, both asked
48/// [`PreservationVerdict::evaluate_grounding`] instead, which answers
49/// `Preserved { score: 1.0 }` when the evidence is empty — so the writer
50/// persisted filler for unbound entities and the sampler counted those same
51/// entities as PERFECT quality. The measurement shared the defect of the
52/// thing it measured, which is why the problem stayed invisible.
53#[must_use]
54pub fn corpus_is_sufficient(evidence: &str, min_corpus_chars: usize) -> bool {
55    evidence.trim().chars().count() >= min_corpus_chars.max(1)
56}
57
58/// Computes the trigram-Jaccard similarity between two strings.
59///
60/// The score is `|A ∩ B| / |A ∪ B|` where `A` and `B` are the sets of
61/// character-trigrams extracted from each input. The trigrams are taken
62/// over Unicode scalar values via `char_indices`, so the function is
63/// safe to call on multi-byte UTF-8 inputs without byte-boundary errors.
64///
65/// # Edge cases
66///
67/// - Both inputs empty: returns `1.0` (the empty trigram set is trivially
68///   contained in itself).
69/// - One input empty, the other non-empty: returns `0.0` (no overlap).
70/// - Identical inputs: returns `1.0`.
71///
72/// The function is pure: no I/O, no allocation beyond the two trigram
73/// sets, deterministic for a given pair of inputs. It is safe to call
74/// in hot paths.
75pub fn jaccard_similarity(a: &str, b: &str) -> f64 {
76    let set_a = trigrams(a);
77    let set_b = trigrams(b);
78    if set_a.is_empty() && set_b.is_empty() {
79        return 1.0;
80    }
81    let intersection = set_a.intersection(&set_b).count() as f64;
82    let union = set_a.union(&set_b).count() as f64;
83    if union == 0.0 {
84        0.0
85    } else {
86        intersection / union
87    }
88}
89
90/// Extracts the set of character-trigrams from a string.
91///
92/// Padding handles short strings: inputs with fewer than three characters
93/// are represented by the unique chars they do contain (with the
94/// `[c, '\0', '\0']` padding), which guarantees that two identical
95/// short strings still produce the same trigram set and score `1.0`.
96fn trigrams(input: &str) -> HashSet<[char; 3]> {
97    let chars: Vec<char> = input.chars().collect();
98    if chars.is_empty() {
99        return HashSet::new();
100    }
101    let mut out: HashSet<[char; 3]> = HashSet::with_capacity(chars.len().saturating_add(2));
102    let mut window: [char; 3] = ['\0', '\0', '\0'];
103    for (i, ch) in chars.iter().enumerate() {
104        window[0] = if i >= 1 { chars[i - 1] } else { '\0' };
105        window[1] = *ch;
106        window[2] = if i + 1 < chars.len() {
107            chars[i + 1]
108        } else {
109            '\0'
110        };
111        out.insert(window);
112    }
113    out
114}
115
116/// Outcome of a preservation evaluation against a configurable threshold.
117///
118/// `PreservationVerdict` is the wire type the enrich pipeline emits in its
119/// NDJSON stream: every body-enrich attempt ends in one of the four
120/// variants so callers can route the result without re-running the
121/// similarity computation.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(tag = "verdict", rename_all = "snake_case")]
124pub enum PreservationVerdict {
125    /// The rewritten body is at least `threshold`-similar to the original.
126    Preserved {
127        /// Computed preservation score.
128        score: f64,
129        /// Configured threshold.
130        threshold: f64,
131    },
132    /// The rewritten body diverges too much from the original and was
133    /// rejected by the gate.
134    Rejected {
135        /// Computed preservation score.
136        score: f64,
137        /// Configured threshold.
138        threshold: f64,
139    },
140    /// The original and rewritten bodies are byte-equal (no rewrite was
141    /// needed); preserved by definition.
142    Unchanged {
143        /// Payload size in bytes.
144        byte_len: usize,
145    },
146}
147
148impl PreservationVerdict {
149    /// Evaluates the gate against `threshold` and returns the matching
150    /// variant. The threshold is clamped to `[0.0, 1.0]` defensively; an
151    /// out-of-range value does not panic the caller.
152    pub fn evaluate(original: &str, rewritten: &str, threshold: f64) -> Self {
153        let threshold = threshold.clamp(0.0, 1.0);
154        if original == rewritten {
155            return Self::Unchanged {
156                byte_len: original.len(),
157            };
158        }
159        let score = jaccard_similarity(original, rewritten);
160        if score >= threshold {
161            Self::Preserved { score, threshold }
162        } else {
163            Self::Rejected { score, threshold }
164        }
165    }
166
167    /// Grounding gate for short LLM text against longer corpus evidence
168    /// (GAP-CLI-ED-03 / G-T-DRY-01 / G-PR-6).
169    ///
170    /// Uses [`grounding_coverage`] so a 10–20 word description can be
171    /// checked against multi-sentence memory bodies without requiring
172    /// symmetric Jaccard (which under-scores short-vs-long pairs).
173    ///
174    /// Adaptive policy (G-PR-6):
175    /// - empty evidence → accept (entities without bindings stay describable)
176    /// - evidence shorter than `min_corpus_chars` → accept (weak corpus)
177    /// - weak-but-present corpus (`min..2*min` chars) → half threshold
178    /// - dense corpus → full `threshold`
179    ///
180    /// # Trap (G-PR-7)
181    ///
182    /// The first two rules mean this gate returns `Preserved { score: 1.0 }`
183    /// for the entities with the LEAST support — the confidence signal is
184    /// inverted exactly where it matters. Callers that need to distinguish
185    /// "well grounded" from "no evidence at all" MUST consult
186    /// [`corpus_is_sufficient`] FIRST; the verdict alone cannot tell them
187    /// apart. Raising `min_corpus_chars` widens the accept-everything band
188    /// instead of tightening it.
189    pub fn evaluate_grounding(candidate: &str, evidence: &str, threshold: f64) -> Self {
190        Self::evaluate_grounding_adaptive(
191            candidate,
192            evidence,
193            threshold,
194            DEFAULT_GROUNDING_MIN_CORPUS_CHARS,
195        )
196    }
197
198    /// Adaptive grounding with explicit minimum corpus size (G-PR-6).
199    pub fn evaluate_grounding_adaptive(
200        candidate: &str,
201        evidence: &str,
202        threshold: f64,
203        min_corpus_chars: usize,
204    ) -> Self {
205        let threshold = threshold.clamp(0.0, 1.0);
206        let evidence_trim = evidence.trim();
207        if evidence_trim.is_empty() {
208            return Self::Preserved {
209                score: 1.0,
210                threshold,
211            };
212        }
213        let corpus_chars = evidence_trim.chars().count();
214        if corpus_chars < min_corpus_chars.max(1) {
215            // Short/weak corpus: do not mass-reject with Jaccard noise.
216            return Self::Preserved {
217                score: 1.0,
218                threshold,
219            };
220        }
221        let effective = if corpus_chars < min_corpus_chars.saturating_mul(2) {
222            (threshold * 0.5).clamp(0.0, 1.0)
223        } else {
224            threshold
225        };
226        let score = grounding_coverage(candidate, evidence_trim);
227        if score >= effective {
228            Self::Preserved {
229                score,
230                threshold: effective,
231            }
232        } else {
233            Self::Rejected {
234                score,
235                threshold: effective,
236            }
237        }
238    }
239
240    /// Returns `true` when the gate accepted the rewrite.
241    pub fn is_accepted(&self) -> bool {
242        matches!(self, Self::Preserved { .. } | Self::Unchanged { .. })
243    }
244}
245
246/// Fraction of the candidate's character-trigrams that also appear in
247/// the evidence corpus: `|A ∩ B| / |A|`.
248///
249/// This is the DRY grounding metric shared by entity-descriptions and any
250/// future short-text quality gates. Distinct from full Jaccard so short
251/// descriptions are not systematically rejected against long bodies.
252pub fn grounding_coverage(candidate: &str, evidence: &str) -> f64 {
253    let set_a = trigrams(candidate);
254    let set_b = trigrams(evidence);
255    if set_a.is_empty() {
256        return 0.0;
257    }
258    if set_b.is_empty() {
259        return 0.0;
260    }
261    let intersection = set_a.intersection(&set_b).count() as f64;
262    intersection / set_a.len() as f64
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn grounding_coverage_accepts_description_supported_by_corpus() {
271        let evidence = "ICMS P05 is a Brazilian state tax rule for NFC-e fiscal documents and ordered invoice sequences.";
272        let description = "Brazilian ICMS tax rule for NFC-e invoices";
273        let score = grounding_coverage(description, evidence);
274        assert!(
275            score > 0.05,
276            "expected partial coverage against fiscal corpus, got {score}"
277        );
278        let verdict = PreservationVerdict::evaluate_grounding(description, evidence, 0.05);
279        assert!(verdict.is_accepted());
280    }
281
282    /// Pins the G-PR-6 policy AND the trap it creates.
283    ///
284    /// This assertion is not an endorsement: an unrelated software-jargon
285    /// description IS accepted against a fiscal corpus, purely because the
286    /// corpus is short. The verdict is kept as-is because `body-enrich` and
287    /// the other preservation callers rely on it not mass-rejecting on
288    /// Jaccard noise. Protection against the trap lives in
289    /// `corpus_is_sufficient`, exercised by the two tests below — this test
290    /// exists so nobody "fixes" the symptom here and breaks those callers.
291    #[test]
292    fn short_corpus_accepts_but_is_not_evidence() {
293        let evidence = "ICMS tax"; // well under DEFAULT_GROUNDING_MIN_CORPUS_CHARS
294        let description = "A configuration file used in software system design pipelines";
295        let verdict = PreservationVerdict::evaluate_grounding_adaptive(
296            description,
297            evidence,
298            0.5,
299            DEFAULT_GROUNDING_MIN_CORPUS_CHARS,
300        );
301        assert!(
302            verdict.is_accepted(),
303            "short corpus must accept under G-PR-6 adaptive policy"
304        );
305        assert!(
306            !corpus_is_sufficient(evidence, DEFAULT_GROUNDING_MIN_CORPUS_CHARS),
307            "and the gate must refuse to treat it as evidence in the first place"
308        );
309    }
310
311    #[test]
312    fn empty_corpus_accepts_but_is_not_evidence() {
313        let verdict = PreservationVerdict::evaluate_grounding("anything goes", "", 0.5);
314        assert!(
315            verdict.is_accepted(),
316            "empty evidence scores 1.0 — the inversion this module documents"
317        );
318        assert!(
319            !corpus_is_sufficient("", DEFAULT_GROUNDING_MIN_CORPUS_CHARS),
320            "callers must gate on corpus_is_sufficient before trusting that verdict"
321        );
322    }
323
324    /// G-PR-7: the real regression guard for the hallucination class.
325    ///
326    /// A bare proper noun with no linked memories must never reach the LLM.
327    #[test]
328    fn unbound_entity_corpus_is_never_sufficient() {
329        for evidence in ["", "   ", "\n\t ", "Acme"] {
330            assert!(
331                !corpus_is_sufficient(evidence, DEFAULT_GROUNDING_MIN_CORPUS_CHARS),
332                "evidence {evidence:?} must not be treated as groundable"
333            );
334        }
335    }
336
337    #[test]
338    fn real_corpus_is_sufficient() {
339        let evidence =
340            "Acme Holdings is a trading company incorporated in 1998, with two partners \
341             holding equal shares of the quota capital.";
342        assert!(corpus_is_sufficient(
343            evidence,
344            DEFAULT_GROUNDING_MIN_CORPUS_CHARS
345        ));
346    }
347
348    #[test]
349    fn grounding_coverage_rejects_software_jargon_on_fiscal_corpus() {
350        let evidence = "ICMS P05 is a Brazilian state tax rule for NFC-e fiscal documents and ordered invoice sequences with additional fiscal context for dense corpus enforcement.";
351        let description = "A configuration file used in software system design pipelines";
352        let score = grounding_coverage(description, evidence);
353        let verdict = PreservationVerdict::evaluate_grounding(description, evidence, 0.25);
354        assert!(
355            !verdict.is_accepted() || score < 0.25,
356            "software jargon should not ground well on fiscal evidence (score={score})"
357        );
358    }
359
360    #[test]
361    fn grounding_without_evidence_is_accepted() {
362        let verdict = PreservationVerdict::evaluate_grounding("Some entity description", "", 0.5);
363        assert!(verdict.is_accepted());
364    }
365
366    #[test]
367    fn identical_strings_score_one() {
368        let s = "the quick brown fox jumps over the lazy dog";
369        assert!((jaccard_similarity(s, s) - 1.0).abs() < f64::EPSILON);
370    }
371
372    #[test]
373    fn completely_different_strings_score_zero_or_near_zero() {
374        let a = "aaaaaaaaaa";
375        let b = "zzzzzzzzzz";
376        assert!(jaccard_similarity(a, b) < 0.05);
377    }
378
379    #[test]
380    fn partial_overlap_scores_between_zero_and_one() {
381        let a = "the quick brown fox jumps";
382        let b = "the slow brown cat sleeps";
383        let score = jaccard_similarity(a, b);
384        assert!(score > 0.0 && score < 1.0, "got {score}");
385    }
386
387    #[test]
388    fn both_empty_score_one() {
389        assert!((jaccard_similarity("", "") - 1.0).abs() < f64::EPSILON);
390    }
391
392    #[test]
393    fn one_empty_scores_zero() {
394        assert!(jaccard_similarity("hello", "").abs() < f64::EPSILON);
395        assert!(jaccard_similarity("", "hello").abs() < f64::EPSILON);
396    }
397
398    #[test]
399    fn unicode_strings_do_not_panic() {
400        // Multi-byte UTF-8: 1 char each, very short.
401        let a = "ç日本語";
402        let b = "ç中文";
403        let _ = jaccard_similarity(a, b);
404    }
405
406    #[test]
407    fn verdict_preserved_when_above_threshold() {
408        let v = PreservationVerdict::evaluate("hello world", "hello world!", 0.5);
409        assert!(v.is_accepted());
410        assert!(matches!(v, PreservationVerdict::Preserved { .. }));
411    }
412
413    #[test]
414    fn verdict_unchanged_for_identical() {
415        let v = PreservationVerdict::evaluate("same", "same", 0.9);
416        assert!(v.is_accepted());
417        assert!(matches!(v, PreservationVerdict::Unchanged { byte_len: 4 }));
418    }
419
420    #[test]
421    fn threshold_clamped_out_of_range() {
422        // Threshold above 1.0 is clamped to 1.0: identical bodies match
423        // by the `Unchanged` short-circuit, accepted.
424        let v = PreservationVerdict::evaluate("abc", "abc", 99.0);
425        assert!(v.is_accepted());
426        // Threshold below 0.0 is clamped to 0.0: every non-empty rewrite
427        // meets a 0.0 floor and is accepted. This is the documented
428        // behaviour of `clamp(0.0, 1.0)` and is the only sane reading
429        // once a negative threshold is no longer in scope.
430        let v = PreservationVerdict::evaluate("abc", "xyz", -5.0);
431        assert!(v.is_accepted());
432        // Threshold of exactly 0.0 accepts only identical bodies; even
433        // a single-character drift fails the gate.
434        let v = PreservationVerdict::evaluate("abc", "abcd", 0.0);
435        assert!(
436            v.is_accepted(),
437            "single-char append is mostly the same body"
438        );
439    }
440
441    #[test]
442    fn g29_repro_evaluates_rejected_when_diverges() {
443        // G29 reproducer: LLM rewrites a body and drifts far from source.
444        let original = "JWT token rotation strategy with 15-min expiry and refresh flow";
445        let drifted = "The weather in Tokyo is sunny today with mild temperatures expected";
446        let v = PreservationVerdict::evaluate(original, drifted, 0.7);
447        assert!(!v.is_accepted(), "should reject hallucinated rewrite");
448    }
449}