1use crate::{Entity, Model};
36use serde::{Deserialize, Serialize};
37use std::collections::HashMap;
38
39struct SimpleRng {
41 state: u64,
42}
43
44impl SimpleRng {
45 fn new(seed: u64) -> Self {
46 Self { state: seed.max(1) }
47 }
48
49 fn next(&mut self) -> u64 {
50 let mut x = self.state;
51 x ^= x << 13;
52 x ^= x >> 7;
53 x ^= x << 17;
54 self.state = x;
55 x
56 }
57
58 fn gen_f64(&mut self) -> f64 {
59 (self.next() as f64) / (u64::MAX as f64)
60 }
61
62 fn gen_bool(&mut self) -> bool {
63 #[allow(clippy::manual_is_multiple_of)]
64 {
65 self.next() % 2 == 0
66 }
67 }
68
69 fn gen_range(&mut self, max: usize) -> usize {
70 if max == 0 {
71 0
72 } else {
73 (self.next() as usize) % max
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
84pub enum Perturbation {
85 None,
87 TypoSwap,
89 TypoInsert,
91 TypoDelete,
93 TypoKeyboard,
95 CaseUpper,
97 CaseLower,
99 CaseTitle,
101 CaseMixed,
103 WhitespaceExtra,
105 WhitespaceRemove,
107 WhitespaceNewline,
109 PunctuationRemove,
111 PunctuationExtra,
113 UnicodeHomoglyph,
115 UnicodeDiacritics,
117 UnicodeZeroWidth,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct RobustnessResults {
128 pub baseline_f1: f64,
130 pub by_perturbation: HashMap<String, PerturbationMetrics>,
132 pub avg_perturbed_f1: f64,
134 pub robustness_score: f64,
136 pub worst_perturbation: String,
138 pub best_perturbation: String,
140 pub total_examples: usize,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct PerturbationMetrics {
147 pub f1: f64,
149 pub precision: f64,
151 pub recall: f64,
153 pub relative_change: f64,
155 pub count: usize,
157}
158
159#[derive(Debug, Clone)]
165pub struct RobustnessEvaluator {
166 pub perturbations: Vec<Perturbation>,
168 pub seed: u64,
170 pub intensity: f64,
172}
173
174impl Default for RobustnessEvaluator {
175 fn default() -> Self {
176 Self {
177 perturbations: vec![
178 Perturbation::None,
179 Perturbation::TypoSwap,
180 Perturbation::TypoDelete,
181 Perturbation::CaseUpper,
182 Perturbation::CaseLower,
183 Perturbation::CaseMixed,
184 Perturbation::WhitespaceExtra,
185 Perturbation::PunctuationRemove,
186 Perturbation::UnicodeHomoglyph,
187 ],
188 seed: 42,
189 intensity: 0.1, }
191 }
192}
193
194impl RobustnessEvaluator {
195 pub fn new(perturbations: Vec<Perturbation>) -> Self {
197 Self {
198 perturbations,
199 ..Default::default()
200 }
201 }
202
203 pub fn generate_variants(&self, text: &str) -> Vec<(Perturbation, String)> {
205 self.perturbations
206 .iter()
207 .map(|&p| (p, self.apply_perturbation(text, p)))
208 .collect()
209 }
210
211 pub fn apply_perturbation(&self, text: &str, perturbation: Perturbation) -> String {
213 let mut rng = SimpleRng::new(self.seed ^ (text.len() as u64));
214
215 match perturbation {
216 Perturbation::None => text.to_string(),
217
218 Perturbation::TypoSwap => {
219 let mut chars: Vec<char> = text.chars().collect();
220 let num_swaps = ((chars.len() as f64 * self.intensity) as usize).max(1);
221 for _ in 0..num_swaps {
222 if chars.len() >= 2 {
223 let idx = rng.gen_range(chars.len() - 1);
224 if chars[idx].is_alphabetic() && chars[idx + 1].is_alphabetic() {
225 chars.swap(idx, idx + 1);
226 }
227 }
228 }
229 chars.into_iter().collect()
230 }
231
232 Perturbation::TypoInsert => {
233 let mut result = String::new();
234 let chars: Vec<char> = text.chars().collect();
235 for (i, c) in chars.iter().enumerate() {
236 result.push(*c);
237 if rng.gen_f64() < self.intensity && c.is_alphabetic() {
238 let adjacent = random_adjacent_char(*c, &mut rng);
240 result.push(adjacent);
241 }
242 if i > 0 && i % 20 == 0 && rng.gen_f64() < 0.1 {
244 break;
245 }
246 }
247 result
248 }
249
250 Perturbation::TypoDelete => {
251 let intensity = self.intensity;
252 text.chars()
253 .filter(|c| !c.is_alphabetic() || rng.gen_f64() > intensity)
254 .collect()
255 }
256
257 Perturbation::TypoKeyboard => {
258 let intensity = self.intensity;
259 text.chars()
260 .map(|c| {
261 if c.is_alphabetic() && rng.gen_f64() < intensity {
262 keyboard_neighbor(c, &mut rng)
263 } else {
264 c
265 }
266 })
267 .collect()
268 }
269
270 Perturbation::CaseUpper => text.to_uppercase(),
271 Perturbation::CaseLower => text.to_lowercase(),
272
273 Perturbation::CaseTitle => text
274 .split_whitespace()
275 .map(|word| {
276 let mut chars = word.chars();
277 match chars.next() {
278 None => String::new(),
279 Some(first) => first
280 .to_uppercase()
281 .chain(chars.flat_map(|c| c.to_lowercase()))
282 .collect(),
283 }
284 })
285 .collect::<Vec<_>>()
286 .join(" "),
287
288 Perturbation::CaseMixed => text.chars().enumerate().fold(
289 String::with_capacity(text.len()),
290 |mut out, (i, c)| {
291 if i % 2 == 0 {
293 out.extend(c.to_uppercase());
294 } else {
295 out.extend(c.to_lowercase());
296 }
297 out
298 },
299 ),
300
301 Perturbation::WhitespaceExtra => {
302 let intensity = self.intensity;
303 text.chars()
304 .flat_map(|c| {
305 if c == ' ' && rng.gen_f64() < intensity * 3.0 {
306 vec![' ', ' ']
307 } else {
308 vec![c]
309 }
310 })
311 .collect()
312 }
313
314 Perturbation::WhitespaceRemove => {
315 let words: Vec<&str> = text.split_whitespace().collect();
316 let mut result = String::new();
317 for (i, word) in words.iter().enumerate() {
318 result.push_str(word);
319 if i < words.len() - 1 && rng.gen_f64() > self.intensity {
320 result.push(' ');
321 }
322 }
323 result
324 }
325
326 Perturbation::WhitespaceNewline => {
327 let intensity = self.intensity;
328 text.chars()
329 .map(|c| {
330 if c == ' ' && rng.gen_f64() < intensity {
331 '\n'
332 } else {
333 c
334 }
335 })
336 .collect()
337 }
338
339 Perturbation::PunctuationRemove => {
340 text.chars().filter(|c| !c.is_ascii_punctuation()).collect()
341 }
342
343 Perturbation::PunctuationExtra => {
344 let intensity = self.intensity;
345 text.chars()
346 .flat_map(|c| {
347 if c.is_ascii_punctuation() && rng.gen_f64() < intensity * 3.0 {
348 vec![c, c]
349 } else {
350 vec![c]
351 }
352 })
353 .collect()
354 }
355
356 Perturbation::UnicodeHomoglyph => {
357 let intensity = self.intensity;
358 text.chars()
359 .map(|c| {
360 if rng.gen_f64() < intensity {
361 homoglyph(c)
362 } else {
363 c
364 }
365 })
366 .collect()
367 }
368
369 Perturbation::UnicodeDiacritics => {
370 let intensity = self.intensity;
371 text.chars()
372 .map(|c| {
373 if c.is_alphabetic() && rng.gen_f64() < intensity {
374 add_diacritic(c)
375 } else {
376 c
377 }
378 })
379 .collect()
380 }
381
382 Perturbation::UnicodeZeroWidth => {
383 let zwsp = '\u{200B}'; let intensity = self.intensity;
385 text.chars()
386 .flat_map(|c| {
387 if rng.gen_f64() < intensity * 0.5 {
388 vec![c, zwsp]
389 } else {
390 vec![c]
391 }
392 })
393 .collect()
394 }
395 }
396 }
397
398 pub fn evaluate(
400 &self,
401 model: &dyn Model,
402 test_cases: &[(String, Vec<Entity>)],
403 ) -> RobustnessResults {
404 let mut by_perturbation: HashMap<String, Vec<(f64, f64, f64)>> = HashMap::new();
405
406 for (text, gold_entities) in test_cases {
407 for &perturbation in &self.perturbations {
408 let perturbed = self.apply_perturbation(text, perturbation);
409 let predicted = model.extract_entities(&perturbed, None).unwrap_or_default();
410
411 let (precision, recall, f1) =
413 compute_simple_metrics(&predicted, gold_entities, text, &perturbed);
414
415 by_perturbation
416 .entry(format!("{:?}", perturbation))
417 .or_default()
418 .push((precision, recall, f1));
419 }
420 }
421
422 let mut aggregated: HashMap<String, PerturbationMetrics> = HashMap::new();
424 let baseline_f1 = by_perturbation
425 .get("None")
426 .map(|v| v.iter().map(|(_, _, f1)| f1).sum::<f64>() / v.len() as f64)
427 .unwrap_or(0.0);
428
429 for (name, metrics) in &by_perturbation {
430 let avg_precision =
431 metrics.iter().map(|(p, _, _)| p).sum::<f64>() / metrics.len() as f64;
432 let avg_recall = metrics.iter().map(|(_, r, _)| r).sum::<f64>() / metrics.len() as f64;
433 let avg_f1 = metrics.iter().map(|(_, _, f)| f).sum::<f64>() / metrics.len() as f64;
434 let relative_change = if baseline_f1 > 0.0 {
435 (avg_f1 - baseline_f1) / baseline_f1
436 } else {
437 0.0
438 };
439
440 aggregated.insert(
441 name.clone(),
442 PerturbationMetrics {
443 f1: avg_f1,
444 precision: avg_precision,
445 recall: avg_recall,
446 relative_change,
447 count: metrics.len(),
448 },
449 );
450 }
451
452 let (worst, _) = aggregated
454 .iter()
455 .filter(|(k, _)| k.as_str() != "None")
456 .min_by(|a, b| {
457 a.1.f1
458 .partial_cmp(&b.1.f1)
459 .unwrap_or(std::cmp::Ordering::Equal)
460 })
461 .map(|(k, v)| (k.clone(), v.f1))
462 .unwrap_or(("None".to_string(), baseline_f1));
463
464 let (best, _) = aggregated
465 .iter()
466 .max_by(|a, b| {
467 a.1.f1
468 .partial_cmp(&b.1.f1)
469 .unwrap_or(std::cmp::Ordering::Equal)
470 })
471 .map(|(k, v)| (k.clone(), v.f1))
472 .unwrap_or(("None".to_string(), baseline_f1));
473
474 let perturbed_f1s: Vec<f64> = aggregated
476 .iter()
477 .filter(|(k, _)| k.as_str() != "None")
478 .map(|(_, v)| v.f1)
479 .collect();
480 let avg_perturbed_f1 = if perturbed_f1s.is_empty() {
481 baseline_f1
482 } else {
483 perturbed_f1s.iter().sum::<f64>() / perturbed_f1s.len() as f64
484 };
485
486 let robustness_score = if baseline_f1 > 0.0 {
487 avg_perturbed_f1 / baseline_f1
488 } else {
489 0.0
490 };
491
492 RobustnessResults {
493 baseline_f1,
494 by_perturbation: aggregated,
495 avg_perturbed_f1,
496 robustness_score,
497 worst_perturbation: worst,
498 best_perturbation: best,
499 total_examples: test_cases.len(),
500 }
501 }
502}
503
504fn keyboard_neighbor(c: char, rng: &mut SimpleRng) -> char {
510 let keyboard: &[(&[char], &[char])] = &[
511 (&['q'], &['w', 'a']),
512 (&['w'], &['q', 'e', 's']),
513 (&['e'], &['w', 'r', 'd']),
514 (&['r'], &['e', 't', 'f']),
515 (&['t'], &['r', 'y', 'g']),
516 (&['a'], &['q', 's', 'z']),
517 (&['s'], &['a', 'd', 'w', 'x']),
518 (&['d'], &['s', 'f', 'e', 'c']),
519 (&['f'], &['d', 'g', 'r', 'v']),
520 (&['g'], &['f', 'h', 't', 'b']),
521 ];
522
523 let lower = c.to_ascii_lowercase();
525 for (keys, neighbors) in keyboard {
526 if keys.contains(&lower) && !neighbors.is_empty() {
527 let idx = rng.gen_range(neighbors.len());
528 let neighbor = neighbors[idx];
529 return if c.is_uppercase() {
530 neighbor.to_ascii_uppercase()
531 } else {
532 neighbor
533 };
534 }
535 }
536 c
537}
538
539fn random_adjacent_char(c: char, rng: &mut SimpleRng) -> char {
541 let offset: i32 = if rng.gen_bool() { 1 } else { -1 };
542 char::from_u32((c as i32 + offset) as u32).unwrap_or(c)
543}
544
545fn homoglyph(c: char) -> char {
547 match c {
548 'a' => 'а', 'e' => 'е', 'o' => 'о', 'p' => 'р', 'c' => 'с', 'A' => 'А', 'E' => 'Е', 'O' => 'О', 'P' => 'Р', 'C' => 'С', _ => c,
559 }
560}
561
562fn add_diacritic(c: char) -> char {
564 match c {
565 'a' => 'á',
566 'e' => 'é',
567 'i' => 'í',
568 'o' => 'ó',
569 'u' => 'ú',
570 'n' => 'ñ',
571 'A' => 'Á',
572 'E' => 'É',
573 'I' => 'Í',
574 'O' => 'Ó',
575 'U' => 'Ú',
576 'N' => 'Ñ',
577 _ => c,
578 }
579}
580
581fn compute_simple_metrics(
583 predicted: &[Entity],
584 gold: &[Entity],
585 _original_text: &str,
586 _perturbed_text: &str,
587) -> (f64, f64, f64) {
588 let mut correct = 0;
590
591 for pred in predicted {
592 if gold.iter().any(|g| {
593 g.entity_type == pred.entity_type && g.text.to_lowercase() == pred.text.to_lowercase()
594 }) {
595 correct += 1;
596 }
597 }
598
599 let precision = if predicted.is_empty() {
600 0.0
601 } else {
602 correct as f64 / predicted.len() as f64
603 };
604 let recall = if gold.is_empty() {
605 0.0
606 } else {
607 correct as f64 / gold.len() as f64
608 };
609 let f1 = if precision + recall > 0.0 {
610 2.0 * precision * recall / (precision + recall)
611 } else {
612 0.0
613 };
614
615 (precision, recall, f1)
616}
617
618pub fn robustness_grade(score: f64) -> &'static str {
620 if score >= 0.95 {
621 "Excellent robustness"
622 } else if score >= 0.85 {
623 "Good robustness"
624 } else if score >= 0.70 {
625 "Moderate robustness"
626 } else if score >= 0.50 {
627 "Poor robustness"
628 } else {
629 "Very poor robustness"
630 }
631}
632
633#[cfg(test)]
638mod tests {
639 use super::*;
640
641 #[test]
642 fn test_typo_swap() {
643 let evaluator = RobustnessEvaluator {
644 intensity: 0.5,
645 ..Default::default()
646 };
647
648 let original = "hello world";
649 let perturbed = evaluator.apply_perturbation(original, Perturbation::TypoSwap);
650
651 assert!(!perturbed.is_empty());
653 }
654
655 #[test]
656 fn test_case_upper() {
657 let evaluator = RobustnessEvaluator::default();
658 let perturbed = evaluator.apply_perturbation("Hello World", Perturbation::CaseUpper);
659 assert_eq!(perturbed, "HELLO WORLD");
660 }
661
662 #[test]
663 fn test_case_lower() {
664 let evaluator = RobustnessEvaluator::default();
665 let perturbed = evaluator.apply_perturbation("Hello World", Perturbation::CaseLower);
666 assert_eq!(perturbed, "hello world");
667 }
668
669 #[test]
670 fn test_punctuation_remove() {
671 let evaluator = RobustnessEvaluator::default();
672 let perturbed =
673 evaluator.apply_perturbation("Hello, World!", Perturbation::PunctuationRemove);
674 assert_eq!(perturbed, "Hello World");
675 }
676
677 #[test]
678 fn test_generate_variants() {
679 let evaluator = RobustnessEvaluator::default();
680 let variants = evaluator.generate_variants("Test text");
681
682 assert!(!variants.is_empty());
683 assert!(variants.iter().any(|(p, _)| *p == Perturbation::None));
684 }
685
686 #[test]
687 fn test_homoglyph() {
688 assert_eq!(homoglyph('a'), 'а'); assert_eq!(homoglyph('z'), 'z'); }
691
692 #[test]
693 fn test_robustness_grades() {
694 assert_eq!(robustness_grade(0.98), "Excellent robustness");
695 assert_eq!(robustness_grade(0.90), "Good robustness");
696 assert_eq!(robustness_grade(0.75), "Moderate robustness");
697 assert_eq!(robustness_grade(0.60), "Poor robustness");
698 assert_eq!(robustness_grade(0.30), "Very poor robustness");
699 }
700}