1use serde::{Deserialize, Serialize};
34use std::collections::{HashMap, HashSet};
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct OODConfig {
43 pub confidence_threshold: f64,
45 pub min_ngram_frequency: usize,
47 pub ngram_size: usize,
49 pub use_subwords: bool,
51 pub vocab_coverage_threshold: f64,
53}
54
55impl Default for OODConfig {
56 fn default() -> Self {
57 Self {
58 confidence_threshold: 0.5,
59 min_ngram_frequency: 1,
60 ngram_size: 3,
61 use_subwords: true,
62 vocab_coverage_threshold: 0.5,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct OODAnalysisResults {
74 pub total_entities: usize,
76 pub ood_count: usize,
78 pub ood_rate: f64,
80 pub by_method: HashMap<String, usize>,
82 pub avg_ood_confidence: f64,
84 pub avg_id_confidence: f64,
86 pub vocab_stats: VocabCoverageStats,
88 pub sample_ood_entities: Vec<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct VocabCoverageStats {
95 pub train_vocab_size: usize,
97 pub test_vocab_size: usize,
99 pub unseen_ngrams: usize,
101 pub coverage_ratio: f64,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct OODStatus {
108 pub text: String,
110 pub is_ood: bool,
112 pub flagged_by: Vec<String>,
114 pub vocab_coverage: f64,
116 pub confidence: Option<f64>,
118}
119
120#[derive(Debug, Clone)]
126pub struct OODDetector {
127 config: OODConfig,
129 train_ngrams: HashSet<String>,
131 ngram_frequencies: HashMap<String, usize>,
133 known_entities: HashSet<String>,
135 type_distributions: HashMap<String, usize>,
137}
138
139impl OODDetector {
140 pub fn new(config: OODConfig) -> Self {
142 Self {
143 config,
144 train_ngrams: HashSet::new(),
145 ngram_frequencies: HashMap::new(),
146 known_entities: HashSet::new(),
147 type_distributions: HashMap::new(),
148 }
149 }
150
151 pub fn fit(mut self, training_entities: &[impl AsRef<str>]) -> Self {
153 for entity in training_entities {
154 let text = entity.as_ref();
155 self.known_entities.insert(text.to_lowercase());
156
157 for ngram in self.extract_ngrams(text) {
159 self.train_ngrams.insert(ngram.clone());
160 *self.ngram_frequencies.entry(ngram).or_insert(0) += 1;
161 }
162 }
163 self
164 }
165
166 pub fn fit_with_types(mut self, training_data: &[(impl AsRef<str>, impl AsRef<str>)]) -> Self {
168 for (entity, entity_type) in training_data {
169 let text = entity.as_ref();
170 let etype = entity_type.as_ref();
171
172 self.known_entities.insert(text.to_lowercase());
173 *self
174 .type_distributions
175 .entry(etype.to_string())
176 .or_insert(0) += 1;
177
178 for ngram in self.extract_ngrams(text) {
179 self.train_ngrams.insert(ngram.clone());
180 *self.ngram_frequencies.entry(ngram).or_insert(0) += 1;
181 }
182 }
183 self
184 }
185
186 pub fn is_ood(&self, entity_text: &str) -> bool {
188 self.check_ood(entity_text, None).is_ood
189 }
190
191 pub fn check_ood(&self, entity_text: &str, confidence: Option<f64>) -> OODStatus {
193 let mut flagged_by = Vec::new();
194
195 let vocab_coverage = self.compute_vocab_coverage(entity_text);
197 if vocab_coverage < self.config.vocab_coverage_threshold {
198 flagged_by.push("low_vocab_coverage".to_string());
199 }
200
201 if !self.known_entities.contains(&entity_text.to_lowercase()) {
203 if vocab_coverage < 0.8 {
205 flagged_by.push("unseen_entity".to_string());
206 }
207 }
208
209 if let Some(conf) = confidence {
211 if conf < self.config.confidence_threshold {
212 flagged_by.push("low_confidence".to_string());
213 }
214 }
215
216 if self.has_unusual_characters(entity_text) {
218 flagged_by.push("unusual_characters".to_string());
219 }
220
221 OODStatus {
222 text: entity_text.to_string(),
223 is_ood: !flagged_by.is_empty(),
224 flagged_by,
225 vocab_coverage,
226 confidence,
227 }
228 }
229
230 pub fn analyze(&self, test_entities: &[(impl AsRef<str>, Option<f64>)]) -> OODAnalysisResults {
232 let mut ood_count = 0;
233 let mut by_method: HashMap<String, usize> = HashMap::new();
234 let mut ood_confidences = Vec::new();
235 let mut id_confidences = Vec::new();
236 let mut sample_ood = Vec::new();
237
238 let mut test_ngrams = HashSet::new();
239
240 for (entity, confidence) in test_entities {
241 let text = entity.as_ref();
242 let status = self.check_ood(text, *confidence);
243
244 for ngram in self.extract_ngrams(text) {
246 test_ngrams.insert(ngram);
247 }
248
249 if status.is_ood {
250 ood_count += 1;
251 for method in &status.flagged_by {
252 *by_method.entry(method.clone()).or_insert(0) += 1;
253 }
254 if let Some(conf) = confidence {
255 ood_confidences.push(*conf);
256 }
257 if sample_ood.len() < 10 {
258 sample_ood.push(text.to_string());
259 }
260 } else if let Some(conf) = confidence {
261 id_confidences.push(*conf);
262 }
263 }
264
265 let unseen: usize = test_ngrams
267 .iter()
268 .filter(|ng| !self.train_ngrams.contains(*ng))
269 .count();
270
271 let coverage_ratio = if test_ngrams.is_empty() {
272 1.0
273 } else {
274 1.0 - (unseen as f64 / test_ngrams.len() as f64)
275 };
276
277 OODAnalysisResults {
278 total_entities: test_entities.len(),
279 ood_count,
280 ood_rate: if test_entities.is_empty() {
281 0.0
282 } else {
283 ood_count as f64 / test_entities.len() as f64
284 },
285 by_method,
286 avg_ood_confidence: if ood_confidences.is_empty() {
287 0.0
288 } else {
289 ood_confidences.iter().sum::<f64>() / ood_confidences.len() as f64
290 },
291 avg_id_confidence: if id_confidences.is_empty() {
292 0.0
293 } else {
294 id_confidences.iter().sum::<f64>() / id_confidences.len() as f64
295 },
296 vocab_stats: VocabCoverageStats {
297 train_vocab_size: self.train_ngrams.len(),
298 test_vocab_size: test_ngrams.len(),
299 unseen_ngrams: unseen,
300 coverage_ratio,
301 },
302 sample_ood_entities: sample_ood,
303 }
304 }
305
306 fn extract_ngrams(&self, text: &str) -> Vec<String> {
309 let chars: Vec<char> = text.to_lowercase().chars().collect();
310 let n = self.config.ngram_size;
311
312 if chars.len() < n {
313 return vec![chars.iter().collect()];
314 }
315
316 (0..=chars.len() - n)
317 .map(|i| chars[i..i + n].iter().collect())
318 .collect()
319 }
320
321 fn compute_vocab_coverage(&self, text: &str) -> f64 {
322 let ngrams = self.extract_ngrams(text);
323 if ngrams.is_empty() {
324 return 1.0;
325 }
326
327 let seen = ngrams
328 .iter()
329 .filter(|ng| self.train_ngrams.contains(*ng))
330 .count();
331
332 seen as f64 / ngrams.len() as f64
333 }
334
335 fn has_unusual_characters(&self, text: &str) -> bool {
336 let unusual_count = text
338 .chars()
339 .filter(|c| {
340 matches!(c, '\u{200B}'..='\u{200F}' | '\u{FEFF}' | '\u{2060}')
342 })
343 .count();
344
345 unusual_count > 0
346 }
347}
348
349impl Default for OODDetector {
350 fn default() -> Self {
351 Self::new(OODConfig::default())
352 }
353}
354
355pub fn ood_rate_grade(rate: f64) -> &'static str {
361 if rate < 0.05 {
362 "Very low OOD (well-covered domain)"
363 } else if rate < 0.15 {
364 "Low OOD (mostly covered)"
365 } else if rate < 0.30 {
366 "Moderate OOD (some gaps)"
367 } else if rate < 0.50 {
368 "High OOD (significant gaps)"
369 } else {
370 "Very high OOD (major domain shift)"
371 }
372}
373
374#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn test_basic_ood_detection() {
384 let training = vec!["John Smith", "Jane Doe", "Google", "Microsoft"];
385 let detector = OODDetector::default().fit(&training);
386
387 assert!(!detector.is_ood("John Smith"));
389
390 let status = detector.check_ood("John Doe", None);
392 assert!(status.vocab_coverage > 0.5);
393 }
394
395 #[test]
396 fn test_unusual_characters() {
397 let detector = OODDetector::default();
398
399 let status = detector.check_ood("John Smith", None);
401 assert!(!status
402 .flagged_by
403 .contains(&"unusual_characters".to_string()));
404
405 let status = detector.check_ood("John\u{200B}Smith", None);
407 assert!(status
408 .flagged_by
409 .contains(&"unusual_characters".to_string()));
410 }
411
412 #[test]
413 fn test_vocab_coverage() {
414 let training = vec!["apple", "banana", "orange"];
415 let detector = OODDetector::default().fit(&training);
416
417 let status = detector.check_ood("apple", None);
419 assert!(status.vocab_coverage > 0.9);
420
421 let status = detector.check_ood("xyz123", None);
423 assert!(status.vocab_coverage < 0.5);
424 }
425
426 #[test]
427 fn test_analyze_dataset() {
428 let training = vec!["John Smith", "Jane Doe"];
429 let detector = OODDetector::default().fit(&training);
430
431 let test_data: Vec<(&str, Option<f64>)> = vec![
432 ("John Smith", Some(0.9)), ("Xiangjun Chen", Some(0.3)), ];
435
436 let results = detector.analyze(&test_data);
437 assert_eq!(results.total_entities, 2);
438 assert!(results.ood_count >= 1);
439 }
440
441 #[test]
442 fn test_confidence_threshold() {
443 let detector = OODDetector::new(OODConfig {
444 confidence_threshold: 0.7,
445 ..Default::default()
446 });
447
448 let status = detector.check_ood("test", Some(0.5));
450 assert!(status.flagged_by.contains(&"low_confidence".to_string()));
451
452 let status = detector.check_ood("test", Some(0.9));
454 assert!(!status.flagged_by.contains(&"low_confidence".to_string()));
455 }
456
457 #[test]
458 fn test_ood_rate_grades() {
459 assert_eq!(ood_rate_grade(0.02), "Very low OOD (well-covered domain)");
460 assert_eq!(ood_rate_grade(0.10), "Low OOD (mostly covered)");
461 assert_eq!(ood_rate_grade(0.25), "Moderate OOD (some gaps)");
462 assert_eq!(ood_rate_grade(0.40), "High OOD (significant gaps)");
463 assert_eq!(ood_rate_grade(0.60), "Very high OOD (major domain shift)");
464 }
465}