sqlite-graphrag 1.2.8

Persistent GraphRAG memory for Claude Code, Codex, Cursor, and 27 AI agents — one self-contained ~19 MiB Rust binary, zero daemon. Never re-explain your codebase again. Hybrid retrieval (FTS5 BM25 + cosine similarity + multi-hop graph traversal) surfaces the right memory in milliseconds. Embedding and entity enrichment run as parallel REST calls against your cloud LLM — no fragile headless subprocesses, no ONNX runtime, no model downloads. Soft-delete with full version history, transactional atomic writes, BLAKE3-tracked mutations. OAuth-only: raw API keys ABORT the spawn.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
//! Preservation checks for LLM-enriched memory bodies (G29 Step 4).
//!
//! When a language model rewrites a memory body, the operator must be
//! protected against silent hallucination: the LLM may invent facts, drop
//! key terms, or drift semantically far from the source. This module
//! provides a lightweight, deterministic similarity metric that runs
//! locally without any model call, so the gate can be enforced before the
//! enriched body touches persistent storage.
//!
//! The default metric is a normalised trigram-Jaccard similarity computed
//! on the union of `set_a` and `set_b`. The score is in `[0.0, 1.0]`,
//! where `1.0` means the two inputs share every trigram and `0.0` means
//! they share none. The threshold default of `0.7` follows the gap G29
//! specification, with `--preserve-threshold <F>` letting operators tune
//! it per workload.
//!
//! # Examples
//!
//! ```
//! use sqlite_graphrag::preservation::{jaccard_similarity, PreservationVerdict};
//!
//! let score = jaccard_similarity("the quick brown fox", "the quick brown fox!");
//! assert!(score > 0.8);
//!
//! let verdict =
//!     PreservationVerdict::evaluate("the quick brown fox", "the quick brown fox!", 0.7);
//! assert!(matches!(verdict, PreservationVerdict::Preserved { .. }));
//!
//! let verdict = PreservationVerdict::evaluate("orig body", "rewritten body", 0.7);
//! assert!(matches!(verdict, PreservationVerdict::Rejected { .. }));
//! ```

use serde::{Deserialize, Serialize};
use std::collections::HashSet;

/// Default minimum evidence length (Unicode scalars) before grounding is
/// enforced. Below this, G-PR-6 accepts the candidate to avoid mass
/// `preservation_failed` on weak corpora. Overridable via XDG
/// `enrich.entity_description.min_corpus_chars`.
pub const DEFAULT_GROUNDING_MIN_CORPUS_CHARS: usize = 40;

/// Whether there is enough evidence to judge a candidate against at all
/// (G-PR-7).
///
/// Single source of truth for "is this corpus worth grounding against",
/// shared by the entity-description write path and the `--status` quality
/// sampler. Before this existed, both asked
/// [`PreservationVerdict::evaluate_grounding`] instead, which answers
/// `Preserved { score: 1.0 }` when the evidence is empty — so the writer
/// persisted filler for unbound entities and the sampler counted those same
/// entities as PERFECT quality. The measurement shared the defect of the
/// thing it measured, which is why the problem stayed invisible.
#[must_use]
pub fn corpus_is_sufficient(evidence: &str, min_corpus_chars: usize) -> bool {
    evidence.trim().chars().count() >= min_corpus_chars.max(1)
}

/// Computes the trigram-Jaccard similarity between two strings.
///
/// The score is `|A ∩ B| / |A ∪ B|` where `A` and `B` are the sets of
/// character-trigrams extracted from each input. The trigrams are taken
/// over Unicode scalar values via `char_indices`, so the function is
/// safe to call on multi-byte UTF-8 inputs without byte-boundary errors.
///
/// # Edge cases
///
/// - Both inputs empty: returns `1.0` (the empty trigram set is trivially
///   contained in itself).
/// - One input empty, the other non-empty: returns `0.0` (no overlap).
/// - Identical inputs: returns `1.0`.
///
/// The function is pure: no I/O, no allocation beyond the two trigram
/// sets, deterministic for a given pair of inputs. It is safe to call
/// in hot paths.
pub fn jaccard_similarity(a: &str, b: &str) -> f64 {
    let set_a = trigrams(a);
    let set_b = trigrams(b);
    if set_a.is_empty() && set_b.is_empty() {
        return 1.0;
    }
    let intersection = set_a.intersection(&set_b).count() as f64;
    let union = set_a.union(&set_b).count() as f64;
    if union == 0.0 {
        0.0
    } else {
        intersection / union
    }
}

/// Extracts the set of character-trigrams from a string.
///
/// Padding handles short strings: inputs with fewer than three characters
/// are represented by the unique chars they do contain (with the
/// `[c, '\0', '\0']` padding), which guarantees that two identical
/// short strings still produce the same trigram set and score `1.0`.
fn trigrams(input: &str) -> HashSet<[char; 3]> {
    let chars: Vec<char> = input.chars().collect();
    if chars.is_empty() {
        return HashSet::new();
    }
    let mut out: HashSet<[char; 3]> = HashSet::with_capacity(chars.len().saturating_add(2));
    let mut window: [char; 3] = ['\0', '\0', '\0'];
    for (i, ch) in chars.iter().enumerate() {
        window[0] = if i >= 1 { chars[i - 1] } else { '\0' };
        window[1] = *ch;
        window[2] = if i + 1 < chars.len() {
            chars[i + 1]
        } else {
            '\0'
        };
        out.insert(window);
    }
    out
}

/// Outcome of a preservation evaluation against a configurable threshold.
///
/// `PreservationVerdict` is the wire type the enrich pipeline emits in its
/// NDJSON stream: every body-enrich attempt ends in one of the four
/// variants so callers can route the result without re-running the
/// similarity computation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "verdict", rename_all = "snake_case")]
pub enum PreservationVerdict {
    /// The rewritten body is at least `threshold`-similar to the original.
    Preserved {
        /// Computed preservation score.
        score: f64,
        /// Configured threshold.
        threshold: f64,
    },
    /// The rewritten body diverges too much from the original and was
    /// rejected by the gate.
    Rejected {
        /// Computed preservation score.
        score: f64,
        /// Configured threshold.
        threshold: f64,
    },
    /// The original and rewritten bodies are byte-equal (no rewrite was
    /// needed); preserved by definition.
    Unchanged {
        /// Payload size in bytes.
        byte_len: usize,
    },
}

impl PreservationVerdict {
    /// Evaluates the gate against `threshold` and returns the matching
    /// variant. The threshold is clamped to `[0.0, 1.0]` defensively; an
    /// out-of-range value does not panic the caller.
    pub fn evaluate(original: &str, rewritten: &str, threshold: f64) -> Self {
        let threshold = threshold.clamp(0.0, 1.0);
        if original == rewritten {
            return Self::Unchanged {
                byte_len: original.len(),
            };
        }
        let score = jaccard_similarity(original, rewritten);
        if score >= threshold {
            Self::Preserved { score, threshold }
        } else {
            Self::Rejected { score, threshold }
        }
    }

    /// Grounding gate for short LLM text against longer corpus evidence
    /// (GAP-CLI-ED-03 / G-T-DRY-01 / G-PR-6).
    ///
    /// Uses [`grounding_coverage`] so a 10–20 word description can be
    /// checked against multi-sentence memory bodies without requiring
    /// symmetric Jaccard (which under-scores short-vs-long pairs).
    ///
    /// Adaptive policy (G-PR-6):
    /// - empty evidence → accept (entities without bindings stay describable)
    /// - evidence shorter than `min_corpus_chars` → accept (weak corpus)
    /// - weak-but-present corpus (`min..2*min` chars) → half threshold
    /// - dense corpus → full `threshold`
    ///
    /// # Trap (G-PR-7)
    ///
    /// The first two rules mean this gate returns `Preserved { score: 1.0 }`
    /// for the entities with the LEAST support — the confidence signal is
    /// inverted exactly where it matters. Callers that need to distinguish
    /// "well grounded" from "no evidence at all" MUST consult
    /// [`corpus_is_sufficient`] FIRST; the verdict alone cannot tell them
    /// apart. Raising `min_corpus_chars` widens the accept-everything band
    /// instead of tightening it.
    pub fn evaluate_grounding(candidate: &str, evidence: &str, threshold: f64) -> Self {
        Self::evaluate_grounding_adaptive(
            candidate,
            evidence,
            threshold,
            DEFAULT_GROUNDING_MIN_CORPUS_CHARS,
        )
    }

    /// Adaptive grounding with explicit minimum corpus size (G-PR-6).
    pub fn evaluate_grounding_adaptive(
        candidate: &str,
        evidence: &str,
        threshold: f64,
        min_corpus_chars: usize,
    ) -> Self {
        let threshold = threshold.clamp(0.0, 1.0);
        let evidence_trim = evidence.trim();
        if evidence_trim.is_empty() {
            return Self::Preserved {
                score: 1.0,
                threshold,
            };
        }
        let corpus_chars = evidence_trim.chars().count();
        if corpus_chars < min_corpus_chars.max(1) {
            // Short/weak corpus: do not mass-reject with Jaccard noise.
            return Self::Preserved {
                score: 1.0,
                threshold,
            };
        }
        let effective = if corpus_chars < min_corpus_chars.saturating_mul(2) {
            (threshold * 0.5).clamp(0.0, 1.0)
        } else {
            threshold
        };
        let score = grounding_coverage(candidate, evidence_trim);
        if score >= effective {
            Self::Preserved {
                score,
                threshold: effective,
            }
        } else {
            Self::Rejected {
                score,
                threshold: effective,
            }
        }
    }

    /// Returns `true` when the gate accepted the rewrite.
    pub fn is_accepted(&self) -> bool {
        matches!(self, Self::Preserved { .. } | Self::Unchanged { .. })
    }
}

/// Fraction of the candidate's character-trigrams that also appear in
/// the evidence corpus: `|A ∩ B| / |A|`.
///
/// This is the DRY grounding metric shared by entity-descriptions and any
/// future short-text quality gates. Distinct from full Jaccard so short
/// descriptions are not systematically rejected against long bodies.
pub fn grounding_coverage(candidate: &str, evidence: &str) -> f64 {
    let set_a = trigrams(candidate);
    let set_b = trigrams(evidence);
    if set_a.is_empty() {
        return 0.0;
    }
    if set_b.is_empty() {
        return 0.0;
    }
    let intersection = set_a.intersection(&set_b).count() as f64;
    intersection / set_a.len() as f64
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn grounding_coverage_accepts_description_supported_by_corpus() {
        let evidence = "ICMS P05 is a Brazilian state tax rule for NFC-e fiscal documents and ordered invoice sequences.";
        let description = "Brazilian ICMS tax rule for NFC-e invoices";
        let score = grounding_coverage(description, evidence);
        assert!(
            score > 0.05,
            "expected partial coverage against fiscal corpus, got {score}"
        );
        let verdict = PreservationVerdict::evaluate_grounding(description, evidence, 0.05);
        assert!(verdict.is_accepted());
    }

    /// Pins the G-PR-6 policy AND the trap it creates.
    ///
    /// This assertion is not an endorsement: an unrelated software-jargon
    /// description IS accepted against a fiscal corpus, purely because the
    /// corpus is short. The verdict is kept as-is because `body-enrich` and
    /// the other preservation callers rely on it not mass-rejecting on
    /// Jaccard noise. Protection against the trap lives in
    /// `corpus_is_sufficient`, exercised by the two tests below — this test
    /// exists so nobody "fixes" the symptom here and breaks those callers.
    #[test]
    fn short_corpus_accepts_but_is_not_evidence() {
        let evidence = "ICMS tax"; // well under DEFAULT_GROUNDING_MIN_CORPUS_CHARS
        let description = "A configuration file used in software system design pipelines";
        let verdict = PreservationVerdict::evaluate_grounding_adaptive(
            description,
            evidence,
            0.5,
            DEFAULT_GROUNDING_MIN_CORPUS_CHARS,
        );
        assert!(
            verdict.is_accepted(),
            "short corpus must accept under G-PR-6 adaptive policy"
        );
        assert!(
            !corpus_is_sufficient(evidence, DEFAULT_GROUNDING_MIN_CORPUS_CHARS),
            "and the gate must refuse to treat it as evidence in the first place"
        );
    }

    #[test]
    fn empty_corpus_accepts_but_is_not_evidence() {
        let verdict = PreservationVerdict::evaluate_grounding("anything goes", "", 0.5);
        assert!(
            verdict.is_accepted(),
            "empty evidence scores 1.0 — the inversion this module documents"
        );
        assert!(
            !corpus_is_sufficient("", DEFAULT_GROUNDING_MIN_CORPUS_CHARS),
            "callers must gate on corpus_is_sufficient before trusting that verdict"
        );
    }

    /// G-PR-7: the real regression guard for the hallucination class.
    ///
    /// A bare proper noun with no linked memories must never reach the LLM.
    #[test]
    fn unbound_entity_corpus_is_never_sufficient() {
        for evidence in ["", "   ", "\n\t ", "Acme"] {
            assert!(
                !corpus_is_sufficient(evidence, DEFAULT_GROUNDING_MIN_CORPUS_CHARS),
                "evidence {evidence:?} must not be treated as groundable"
            );
        }
    }

    #[test]
    fn real_corpus_is_sufficient() {
        let evidence =
            "Acme Holdings is a trading company incorporated in 1998, with two partners \
             holding equal shares of the quota capital.";
        assert!(corpus_is_sufficient(
            evidence,
            DEFAULT_GROUNDING_MIN_CORPUS_CHARS
        ));
    }

    #[test]
    fn grounding_coverage_rejects_software_jargon_on_fiscal_corpus() {
        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.";
        let description = "A configuration file used in software system design pipelines";
        let score = grounding_coverage(description, evidence);
        let verdict = PreservationVerdict::evaluate_grounding(description, evidence, 0.25);
        assert!(
            !verdict.is_accepted() || score < 0.25,
            "software jargon should not ground well on fiscal evidence (score={score})"
        );
    }

    #[test]
    fn grounding_without_evidence_is_accepted() {
        let verdict = PreservationVerdict::evaluate_grounding("Some entity description", "", 0.5);
        assert!(verdict.is_accepted());
    }

    #[test]
    fn identical_strings_score_one() {
        let s = "the quick brown fox jumps over the lazy dog";
        assert!((jaccard_similarity(s, s) - 1.0).abs() < f64::EPSILON);
    }

    #[test]
    fn completely_different_strings_score_zero_or_near_zero() {
        let a = "aaaaaaaaaa";
        let b = "zzzzzzzzzz";
        assert!(jaccard_similarity(a, b) < 0.05);
    }

    #[test]
    fn partial_overlap_scores_between_zero_and_one() {
        let a = "the quick brown fox jumps";
        let b = "the slow brown cat sleeps";
        let score = jaccard_similarity(a, b);
        assert!(score > 0.0 && score < 1.0, "got {score}");
    }

    #[test]
    fn both_empty_score_one() {
        assert!((jaccard_similarity("", "") - 1.0).abs() < f64::EPSILON);
    }

    #[test]
    fn one_empty_scores_zero() {
        assert!(jaccard_similarity("hello", "").abs() < f64::EPSILON);
        assert!(jaccard_similarity("", "hello").abs() < f64::EPSILON);
    }

    #[test]
    fn unicode_strings_do_not_panic() {
        // Multi-byte UTF-8: 1 char each, very short.
        let a = "ç日本語";
        let b = "ç中文";
        let _ = jaccard_similarity(a, b);
    }

    #[test]
    fn verdict_preserved_when_above_threshold() {
        let v = PreservationVerdict::evaluate("hello world", "hello world!", 0.5);
        assert!(v.is_accepted());
        assert!(matches!(v, PreservationVerdict::Preserved { .. }));
    }

    #[test]
    fn verdict_unchanged_for_identical() {
        let v = PreservationVerdict::evaluate("same", "same", 0.9);
        assert!(v.is_accepted());
        assert!(matches!(v, PreservationVerdict::Unchanged { byte_len: 4 }));
    }

    #[test]
    fn threshold_clamped_out_of_range() {
        // Threshold above 1.0 is clamped to 1.0: identical bodies match
        // by the `Unchanged` short-circuit, accepted.
        let v = PreservationVerdict::evaluate("abc", "abc", 99.0);
        assert!(v.is_accepted());
        // Threshold below 0.0 is clamped to 0.0: every non-empty rewrite
        // meets a 0.0 floor and is accepted. This is the documented
        // behaviour of `clamp(0.0, 1.0)` and is the only sane reading
        // once a negative threshold is no longer in scope.
        let v = PreservationVerdict::evaluate("abc", "xyz", -5.0);
        assert!(v.is_accepted());
        // Threshold of exactly 0.0 accepts only identical bodies; even
        // a single-character drift fails the gate.
        let v = PreservationVerdict::evaluate("abc", "abcd", 0.0);
        assert!(
            v.is_accepted(),
            "single-char append is mostly the same body"
        );
    }

    #[test]
    fn g29_repro_evaluates_rejected_when_diverges() {
        // G29 reproducer: LLM rewrites a body and drifts far from source.
        let original = "JWT token rotation strategy with 15-min expiry and refresh flow";
        let drifted = "The weather in Tokyo is sunny today with mild temperatures expected";
        let v = PreservationVerdict::evaluate(original, drifted, 0.7);
        assert!(!v.is_accepted(), "should reject hallucinated rewrite");
    }
}