sqlite_graphrag/
preservation.rs1use serde::{Deserialize, Serialize};
34use std::collections::HashSet;
35
36pub fn jaccard_similarity(a: &str, b: &str) -> f64 {
54 let set_a = trigrams(a);
55 let set_b = trigrams(b);
56 if set_a.is_empty() && set_b.is_empty() {
57 return 1.0;
58 }
59 let intersection = set_a.intersection(&set_b).count() as f64;
60 let union = set_a.union(&set_b).count() as f64;
61 if union == 0.0 {
62 0.0
63 } else {
64 intersection / union
65 }
66}
67
68fn trigrams(input: &str) -> HashSet<[char; 3]> {
75 let chars: Vec<char> = input.chars().collect();
76 if chars.is_empty() {
77 return HashSet::new();
78 }
79 let mut out: HashSet<[char; 3]> = HashSet::with_capacity(chars.len().saturating_add(2));
80 let mut window: [char; 3] = ['\0', '\0', '\0'];
81 for (i, ch) in chars.iter().enumerate() {
82 window[0] = if i >= 1 { chars[i - 1] } else { '\0' };
83 window[1] = *ch;
84 window[2] = if i + 1 < chars.len() {
85 chars[i + 1]
86 } else {
87 '\0'
88 };
89 out.insert(window);
90 }
91 out
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(tag = "verdict", rename_all = "snake_case")]
102pub enum PreservationVerdict {
103 Preserved { score: f64, threshold: f64 },
105 Rejected { score: f64, threshold: f64 },
108 Unchanged { byte_len: usize },
111}
112
113impl PreservationVerdict {
114 pub fn evaluate(original: &str, rewritten: &str, threshold: f64) -> Self {
118 let threshold = threshold.clamp(0.0, 1.0);
119 if original == rewritten {
120 return Self::Unchanged {
121 byte_len: original.len(),
122 };
123 }
124 let score = jaccard_similarity(original, rewritten);
125 if score >= threshold {
126 Self::Preserved { score, threshold }
127 } else {
128 Self::Rejected { score, threshold }
129 }
130 }
131
132 pub fn evaluate_grounding(candidate: &str, evidence: &str, threshold: f64) -> Self {
139 let threshold = threshold.clamp(0.0, 1.0);
140 if evidence.trim().is_empty() {
141 return Self::Preserved {
144 score: 1.0,
145 threshold,
146 };
147 }
148 let score = grounding_coverage(candidate, evidence);
149 if score >= threshold {
150 Self::Preserved { score, threshold }
151 } else {
152 Self::Rejected { score, threshold }
153 }
154 }
155
156 pub fn is_accepted(&self) -> bool {
158 matches!(self, Self::Preserved { .. } | Self::Unchanged { .. })
159 }
160}
161
162pub fn grounding_coverage(candidate: &str, evidence: &str) -> f64 {
169 let set_a = trigrams(candidate);
170 let set_b = trigrams(evidence);
171 if set_a.is_empty() {
172 return 0.0;
173 }
174 if set_b.is_empty() {
175 return 0.0;
176 }
177 let intersection = set_a.intersection(&set_b).count() as f64;
178 intersection / set_a.len() as f64
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn grounding_coverage_accepts_description_supported_by_corpus() {
187 let evidence = "ICMS P05 is a Brazilian state tax rule for NFC-e fiscal documents and ordered invoice sequences.";
188 let description = "Brazilian ICMS tax rule for NFC-e invoices";
189 let score = grounding_coverage(description, evidence);
190 assert!(
191 score > 0.05,
192 "expected partial coverage against fiscal corpus, got {score}"
193 );
194 let verdict = PreservationVerdict::evaluate_grounding(description, evidence, 0.05);
195 assert!(verdict.is_accepted());
196 }
197
198 #[test]
199 fn grounding_coverage_rejects_software_jargon_on_fiscal_corpus() {
200 let evidence = "ICMS P05 is a Brazilian state tax rule for NFC-e fiscal documents.";
201 let description = "A configuration file used in software system design pipelines";
202 let score = grounding_coverage(description, evidence);
203 let verdict = PreservationVerdict::evaluate_grounding(description, evidence, 0.25);
204 assert!(
205 !verdict.is_accepted() || score < 0.25,
206 "software jargon should not ground well on fiscal evidence (score={score})"
207 );
208 }
209
210 #[test]
211 fn grounding_without_evidence_is_accepted() {
212 let verdict = PreservationVerdict::evaluate_grounding(
213 "Some entity description",
214 "",
215 0.5,
216 );
217 assert!(verdict.is_accepted());
218 }
219
220
221 #[test]
222 fn identical_strings_score_one() {
223 let s = "the quick brown fox jumps over the lazy dog";
224 assert!((jaccard_similarity(s, s) - 1.0).abs() < f64::EPSILON);
225 }
226
227 #[test]
228 fn completely_different_strings_score_zero_or_near_zero() {
229 let a = "aaaaaaaaaa";
230 let b = "zzzzzzzzzz";
231 assert!(jaccard_similarity(a, b) < 0.05);
232 }
233
234 #[test]
235 fn partial_overlap_scores_between_zero_and_one() {
236 let a = "the quick brown fox jumps";
237 let b = "the slow brown cat sleeps";
238 let score = jaccard_similarity(a, b);
239 assert!(score > 0.0 && score < 1.0, "got {score}");
240 }
241
242 #[test]
243 fn both_empty_score_one() {
244 assert!((jaccard_similarity("", "") - 1.0).abs() < f64::EPSILON);
245 }
246
247 #[test]
248 fn one_empty_scores_zero() {
249 assert!(jaccard_similarity("hello", "").abs() < f64::EPSILON);
250 assert!(jaccard_similarity("", "hello").abs() < f64::EPSILON);
251 }
252
253 #[test]
254 fn unicode_strings_do_not_panic() {
255 let a = "ç日本語";
257 let b = "ç中文";
258 let _ = jaccard_similarity(a, b);
259 }
260
261 #[test]
262 fn verdict_preserved_when_above_threshold() {
263 let v = PreservationVerdict::evaluate("hello world", "hello world!", 0.5);
264 assert!(v.is_accepted());
265 assert!(matches!(v, PreservationVerdict::Preserved { .. }));
266 }
267
268 #[test]
269 fn verdict_unchanged_for_identical() {
270 let v = PreservationVerdict::evaluate("same", "same", 0.9);
271 assert!(v.is_accepted());
272 assert!(matches!(v, PreservationVerdict::Unchanged { byte_len: 4 }));
273 }
274
275 #[test]
276 fn threshold_clamped_out_of_range() {
277 let v = PreservationVerdict::evaluate("abc", "abc", 99.0);
280 assert!(v.is_accepted());
281 let v = PreservationVerdict::evaluate("abc", "xyz", -5.0);
286 assert!(v.is_accepted());
287 let v = PreservationVerdict::evaluate("abc", "abcd", 0.0);
290 assert!(
291 v.is_accepted(),
292 "single-char append is mostly the same body"
293 );
294 }
295
296 #[test]
297 fn g29_repro_evaluates_rejected_when_diverges() {
298 let original = "JWT token rotation strategy with 15-min expiry and refresh flow";
300 let drifted = "The weather in Tokyo is sunny today with mild temperatures expected";
301 let v = PreservationVerdict::evaluate(original, drifted, 0.7);
302 assert!(!v.is_accepted(), "should reject hallucinated rewrite");
303 }
304}