anno_eval/eval/modes.rs
1//! NER evaluation modes following SemEval-2013 Task 9.1.
2//!
3//! # The Core Problem
4//!
5//! Suppose your model predicted "New York City" but the gold label was "New York":
6//!
7//! ```text
8//! Text: "I visited New York City yesterday"
9//! ▼▼▼▼▼▼▼▼▼▼▼▼▼
10//! Gold: [====New York====]
11//! 0 8
12//!
13//! Predicted: [=====New York City=====]
14//! 0 13
15//!
16//! Is this prediction correct? It depends on what you're measuring.
17//! ```
18//!
19//! # Visual Guide to Each Mode
20//!
21//! ## Strict Mode (CoNLL Standard)
22//!
23//! "Did you get EXACTLY the right span AND the right type?"
24//!
25//! ```text
26//! Case 1: Perfect match
27//! Gold: [John] type=PER
28//! Pred: [John] type=PER
29//! Result: ✓ TRUE POSITIVE
30//!
31//! Case 2: Wrong boundary
32//! Gold: [New York] type=LOC
33//! Pred: [New York City] type=LOC
34//! Result: ✗ Both boundaries must match exactly
35//!
36//! Case 3: Wrong type
37//! Gold: [Apple] type=ORG
38//! Pred: [Apple] type=LOC
39//! Result: ✗ Type must match exactly
40//! ```
41//!
42//! ## Partial Mode (Lenient Boundaries)
43//!
44//! "Did you find something that OVERLAPS the gold span with the RIGHT type?"
45//!
46//! ```text
47//! Gold: [====New York====]
48//! 0 8
49//!
50//! Pred: [=====New York City=====]
51//! 0 13
52//!
53//! |◄──overlap──►|
54//! Chars 0-8 are shared
55//!
56//! Overlap? ✓ Yes (8 chars)
57//! Type? ✓ Both LOC
58//! Result: ✓ TRUE POSITIVE in Partial mode
59//! ```
60//!
61//! ## Exact Mode (Boundary Detection)
62//!
63//! "Did you find the EXACT span, regardless of type?"
64//!
65//! ```text
66//! Gold: [Apple] type=ORG
67//! Pred: [Apple] type=LOC (wrong type!)
68//!
69//! Boundaries match? ✓ Yes (both 0-5)
70//! Result: ✓ TRUE POSITIVE in Exact mode
71//!
72//! Use case: "Can my model find entity boundaries at all?"
73//! ```
74//!
75//! ## Type Mode (Classification Focus)
76//!
77//! "Did you identify the RIGHT TYPE somewhere in the overlapping region?"
78//!
79//! ```text
80//! Gold: [The Apple Company] type=ORG
81//! Pred: [Apple] type=ORG
82//!
83//! Overlap? ✓ Yes ("Apple" is inside)
84//! Type? ✓ Both ORG
85//! Result: ✓ TRUE POSITIVE in Type mode
86//!
87//! Use case: "Can my model classify entity types correctly?"
88//! ```
89//!
90//! # Diagnostic Patterns
91//!
92//! ```text
93//! ┌─────────────────────────────────────────────────────────────────┐
94//! │ What Your Scores Tell You │
95//! ├─────────────────────────────────────────────────────────────────┤
96//! │ │
97//! │ High Strict, Low Partial │
98//! │ ───────────────────────── │
99//! │ → Model finds exact spans but confuses types │
100//! │ → Fix: Better type classification │
101//! │ │
102//! │ Low Strict, High Partial │
103//! │ ───────────────────────── │
104//! │ → Model finds general area but not exact boundaries │
105//! │ → Fix: Better boundary detection (tokenization? BIO tags?) │
106//! │ │
107//! │ Low Strict, Low Partial │
108//! │ ───────────────────────── │
109//! │ → Model is missing entities entirely │
110//! │ → Fix: More training data, lower threshold │
111//! │ │
112//! │ High Strict, High Partial (ideal!) │
113//! │ ─────────────────────────────────── │
114//! │ → Model is working well │
115//! │ │
116//! └─────────────────────────────────────────────────────────────────┘
117//! ```
118//!
119//! # Summary Table
120//!
121//! | Mode | Boundary | Type | Use Case |
122//! |------|----------|------|----------|
123//! | **Strict** | Exact | Exact | Production benchmarks (CoNLL standard) |
124//! | **Exact** | Exact | Any | Boundary detection evaluation |
125//! | **Partial** | Overlap | Exact | Lenient type evaluation |
126//! | **Type** | Any | Exact | Type classification evaluation |
127//!
128//! # Example
129//!
130//! ```rust
131//! use anno_eval::eval::modes::{EvalMode, evaluate_with_mode, MultiModeResults};
132//! use anno_eval::eval::GoldEntity;
133//! use anno::{Entity, EntityType};
134//!
135//! let predicted = vec![
136//! Entity::new("New York City", EntityType::Location, 0, 13, 0.9),
137//! ];
138//! let gold = vec![
139//! GoldEntity::new("New York", EntityType::Location, 0),
140//! ];
141//!
142//! // Strict mode (default) - requires exact boundary + type
143//! let strict = evaluate_with_mode(&predicted, &gold, EvalMode::Strict);
144//! println!("Strict F1: {:.1}%", strict.f1 * 100.0);
145//!
146//! // Partial mode - allows boundary overlap
147//! let partial = evaluate_with_mode(&predicted, &gold, EvalMode::Partial);
148//! println!("Partial F1: {:.1}%", partial.f1 * 100.0);
149//!
150//! // Get all modes at once
151//! let all = MultiModeResults::compute(&predicted, &gold);
152//! println!("Strict: {:.1}%, Partial: {:.1}%",
153//! all.strict.f1 * 100.0, all.partial.f1 * 100.0);
154//! ```
155
156use super::datasets::GoldEntity;
157use anno::{Entity, EntityType};
158use serde::{Deserialize, Serialize};
159
160// =============================================================================
161// Evaluation Configuration
162// =============================================================================
163
164/// Configuration for NER evaluation.
165///
166/// Partial matching modes (Partial, Type) accept any overlap by default.
167/// In practice, you may want to require a minimum overlap ratio to avoid
168/// counting barely-touching spans as matches.
169///
170/// # Example
171///
172/// ```rust
173/// use anno_eval::eval::modes::EvalConfig;
174///
175/// // Require at least 50% overlap for partial matches
176/// let config = EvalConfig::new().with_min_overlap(0.5);
177///
178/// // Strict config (default behavior)
179/// let strict = EvalConfig::strict();
180/// ```
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct EvalConfig {
183 /// Minimum overlap ratio (IoU) required for partial/type matches.
184 ///
185 /// Range: 0.0 to 1.0
186 /// - 0.0: Any overlap counts (default)
187 /// - 0.5: At least 50% overlap required
188 /// - 1.0: Effectively requires exact boundaries
189 pub min_overlap: f64,
190}
191
192impl Default for EvalConfig {
193 fn default() -> Self {
194 Self { min_overlap: 0.0 }
195 }
196}
197
198impl EvalConfig {
199 /// Create a new configuration with default settings.
200 #[must_use]
201 pub fn new() -> Self {
202 Self::default()
203 }
204
205 /// Create a strict configuration (default overlap behavior).
206 #[must_use]
207 pub fn strict() -> Self {
208 Self::default()
209 }
210
211 /// Set the minimum overlap threshold for partial matches.
212 ///
213 /// # Arguments
214 ///
215 /// * `threshold` - Minimum IoU (0.0-1.0) for partial matches
216 #[must_use]
217 pub fn with_min_overlap(mut self, threshold: f64) -> Self {
218 self.min_overlap = threshold.clamp(0.0, 1.0);
219 self
220 }
221}
222
223// =============================================================================
224// Evaluation Modes
225// =============================================================================
226
227/// Evaluation mode following SemEval-2013 Task 9.1.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
229pub enum EvalMode {
230 /// Strict: Exact boundary AND exact type (CoNLL standard).
231 /// This is the default and most commonly reported metric.
232 #[default]
233 Strict,
234
235 /// Exact boundary match only (type can differ).
236 /// Useful for evaluating span detection separately from classification.
237 Exact,
238
239 /// Partial boundary overlap with exact type.
240 /// More lenient than strict; gives credit for overlapping predictions.
241 Partial,
242
243 /// Any overlap with exact type.
244 /// Most lenient; only requires some overlap and correct type.
245 Type,
246}
247
248impl EvalMode {
249 /// All available modes.
250 pub fn all() -> &'static [EvalMode] {
251 &[
252 EvalMode::Strict,
253 EvalMode::Exact,
254 EvalMode::Partial,
255 EvalMode::Type,
256 ]
257 }
258
259 /// Human-readable name.
260 #[must_use]
261 pub fn name(&self) -> &'static str {
262 match self {
263 EvalMode::Strict => "Strict",
264 EvalMode::Exact => "Exact",
265 EvalMode::Partial => "Partial",
266 EvalMode::Type => "Type",
267 }
268 }
269
270 /// Description of what this mode evaluates.
271 #[must_use]
272 pub fn description(&self) -> &'static str {
273 match self {
274 EvalMode::Strict => "Exact boundary + exact type (CoNLL standard)",
275 EvalMode::Exact => "Exact boundary only (type can differ)",
276 EvalMode::Partial => "Partial boundary overlap + exact type",
277 EvalMode::Type => "Any overlap + exact type",
278 }
279 }
280}
281
282// =============================================================================
283// Mode-specific Results
284// =============================================================================
285
286/// Results for a single evaluation mode.
287#[derive(Debug, Clone, Default, Serialize, Deserialize)]
288pub struct ModeResults {
289 /// Evaluation mode used
290 pub mode: EvalMode,
291 /// Precision (0.0-1.0)
292 pub precision: f64,
293 /// Recall (0.0-1.0)
294 pub recall: f64,
295 /// F1 score (0.0-1.0)
296 pub f1: f64,
297 /// True positives (matches)
298 pub true_positives: usize,
299 /// False positives (spurious predictions)
300 pub false_positives: usize,
301 /// False negatives (missed entities)
302 pub false_negatives: usize,
303}
304
305impl ModeResults {
306 /// Compute results for a specific mode.
307 ///
308 /// Formulas:
309 /// - `Precision = TP / (TP + FP)`
310 /// - `Recall = TP / (TP + FN)`
311 /// - `F1 = 2 × (P × R) / (P + R)` (harmonic mean)
312 ///
313 /// Reference: Manning et al. (2008) [Introduction to Information Retrieval](https://nlp.stanford.edu/IR-book/html/htmledition/evaluation-in-information-retrieval-1.html)
314 #[must_use]
315 pub fn compute(predicted: &[Entity], gold: &[GoldEntity], mode: EvalMode) -> Self {
316 let (tp, fp, fn_count) = count_matches(predicted, gold, mode);
317
318 let precision = if tp + fp > 0 {
319 tp as f64 / (tp + fp) as f64
320 } else {
321 0.0
322 };
323
324 let recall = if tp + fn_count > 0 {
325 tp as f64 / (tp + fn_count) as f64
326 } else {
327 0.0
328 };
329
330 let f1 = if precision + recall > 0.0 {
331 2.0 * precision * recall / (precision + recall)
332 } else {
333 0.0
334 };
335
336 Self {
337 mode,
338 precision,
339 recall,
340 f1,
341 true_positives: tp,
342 false_positives: fp,
343 false_negatives: fn_count,
344 }
345 }
346}
347
348/// Results across all evaluation modes.
349#[derive(Debug, Clone, Default, Serialize, Deserialize)]
350pub struct MultiModeResults {
351 /// Strict mode (exact boundary + exact type)
352 pub strict: ModeResults,
353 /// Exact mode (exact boundary only)
354 pub exact: ModeResults,
355 /// Partial mode (partial boundary + exact type)
356 pub partial: ModeResults,
357 /// Type mode (any overlap + exact type)
358 pub type_mode: ModeResults,
359}
360
361impl MultiModeResults {
362 /// Compute all modes at once.
363 #[must_use]
364 pub fn compute(predicted: &[Entity], gold: &[GoldEntity]) -> Self {
365 Self {
366 strict: ModeResults::compute(predicted, gold, EvalMode::Strict),
367 exact: ModeResults::compute(predicted, gold, EvalMode::Exact),
368 partial: ModeResults::compute(predicted, gold, EvalMode::Partial),
369 type_mode: ModeResults::compute(predicted, gold, EvalMode::Type),
370 }
371 }
372
373 /// Get results for a specific mode.
374 #[must_use]
375 pub fn get(&self, mode: EvalMode) -> &ModeResults {
376 match mode {
377 EvalMode::Strict => &self.strict,
378 EvalMode::Exact => &self.exact,
379 EvalMode::Partial => &self.partial,
380 EvalMode::Type => &self.type_mode,
381 }
382 }
383
384 /// Print summary table.
385 pub fn print_summary(&self) {
386 println!("Evaluation Mode Results:");
387 println!(
388 "{:<10} {:>10} {:>10} {:>10}",
389 "Mode", "Precision", "Recall", "F1"
390 );
391 println!("{:-<43}", "");
392 for mode in EvalMode::all() {
393 let r = self.get(*mode);
394 println!(
395 "{:<10} {:>9.1}% {:>9.1}% {:>9.1}%",
396 mode.name(),
397 r.precision * 100.0,
398 r.recall * 100.0,
399 r.f1 * 100.0
400 );
401 }
402 }
403}
404
405// =============================================================================
406// Matching Logic
407// =============================================================================
408
409/// Check if two entities match according to the given mode.
410fn entities_match(pred: &Entity, gold: &GoldEntity, mode: EvalMode) -> bool {
411 match mode {
412 EvalMode::Strict => {
413 // Exact boundary AND exact type
414 pred.start() == gold.start
415 && pred.end() == gold.end
416 && types_match(&pred.entity_type, &gold.entity_type)
417 }
418 EvalMode::Exact => {
419 // Exact boundary only (type can differ)
420 pred.start() == gold.start && pred.end() == gold.end
421 }
422 EvalMode::Partial => {
423 // Partial overlap AND exact type
424 has_overlap(pred.start(), pred.end(), gold.start, gold.end)
425 && types_match(&pred.entity_type, &gold.entity_type)
426 }
427 EvalMode::Type => {
428 // Any overlap AND exact type
429 has_overlap(pred.start(), pred.end(), gold.start, gold.end)
430 && types_match(&pred.entity_type, &gold.entity_type)
431 }
432 }
433}
434
435/// Check if two entity types match.
436fn types_match(a: &EntityType, b: &EntityType) -> bool {
437 // Use the existing entity_type_matches logic
438 super::entity_type_matches(a, b)
439}
440
441/// Check if two spans have any overlap.
442fn has_overlap(start1: usize, end1: usize, start2: usize, end2: usize) -> bool {
443 start1 < end2 && start2 < end1
444}
445
446/// Check if overlap meets minimum threshold.
447///
448/// This allows requiring a minimum overlap ratio for partial matches,
449/// useful when barely-touching spans shouldn't count as matches.
450/// Default threshold of 0.0 accepts any overlap.
451fn has_sufficient_overlap(
452 start1: usize,
453 end1: usize,
454 start2: usize,
455 end2: usize,
456 min_threshold: f64,
457) -> bool {
458 if !has_overlap(start1, end1, start2, end2) {
459 return false;
460 }
461 if min_threshold <= 0.0 {
462 return true;
463 }
464 overlap_ratio(start1, end1, start2, end2) >= min_threshold
465}
466
467/// Calculate overlap ratio (IoU) between two spans.
468#[must_use]
469pub fn overlap_ratio(start1: usize, end1: usize, start2: usize, end2: usize) -> f64 {
470 let intersection_start = start1.max(start2);
471 let intersection_end = end1.min(end2);
472
473 if intersection_start >= intersection_end {
474 return 0.0;
475 }
476
477 let intersection = (intersection_end - intersection_start) as f64;
478 let union =
479 ((end1 - start1) + (end2 - start2) - (intersection_end - intersection_start)) as f64;
480
481 if union == 0.0 {
482 1.0
483 } else {
484 intersection / union
485 }
486}
487
488/// Count true positives, false positives, and false negatives.
489fn count_matches(
490 predicted: &[Entity],
491 gold: &[GoldEntity],
492 mode: EvalMode,
493) -> (usize, usize, usize) {
494 let mut gold_matched = vec![false; gold.len()];
495 let mut tp = 0;
496 let mut fp = 0;
497
498 // For each prediction, try to find a matching gold entity
499 for pred in predicted {
500 let mut found_match = false;
501
502 for (i, g) in gold.iter().enumerate() {
503 if gold_matched[i] {
504 continue;
505 }
506
507 if entities_match(pred, g, mode) {
508 gold_matched[i] = true;
509 found_match = true;
510 tp += 1;
511 break;
512 }
513 }
514
515 if !found_match {
516 fp += 1;
517 }
518 }
519
520 let fn_count = gold_matched.iter().filter(|&&m| !m).count();
521
522 (tp, fp, fn_count)
523}
524
525/// Evaluate with a specific mode.
526#[must_use]
527pub fn evaluate_with_mode(
528 predicted: &[Entity],
529 gold: &[GoldEntity],
530 mode: EvalMode,
531) -> ModeResults {
532 ModeResults::compute(predicted, gold, mode)
533}
534
535/// Evaluate with a specific mode and configuration.
536///
537/// This allows customizing behavior like minimum overlap thresholds.
538///
539/// # Example
540///
541/// ```rust
542/// use anno_eval::eval::modes::{EvalMode, EvalConfig, evaluate_with_config};
543/// use anno_eval::eval::GoldEntity;
544/// use anno::{Entity, EntityType};
545///
546/// let predicted = vec![Entity::new("New York", EntityType::Location, 0, 8, 0.9)];
547/// let gold = vec![GoldEntity::new("New York City", EntityType::Location, 0)];
548///
549/// // Require 50% overlap for partial matches
550/// let config = EvalConfig::new().with_min_overlap(0.5);
551/// let results = evaluate_with_config(&predicted, &gold, EvalMode::Partial, &config);
552/// ```
553#[must_use]
554pub fn evaluate_with_config(
555 predicted: &[Entity],
556 gold: &[GoldEntity],
557 mode: EvalMode,
558 config: &EvalConfig,
559) -> ModeResults {
560 let (tp, fp, fn_count) = count_matches_with_config(predicted, gold, mode, config);
561
562 let precision = if tp + fp > 0 {
563 tp as f64 / (tp + fp) as f64
564 } else {
565 0.0
566 };
567
568 let recall = if tp + fn_count > 0 {
569 tp as f64 / (tp + fn_count) as f64
570 } else {
571 0.0
572 };
573
574 let f1 = if precision + recall > 0.0 {
575 2.0 * precision * recall / (precision + recall)
576 } else {
577 0.0
578 };
579
580 ModeResults {
581 mode,
582 precision,
583 recall,
584 f1,
585 true_positives: tp,
586 false_positives: fp,
587 false_negatives: fn_count,
588 }
589}
590
591/// Count matches with configuration.
592fn count_matches_with_config(
593 predicted: &[Entity],
594 gold: &[GoldEntity],
595 mode: EvalMode,
596 config: &EvalConfig,
597) -> (usize, usize, usize) {
598 let mut gold_matched = vec![false; gold.len()];
599 let mut tp = 0;
600 let mut fp = 0;
601
602 for pred in predicted {
603 let mut found_match = false;
604
605 for (i, g) in gold.iter().enumerate() {
606 if gold_matched[i] {
607 continue;
608 }
609
610 if entities_match_with_config(pred, g, mode, config) {
611 gold_matched[i] = true;
612 found_match = true;
613 tp += 1;
614 break;
615 }
616 }
617
618 if !found_match {
619 fp += 1;
620 }
621 }
622
623 let fn_count = gold_matched.iter().filter(|&&m| !m).count();
624
625 (tp, fp, fn_count)
626}
627
628/// Check if entities match with configuration.
629fn entities_match_with_config(
630 pred: &Entity,
631 gold: &GoldEntity,
632 mode: EvalMode,
633 config: &EvalConfig,
634) -> bool {
635 match mode {
636 EvalMode::Strict => {
637 pred.start() == gold.start
638 && pred.end() == gold.end
639 && types_match(&pred.entity_type, &gold.entity_type)
640 }
641 EvalMode::Exact => pred.start() == gold.start && pred.end() == gold.end,
642 EvalMode::Partial | EvalMode::Type => {
643 has_sufficient_overlap(
644 pred.start(),
645 pred.end(),
646 gold.start,
647 gold.end,
648 config.min_overlap,
649 ) && types_match(&pred.entity_type, &gold.entity_type)
650 }
651 }
652}
653
654// =============================================================================
655// Tests
656// =============================================================================
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 fn pred(text: &str, ty: EntityType, start: usize, end: usize) -> Entity {
663 Entity::new(text, ty, start, end, 0.9)
664 }
665
666 fn gold(text: &str, ty: EntityType, start: usize) -> GoldEntity {
667 GoldEntity::new(text, ty, start)
668 }
669
670 #[test]
671 fn test_strict_exact_match() {
672 let predicted = vec![pred("John", EntityType::Person, 0, 4)];
673 let gold_entities = vec![gold("John", EntityType::Person, 0)];
674
675 let results = ModeResults::compute(&predicted, &gold_entities, EvalMode::Strict);
676 assert!((results.f1 - 1.0).abs() < 0.001);
677 }
678
679 #[test]
680 fn test_strict_wrong_boundary() {
681 let predicted = vec![pred("John Smith", EntityType::Person, 0, 10)];
682 let gold_entities = vec![gold("John", EntityType::Person, 0)];
683
684 let results = ModeResults::compute(&predicted, &gold_entities, EvalMode::Strict);
685 assert_eq!(results.f1, 0.0); // Strict mode fails
686
687 // But partial mode should match
688 let partial = ModeResults::compute(&predicted, &gold_entities, EvalMode::Partial);
689 assert!((partial.f1 - 1.0).abs() < 0.001);
690 }
691
692 #[test]
693 fn test_strict_wrong_type() {
694 let predicted = vec![pred("Apple", EntityType::Organization, 0, 5)];
695 let gold_entities = vec![gold("Apple", EntityType::Location, 0)];
696
697 let results = ModeResults::compute(&predicted, &gold_entities, EvalMode::Strict);
698 assert_eq!(results.f1, 0.0); // Wrong type
699
700 // But exact mode (boundary only) should match
701 let exact = ModeResults::compute(&predicted, &gold_entities, EvalMode::Exact);
702 assert!((exact.f1 - 1.0).abs() < 0.001);
703 }
704
705 #[test]
706 fn test_partial_overlap() {
707 // "New York City" vs "New York"
708 let predicted = vec![pred("New York City", EntityType::Location, 0, 13)];
709 let gold_entities = vec![gold("New York", EntityType::Location, 0)];
710
711 // Strict: fail (different boundary)
712 let strict = ModeResults::compute(&predicted, &gold_entities, EvalMode::Strict);
713 assert_eq!(strict.f1, 0.0);
714
715 // Partial: pass (overlap + same type)
716 let partial = ModeResults::compute(&predicted, &gold_entities, EvalMode::Partial);
717 assert!((partial.f1 - 1.0).abs() < 0.001);
718 }
719
720 #[test]
721 fn test_no_overlap() {
722 let predicted = vec![pred("John", EntityType::Person, 0, 4)];
723 let gold_entities = vec![gold("Mary", EntityType::Person, 10)];
724
725 for mode in EvalMode::all() {
726 let results = ModeResults::compute(&predicted, &gold_entities, *mode);
727 assert_eq!(
728 results.f1, 0.0,
729 "Mode {:?} should fail with no overlap",
730 mode
731 );
732 }
733 }
734
735 #[test]
736 fn test_multi_mode_results() {
737 let predicted = vec![
738 pred("John", EntityType::Person, 0, 4),
739 pred("New York City", EntityType::Location, 10, 23),
740 ];
741 let gold_entities = vec![
742 gold("John", EntityType::Person, 0),
743 gold("New York", EntityType::Location, 10),
744 ];
745
746 let all = MultiModeResults::compute(&predicted, &gold_entities);
747
748 // Strict: 1/2 (John matches, NYC doesn't)
749 assert!((all.strict.precision - 0.5).abs() < 0.001);
750
751 // Partial: 2/2 (both overlap)
752 assert!((all.partial.f1 - 1.0).abs() < 0.001);
753 }
754
755 #[test]
756 fn test_overlap_ratio() {
757 // Complete overlap
758 assert!((overlap_ratio(0, 10, 0, 10) - 1.0).abs() < 0.001);
759
760 // No overlap
761 assert!((overlap_ratio(0, 5, 10, 15) - 0.0).abs() < 0.001);
762
763 // Partial overlap: [0,10] and [5,15]
764 // Intersection: [5,10] = 5 chars
765 // Union: 10 + 10 - 5 = 15 chars
766 // IoU = 5/15 = 0.333...
767 assert!(
768 (overlap_ratio(0, 10, 5, 15) - (5.0 / 15.0)).abs() < 0.001,
769 "Expected IoU of 5/15 = {}, got {}",
770 5.0 / 15.0,
771 overlap_ratio(0, 10, 5, 15)
772 );
773 }
774
775 #[test]
776 fn test_empty_inputs() {
777 let empty_pred: Vec<Entity> = vec![];
778 let empty_gold: Vec<GoldEntity> = vec![];
779
780 let results = ModeResults::compute(&empty_pred, &empty_gold, EvalMode::Strict);
781 assert_eq!(results.f1, 0.0);
782 assert_eq!(results.true_positives, 0);
783 assert_eq!(results.false_positives, 0);
784 assert_eq!(results.false_negatives, 0);
785 }
786
787 // === EvalConfig tests ===
788
789 #[test]
790 fn test_config_default() {
791 let config = EvalConfig::default();
792 assert_eq!(config.min_overlap, 0.0);
793 }
794
795 #[test]
796 fn test_config_with_overlap() {
797 let config = EvalConfig::new().with_min_overlap(0.5);
798 assert_eq!(config.min_overlap, 0.5);
799 }
800
801 #[test]
802 fn test_config_clamp() {
803 // Values outside 0-1 should be clamped
804 let config = EvalConfig::new().with_min_overlap(1.5);
805 assert_eq!(config.min_overlap, 1.0);
806
807 let config = EvalConfig::new().with_min_overlap(-0.5);
808 assert_eq!(config.min_overlap, 0.0);
809 }
810
811 #[test]
812 fn test_partial_with_zero_threshold() {
813 // Default: any overlap counts
814 let predicted = vec![pred("New York City", EntityType::Location, 0, 13)];
815 let gold_entities = vec![gold("New York", EntityType::Location, 0)];
816
817 let config = EvalConfig::default();
818 let results = evaluate_with_config(&predicted, &gold_entities, EvalMode::Partial, &config);
819
820 // Should match (overlap exists)
821 assert!((results.f1 - 1.0).abs() < 0.001);
822 }
823
824 #[test]
825 fn test_partial_with_high_threshold() {
826 // "New York City" [0,13] vs "New York" [0,8]
827 // Overlap: 8 chars, Union: 13 chars
828 // IoU = 8/13 ≈ 0.615
829 let predicted = vec![pred("New York City", EntityType::Location, 0, 13)];
830 let gold_entities = vec![gold("New York", EntityType::Location, 0)];
831
832 // 50% threshold - should pass (0.615 > 0.5)
833 let config = EvalConfig::new().with_min_overlap(0.5);
834 let results = evaluate_with_config(&predicted, &gold_entities, EvalMode::Partial, &config);
835 assert!(
836 (results.f1 - 1.0).abs() < 0.001,
837 "0.5 threshold should pass"
838 );
839
840 // 70% threshold - should fail (0.615 < 0.7)
841 let config = EvalConfig::new().with_min_overlap(0.7);
842 let results = evaluate_with_config(&predicted, &gold_entities, EvalMode::Partial, &config);
843 assert_eq!(results.f1, 0.0, "0.7 threshold should fail");
844 }
845
846 #[test]
847 fn test_partial_barely_touching() {
848 // Entities that barely touch: [0,5] and [4,10]
849 // Overlap: 1 char, Union: 10 chars
850 // IoU = 1/10 = 0.1
851 let predicted = vec![pred("Apple", EntityType::Organization, 0, 5)];
852 let gold_entities = vec![gold("Banana", EntityType::Organization, 4)];
853
854 // Default (0%) should match
855 let config = EvalConfig::default();
856 let results = evaluate_with_config(&predicted, &gold_entities, EvalMode::Partial, &config);
857 assert!((results.f1 - 1.0).abs() < 0.001);
858
859 // 20% threshold should fail (0.1 < 0.2)
860 let config = EvalConfig::new().with_min_overlap(0.2);
861 let results = evaluate_with_config(&predicted, &gold_entities, EvalMode::Partial, &config);
862 assert_eq!(results.f1, 0.0);
863 }
864
865 #[test]
866 fn test_strict_mode_ignores_threshold() {
867 // Strict mode requires exact boundaries, threshold shouldn't matter
868 let predicted = vec![pred("John", EntityType::Person, 0, 4)];
869 let gold_entities = vec![gold("John", EntityType::Person, 0)];
870
871 let config = EvalConfig::new().with_min_overlap(0.99);
872 let results = evaluate_with_config(&predicted, &gold_entities, EvalMode::Strict, &config);
873
874 // Should still pass (exact match)
875 assert!((results.f1 - 1.0).abs() < 0.001);
876 }
877
878 #[test]
879 fn test_has_sufficient_overlap() {
880 // Any overlap with 0% threshold
881 assert!(has_sufficient_overlap(0, 10, 5, 15, 0.0));
882
883 // Needs at least 50% IoU
884 // [0,10] and [5,15]: IoU = 5/15 ≈ 0.33
885 assert!(!has_sufficient_overlap(0, 10, 5, 15, 0.5));
886
887 // [0,10] and [2,12]: IoU = 8/12 ≈ 0.67
888 assert!(has_sufficient_overlap(0, 10, 2, 12, 0.5));
889
890 // No overlap at all
891 assert!(!has_sufficient_overlap(0, 5, 10, 15, 0.0));
892 }
893}