1use serde::{Deserialize, Serialize};
34use std::collections::HashSet;
35
36pub const DEFAULT_GROUNDING_MIN_CORPUS_CHARS: usize = 40;
41
42pub 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
74fn 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107#[serde(tag = "verdict", rename_all = "snake_case")]
108pub enum PreservationVerdict {
109 Preserved {
111 score: f64,
113 threshold: f64,
115 },
116 Rejected {
119 score: f64,
121 threshold: f64,
123 },
124 Unchanged {
127 byte_len: usize,
129 },
130}
131
132impl PreservationVerdict {
133 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 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 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 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 pub fn is_accepted(&self) -> bool {
216 matches!(self, Self::Preserved { .. } | Self::Unchanged { .. })
217 }
218}
219
220pub 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"; 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 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 let v = PreservationVerdict::evaluate("abc", "abc", 99.0);
360 assert!(v.is_accepted());
361 let v = PreservationVerdict::evaluate("abc", "xyz", -5.0);
366 assert!(v.is_accepted());
367 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 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}