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/// Computes the trigram-Jaccard similarity between two strings.
43///
44/// The score is `|A ∩ B| / |A ∪ B|` where `A` and `B` are the sets of
45/// character-trigrams extracted from each input. The trigrams are taken
46/// over Unicode scalar values via `char_indices`, so the function is
47/// safe to call on multi-byte UTF-8 inputs without byte-boundary errors.
48///
49/// # Edge cases
50///
51/// - Both inputs empty: returns `1.0` (the empty trigram set is trivially
52///   contained in itself).
53/// - One input empty, the other non-empty: returns `0.0` (no overlap).
54/// - Identical inputs: returns `1.0`.
55///
56/// The function is pure: no I/O, no allocation beyond the two trigram
57/// sets, deterministic for a given pair of inputs. It is safe to call
58/// in hot paths.
59pub fn jaccard_similarity(a: &str, b: &str) -> f64 {
60    let set_a = trigrams(a);
61    let set_b = trigrams(b);
62    if set_a.is_empty() && set_b.is_empty() {
63        return 1.0;
64    }
65    let intersection = set_a.intersection(&set_b).count() as f64;
66    let union = set_a.union(&set_b).count() as f64;
67    if union == 0.0 {
68        0.0
69    } else {
70        intersection / union
71    }
72}
73
74/// Extracts the set of character-trigrams from a string.
75///
76/// Padding handles short strings: inputs with fewer than three characters
77/// are represented by the unique chars they do contain (with the
78/// `[c, '\0', '\0']` padding), which guarantees that two identical
79/// short strings still produce the same trigram set and score `1.0`.
80fn trigrams(input: &str) -> HashSet<[char; 3]> {
81    let chars: Vec<char> = input.chars().collect();
82    if chars.is_empty() {
83        return HashSet::new();
84    }
85    let mut out: HashSet<[char; 3]> = HashSet::with_capacity(chars.len().saturating_add(2));
86    let mut window: [char; 3] = ['\0', '\0', '\0'];
87    for (i, ch) in chars.iter().enumerate() {
88        window[0] = if i >= 1 { chars[i - 1] } else { '\0' };
89        window[1] = *ch;
90        window[2] = if i + 1 < chars.len() {
91            chars[i + 1]
92        } else {
93            '\0'
94        };
95        out.insert(window);
96    }
97    out
98}
99
100/// Outcome of a preservation evaluation against a configurable threshold.
101///
102/// `PreservationVerdict` is the wire type the enrich pipeline emits in its
103/// NDJSON stream: every body-enrich attempt ends in one of the four
104/// variants so callers can route the result without re-running the
105/// similarity computation.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107#[serde(tag = "verdict", rename_all = "snake_case")]
108pub enum PreservationVerdict {
109    /// The rewritten body is at least `threshold`-similar to the original.
110    Preserved {
111        /// Computed preservation score.
112        score: f64,
113        /// Configured threshold.
114        threshold: f64,
115    },
116    /// The rewritten body diverges too much from the original and was
117    /// rejected by the gate.
118    Rejected {
119        /// Computed preservation score.
120        score: f64,
121        /// Configured threshold.
122        threshold: f64,
123    },
124    /// The original and rewritten bodies are byte-equal (no rewrite was
125    /// needed); preserved by definition.
126    Unchanged {
127        /// Payload size in bytes.
128        byte_len: usize,
129    },
130}
131
132impl PreservationVerdict {
133    /// Evaluates the gate against `threshold` and returns the matching
134    /// variant. The threshold is clamped to `[0.0, 1.0]` defensively; an
135    /// out-of-range value does not panic the caller.
136    pub fn evaluate(original: &str, rewritten: &str, threshold: f64) -> Self {
137        let threshold = threshold.clamp(0.0, 1.0);
138        if original == rewritten {
139            return Self::Unchanged {
140                byte_len: original.len(),
141            };
142        }
143        let score = jaccard_similarity(original, rewritten);
144        if score >= threshold {
145            Self::Preserved { score, threshold }
146        } else {
147            Self::Rejected { score, threshold }
148        }
149    }
150
151    /// Grounding gate for short LLM text against longer corpus evidence
152    /// (GAP-CLI-ED-03 / G-T-DRY-01 / G-PR-6).
153    ///
154    /// Uses [`grounding_coverage`] so a 10–20 word description can be
155    /// checked against multi-sentence memory bodies without requiring
156    /// symmetric Jaccard (which under-scores short-vs-long pairs).
157    ///
158    /// Adaptive policy (G-PR-6):
159    /// - empty evidence → accept (entities without bindings stay describable)
160    /// - evidence shorter than `min_corpus_chars` → accept (weak corpus)
161    /// - weak-but-present corpus (`min..2*min` chars) → half threshold
162    /// - dense corpus → full `threshold`
163    pub fn evaluate_grounding(candidate: &str, evidence: &str, threshold: f64) -> Self {
164        Self::evaluate_grounding_adaptive(
165            candidate,
166            evidence,
167            threshold,
168            DEFAULT_GROUNDING_MIN_CORPUS_CHARS,
169        )
170    }
171
172    /// Adaptive grounding with explicit minimum corpus size (G-PR-6).
173    pub fn evaluate_grounding_adaptive(
174        candidate: &str,
175        evidence: &str,
176        threshold: f64,
177        min_corpus_chars: usize,
178    ) -> Self {
179        let threshold = threshold.clamp(0.0, 1.0);
180        let evidence_trim = evidence.trim();
181        if evidence_trim.is_empty() {
182            return Self::Preserved {
183                score: 1.0,
184                threshold,
185            };
186        }
187        let corpus_chars = evidence_trim.chars().count();
188        if corpus_chars < min_corpus_chars.max(1) {
189            // Short/weak corpus: do not mass-reject with Jaccard noise.
190            return Self::Preserved {
191                score: 1.0,
192                threshold,
193            };
194        }
195        let effective = if corpus_chars < min_corpus_chars.saturating_mul(2) {
196            (threshold * 0.5).clamp(0.0, 1.0)
197        } else {
198            threshold
199        };
200        let score = grounding_coverage(candidate, evidence_trim);
201        if score >= effective {
202            Self::Preserved {
203                score,
204                threshold: effective,
205            }
206        } else {
207            Self::Rejected {
208                score,
209                threshold: effective,
210            }
211        }
212    }
213
214    /// Returns `true` when the gate accepted the rewrite.
215    pub fn is_accepted(&self) -> bool {
216        matches!(self, Self::Preserved { .. } | Self::Unchanged { .. })
217    }
218}
219
220/// Fraction of the candidate's character-trigrams that also appear in
221/// the evidence corpus: `|A ∩ B| / |A|`.
222///
223/// This is the DRY grounding metric shared by entity-descriptions and any
224/// future short-text quality gates. Distinct from full Jaccard so short
225/// descriptions are not systematically rejected against long bodies.
226pub fn grounding_coverage(candidate: &str, evidence: &str) -> f64 {
227    let set_a = trigrams(candidate);
228    let set_b = trigrams(evidence);
229    if set_a.is_empty() {
230        return 0.0;
231    }
232    if set_b.is_empty() {
233        return 0.0;
234    }
235    let intersection = set_a.intersection(&set_b).count() as f64;
236    intersection / set_a.len() as f64
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn grounding_coverage_accepts_description_supported_by_corpus() {
245        let evidence = "ICMS P05 is a Brazilian state tax rule for NFC-e fiscal documents and ordered invoice sequences.";
246        let description = "Brazilian ICMS tax rule for NFC-e invoices";
247        let score = grounding_coverage(description, evidence);
248        assert!(
249            score > 0.05,
250            "expected partial coverage against fiscal corpus, got {score}"
251        );
252        let verdict = PreservationVerdict::evaluate_grounding(description, evidence, 0.05);
253        assert!(verdict.is_accepted());
254    }
255
256    #[test]
257    fn short_corpus_accepts_without_strict_grounding() {
258        let evidence = "ICMS tax"; // well under DEFAULT_GROUNDING_MIN_CORPUS_CHARS
259        let description = "A configuration file used in software system design pipelines";
260        let verdict = PreservationVerdict::evaluate_grounding_adaptive(
261            description,
262            evidence,
263            0.5,
264            DEFAULT_GROUNDING_MIN_CORPUS_CHARS,
265        );
266        assert!(
267            verdict.is_accepted(),
268            "short corpus must accept under G-PR-6 adaptive policy"
269        );
270    }
271
272    #[test]
273    fn empty_corpus_still_accepts() {
274        let verdict = PreservationVerdict::evaluate_grounding("anything goes", "", 0.5);
275        assert!(verdict.is_accepted());
276    }
277
278    #[test]
279    fn grounding_coverage_rejects_software_jargon_on_fiscal_corpus() {
280        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.";
281        let description = "A configuration file used in software system design pipelines";
282        let score = grounding_coverage(description, evidence);
283        let verdict = PreservationVerdict::evaluate_grounding(description, evidence, 0.25);
284        assert!(
285            !verdict.is_accepted() || score < 0.25,
286            "software jargon should not ground well on fiscal evidence (score={score})"
287        );
288    }
289
290    #[test]
291    fn grounding_without_evidence_is_accepted() {
292        let verdict = PreservationVerdict::evaluate_grounding(
293            "Some entity description",
294            "",
295            0.5,
296        );
297        assert!(verdict.is_accepted());
298    }
299
300
301    #[test]
302    fn identical_strings_score_one() {
303        let s = "the quick brown fox jumps over the lazy dog";
304        assert!((jaccard_similarity(s, s) - 1.0).abs() < f64::EPSILON);
305    }
306
307    #[test]
308    fn completely_different_strings_score_zero_or_near_zero() {
309        let a = "aaaaaaaaaa";
310        let b = "zzzzzzzzzz";
311        assert!(jaccard_similarity(a, b) < 0.05);
312    }
313
314    #[test]
315    fn partial_overlap_scores_between_zero_and_one() {
316        let a = "the quick brown fox jumps";
317        let b = "the slow brown cat sleeps";
318        let score = jaccard_similarity(a, b);
319        assert!(score > 0.0 && score < 1.0, "got {score}");
320    }
321
322    #[test]
323    fn both_empty_score_one() {
324        assert!((jaccard_similarity("", "") - 1.0).abs() < f64::EPSILON);
325    }
326
327    #[test]
328    fn one_empty_scores_zero() {
329        assert!(jaccard_similarity("hello", "").abs() < f64::EPSILON);
330        assert!(jaccard_similarity("", "hello").abs() < f64::EPSILON);
331    }
332
333    #[test]
334    fn unicode_strings_do_not_panic() {
335        // Multi-byte UTF-8: 1 char each, very short.
336        let a = "ç日本語";
337        let b = "ç中文";
338        let _ = jaccard_similarity(a, b);
339    }
340
341    #[test]
342    fn verdict_preserved_when_above_threshold() {
343        let v = PreservationVerdict::evaluate("hello world", "hello world!", 0.5);
344        assert!(v.is_accepted());
345        assert!(matches!(v, PreservationVerdict::Preserved { .. }));
346    }
347
348    #[test]
349    fn verdict_unchanged_for_identical() {
350        let v = PreservationVerdict::evaluate("same", "same", 0.9);
351        assert!(v.is_accepted());
352        assert!(matches!(v, PreservationVerdict::Unchanged { byte_len: 4 }));
353    }
354
355    #[test]
356    fn threshold_clamped_out_of_range() {
357        // Threshold above 1.0 is clamped to 1.0: identical bodies match
358        // by the `Unchanged` short-circuit, accepted.
359        let v = PreservationVerdict::evaluate("abc", "abc", 99.0);
360        assert!(v.is_accepted());
361        // Threshold below 0.0 is clamped to 0.0: every non-empty rewrite
362        // meets a 0.0 floor and is accepted. This is the documented
363        // behaviour of `clamp(0.0, 1.0)` and is the only sane reading
364        // once a negative threshold is no longer in scope.
365        let v = PreservationVerdict::evaluate("abc", "xyz", -5.0);
366        assert!(v.is_accepted());
367        // Threshold of exactly 0.0 accepts only identical bodies; even
368        // a single-character drift fails the gate.
369        let v = PreservationVerdict::evaluate("abc", "abcd", 0.0);
370        assert!(
371            v.is_accepted(),
372            "single-char append is mostly the same body"
373        );
374    }
375
376    #[test]
377    fn g29_repro_evaluates_rejected_when_diverges() {
378        // G29 reproducer: LLM rewrites a body and drifts far from source.
379        let original = "JWT token rotation strategy with 15-min expiry and refresh flow";
380        let drifted = "The weather in Tokyo is sunny today with mild temperatures expected";
381        let v = PreservationVerdict::evaluate(original, drifted, 0.7);
382        assert!(!v.is_accepted(), "should reject hallucinated rewrite");
383    }
384}