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}