Skip to main content

anno_eval/eval/
types.rs

1//! Evaluation types: MetricValue, GoalCheckResult, etc.
2//!
3//! These are shared primitives for evaluation that can be reused
4//! across NER evaluation and other evaluation tasks.
5
6use anno::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// A type-safe metric value bounded to [0.0, 1.0].
11///
12/// Ensures metrics like precision, recall, and F1 are always valid.
13#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd)]
14#[serde(transparent)]
15pub struct MetricValue(f64);
16
17/// A metric with variance and confidence interval.
18///
19/// Tracks the mean, standard deviation, and 95% confidence interval
20/// for a metric computed across multiple samples/runs/datasets.
21///
22/// # Example
23///
24/// ```rust
25/// use anno_eval::eval::MetricWithVariance;
26///
27/// let metric = MetricWithVariance::from_samples(&[0.85, 0.87, 0.82, 0.88, 0.84]);
28/// println!("F1: {:.1}% ± {:.1}% (95% CI)", metric.mean * 100.0, metric.ci_95 * 100.0);
29/// // F1: 85.2% ± 2.1% (95% CI)
30/// ```
31#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
32pub struct MetricWithVariance {
33    /// Mean value of the metric
34    pub mean: f64,
35    /// Standard deviation
36    pub std_dev: f64,
37    /// 95% confidence interval (±)
38    pub ci_95: f64,
39    /// Minimum observed value
40    pub min: f64,
41    /// Maximum observed value
42    pub max: f64,
43    /// Number of samples
44    pub n: usize,
45}
46
47impl MetricWithVariance {
48    /// Create from a slice of sample values.
49    ///
50    /// Uses sample standard deviation (Bessel's correction) and
51    /// t-distribution approximation for 95% CI.
52    pub fn from_samples(samples: &[f64]) -> Self {
53        if samples.is_empty() {
54            return Self {
55                mean: 0.0,
56                std_dev: 0.0,
57                ci_95: 0.0,
58                min: 0.0,
59                max: 0.0,
60                n: 0,
61            };
62        }
63
64        let n = samples.len();
65        let mean = samples.iter().sum::<f64>() / n as f64;
66        let min = samples.iter().cloned().fold(f64::INFINITY, f64::min);
67        let max = samples.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
68
69        let std_dev = if n > 1 {
70            let variance = samples.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
71            variance.sqrt()
72        } else {
73            0.0
74        };
75
76        // 95% CI using t-distribution approximation
77        // For n >= 30, use z = 1.96; otherwise approximate with t
78        let t_value = if n >= 30 {
79            1.96
80        } else {
81            // Conservative t-value approximation for smaller samples
82            2.0 + 0.1 / (n as f64).sqrt()
83        };
84        let ci_95 = if n > 1 {
85            t_value * std_dev / (n as f64).sqrt()
86        } else {
87            0.0
88        };
89
90        Self {
91            mean,
92            std_dev,
93            ci_95,
94            min,
95            max,
96            n,
97        }
98    }
99
100    /// Format as "mean ± ci95" string.
101    pub fn format_with_ci(&self) -> String {
102        if self.n == 0 {
103            return "N/A".to_string();
104        }
105        format!("{:.1}% ± {:.1}%", self.mean * 100.0, self.ci_95 * 100.0)
106    }
107
108    /// Format as "mean (min-max)" string.
109    pub fn format_with_range(&self) -> String {
110        if self.n == 0 {
111            return "N/A".to_string();
112        }
113        format!(
114            "{:.1}% ({:.1}%-{:.1}%)",
115            self.mean * 100.0,
116            self.min * 100.0,
117            self.max * 100.0
118        )
119    }
120
121    /// Get coefficient of variation (CV = std_dev / mean).
122    pub fn coefficient_of_variation(&self) -> f64 {
123        if self.mean.abs() < 1e-10 {
124            0.0
125        } else {
126            self.std_dev / self.mean
127        }
128    }
129}
130
131impl Default for MetricWithVariance {
132    fn default() -> Self {
133        Self {
134            mean: 0.0,
135            std_dev: 0.0,
136            ci_95: 0.0,
137            min: 0.0,
138            max: 0.0,
139            n: 0,
140        }
141    }
142}
143
144impl std::fmt::Display for MetricWithVariance {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        write!(f, "{}", self.format_with_ci())
147    }
148}
149
150impl MetricValue {
151    /// Create a new MetricValue, clamping to [0.0, 1.0].
152    ///
153    /// # Example
154    /// ```
155    /// use anno_eval::eval::MetricValue;
156    /// let v = MetricValue::new(0.95);
157    /// assert!((v.get() - 0.95).abs() < 1e-6);
158    /// ```
159    pub fn new(value: f64) -> Self {
160        MetricValue(value.clamp(0.0, 1.0))
161    }
162
163    /// Try to create a MetricValue, returning error if out of bounds.
164    pub fn try_new(value: f64) -> Result<Self> {
165        if !(0.0..=1.0).contains(&value) {
166            return Err(Error::InvalidInput(format!(
167                "MetricValue must be in [0.0, 1.0], got {}",
168                value
169            )));
170        }
171        Ok(MetricValue(value))
172    }
173
174    /// Get the underlying value.
175    #[inline]
176    pub fn get(&self) -> f64 {
177        self.0
178    }
179}
180
181impl Default for MetricValue {
182    fn default() -> Self {
183        MetricValue(0.0)
184    }
185}
186
187impl std::fmt::Display for MetricValue {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        write!(f, "{:.4}", self.0)
190    }
191}
192
193impl From<f64> for MetricValue {
194    fn from(value: f64) -> Self {
195        MetricValue::new(value)
196    }
197}
198
199/// Result of checking evaluation goals.
200#[derive(Debug, Clone, Serialize, Deserialize, Default)]
201pub struct GoalCheckResult {
202    /// Whether all goals were met.
203    pub passed: bool,
204    /// Individual goal check results.
205    pub checks: HashMap<String, GoalCheck>,
206    /// Summary message.
207    pub summary: Option<String>,
208}
209
210impl GoalCheckResult {
211    /// Create a new GoalCheckResult (defaults to passed = true).
212    #[must_use]
213    pub fn new() -> Self {
214        Self {
215            passed: true,
216            checks: HashMap::new(),
217            summary: None,
218        }
219    }
220
221    /// Add a goal check result.
222    pub fn add_check(&mut self, name: impl Into<String>, check: GoalCheck) {
223        if !check.passed {
224            self.passed = false;
225        }
226        self.checks.insert(name.into(), check);
227    }
228
229    /// Add a failure (convenience method for add_check with fail).
230    pub fn add_failure(&mut self, name: impl Into<String>, actual: f64, threshold: f64) {
231        self.add_check(name, GoalCheck::fail(threshold, actual));
232    }
233
234    /// Add a success (convenience method for add_check with pass).
235    pub fn add_success(&mut self, name: impl Into<String>, actual: f64, threshold: f64) {
236        self.add_check(name, GoalCheck::pass(threshold, actual));
237    }
238
239    /// Get number of passed checks.
240    pub fn passed_count(&self) -> usize {
241        self.checks.values().filter(|c| c.passed).count()
242    }
243
244    /// Get number of failed checks.
245    pub fn failed_count(&self) -> usize {
246        self.checks.values().filter(|c| !c.passed).count()
247    }
248}
249
250/// Individual goal check.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct GoalCheck {
253    /// Whether this goal was met.
254    pub passed: bool,
255    /// Expected threshold.
256    pub threshold: f64,
257    /// Actual value achieved.
258    pub actual: f64,
259    /// Optional message.
260    pub message: Option<String>,
261}
262
263impl GoalCheck {
264    /// Create a new goal check.
265    pub fn new(passed: bool, threshold: f64, actual: f64) -> Self {
266        Self {
267            passed,
268            threshold,
269            actual,
270            message: None,
271        }
272    }
273
274    /// Create a passing check.
275    pub fn pass(threshold: f64, actual: f64) -> Self {
276        Self::new(true, threshold, actual)
277    }
278
279    /// Create a failing check.
280    pub fn fail(threshold: f64, actual: f64) -> Self {
281        Self::new(false, threshold, actual)
282    }
283
284    /// Add a message to the check.
285    #[must_use]
286    pub fn with_message(mut self, msg: impl Into<String>) -> Self {
287        self.message = Some(msg.into());
288        self
289    }
290}
291
292// =============================================================================
293// Label Shift Quantification (Familiarity-inspired)
294// =============================================================================
295
296/// Label shift between training and evaluation entity types.
297///
298/// # Why This Matters
299///
300/// Imagine you trained a model on `{PER, ORG, LOC}` and then evaluate on
301/// `{PERSON, COMPANY, CITY}`. Is that zero-shot? Technically yes (new labels).
302/// Practically no (same concepts).
303///
304/// ```text
305/// ┌─────────────────────────────────────────────────────────────────────────┐
306/// │                    THE LABEL SHIFT PROBLEM                              │
307/// ├─────────────────────────────────────────────────────────────────────────┤
308/// │                                                                         │
309/// │  TRAINING LABELS           EVAL LABELS         ARE THEY THE SAME?       │
310/// │  ───────────────           ───────────         ──────────────────       │
311/// │                                                                         │
312/// │  PER ───────────────────── PERSON             ✓ Obviously (renamed)     │
313/// │  ORG ───────────────────── COMPANY            ✓ Subset relationship     │
314/// │  LOC ───────────────────── CITY               ✓ Subset relationship     │
315/// │                                                                         │
316/// │  ??? ←─────────────────── DISEASE            ✗ TRUE ZERO-SHOT!         │
317/// │  ??? ←─────────────────── DRUG               ✗ TRUE ZERO-SHOT!         │
318/// │                                                                         │
319/// │  If 80% of eval types have training equivalents, your F1 is inflated.   │
320/// └─────────────────────────────────────────────────────────────────────────┘
321/// ```
322///
323/// # Embedding Space View
324///
325/// Labels that seem different can be close in embedding space:
326///
327/// ```text
328///                    EMBEDDING SPACE (2D projection)
329///                    ───────────────────────────────
330///
331///            PER ●───────────────● PERSON
332///                      │
333///                 very close in
334///                embedding space
335///
336///            ORG ●─────● COMPANY
337///
338///            LOC ●─────────● CITY
339///
340///
341///                                        ● DISEASE    ← Far from all
342///                                                       training types!
343///                                        ● DRUG       ← This is TRUE
344///                                                       zero-shot.
345///
346/// F1 on {PERSON, COMPANY, CITY}:  85%  (but model "knew" these)
347/// F1 on {DISEASE, DRUG}:          45%  (honest zero-shot)
348/// ```
349///
350/// # Research Context (arXiv:2412.10121 "Familiarity")
351///
352/// Key findings from Golde et al. (2024):
353/// - 80%+ label overlap in NuNER/PileNER → inflated F1 scores
354/// - True zero-shot: evaluate only on types NOT in training
355/// - Familiarity = semantic similarity × frequency weighting
356///
357/// # Example
358///
359/// ```rust
360/// use anno_eval::eval::LabelShift;
361///
362/// let shift = LabelShift {
363///     overlap_ratio: 0.85,    // 85% of eval types in train
364///     familiarity: 0.72,      // Semantic similarity score
365///     true_zero_shot_types: vec!["DISEASE".into(), "DRUG".into()],
366///     transfer_difficulty: "low".into(),
367/// };
368///
369/// // High overlap = easy transfer, but NOT true zero-shot
370/// assert!(shift.is_inflated());
371/// ```
372#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct LabelShift {
374    /// Fraction of eval types found in training data (exact string match).
375    pub overlap_ratio: f64,
376
377    /// Familiarity score: semantic similarity weighted by frequency.
378    /// Range: [0, 1]. Higher = more similar training/eval types.
379    pub familiarity: f64,
380
381    /// Entity types in eval NOT present in training (true zero-shot).
382    pub true_zero_shot_types: Vec<String>,
383
384    /// Qualitative difficulty: "low", "medium", "high".
385    pub transfer_difficulty: String,
386}
387
388impl LabelShift {
389    /// Check if F1 scores are likely inflated due to high label overlap.
390    ///
391    /// Threshold from Familiarity paper: >0.8 overlap is concernoing.
392    #[must_use]
393    pub fn is_inflated(&self) -> bool {
394        self.overlap_ratio > 0.8 || self.familiarity > 0.85
395    }
396
397    /// Get count of true zero-shot types.
398    #[must_use]
399    pub fn true_zero_shot_count(&self) -> usize {
400        self.true_zero_shot_types.len()
401    }
402
403    /// Compute label shift from training and eval type sets.
404    ///
405    /// # Arguments
406    /// * `train_types` - Entity types seen during training
407    /// * `eval_types` - Entity types in evaluation benchmark
408    ///
409    /// # Note
410    ///
411    /// This computes both string-match overlap and semantic similarity-based familiarity.
412    /// For true semantic similarity, use `from_type_sets_with_embeddings()` if embeddings are available.
413    /// See arXiv:2412.10121 for details.
414    #[must_use]
415    pub fn from_type_sets(train_types: &[String], eval_types: &[String]) -> Self {
416        let train_set: std::collections::HashSet<_> = train_types.iter().collect();
417        let eval_set: std::collections::HashSet<_> = eval_types.iter().collect();
418
419        // Exact match overlap
420        let overlap_count = eval_set.intersection(&train_set).count();
421        let overlap_ratio = if eval_types.is_empty() {
422            0.0
423        } else {
424            overlap_count as f64 / eval_types.len() as f64
425        };
426
427        // True zero-shot = eval types NOT in training
428        let true_zero_shot_types: Vec<String> = eval_set
429            .difference(&train_set)
430            .map(|s| (*s).clone())
431            .collect();
432
433        // Compute familiarity using string similarity (improved heuristic)
434        // This is better than just overlap_ratio but still not true semantic similarity
435        let familiarity = compute_string_based_familiarity(train_types, eval_types);
436
437        let transfer_difficulty = if overlap_ratio > 0.8 || familiarity > 0.85 {
438            "low"
439        } else if overlap_ratio > 0.4 || familiarity > 0.5 {
440            "medium"
441        } else {
442            "high"
443        }
444        .to_string();
445
446        Self {
447            overlap_ratio,
448            familiarity,
449            true_zero_shot_types,
450            transfer_difficulty,
451        }
452    }
453
454    /// Compute label shift with embedding-based familiarity.
455    ///
456    /// # Arguments
457    /// * `train_types` - Entity types seen during training
458    /// * `eval_types` - Entity types in evaluation benchmark
459    /// * `embedding_fn` - Function that computes embedding for a label name
460    ///
461    /// # Note
462    ///
463    /// This computes true semantic similarity using embeddings, as recommended
464    /// in the Familiarity paper (arXiv:2412.10121). Familiarity = semantic similarity × frequency weighting.
465    ///
466    /// The embedding function should return a normalized vector (unit length) for cosine similarity.
467    #[must_use]
468    pub fn from_type_sets_with_embeddings<F>(
469        train_types: &[String],
470        eval_types: &[String],
471        embedding_fn: F,
472    ) -> Self
473    where
474        F: Fn(&str) -> Option<Vec<f32>>,
475    {
476        let mut result = Self::from_type_sets(train_types, eval_types);
477
478        // Compute embedding-based familiarity
479        if let Some(familiarity) =
480            compute_embedding_based_familiarity(train_types, eval_types, &embedding_fn)
481        {
482            result.familiarity = familiarity;
483        }
484
485        result
486    }
487}
488
489impl std::fmt::Display for LabelShift {
490    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
491        write!(
492            f,
493            "LabelShift(overlap={:.0}%, familiarity={:.2}, zero-shot={}, difficulty={})",
494            self.overlap_ratio * 100.0,
495            self.familiarity,
496            self.true_zero_shot_types.len(),
497            self.transfer_difficulty
498        )
499    }
500}
501
502// =============================================================================
503// Coreference Chain Statistics (arXiv:2401.00238 inspired)
504// =============================================================================
505
506/// Statistics for stratified coreference evaluation.
507///
508/// # Why Chain Length Matters: A Narrative
509///
510/// Imagine analyzing "Pride and Prejudice":
511///
512/// ```text
513/// ┌─────────────────────────────────────────────────────────────────────────┐
514/// │                    COREFERENCE IN A NOVEL                               │
515/// ├─────────────────────────────────────────────────────────────────────────┤
516/// │                                                                         │
517/// │  LONG CHAINS (>10 mentions) - THE PROTAGONISTS                          │
518/// │  ─────────────────────────────────────────────                          │
519/// │                                                                         │
520/// │  "Elizabeth" ─── "she" ─── "Lizzy" ─── "her" ─── "Miss Bennet" ───...  │
521/// │       │            │          │          │            │                 │
522/// │       └────────────┴──────────┴──────────┴────────────┘                 │
523/// │                         800+ mentions                                   │
524/// │                                                                         │
525/// │  Getting these right = understanding the PLOT.                          │
526/// │  Who did what to whom? What's Elizabeth's arc?                          │
527/// │                                                                         │
528/// │  SHORT CHAINS (2-10 mentions) - SECONDARY CHARACTERS                    │
529/// │  ───────────────────────────────────────────────────                    │
530/// │                                                                         │
531/// │  "Mr. Collins" ─── "he" ─── "the clergyman"                             │
532/// │       │              │             │                                    │
533/// │       └──────────────┴─────────────┘                                    │
534/// │                  15 mentions                                            │
535/// │                                                                         │
536/// │  Important for context, but errors here are less catastrophic.          │
537/// │                                                                         │
538/// │  SINGLETONS (1 mention) - BACKGROUND                                    │
539/// │  ───────────────────────────────────────                                │
540/// │                                                                         │
541/// │  "a tall man" ─── (no other mentions)                                   │
542/// │  "the servant" ─── (no other mentions)                                  │
543/// │                                                                         │
544/// │  These are closer to entity detection than coreference.                  │
545/// │  Including them in CoNLL F1 can change the interpretation of the score.  │
546/// └─────────────────────────────────────────────────────────────────────────┘
547/// ```
548///
549/// # The Problem with Averaged Metrics
550///
551/// ```text
552/// Model Performance:
553///
554/// A single averaged metric can hide systematic differences across chain sizes.
555/// Prefer reporting stratified metrics by chain length (and be explicit about
556/// whether singletons are included).
557/// ```
558///
559/// # Research Context (arXiv:2401.00238)
560///
561/// "How to Evaluate Coreference in Literary Texts?"
562/// - A single CoNLL F1 score is "uninformative, or even misleading."
563/// - Stratify by chain length for interpretable results.
564///
565/// # Example
566///
567/// ```rust
568/// use anno_eval::eval::CorefChainStats;
569///
570/// let stats = CorefChainStats {
571///     long_chain_count: 3,      // Main characters
572///     short_chain_count: 15,    // Secondary
573///     singleton_count: 42,      // Isolated
574///     long_chain_f1: 0.92,      // Good on main characters
575///     short_chain_f1: 0.71,     // Weaker on secondary
576///     singleton_f1: 0.45,       // Poor on singletons
577/// };
578///
579/// // Report metrics separately, not averaged
580/// println!("Main characters: {:.1}% F1", stats.long_chain_f1 * 100.0);
581/// ```
582pub use anno::metrics::types::CorefChainStats;
583
584// =============================================================================
585// Document Scale Classification (Bourgois & Poibeau 2025)
586// =============================================================================
587
588/// Document scale classification based on token count.
589///
590/// # Research Context (Bourgois & Poibeau 2025, arXiv:2510.15594)
591///
592/// The paper shows that coreference performance degrades significantly with
593/// document length. These thresholds are informed by their analysis:
594///
595/// ```text
596/// Scale           Token Range     Performance Impact
597/// ─────────────────────────────────────────────────────
598/// Short           <2k             Baseline (OntoNotes-like)
599/// Medium          2k-10k          -5% CoNLL F1
600/// Long            10k-50k         -10% CoNLL F1
601/// BookScale       >50k            -15% CoNLL F1, metrics unreliable
602/// ```
603///
604/// # Example
605///
606/// ```rust
607/// use anno_eval::eval::DocumentScale;
608///
609/// let scale = DocumentScale::from_tokens(95_000);
610/// assert!(scale.is_book_scale());
611/// assert!(scale.metrics_may_be_unreliable());
612/// ```
613#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
614pub enum DocumentScale {
615    /// Short document (<2k tokens). OntoNotes-like scale.
616    #[default]
617    Short,
618    /// Medium document (2k-10k tokens). Slight performance drop.
619    Medium,
620    /// Long document (10k-50k tokens). Noticeable degradation.
621    Long,
622    /// Book-scale document (>50k tokens). Metrics may be unreliable.
623    BookScale,
624}
625
626impl DocumentScale {
627    /// Classify document scale from token count.
628    #[must_use]
629    pub fn from_tokens(token_count: usize) -> Self {
630        match token_count {
631            0..=2000 => Self::Short,
632            2001..=10000 => Self::Medium,
633            10001..=50000 => Self::Long,
634            _ => Self::BookScale,
635        }
636    }
637
638    /// Check if this is book-scale (>50k tokens).
639    #[must_use]
640    pub fn is_book_scale(&self) -> bool {
641        matches!(self, Self::BookScale)
642    }
643
644    /// Check if coreference metrics may be unreliable at this scale.
645    ///
646    /// At book scale, MUC tends to inflate while CEAF-e tends to collapse.
647    #[must_use]
648    pub fn metrics_may_be_unreliable(&self) -> bool {
649        matches!(self, Self::Long | Self::BookScale)
650    }
651
652    /// Get expected CoNLL F1 degradation relative to short documents.
653    #[must_use]
654    pub fn expected_degradation(&self) -> f64 {
655        match self {
656            Self::Short => 0.0,
657            Self::Medium => 0.05,
658            Self::Long => 0.10,
659            Self::BookScale => 0.15,
660        }
661    }
662}
663
664impl std::fmt::Display for DocumentScale {
665    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
666        match self {
667            Self::Short => write!(f, "Short (<2k tokens)"),
668            Self::Medium => write!(f, "Medium (2k-10k tokens)"),
669            Self::Long => write!(f, "Long (10k-50k tokens)"),
670            Self::BookScale => write!(f, "Book-scale (>50k tokens)"),
671        }
672    }
673}
674
675// =============================================================================
676// Metric Divergence (Book-scale Coreference Analysis)
677// =============================================================================
678
679/// Divergence between coreference metrics.
680///
681/// # Research Context
682///
683/// At book scale, different metrics diverge significantly:
684/// - MUC tends to be inflated (favors link-based evaluation)
685/// - CEAF-e tends to collapse (entity alignment struggles)
686/// - B³ falls between but is more stable
687///
688/// Large divergence (>0.20) indicates potential metric unreliability.
689///
690/// # Example
691///
692/// ```rust
693/// use anno_eval::eval::MetricDivergence;
694///
695/// let divergence = MetricDivergence::from_scores(0.90, 0.65, 0.45);
696/// assert!(divergence.has_high_divergence());
697/// println!("MUC-CEAF divergence: {:.0}%", divergence.muc_ceaf_divergence * 100.0);
698/// ```
699#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
700pub struct MetricDivergence {
701    /// MUC F1 score.
702    pub muc_f1: f64,
703    /// B³ F1 score.
704    pub b3_f1: f64,
705    /// CEAF-e F1 score.
706    pub ceaf_e_f1: f64,
707    /// Divergence between MUC and CEAF-e (absolute difference).
708    pub muc_ceaf_divergence: f64,
709    /// Divergence between MUC and B³.
710    pub muc_b3_divergence: f64,
711    /// Divergence between B³ and CEAF-e.
712    pub b3_ceaf_divergence: f64,
713}
714
715impl MetricDivergence {
716    /// Compute divergence from raw scores.
717    #[must_use]
718    pub fn from_scores(muc_f1: f64, b3_f1: f64, ceaf_e_f1: f64) -> Self {
719        Self {
720            muc_f1,
721            b3_f1,
722            ceaf_e_f1,
723            muc_ceaf_divergence: (muc_f1 - ceaf_e_f1).abs(),
724            muc_b3_divergence: (muc_f1 - b3_f1).abs(),
725            b3_ceaf_divergence: (b3_f1 - ceaf_e_f1).abs(),
726        }
727    }
728
729    /// Check if divergence is high (>0.20), indicating unreliable metrics.
730    #[must_use]
731    pub fn has_high_divergence(&self) -> bool {
732        self.muc_ceaf_divergence > 0.20
733    }
734
735    /// Check if MUC is likely inflated (MUC >> CEAF-e).
736    #[must_use]
737    pub fn muc_likely_inflated(&self) -> bool {
738        self.muc_f1 > self.ceaf_e_f1 + 0.15
739    }
740
741    /// Check if CEAF-e is likely collapsed (CEAF-e << others).
742    #[must_use]
743    pub fn ceaf_likely_collapsed(&self) -> bool {
744        self.ceaf_e_f1 < self.b3_f1 - 0.15 && self.ceaf_e_f1 < self.muc_f1 - 0.20
745    }
746
747    /// Get most reliable metric recommendation.
748    #[must_use]
749    pub fn most_reliable_metric(&self) -> &'static str {
750        if self.muc_likely_inflated() && self.ceaf_likely_collapsed() {
751            "B³ (MUC inflated, CEAF-e collapsed)"
752        } else if self.muc_likely_inflated() {
753            "B³ or CEAF-e (MUC inflated)"
754        } else if self.ceaf_likely_collapsed() {
755            "MUC or B³ (CEAF-e collapsed)"
756        } else {
757            "CoNLL F1 (metrics agree)"
758        }
759    }
760}
761
762// =============================================================================
763// Document Statistics for Coreference (Entity Spread)
764// =============================================================================
765
766/// Document-level statistics for coreference evaluation.
767///
768/// # Research Context (Bourgois & Poibeau 2025)
769///
770/// The paper introduces "entity spread" as a key metric:
771/// > "The entity spread refers to the distance between the first and the last
772/// > mention of an entity."
773///
774/// Their Long-LitBank-fr corpus shows:
775/// - Average entity spread: 17,529 tokens
776/// - Maximum entity spread: 115,369 tokens (spanning entire novels)
777///
778/// This metric characterizes the difficulty of coreference:
779/// high spread = mentions far apart = harder to resolve.
780///
781/// # Example
782///
783/// ```rust
784/// use anno_eval::eval::{CorefDocStats, coref::CorefChain};
785///
786/// // Create from chains (would use actual chains in practice)
787/// let stats = CorefDocStats {
788///     chain_count: 159,
789///     mention_count: 13178,
790///     avg_chain_length: 82.9,
791///     avg_entity_spread: 17529,
792///     max_entity_spread: 115369,
793///     ..Default::default()
794/// };
795///
796/// println!("Avg entity spread: {} tokens", stats.avg_entity_spread);
797/// ```
798#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
799pub struct CorefDocStats {
800    /// Document length in tokens (approximate).
801    pub doc_length: usize,
802    /// Total number of coreference chains.
803    pub chain_count: usize,
804    /// Total number of mentions.
805    pub mention_count: usize,
806    /// Average mentions per chain.
807    pub avg_chain_length: f64,
808    /// Maximum chain length.
809    pub max_chain_length: usize,
810
811    // =========================================================================
812    // Entity Spread (Bourgois & Poibeau 2025)
813    // =========================================================================
814    /// Average entity spread in tokens.
815    /// Entity spread = distance between first and last mention of an entity.
816    pub avg_entity_spread: usize,
817
818    /// Maximum entity spread in tokens.
819    /// For protagonists in novels, this can exceed 100k tokens.
820    pub max_entity_spread: usize,
821
822    /// Median entity spread in tokens.
823    pub median_entity_spread: usize,
824
825    // =========================================================================
826    // Mention Type Distribution
827    // =========================================================================
828    /// Proportion of pronominal mentions.
829    pub pronoun_ratio: f64,
830    /// Proportion of proper noun mentions.
831    pub proper_ratio: f64,
832    /// Proportion of nominal mentions.
833    pub nominal_ratio: f64,
834    /// Proportion of singleton chains.
835    pub singleton_ratio: f64,
836}
837
838impl CorefDocStats {
839    /// Compute statistics from coreference chains.
840    ///
841    /// Chains should have mentions with character offsets.
842    /// Use `doc_length` to set the token count separately.
843    #[must_use]
844    pub fn from_chains(chains: &[crate::eval::coref::CorefChain]) -> Self {
845        if chains.is_empty() {
846            return Self::default();
847        }
848
849        let chain_count = chains.len();
850        let mention_count: usize = chains.iter().map(|c| c.mentions.len()).sum();
851        let avg_chain_length = mention_count as f64 / chain_count as f64;
852        let max_chain_length = chains.iter().map(|c| c.mentions.len()).max().unwrap_or(0);
853
854        // Count singletons
855        let singleton_count = chains.iter().filter(|c| c.mentions.len() == 1).count();
856        let singleton_ratio = singleton_count as f64 / chain_count as f64;
857
858        // Compute entity spread for each chain
859        let mut spreads: Vec<usize> = Vec::with_capacity(chain_count);
860        for chain in chains {
861            if chain.mentions.len() <= 1 {
862                spreads.push(0);
863                continue;
864            }
865
866            let first_start = chain.mentions.iter().map(|m| m.start).min().unwrap_or(0);
867            let last_end = chain.mentions.iter().map(|m| m.end).max().unwrap_or(0);
868            let spread = last_end.saturating_sub(first_start);
869            spreads.push(spread);
870        }
871
872        let avg_entity_spread = if !spreads.is_empty() {
873            spreads.iter().sum::<usize>() / spreads.len()
874        } else {
875            0
876        };
877
878        let max_entity_spread = spreads.iter().copied().max().unwrap_or(0);
879
880        // Compute median spread
881        spreads.sort_unstable();
882        let median_entity_spread = if spreads.is_empty() {
883            0
884        } else {
885            spreads[spreads.len() / 2]
886        };
887
888        // Compute mention type ratios (approximate from text patterns)
889        // This is a heuristic; proper classification requires POS tagging
890        let mut pronoun_count = 0usize;
891        let mut proper_count = 0usize;
892        let mut nominal_count = 0usize;
893
894        for chain in chains {
895            for mention in &chain.mentions {
896                let text_lower = mention.text.to_lowercase();
897                let is_pronoun = matches!(
898                    text_lower.as_str(),
899                    "he" | "she"
900                        | "it"
901                        | "they"
902                        | "him"
903                        | "her"
904                        | "them"
905                        | "his"
906                        | "hers"
907                        | "its"
908                        | "their"
909                        | "i"
910                        | "me"
911                        | "we"
912                        | "us"
913                        | "you"
914                );
915
916                if is_pronoun {
917                    pronoun_count += 1;
918                } else if mention
919                    .text
920                    .chars()
921                    .next()
922                    .is_some_and(|c| c.is_uppercase())
923                {
924                    proper_count += 1;
925                } else {
926                    nominal_count += 1;
927                }
928            }
929        }
930
931        let total_mentions = mention_count.max(1) as f64;
932        let pronoun_ratio = pronoun_count as f64 / total_mentions;
933        let proper_ratio = proper_count as f64 / total_mentions;
934        let nominal_ratio = nominal_count as f64 / total_mentions;
935
936        Self {
937            doc_length: 0, // Must be set separately
938            chain_count,
939            mention_count,
940            avg_chain_length,
941            max_chain_length,
942            avg_entity_spread,
943            max_entity_spread,
944            median_entity_spread,
945            pronoun_ratio,
946            proper_ratio,
947            nominal_ratio,
948            singleton_ratio,
949        }
950    }
951
952    /// Get document scale classification.
953    #[must_use]
954    pub fn scale_classification(&self) -> DocumentScale {
955        DocumentScale::from_tokens(self.doc_length)
956    }
957
958    /// Check if entity spread suggests book-scale complexity.
959    ///
960    /// Book-scale documents typically have entities spanning >10k tokens.
961    #[must_use]
962    pub fn has_book_scale_spread(&self) -> bool {
963        self.avg_entity_spread > 5000 || self.max_entity_spread > 20000
964    }
965
966    /// Format as summary string.
967    #[must_use]
968    pub fn format_summary(&self) -> String {
969        format!(
970            "Chains: {}, Mentions: {}, Avg length: {:.1}, Spread: avg={} max={}",
971            self.chain_count,
972            self.mention_count,
973            self.avg_chain_length,
974            self.avg_entity_spread,
975            self.max_entity_spread,
976        )
977    }
978}
979
980/// Compute string-based familiarity using normalized edit distance and substring matching.
981///
982/// This is an improved heuristic over simple overlap ratio, but still not true semantic similarity.
983fn compute_string_based_familiarity(train_types: &[String], eval_types: &[String]) -> f64 {
984    if eval_types.is_empty() {
985        return 0.0;
986    }
987
988    let mut total_similarity = 0.0;
989    let mut counts = std::collections::HashMap::<String, usize>::new();
990
991    // Count frequency of each eval type (for weighting)
992    for eval_type in eval_types {
993        *counts.entry(eval_type.clone()).or_insert(0) += 1;
994    }
995
996    let total_eval_count = eval_types.len() as f64;
997
998    for (eval_type, freq) in counts {
999        let max_sim = train_types
1000            .iter()
1001            .map(|train_type| string_similarity(&eval_type, train_type))
1002            .fold(0.0, f64::max);
1003
1004        // Weight by frequency (as in Familiarity paper)
1005        let weight = freq as f64 / total_eval_count;
1006        total_similarity += max_sim * weight;
1007    }
1008
1009    total_similarity
1010}
1011
1012/// Compute embedding-based familiarity (semantic similarity × frequency weighting).
1013///
1014/// Returns None if embeddings cannot be computed for any type.
1015fn compute_embedding_based_familiarity<F>(
1016    train_types: &[String],
1017    eval_types: &[String],
1018    embedding_fn: &F,
1019) -> Option<f64>
1020where
1021    F: Fn(&str) -> Option<Vec<f32>>,
1022{
1023    if eval_types.is_empty() {
1024        return Some(0.0);
1025    }
1026
1027    // Compute embeddings for all types
1028    let train_embeddings: Vec<(String, Vec<f32>)> = train_types
1029        .iter()
1030        .filter_map(|t| embedding_fn(t).map(|e| (t.clone(), e)))
1031        .collect();
1032
1033    if train_embeddings.is_empty() {
1034        return None; // Can't compute without train embeddings
1035    }
1036
1037    let mut counts = std::collections::HashMap::<String, usize>::new();
1038    for eval_type in eval_types {
1039        *counts.entry(eval_type.clone()).or_insert(0) += 1;
1040    }
1041
1042    let total_eval_count = eval_types.len() as f64;
1043    let mut total_similarity = 0.0;
1044
1045    for (eval_type, freq) in counts {
1046        if let Some(eval_emb) = embedding_fn(&eval_type) {
1047            // Find maximum cosine similarity with any training type
1048            let max_sim = train_embeddings
1049                .iter()
1050                .map(|(_, train_emb)| cosine_similarity(&eval_emb, train_emb))
1051                .fold(0.0, f64::max);
1052
1053            // Weight by frequency
1054            let weight = freq as f64 / total_eval_count;
1055            total_similarity += max_sim * weight;
1056        } else {
1057            // If we can't embed this type, fall back to string similarity
1058            let max_sim = train_types
1059                .iter()
1060                .map(|train_type| string_similarity(&eval_type, train_type))
1061                .fold(0.0, f64::max);
1062            let weight = freq as f64 / total_eval_count;
1063            total_similarity += max_sim * weight;
1064        }
1065    }
1066
1067    Some(total_similarity)
1068}
1069
1070/// Compute cosine similarity between two normalized vectors.
1071fn cosine_similarity(a: &[f32], b: &[f32]) -> f64 {
1072    if a.len() != b.len() {
1073        return 0.0;
1074    }
1075    let dot_product: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
1076    dot_product as f64
1077}
1078
1079/// Compute string similarity using normalized edit distance and substring matching.
1080///
1081/// Returns a value in [0, 1] where 1.0 = identical strings.
1082fn string_similarity(a: &str, b: &str) -> f64 {
1083    let a_lower = a.to_lowercase();
1084    let b_lower = b.to_lowercase();
1085
1086    // Exact match
1087    if a_lower == b_lower {
1088        return 1.0;
1089    }
1090
1091    // Substring match (e.g., "PERSON" contains "PER")
1092    if a_lower.contains(&b_lower) || b_lower.contains(&a_lower) {
1093        return 0.8;
1094    }
1095
1096    // Normalized edit distance (Levenshtein)
1097    let max_len = a_lower.len().max(b_lower.len());
1098    if max_len == 0 {
1099        return 1.0;
1100    }
1101
1102    let distance = levenshtein_distance(&a_lower, &b_lower);
1103    1.0 - (distance as f64 / max_len as f64)
1104}
1105
1106/// Compute Levenshtein distance between two strings.
1107///
1108/// Delegates to `anno::edit_distance::levenshtein` (single-row optimized,
1109/// Unicode-correct implementation).
1110fn levenshtein_distance(a: &str, b: &str) -> usize {
1111    anno::edit_distance::levenshtein(a, b)
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116    use super::*;
1117
1118    #[test]
1119    fn test_familiarity_computation() {
1120        let train_types = vec![
1121            "person".to_string(),
1122            "organization".to_string(),
1123            "location".to_string(),
1124        ];
1125
1126        let eval_types = vec![
1127            "PERSON".to_string(),  // Should match "person" via similarity (not zero-shot)
1128            "ORG".to_string(),     // Should match "organization" via similarity (not zero-shot)
1129            "DISEASE".to_string(), // True zero-shot (no similarity)
1130        ];
1131
1132        let shift = LabelShift::from_type_sets(&train_types, &eval_types);
1133
1134        // Should detect similarity even without exact match
1135        assert!(shift.familiarity > 0.0, "Should have non-zero familiarity");
1136        // Note: String similarity may match PERSON->person and ORG->organization,
1137        // so true_zero_shot_types may only contain DISEASE, or all three if similarity
1138        // threshold is low. The important thing is familiarity > 0.
1139        assert!(
1140            !shift.true_zero_shot_types.is_empty(),
1141            "Should have at least 1 true zero-shot type"
1142        );
1143        assert!(shift.true_zero_shot_types.contains(&"DISEASE".to_string()));
1144    }
1145
1146    #[test]
1147    fn test_familiarity_inflation_detection() {
1148        let train_types = vec![
1149            "person".to_string(),
1150            "organization".to_string(),
1151            "location".to_string(),
1152        ];
1153
1154        let eval_types = vec![
1155            "PERSON".to_string(),
1156            "ORGANIZATION".to_string(),
1157            "LOCATION".to_string(),
1158        ];
1159
1160        let shift = LabelShift::from_type_sets(&train_types, &eval_types);
1161
1162        // High similarity should trigger high familiarity
1163        assert!(shift.familiarity > 0.5, "Should have high familiarity");
1164    }
1165
1166    #[test]
1167    fn test_label_shift_zero_shot_types() {
1168        let train_types = vec!["person".to_string()];
1169        let eval_types = vec![
1170            "person".to_string(),
1171            "disease".to_string(),
1172            "drug".to_string(),
1173        ];
1174
1175        let shift = LabelShift::from_type_sets(&train_types, &eval_types);
1176
1177        assert_eq!(shift.true_zero_shot_types.len(), 2);
1178        assert!(shift.true_zero_shot_types.contains(&"disease".to_string()));
1179        assert!(shift.true_zero_shot_types.contains(&"drug".to_string()));
1180    }
1181
1182    #[test]
1183    fn test_metric_value_clamping() {
1184        assert_eq!(MetricValue::new(0.5).get(), 0.5);
1185        assert_eq!(MetricValue::new(-0.5).get(), 0.0);
1186        assert_eq!(MetricValue::new(1.5).get(), 1.0);
1187    }
1188
1189    #[test]
1190    fn test_metric_value_try_new() {
1191        assert!(MetricValue::try_new(0.5).is_ok());
1192        assert!(MetricValue::try_new(-0.1).is_err());
1193        assert!(MetricValue::try_new(1.1).is_err());
1194    }
1195
1196    #[test]
1197    fn test_goal_check_result() {
1198        let mut result = GoalCheckResult::new();
1199        assert!(result.passed);
1200
1201        result.add_check("precision", GoalCheck::pass(0.8, 0.85));
1202        assert!(result.passed);
1203
1204        result.add_check("recall", GoalCheck::fail(0.9, 0.75));
1205        assert!(!result.passed);
1206
1207        assert_eq!(result.passed_count(), 1);
1208        assert_eq!(result.failed_count(), 1);
1209    }
1210
1211    #[test]
1212    fn test_metric_with_variance_from_samples() {
1213        let samples = vec![0.85, 0.87, 0.82, 0.88, 0.84];
1214        let m = MetricWithVariance::from_samples(&samples);
1215
1216        // Mean should be 0.852
1217        assert!((m.mean - 0.852).abs() < 0.001);
1218        assert_eq!(m.n, 5);
1219        assert!((m.min - 0.82).abs() < 0.001);
1220        assert!((m.max - 0.88).abs() < 0.001);
1221        assert!(m.std_dev > 0.0);
1222        assert!(m.ci_95 > 0.0);
1223    }
1224
1225    #[test]
1226    fn test_metric_with_variance_empty() {
1227        let m = MetricWithVariance::from_samples(&[]);
1228        assert_eq!(m.n, 0);
1229        assert_eq!(m.mean, 0.0);
1230        assert_eq!(m.format_with_ci(), "N/A");
1231    }
1232
1233    #[test]
1234    fn test_metric_with_variance_single() {
1235        let m = MetricWithVariance::from_samples(&[0.9]);
1236        assert!((m.mean - 0.9).abs() < 0.001);
1237        assert_eq!(m.std_dev, 0.0);
1238        assert_eq!(m.ci_95, 0.0);
1239        assert_eq!(m.n, 1);
1240    }
1241
1242    #[test]
1243    fn test_metric_with_variance_format() {
1244        let samples = vec![0.85, 0.87, 0.82, 0.88, 0.84];
1245        let m = MetricWithVariance::from_samples(&samples);
1246
1247        // Should format nicely
1248        let formatted = m.format_with_ci();
1249        assert!(formatted.contains("%"));
1250        assert!(formatted.contains("±"));
1251
1252        let range = m.format_with_range();
1253        assert!(range.contains("82.0%"));
1254        assert!(range.contains("88.0%"));
1255    }
1256
1257    // =========================================================================
1258    // Tests for DocumentScale (Bourgois & Poibeau 2025)
1259    // =========================================================================
1260
1261    #[test]
1262    fn test_document_scale_classification() {
1263        // Short documents (<2k tokens)
1264        assert_eq!(DocumentScale::from_tokens(500), DocumentScale::Short);
1265        assert_eq!(DocumentScale::from_tokens(2000), DocumentScale::Short);
1266
1267        // Medium documents (2k-10k tokens)
1268        assert_eq!(DocumentScale::from_tokens(2001), DocumentScale::Medium);
1269        assert_eq!(DocumentScale::from_tokens(5000), DocumentScale::Medium);
1270        assert_eq!(DocumentScale::from_tokens(10000), DocumentScale::Medium);
1271
1272        // Long documents (10k-50k tokens)
1273        assert_eq!(DocumentScale::from_tokens(10001), DocumentScale::Long);
1274        assert_eq!(DocumentScale::from_tokens(30000), DocumentScale::Long);
1275        assert_eq!(DocumentScale::from_tokens(50000), DocumentScale::Long);
1276
1277        // Book-scale documents (>50k tokens)
1278        assert_eq!(DocumentScale::from_tokens(50001), DocumentScale::BookScale);
1279        assert_eq!(DocumentScale::from_tokens(100000), DocumentScale::BookScale);
1280    }
1281
1282    #[test]
1283    fn test_document_scale_is_book_scale() {
1284        assert!(!DocumentScale::Short.is_book_scale());
1285        assert!(!DocumentScale::Medium.is_book_scale());
1286        assert!(!DocumentScale::Long.is_book_scale());
1287        assert!(DocumentScale::BookScale.is_book_scale());
1288    }
1289
1290    #[test]
1291    fn test_document_scale_metrics_reliability() {
1292        assert!(!DocumentScale::Short.metrics_may_be_unreliable());
1293        assert!(!DocumentScale::Medium.metrics_may_be_unreliable());
1294        assert!(DocumentScale::Long.metrics_may_be_unreliable());
1295        assert!(DocumentScale::BookScale.metrics_may_be_unreliable());
1296    }
1297
1298    #[test]
1299    fn test_document_scale_expected_degradation() {
1300        assert!((DocumentScale::Short.expected_degradation() - 0.0).abs() < 0.001);
1301        assert!((DocumentScale::Medium.expected_degradation() - 0.05).abs() < 0.001);
1302        assert!((DocumentScale::Long.expected_degradation() - 0.10).abs() < 0.001);
1303        assert!((DocumentScale::BookScale.expected_degradation() - 0.15).abs() < 0.001);
1304    }
1305
1306    #[test]
1307    fn test_document_scale_display() {
1308        assert!(DocumentScale::Short.to_string().contains("Short"));
1309        assert!(DocumentScale::BookScale.to_string().contains("Book-scale"));
1310    }
1311
1312    // =========================================================================
1313    // Tests for MetricDivergence
1314    // =========================================================================
1315
1316    #[test]
1317    fn test_metric_divergence_computation() {
1318        // Typical book-scale pattern: high MUC, lower B³, collapsed CEAF-e
1319        let divergence = MetricDivergence::from_scores(0.90, 0.65, 0.45);
1320
1321        assert!((divergence.muc_f1 - 0.90).abs() < 0.001);
1322        assert!((divergence.b3_f1 - 0.65).abs() < 0.001);
1323        assert!((divergence.ceaf_e_f1 - 0.45).abs() < 0.001);
1324
1325        // MUC-CEAF divergence should be 0.45
1326        assert!((divergence.muc_ceaf_divergence - 0.45).abs() < 0.001);
1327    }
1328
1329    #[test]
1330    fn test_metric_divergence_high_divergence_detection() {
1331        // High divergence (>0.20)
1332        let high = MetricDivergence::from_scores(0.90, 0.70, 0.50);
1333        assert!(high.has_high_divergence());
1334
1335        // Low divergence (<0.20)
1336        let low = MetricDivergence::from_scores(0.80, 0.75, 0.70);
1337        assert!(!low.has_high_divergence());
1338    }
1339
1340    #[test]
1341    fn test_metric_divergence_muc_inflation() {
1342        // MUC inflated (MUC >> CEAF-e by >0.15)
1343        let inflated = MetricDivergence::from_scores(0.90, 0.70, 0.50);
1344        assert!(inflated.muc_likely_inflated());
1345
1346        // MUC not inflated
1347        let not_inflated = MetricDivergence::from_scores(0.80, 0.75, 0.70);
1348        assert!(!not_inflated.muc_likely_inflated());
1349    }
1350
1351    #[test]
1352    fn test_metric_divergence_ceaf_collapse() {
1353        // CEAF-e collapsed (much lower than others)
1354        let collapsed = MetricDivergence::from_scores(0.90, 0.70, 0.40);
1355        assert!(collapsed.ceaf_likely_collapsed());
1356
1357        // CEAF-e not collapsed
1358        let not_collapsed = MetricDivergence::from_scores(0.80, 0.75, 0.70);
1359        assert!(!not_collapsed.ceaf_likely_collapsed());
1360    }
1361
1362    #[test]
1363    fn test_metric_divergence_recommendation() {
1364        // When both MUC inflated and CEAF-e collapsed -> recommend B³
1365        let both_bad = MetricDivergence::from_scores(0.90, 0.65, 0.40);
1366        assert!(both_bad.most_reliable_metric().contains("B³"));
1367
1368        // When metrics agree -> recommend CoNLL F1
1369        let agree = MetricDivergence::from_scores(0.75, 0.73, 0.71);
1370        assert!(agree.most_reliable_metric().contains("CoNLL"));
1371    }
1372
1373    // =========================================================================
1374    // Tests for CorefDocStats (Entity Spread)
1375    // =========================================================================
1376
1377    #[test]
1378    fn test_coref_doc_stats_default() {
1379        let stats = CorefDocStats::default();
1380        assert_eq!(stats.chain_count, 0);
1381        assert_eq!(stats.mention_count, 0);
1382        assert_eq!(stats.avg_entity_spread, 0);
1383        assert_eq!(stats.max_entity_spread, 0);
1384    }
1385
1386    #[test]
1387    fn test_coref_doc_stats_scale_classification() {
1388        let mut stats = CorefDocStats {
1389            doc_length: 1000,
1390            ..Default::default()
1391        };
1392        assert_eq!(stats.scale_classification(), DocumentScale::Short);
1393
1394        stats.doc_length = 5000;
1395        assert_eq!(stats.scale_classification(), DocumentScale::Medium);
1396
1397        stats.doc_length = 30000;
1398        assert_eq!(stats.scale_classification(), DocumentScale::Long);
1399
1400        stats.doc_length = 100000;
1401        assert_eq!(stats.scale_classification(), DocumentScale::BookScale);
1402    }
1403
1404    #[test]
1405    fn test_coref_doc_stats_book_scale_spread() {
1406        let mut stats = CorefDocStats {
1407            avg_entity_spread: 1000,
1408            max_entity_spread: 5000,
1409            ..Default::default()
1410        };
1411
1412        // Low spread - not book-scale
1413        assert!(!stats.has_book_scale_spread());
1414
1415        // High avg spread - book-scale
1416        stats.avg_entity_spread = 6000;
1417        stats.max_entity_spread = 10000;
1418        assert!(stats.has_book_scale_spread());
1419
1420        // High max spread - book-scale
1421        stats.avg_entity_spread = 2000;
1422        stats.max_entity_spread = 25000;
1423        assert!(stats.has_book_scale_spread());
1424    }
1425
1426    #[test]
1427    fn test_coref_doc_stats_format_summary() {
1428        let stats = CorefDocStats {
1429            chain_count: 159,
1430            mention_count: 13178,
1431            avg_chain_length: 82.9,
1432            avg_entity_spread: 17529,
1433            max_entity_spread: 115369,
1434            ..Default::default()
1435        };
1436
1437        let summary = stats.format_summary();
1438        assert!(summary.contains("159"));
1439        assert!(summary.contains("13178"));
1440        assert!(summary.contains("17529"));
1441        assert!(summary.contains("115369"));
1442    }
1443
1444    #[test]
1445    fn test_coref_doc_stats_from_chains() {
1446        use crate::eval::coref::{CorefChain, Mention};
1447
1448        // Create test chains
1449        let chains = vec![
1450            CorefChain::new(vec![
1451                Mention::new("John", 0, 4),
1452                Mention::new("he", 20, 22),
1453                Mention::new("him", 50, 53),
1454            ]),
1455            CorefChain::new(vec![
1456                Mention::new("Mary", 5, 9),
1457                Mention::new("she", 30, 33),
1458            ]),
1459            // Singleton
1460            CorefChain::new(vec![Mention::new("London", 60, 66)]),
1461        ];
1462
1463        let stats = CorefDocStats::from_chains(&chains);
1464
1465        assert_eq!(stats.chain_count, 3);
1466        assert_eq!(stats.mention_count, 6);
1467        assert!((stats.avg_chain_length - 2.0).abs() < 0.01);
1468        assert_eq!(stats.max_chain_length, 3);
1469
1470        // Entity spread: John chain spans 0-53 = 53, Mary chain spans 5-33 = 28
1471        assert!(stats.avg_entity_spread > 0);
1472        assert!(stats.max_entity_spread >= 53);
1473
1474        // Singleton ratio: 1/3 = 0.333
1475        assert!((stats.singleton_ratio - 0.333).abs() < 0.01);
1476    }
1477
1478    #[test]
1479    fn test_coref_doc_stats_mention_type_ratios() {
1480        use crate::eval::coref::{CorefChain, Mention};
1481
1482        // Create chains with mixed mention types
1483        let chains = vec![
1484            CorefChain::new(vec![
1485                Mention::new("John", 0, 4),  // Proper (capitalized)
1486                Mention::new("he", 10, 12),  // Pronoun
1487                Mention::new("him", 20, 23), // Pronoun
1488            ]),
1489            CorefChain::new(vec![
1490                Mention::new("Mary", 30, 34), // Proper
1491                Mention::new("she", 40, 43),  // Pronoun
1492            ]),
1493        ];
1494
1495        let stats = CorefDocStats::from_chains(&chains);
1496
1497        // 3 pronouns (he, him, she), 2 proper (John, Mary)
1498        // pronoun_ratio = 3/5 = 0.6, proper_ratio = 2/5 = 0.4
1499        assert!(stats.pronoun_ratio > 0.5, "Should have majority pronouns");
1500        assert!(stats.proper_ratio > 0.3, "Should have some proper nouns");
1501        assert_eq!(stats.mention_count, 5);
1502    }
1503}