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("Some entity description", "", 0.5);
293 assert!(verdict.is_accepted());
294 }
295
296 #[test]
297 fn identical_strings_score_one() {
298 let s = "the quick brown fox jumps over the lazy dog";
299 assert!((jaccard_similarity(s, s) - 1.0).abs() < f64::EPSILON);
300 }
301
302 #[test]
303 fn completely_different_strings_score_zero_or_near_zero() {
304 let a = "aaaaaaaaaa";
305 let b = "zzzzzzzzzz";
306 assert!(jaccard_similarity(a, b) < 0.05);
307 }
308
309 #[test]
310 fn partial_overlap_scores_between_zero_and_one() {
311 let a = "the quick brown fox jumps";
312 let b = "the slow brown cat sleeps";
313 let score = jaccard_similarity(a, b);
314 assert!(score > 0.0 && score < 1.0, "got {score}");
315 }
316
317 #[test]
318 fn both_empty_score_one() {
319 assert!((jaccard_similarity("", "") - 1.0).abs() < f64::EPSILON);
320 }
321
322 #[test]
323 fn one_empty_scores_zero() {
324 assert!(jaccard_similarity("hello", "").abs() < f64::EPSILON);
325 assert!(jaccard_similarity("", "hello").abs() < f64::EPSILON);
326 }
327
328 #[test]
329 fn unicode_strings_do_not_panic() {
330 let a = "ç日本語";
332 let b = "ç中文";
333 let _ = jaccard_similarity(a, b);
334 }
335
336 #[test]
337 fn verdict_preserved_when_above_threshold() {
338 let v = PreservationVerdict::evaluate("hello world", "hello world!", 0.5);
339 assert!(v.is_accepted());
340 assert!(matches!(v, PreservationVerdict::Preserved { .. }));
341 }
342
343 #[test]
344 fn verdict_unchanged_for_identical() {
345 let v = PreservationVerdict::evaluate("same", "same", 0.9);
346 assert!(v.is_accepted());
347 assert!(matches!(v, PreservationVerdict::Unchanged { byte_len: 4 }));
348 }
349
350 #[test]
351 fn threshold_clamped_out_of_range() {
352 let v = PreservationVerdict::evaluate("abc", "abc", 99.0);
355 assert!(v.is_accepted());
356 let v = PreservationVerdict::evaluate("abc", "xyz", -5.0);
361 assert!(v.is_accepted());
362 let v = PreservationVerdict::evaluate("abc", "abcd", 0.0);
365 assert!(
366 v.is_accepted(),
367 "single-char append is mostly the same body"
368 );
369 }
370
371 #[test]
372 fn g29_repro_evaluates_rejected_when_diverges() {
373 let original = "JWT token rotation strategy with 15-min expiry and refresh flow";
375 let drifted = "The weather in Tokyo is sunny today with mild temperatures expected";
376 let v = PreservationVerdict::evaluate(original, drifted, 0.7);
377 assert!(!v.is_accepted(), "should reject hallucinated rewrite");
378 }
379}