Skip to main content

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}