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}