Skip to main content

anno_eval/eval/
book_scale.rs

1//! Book-Scale Coreference Diagnostics
2//!
3//! This module provides enhanced diagnostics for evaluating coreference
4//! resolution at book scale, incorporating insights from BOOKCOREF
5//! (Martinelli et al., 2025) and "How to Evaluate Coreference in Literary
6//! Texts?" (Duron-Tejedor et al., 2023; arXiv:2401.00238).
7//!
8//! # The Book-Scale Challenge
9//!
10//! Coreference systems optimized for short documents can show different behavior
11//! at book scale. In particular, common metrics can diverge in what they “reward”
12//! (e.g., favoring long chains vs penalizing boundary errors).
13//!
14//! # Key Insights
15//!
16//! 1. **Metric divergence**: different metrics can disagree substantially at scale.
17//! 2. **Windowed vs full**: long-range dependencies can be masked by windowed evaluation.
18//! 3. **Long chains dominate**: a few long chains can dominate aggregate scores.
19//!
20//! # This Module Provides
21//!
22//! - `BookScaleAnalysis`: Complete diagnostic report
23//! - `PerBookBreakdown`: Individual book performance (like Table 3)
24//! - `ChainLengthStratification`: Performance by chain length
25//! - `WindowedVsFullComparison`: Diagnose long-range dependency issues
26//! - `MetricReliabilityAssessment`: Which metrics to trust at this scale
27//!
28//! # Example
29//!
30//! ```rust,ignore
31//! use anno_eval::eval::book_scale::{BookScaleAnalyzer, BookScaleConfig};
32//! use anno::metrics::coref::CorefChain;
33//!
34//! let analyzer = BookScaleAnalyzer::new(BookScaleConfig::default());
35//!
36//! let analysis = analyzer.analyze(
37//!     &predicted_chains,
38//!     &gold_chains,
39//!     document_length,
40//! );
41//!
42//! if analysis.has_scale_issues() {
43//!     println!("Book-scale issues detected:");
44//!     println!("{}", analysis.diagnostic_report());
45//! }
46//! ```
47
48use super::coref::{CorefChain, Mention};
49use super::coref_metrics::{b_cubed_score, ceaf_e_score, ceaf_m_score, lea_score, muc_score};
50use super::types::{CorefDocStats, DocumentScale, MetricDivergence};
51use serde::{Deserialize, Serialize};
52
53// =============================================================================
54// Serializable Score Types (self-contained for book_scale module)
55// =============================================================================
56
57/// Precision/Recall/F1 scores (serializable).
58#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
59pub struct Scores {
60    /// Precision
61    pub precision: f64,
62    /// Recall
63    pub recall: f64,
64    /// F1 score
65    pub f1: f64,
66}
67
68impl Scores {
69    /// Create from tuple (p, r, f1).
70    pub fn from_tuple((precision, recall, f1): (f64, f64, f64)) -> Self {
71        Self {
72            precision,
73            recall,
74            f1,
75        }
76    }
77}
78
79// =============================================================================
80// Configuration
81// =============================================================================
82
83/// Configuration for book-scale analysis.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct BookScaleConfig {
86    /// Window size for windowed evaluation (tokens)
87    pub window_size: usize,
88    /// Overlap between windows
89    pub window_overlap: usize,
90    /// Threshold for "long" chains (mentions)
91    pub long_chain_threshold: usize,
92    /// Threshold for "short" chains (mentions)
93    pub short_chain_threshold: usize,
94    /// MUC-CEAF divergence threshold for scale issues
95    pub divergence_threshold: f64,
96    /// Performance drop threshold (windowed → full) for concerno
97    pub performance_drop_threshold: f64,
98}
99
100impl Default for BookScaleConfig {
101    fn default() -> Self {
102        Self {
103            window_size: 1500,
104            window_overlap: 200,
105            long_chain_threshold: 10,         // >10 mentions = long chain
106            short_chain_threshold: 2,         // 2-10 = short, 1 = singleton
107            divergence_threshold: 0.30,       // 30 F1 point gap
108            performance_drop_threshold: 0.15, // 15 F1 point drop
109        }
110    }
111}
112
113// =============================================================================
114// Book-Scale Analysis
115// =============================================================================
116
117/// Coreference evaluation scores (serializable version).
118///
119/// This is a simplified, serializable version of the full CorefEvaluation.
120#[derive(Debug, Clone, Default, Serialize, Deserialize)]
121pub struct CorefEvalScores {
122    /// MUC scores
123    pub muc: Scores,
124    /// B³ scores
125    pub b_cubed: Scores,
126    /// CEAF-e scores
127    pub ceaf_e: Scores,
128    /// CEAF-m scores
129    pub ceaf_m: Scores,
130    /// LEA scores
131    pub lea: Scores,
132    /// CoNLL F1 (average of MUC, B³, CEAF-e)
133    pub conll_f1: f64,
134}
135
136/// Complete book-scale analysis results.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct BookScaleAnalysis {
139    /// Full document evaluation
140    pub full_doc_eval: CorefEvalScores,
141    /// Windowed evaluation (average over windows)
142    pub windowed_eval: Option<WindowedEvaluation>,
143    /// Per-chain-length stratified evaluation
144    pub stratified: StratifiedEvaluation,
145    /// Document statistics
146    pub doc_stats: CorefDocStats,
147    /// Metric reliability assessment
148    pub reliability: MetricReliability,
149    /// Overall scale classification
150    pub scale: DocumentScale,
151    /// Diagnostic flags
152    pub diagnostics: BookScaleDiagnostics,
153}
154
155impl BookScaleAnalysis {
156    /// Check if document has book-scale issues.
157    pub fn has_scale_issues(&self) -> bool {
158        self.diagnostics.has_issues()
159    }
160
161    /// Generate a diagnostic report.
162    pub fn diagnostic_report(&self) -> String {
163        let mut report = String::new();
164
165        report.push_str("=== Book-Scale Coreference Analysis ===\n\n");
166        report.push_str(&format!("Document Scale: {}\n", self.scale));
167        report.push_str(&format!(
168            "Document Length: {} chars ({} mentions in {} chains)\n\n",
169            self.doc_stats.doc_length, self.doc_stats.mention_count, self.doc_stats.chain_count
170        ));
171
172        // Metrics summary
173        report.push_str("Full-Document Metrics:\n");
174        report.push_str(&format!(
175            "  MUC:    {:.1}%\n",
176            self.full_doc_eval.muc.f1 * 100.0
177        ));
178        report.push_str(&format!(
179            "  B³:     {:.1}%\n",
180            self.full_doc_eval.b_cubed.f1 * 100.0
181        ));
182        report.push_str(&format!(
183            "  CEAF-e: {:.1}%\n",
184            self.full_doc_eval.ceaf_e.f1 * 100.0
185        ));
186        report.push_str(&format!(
187            "  CoNLL:  {:.1}%\n\n",
188            self.full_doc_eval.conll_f1 * 100.0
189        ));
190
191        // Windowed comparison
192        if let Some(ref windowed) = self.windowed_eval {
193            report.push_str("Windowed vs Full-Document Comparison:\n");
194            report.push_str(&format!(
195                "  Windowed CoNLL:  {:.1}%\n",
196                windowed.avg_conll_f1 * 100.0
197            ));
198            report.push_str(&format!(
199                "  Full-Doc CoNLL:  {:.1}%\n",
200                self.full_doc_eval.conll_f1 * 100.0
201            ));
202            report.push_str(&format!(
203                "  Performance Drop: {:.1} F1 points\n\n",
204                windowed.performance_drop * 100.0
205            ));
206        }
207
208        // Stratified evaluation
209        report.push_str("Chain-Length Stratified Evaluation:\n");
210        report.push_str(&format!(
211            "  Long chains (>10):  {:.1}% F1 ({} chains)\n",
212            self.stratified.long_chains.f1 * 100.0,
213            self.stratified.long_chain_count
214        ));
215        report.push_str(&format!(
216            "  Short chains (2-10): {:.1}% F1 ({} chains)\n",
217            self.stratified.short_chains.f1 * 100.0,
218            self.stratified.short_chain_count
219        ));
220        report.push_str(&format!(
221            "  Singletons (1):     {:.1}% F1 ({} chains)\n\n",
222            self.stratified.singletons.f1 * 100.0,
223            self.stratified.singleton_count
224        ));
225
226        // Reliability assessment
227        report.push_str("Metric Reliability:\n");
228        report.push_str(&format!(
229            "  MUC:    {} ({})\n",
230            self.reliability.muc_reliability, self.reliability.muc_note
231        ));
232        report.push_str(&format!(
233            "  B³:     {} ({})\n",
234            self.reliability.b_cubed_reliability, self.reliability.b_cubed_note
235        ));
236        report.push_str(&format!(
237            "  CEAF-e: {} ({})\n",
238            self.reliability.ceaf_e_reliability, self.reliability.ceaf_e_note
239        ));
240        report.push_str(&format!(
241            "  LEA:    {} ({})\n\n",
242            self.reliability.lea_reliability, self.reliability.lea_note
243        ));
244
245        // Diagnostics
246        if self.has_scale_issues() {
247            report.push_str("⚠️ ISSUES DETECTED:\n");
248            if self.diagnostics.high_metric_divergence {
249                report
250                    .push_str("  • High metric divergence - MUC and CEAF disagree significantly\n");
251            }
252            if self.diagnostics.large_performance_drop {
253                report.push_str(
254                    "  • Large windowed→full performance drop - long-range dependencies failing\n",
255                );
256            }
257            if self.diagnostics.long_chain_dominance {
258                report.push_str("  • Long chains dominate - main characters skewing metrics\n");
259            }
260            if self.diagnostics.singleton_neglect {
261                report.push_str("  • Singleton neglect - minor entities being ignored\n");
262            }
263            report.push_str("\nRECOMMENDATIONS:\n");
264            for rec in &self.diagnostics.recommendations {
265                report.push_str(&format!("  → {}\n", rec));
266            }
267        } else {
268            report.push_str("✓ No significant scale issues detected.\n");
269        }
270
271        report
272    }
273}
274
275/// Windowed evaluation results.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct WindowedEvaluation {
278    /// Number of windows
279    pub num_windows: usize,
280    /// Window size used
281    pub window_size: usize,
282    /// Average CoNLL F1 across windows
283    pub avg_conll_f1: f64,
284    /// Standard deviation of CoNLL F1
285    pub std_conll_f1: f64,
286    /// Performance drop (windowed - full_doc)
287    pub performance_drop: f64,
288    /// Per-window evaluations
289    pub window_evals: Vec<CorefEvalScores>,
290}
291
292/// Stratified evaluation by chain length.
293#[derive(Debug, Clone, Serialize, Deserialize, Default)]
294pub struct StratifiedEvaluation {
295    /// Evaluation on long chains (>threshold mentions)
296    pub long_chains: Scores,
297    /// Evaluation on short chains (2-threshold mentions)
298    pub short_chains: Scores,
299    /// Evaluation on singletons
300    pub singletons: Scores,
301    /// Count of long chains
302    pub long_chain_count: usize,
303    /// Count of short chains
304    pub short_chain_count: usize,
305    /// Count of singletons
306    pub singleton_count: usize,
307}
308
309/// Reliability assessment for each coreference metric at book scale.
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct MetricReliability {
312    /// MUC metric reliability level
313    pub muc_reliability: ReliabilityLevel,
314    /// Notes about MUC reliability
315    pub muc_note: String,
316    /// B-cubed metric reliability level
317    pub b_cubed_reliability: ReliabilityLevel,
318    /// Notes about B-cubed reliability
319    pub b_cubed_note: String,
320    /// CEAF-e metric reliability level
321    pub ceaf_e_reliability: ReliabilityLevel,
322    /// Notes about CEAF-e reliability
323    pub ceaf_e_note: String,
324    /// LEA metric reliability level
325    pub lea_reliability: ReliabilityLevel,
326    /// Notes about LEA reliability
327    pub lea_note: String,
328}
329
330impl Default for MetricReliability {
331    fn default() -> Self {
332        Self {
333            muc_reliability: ReliabilityLevel::Medium,
334            muc_note: "May be inflated at scale".to_string(),
335            b_cubed_reliability: ReliabilityLevel::Medium,
336            b_cubed_note: "Moderate reliability".to_string(),
337            ceaf_e_reliability: ReliabilityLevel::Medium,
338            ceaf_e_note: "May collapse at scale".to_string(),
339            lea_reliability: ReliabilityLevel::High,
340            lea_note: "Most stable across scales".to_string(),
341        }
342    }
343}
344
345/// Reliability level for a metric at book scale.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
347pub enum ReliabilityLevel {
348    /// High reliability - metric is stable at scale
349    High,
350    /// Medium reliability - some degradation expected
351    Medium,
352    /// Low reliability - significant variance at scale
353    Low,
354    /// Unreliable - metric not recommended for book-length texts
355    Unreliable,
356}
357
358impl std::fmt::Display for ReliabilityLevel {
359    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360        match self {
361            ReliabilityLevel::High => write!(f, "HIGH"),
362            ReliabilityLevel::Medium => write!(f, "MEDIUM"),
363            ReliabilityLevel::Low => write!(f, "LOW"),
364            ReliabilityLevel::Unreliable => write!(f, "UNRELIABLE"),
365        }
366    }
367}
368
369/// Diagnostic flags for book-scale issues.
370#[derive(Debug, Clone, Default, Serialize, Deserialize)]
371pub struct BookScaleDiagnostics {
372    /// MUC-CEAF divergence exceeds threshold
373    pub high_metric_divergence: bool,
374    /// Windowed→Full performance drop exceeds threshold
375    pub large_performance_drop: bool,
376    /// Long chains dominate the evaluation
377    pub long_chain_dominance: bool,
378    /// Singletons have very low performance
379    pub singleton_neglect: bool,
380    /// Recommendations for improvement
381    pub recommendations: Vec<String>,
382}
383
384impl BookScaleDiagnostics {
385    /// Check if any issues were detected.
386    pub fn has_issues(&self) -> bool {
387        self.high_metric_divergence
388            || self.large_performance_drop
389            || self.long_chain_dominance
390            || self.singleton_neglect
391    }
392}
393
394// =============================================================================
395// Analyzer
396// =============================================================================
397
398/// Book-scale coreference analyzer.
399pub struct BookScaleAnalyzer {
400    config: BookScaleConfig,
401}
402
403impl Default for BookScaleAnalyzer {
404    fn default() -> Self {
405        Self::new(BookScaleConfig::default())
406    }
407}
408
409impl BookScaleAnalyzer {
410    /// Create a new analyzer with configuration.
411    pub fn new(config: BookScaleConfig) -> Self {
412        Self { config }
413    }
414
415    /// Perform complete book-scale analysis.
416    pub fn analyze(
417        &self,
418        predicted: &[CorefChain],
419        gold: &[CorefChain],
420        doc_length: usize,
421    ) -> BookScaleAnalysis {
422        // Compute full-document evaluation
423        let full_doc_eval = self.evaluate_chains(predicted, gold);
424
425        // Compute document statistics
426        let mut doc_stats = CorefDocStats::from_chains(gold);
427        doc_stats.doc_length = doc_length;
428
429        // Determine scale
430        let scale = doc_stats.scale_classification();
431
432        // Compute windowed evaluation (if document is long enough)
433        let windowed_eval = if doc_length > self.config.window_size * 2 {
434            Some(self.compute_windowed_eval(predicted, gold, doc_length))
435        } else {
436            None
437        };
438
439        // Compute stratified evaluation
440        let stratified = self.compute_stratified_eval(predicted, gold);
441
442        // Assess metric reliability
443        let reliability = self.assess_reliability(&full_doc_eval, &stratified, scale);
444
445        // Generate diagnostics
446        let diagnostics =
447            self.generate_diagnostics(&full_doc_eval, windowed_eval.as_ref(), &stratified, scale);
448
449        BookScaleAnalysis {
450            full_doc_eval,
451            windowed_eval,
452            stratified,
453            doc_stats,
454            reliability,
455            scale,
456            diagnostics,
457        }
458    }
459
460    /// Evaluate chains using standard metrics.
461    fn evaluate_chains(&self, predicted: &[CorefChain], gold: &[CorefChain]) -> CorefEvalScores {
462        let muc = Scores::from_tuple(muc_score(predicted, gold));
463        let b_cubed = Scores::from_tuple(b_cubed_score(predicted, gold));
464        let ceaf_e = Scores::from_tuple(ceaf_e_score(predicted, gold));
465        let ceaf_m = Scores::from_tuple(ceaf_m_score(predicted, gold));
466        let lea = Scores::from_tuple(lea_score(predicted, gold));
467
468        let conll_f1 = (muc.f1 + b_cubed.f1 + ceaf_e.f1) / 3.0;
469
470        CorefEvalScores {
471            muc,
472            b_cubed,
473            ceaf_e,
474            ceaf_m,
475            lea,
476            conll_f1,
477        }
478    }
479
480    /// Compute windowed evaluation.
481    fn compute_windowed_eval(
482        &self,
483        predicted: &[CorefChain],
484        gold: &[CorefChain],
485        doc_length: usize,
486    ) -> WindowedEvaluation {
487        let step = self
488            .config
489            .window_size
490            .saturating_sub(self.config.window_overlap);
491        let mut window_evals = Vec::new();
492
493        let mut offset = 0;
494        while offset < doc_length {
495            let window_end = (offset + self.config.window_size).min(doc_length);
496
497            // Filter chains to this window
498            let pred_window = self.filter_to_window(predicted, offset, window_end);
499            let gold_window = self.filter_to_window(gold, offset, window_end);
500
501            if !pred_window.is_empty() || !gold_window.is_empty() {
502                let eval = self.evaluate_chains(&pred_window, &gold_window);
503                window_evals.push(eval);
504            }
505
506            if window_end >= doc_length {
507                break;
508            }
509            offset += step.max(1);
510        }
511
512        // Compute statistics
513        let conll_scores: Vec<f64> = window_evals.iter().map(|e| e.conll_f1).collect();
514        let avg_conll_f1 = if !conll_scores.is_empty() {
515            conll_scores.iter().sum::<f64>() / conll_scores.len() as f64
516        } else {
517            0.0
518        };
519
520        let std_conll_f1 = if conll_scores.len() > 1 {
521            let variance = conll_scores
522                .iter()
523                .map(|x| (x - avg_conll_f1).powi(2))
524                .sum::<f64>()
525                / (conll_scores.len() - 1) as f64;
526            variance.sqrt()
527        } else {
528            0.0
529        };
530
531        // Performance drop (positive = windowed is better)
532        let full_doc_eval = self.evaluate_chains(predicted, gold);
533        let performance_drop = avg_conll_f1 - full_doc_eval.conll_f1;
534
535        WindowedEvaluation {
536            num_windows: window_evals.len(),
537            window_size: self.config.window_size,
538            avg_conll_f1,
539            std_conll_f1,
540            performance_drop,
541            window_evals,
542        }
543    }
544
545    /// Filter chains to a specific window.
546    fn filter_to_window(&self, chains: &[CorefChain], start: usize, end: usize) -> Vec<CorefChain> {
547        chains
548            .iter()
549            .filter_map(|chain| {
550                let filtered_mentions: Vec<Mention> = chain
551                    .mentions
552                    .iter()
553                    .filter(|m| m.start >= start && m.end <= end)
554                    .cloned()
555                    .collect();
556
557                if filtered_mentions.is_empty() {
558                    None
559                } else {
560                    let mut new_chain = CorefChain::new(filtered_mentions);
561                    new_chain.cluster_id = chain.cluster_id;
562                    new_chain.entity_type = chain.entity_type.clone();
563                    Some(new_chain)
564                }
565            })
566            .collect()
567    }
568
569    /// Compute stratified evaluation by chain length.
570    fn compute_stratified_eval(
571        &self,
572        predicted: &[CorefChain],
573        gold: &[CorefChain],
574    ) -> StratifiedEvaluation {
575        let (pred_long, pred_short, pred_singleton) = self.stratify_chains(predicted);
576        let (gold_long, gold_short, gold_singleton) = self.stratify_chains(gold);
577
578        let long_chains = if !pred_long.is_empty() || !gold_long.is_empty() {
579            Scores::from_tuple(muc_score(&pred_long, &gold_long))
580        } else {
581            Scores::default()
582        };
583
584        let short_chains = if !pred_short.is_empty() || !gold_short.is_empty() {
585            Scores::from_tuple(muc_score(&pred_short, &gold_short))
586        } else {
587            Scores::default()
588        };
589
590        let singletons = if !pred_singleton.is_empty() || !gold_singleton.is_empty() {
591            // Singletons need different handling - use B³ or exact match
592            Scores::from_tuple(b_cubed_score(&pred_singleton, &gold_singleton))
593        } else {
594            Scores::default()
595        };
596
597        StratifiedEvaluation {
598            long_chains,
599            short_chains,
600            singletons,
601            long_chain_count: gold_long.len(),
602            short_chain_count: gold_short.len(),
603            singleton_count: gold_singleton.len(),
604        }
605    }
606
607    /// Stratify chains by length.
608    fn stratify_chains(
609        &self,
610        chains: &[CorefChain],
611    ) -> (Vec<CorefChain>, Vec<CorefChain>, Vec<CorefChain>) {
612        let mut long = Vec::new();
613        let mut short = Vec::new();
614        let mut singleton = Vec::new();
615
616        for chain in chains {
617            let len = chain.len();
618            if len > self.config.long_chain_threshold {
619                long.push(chain.clone());
620            } else if len >= self.config.short_chain_threshold {
621                short.push(chain.clone());
622            } else {
623                singleton.push(chain.clone());
624            }
625        }
626
627        (long, short, singleton)
628    }
629
630    /// Assess metric reliability based on document characteristics.
631    fn assess_reliability(
632        &self,
633        eval: &CorefEvalScores,
634        _stratified: &StratifiedEvaluation,
635        scale: DocumentScale,
636    ) -> MetricReliability {
637        let divergence =
638            MetricDivergence::from_scores(eval.muc.f1, eval.b_cubed.f1, eval.ceaf_e.f1);
639
640        // MUC reliability
641        let (muc_rel, muc_note) = if divergence.muc_ceaf_divergence > 0.40 {
642            (
643                ReliabilityLevel::Low,
644                "Severely inflated due to long chains".to_string(),
645            )
646        } else if divergence.muc_ceaf_divergence > 0.25 {
647            (
648                ReliabilityLevel::Medium,
649                "May be inflated at this scale".to_string(),
650            )
651        } else {
652            (ReliabilityLevel::High, "Reliable at this scale".to_string())
653        };
654
655        // CEAF-e reliability
656        let (ceaf_rel, ceaf_note) = match scale {
657            DocumentScale::BookScale => (
658                ReliabilityLevel::Low,
659                "Known to collapse at book scale".to_string(),
660            ),
661            DocumentScale::Long => (
662                ReliabilityLevel::Medium,
663                "May underestimate at this length".to_string(),
664            ),
665            _ => (ReliabilityLevel::High, "Reliable at this scale".to_string()),
666        };
667
668        // B³ reliability
669        let (b3_rel, b3_note) = if divergence.muc_b3_divergence > 0.30 {
670            (
671                ReliabilityLevel::Medium,
672                "Moderate divergence from MUC".to_string(),
673            )
674        } else {
675            (ReliabilityLevel::High, "Stable metric".to_string())
676        };
677
678        // LEA is generally most stable
679        let (lea_rel, lea_note) = (
680            ReliabilityLevel::High,
681            "Most stable across document scales".to_string(),
682        );
683
684        MetricReliability {
685            muc_reliability: muc_rel,
686            muc_note,
687            b_cubed_reliability: b3_rel,
688            b_cubed_note: b3_note,
689            ceaf_e_reliability: ceaf_rel,
690            ceaf_e_note: ceaf_note,
691            lea_reliability: lea_rel,
692            lea_note,
693        }
694    }
695
696    /// Generate diagnostic flags and recommendations.
697    fn generate_diagnostics(
698        &self,
699        eval: &CorefEvalScores,
700        windowed: Option<&WindowedEvaluation>,
701        stratified: &StratifiedEvaluation,
702        scale: DocumentScale,
703    ) -> BookScaleDiagnostics {
704        let mut diagnostics = BookScaleDiagnostics::default();
705
706        // Check metric divergence
707        let divergence = (eval.muc.f1 - eval.ceaf_e.f1).abs();
708        if divergence > self.config.divergence_threshold {
709            diagnostics.high_metric_divergence = true;
710            diagnostics
711                .recommendations
712                .push("Use LEA or stratified metrics instead of CoNLL F1".to_string());
713        }
714
715        // Check performance drop
716        if let Some(w) = windowed {
717            if w.performance_drop > self.config.performance_drop_threshold {
718                diagnostics.large_performance_drop = true;
719                diagnostics.recommendations.push(
720                    "Consider incremental/streaming coref approach (Longdoc-style)".to_string(),
721                );
722            }
723        }
724
725        // Check long chain dominance
726        let total_chains =
727            stratified.long_chain_count + stratified.short_chain_count + stratified.singleton_count;
728        if total_chains > 0 {
729            let _long_chain_ratio = stratified.long_chain_count as f64 / total_chains as f64;
730            // Even if few long chains, they might dominate mentions
731            if stratified.long_chains.f1 > stratified.short_chains.f1 + 0.20 {
732                diagnostics.long_chain_dominance = true;
733                diagnostics
734                    .recommendations
735                    .push("Report per-chain-length metrics separately".to_string());
736            }
737        }
738
739        // Check singleton neglect
740        if stratified.singleton_count > 0 && stratified.singletons.f1 < 0.50 {
741            diagnostics.singleton_neglect = true;
742            diagnostics
743                .recommendations
744                .push("System may be ignoring minor entities".to_string());
745        }
746
747        // Scale-specific recommendations
748        match scale {
749            DocumentScale::BookScale => {
750                diagnostics
751                    .recommendations
752                    .push("Consider BOOKCOREF-style windowed+grouped evaluation".to_string());
753            }
754            DocumentScale::Long => {
755                diagnostics
756                    .recommendations
757                    .push("Monitor for metric divergence as length increases".to_string());
758            }
759            _ => {}
760        }
761
762        diagnostics
763    }
764}
765
766// =============================================================================
767// Per-Book Breakdown (like BOOKCOREF Table 3)
768// =============================================================================
769
770/// Per-book evaluation breakdown.
771#[derive(Debug, Clone, Serialize, Deserialize)]
772pub struct PerBookEvaluation {
773    /// Book identifier
774    pub book_id: String,
775    /// Book title (optional)
776    pub title: Option<String>,
777    /// Author (optional)
778    pub author: Option<String>,
779    /// Token count
780    pub token_count: usize,
781    /// Full-document evaluation
782    pub full_doc: CorefEvalScores,
783    /// Windowed evaluation
784    pub windowed: Option<WindowedEvaluation>,
785    /// Scale classification
786    pub scale: DocumentScale,
787}
788
789/// Multi-book evaluation report.
790#[derive(Debug, Clone, Serialize, Deserialize)]
791pub struct MultiBookReport {
792    /// Individual book evaluations
793    pub books: Vec<PerBookEvaluation>,
794    /// Aggregate statistics
795    pub aggregate: AggregateStats,
796}
797
798/// Aggregate statistics across multiple books.
799#[derive(Debug, Clone, Serialize, Deserialize)]
800pub struct AggregateStats {
801    /// Total books
802    pub total_books: usize,
803    /// Mean CoNLL F1 (full doc)
804    pub mean_conll_f1: f64,
805    /// Std dev of CoNLL F1
806    pub std_conll_f1: f64,
807    /// Mean performance drop (windowed → full)
808    pub mean_performance_drop: f64,
809    /// Books with scale issues
810    pub books_with_issues: usize,
811}
812
813impl MultiBookReport {
814    /// Generate from per-book evaluations.
815    pub fn from_books(books: Vec<PerBookEvaluation>) -> Self {
816        let total_books = books.len();
817
818        let conll_scores: Vec<f64> = books.iter().map(|b| b.full_doc.conll_f1).collect();
819
820        let mean_conll_f1 = if !conll_scores.is_empty() {
821            conll_scores.iter().sum::<f64>() / conll_scores.len() as f64
822        } else {
823            0.0
824        };
825
826        let std_conll_f1 = if conll_scores.len() > 1 {
827            let variance = conll_scores
828                .iter()
829                .map(|x| (x - mean_conll_f1).powi(2))
830                .sum::<f64>()
831                / (conll_scores.len() - 1) as f64;
832            variance.sqrt()
833        } else {
834            0.0
835        };
836
837        let performance_drops: Vec<f64> = books
838            .iter()
839            .filter_map(|b| b.windowed.as_ref().map(|w| w.performance_drop))
840            .collect();
841
842        let mean_performance_drop = if !performance_drops.is_empty() {
843            performance_drops.iter().sum::<f64>() / performance_drops.len() as f64
844        } else {
845            0.0
846        };
847
848        let books_with_issues = books
849            .iter()
850            .filter(|b| {
851                let divergence = (b.full_doc.muc.f1 - b.full_doc.ceaf_e.f1).abs();
852                divergence > 0.30
853                    || b.windowed
854                        .as_ref()
855                        .map(|w| w.performance_drop > 0.15)
856                        .unwrap_or(false)
857            })
858            .count();
859
860        let aggregate = AggregateStats {
861            total_books,
862            mean_conll_f1,
863            std_conll_f1,
864            mean_performance_drop,
865            books_with_issues,
866        };
867
868        Self { books, aggregate }
869    }
870
871    /// Format as table (similar to BOOKCOREF Table 3).
872    pub fn format_table(&self) -> String {
873        let mut table = String::new();
874
875        // Header
876        table.push_str(&format!(
877            "{:<30} {:>8} {:>8} {:>8} {:>8} {:>8}\n",
878            "Book", "Tokens", "MUC", "B³", "CEAF", "CoNLL"
879        ));
880        table.push_str(&format!("{}\n", "-".repeat(78)));
881
882        // Per-book rows
883        for book in &self.books {
884            let title = book
885                .title
886                .as_deref()
887                .unwrap_or(&book.book_id)
888                .chars()
889                .take(28)
890                .collect::<String>();
891
892            table.push_str(&format!(
893                "{:<30} {:>8} {:>7.1}% {:>7.1}% {:>7.1}% {:>7.1}%\n",
894                title,
895                book.token_count,
896                book.full_doc.muc.f1 * 100.0,
897                book.full_doc.b_cubed.f1 * 100.0,
898                book.full_doc.ceaf_e.f1 * 100.0,
899                book.full_doc.conll_f1 * 100.0,
900            ));
901        }
902
903        // Separator
904        table.push_str(&format!("{}\n", "-".repeat(78)));
905
906        // Aggregate
907        table.push_str(&format!(
908            "{:<30} {:>8} {:>7.1}% ±{:.1}\n",
909            "MEAN",
910            "",
911            self.aggregate.mean_conll_f1 * 100.0,
912            self.aggregate.std_conll_f1 * 100.0
913        ));
914
915        table
916    }
917}
918
919// =============================================================================
920// Tests
921// =============================================================================
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    fn make_chain(mentions: Vec<(&str, usize, usize)>) -> CorefChain {
928        let m: Vec<Mention> = mentions
929            .into_iter()
930            .map(|(text, start, end)| Mention::new(text, start, end))
931            .collect();
932        CorefChain::new(m)
933    }
934
935    #[test]
936    fn test_stratify_chains() {
937        let config = BookScaleConfig::default();
938        let analyzer = BookScaleAnalyzer::new(config);
939
940        let chains = vec![
941            make_chain(vec![("a", 0, 1)]),                           // singleton
942            make_chain(vec![("b", 0, 1), ("c", 2, 3), ("d", 4, 5)]), // short
943            make_chain((0..15).map(|i| ("x", i * 10, i * 10 + 1)).collect()), // long
944        ];
945
946        let (long, short, single) = analyzer.stratify_chains(&chains);
947        assert_eq!(single.len(), 1);
948        assert_eq!(short.len(), 1);
949        assert_eq!(long.len(), 1);
950    }
951
952    #[test]
953    fn test_reliability_assessment() {
954        let config = BookScaleConfig::default();
955        let analyzer = BookScaleAnalyzer::new(config);
956
957        let eval = CorefEvalScores {
958            muc: Scores {
959                precision: 0.9,
960                recall: 0.9,
961                f1: 0.9,
962            },
963            b_cubed: Scores {
964                precision: 0.7,
965                recall: 0.7,
966                f1: 0.7,
967            },
968            ceaf_e: Scores {
969                precision: 0.4,
970                recall: 0.4,
971                f1: 0.4,
972            },
973            ceaf_m: Scores::default(),
974            lea: Scores::default(),
975            conll_f1: 0.67,
976        };
977
978        let stratified = StratifiedEvaluation::default();
979        let reliability = analyzer.assess_reliability(&eval, &stratified, DocumentScale::BookScale);
980
981        // MUC should be low reliability (large divergence)
982        assert!(matches!(
983            reliability.muc_reliability,
984            ReliabilityLevel::Low | ReliabilityLevel::Medium
985        ));
986    }
987
988    #[test]
989    fn test_diagnostics_generation() {
990        let config = BookScaleConfig::default();
991        let analyzer = BookScaleAnalyzer::new(config);
992
993        let eval = CorefEvalScores {
994            muc: Scores {
995                precision: 0.93,
996                recall: 0.93,
997                f1: 0.93,
998            },
999            b_cubed: Scores {
1000                precision: 0.62,
1001                recall: 0.62,
1002                f1: 0.62,
1003            },
1004            ceaf_e: Scores {
1005                precision: 0.33,
1006                recall: 0.33,
1007                f1: 0.33,
1008            },
1009            ceaf_m: Scores::default(),
1010            lea: Scores::default(),
1011            conll_f1: 0.63,
1012        };
1013
1014        let windowed = WindowedEvaluation {
1015            num_windows: 10,
1016            window_size: 1500,
1017            avg_conll_f1: 0.78,
1018            std_conll_f1: 0.05,
1019            performance_drop: 0.15,
1020            window_evals: vec![],
1021        };
1022
1023        let stratified = StratifiedEvaluation::default();
1024
1025        let diagnostics = analyzer.generate_diagnostics(
1026            &eval,
1027            Some(&windowed),
1028            &stratified,
1029            DocumentScale::BookScale,
1030        );
1031
1032        assert!(diagnostics.high_metric_divergence);
1033        assert!(diagnostics.has_issues());
1034    }
1035
1036    #[test]
1037    fn test_multi_book_report() {
1038        let books = vec![
1039            PerBookEvaluation {
1040                book_id: "animal_farm".to_string(),
1041                title: Some("Animal Farm".to_string()),
1042                author: Some("George Orwell".to_string()),
1043                token_count: 29853,
1044                full_doc: CorefEvalScores {
1045                    muc: Scores {
1046                        precision: 0.9,
1047                        recall: 0.9,
1048                        f1: 0.9,
1049                    },
1050                    b_cubed: Scores {
1051                        precision: 0.6,
1052                        recall: 0.6,
1053                        f1: 0.6,
1054                    },
1055                    ceaf_e: Scores {
1056                        precision: 0.5,
1057                        recall: 0.5,
1058                        f1: 0.5,
1059                    },
1060                    ceaf_m: Scores::default(),
1061                    lea: Scores::default(),
1062                    conll_f1: 0.67,
1063                },
1064                windowed: None,
1065                scale: DocumentScale::Long,
1066            },
1067            PerBookEvaluation {
1068                book_id: "pride_prejudice".to_string(),
1069                title: Some("Pride and Prejudice".to_string()),
1070                author: Some("Jane Austen".to_string()),
1071                token_count: 121869,
1072                full_doc: CorefEvalScores {
1073                    muc: Scores {
1074                        precision: 0.85,
1075                        recall: 0.85,
1076                        f1: 0.85,
1077                    },
1078                    b_cubed: Scores {
1079                        precision: 0.55,
1080                        recall: 0.55,
1081                        f1: 0.55,
1082                    },
1083                    ceaf_e: Scores {
1084                        precision: 0.35,
1085                        recall: 0.35,
1086                        f1: 0.35,
1087                    },
1088                    ceaf_m: Scores::default(),
1089                    lea: Scores::default(),
1090                    conll_f1: 0.58,
1091                },
1092                windowed: None,
1093                scale: DocumentScale::BookScale,
1094            },
1095        ];
1096
1097        let report = MultiBookReport::from_books(books);
1098        assert_eq!(report.aggregate.total_books, 2);
1099        assert!(report.aggregate.mean_conll_f1 > 0.5);
1100
1101        let table = report.format_table();
1102        assert!(table.contains("Animal Farm"));
1103        assert!(table.contains("Pride and Prejudice"));
1104    }
1105
1106    // =========================================================================
1107    // Additional Edge Case Tests
1108    // =========================================================================
1109
1110    #[test]
1111    fn test_document_scale_classification() {
1112        // Short: 0-2000 tokens
1113        assert_eq!(DocumentScale::from_tokens(100), DocumentScale::Short);
1114        assert_eq!(DocumentScale::from_tokens(2000), DocumentScale::Short);
1115        // Medium: 2001-10000 tokens
1116        assert_eq!(DocumentScale::from_tokens(5000), DocumentScale::Medium);
1117        // Long: 10001-50000 tokens
1118        assert_eq!(DocumentScale::from_tokens(30000), DocumentScale::Long);
1119        // BookScale: >50000 tokens
1120        assert_eq!(DocumentScale::from_tokens(100000), DocumentScale::BookScale);
1121    }
1122
1123    #[test]
1124    fn test_empty_chains_stratification() {
1125        let config = BookScaleConfig::default();
1126        let analyzer = BookScaleAnalyzer::new(config);
1127
1128        let chains: Vec<CorefChain> = vec![];
1129        let (long, short, single) = analyzer.stratify_chains(&chains);
1130
1131        assert!(long.is_empty());
1132        assert!(short.is_empty());
1133        assert!(single.is_empty());
1134    }
1135
1136    #[test]
1137    fn test_all_singletons() {
1138        let config = BookScaleConfig::default();
1139        let analyzer = BookScaleAnalyzer::new(config);
1140
1141        let chains = vec![
1142            make_chain(vec![("a", 0, 1)]),
1143            make_chain(vec![("b", 10, 11)]),
1144            make_chain(vec![("c", 20, 21)]),
1145        ];
1146
1147        let (long, short, single) = analyzer.stratify_chains(&chains);
1148
1149        assert!(long.is_empty());
1150        assert!(short.is_empty());
1151        assert_eq!(single.len(), 3);
1152    }
1153
1154    #[test]
1155    fn test_all_long_chains() {
1156        let config = BookScaleConfig::default();
1157        let analyzer = BookScaleAnalyzer::new(config);
1158
1159        // Create chains with 15+ mentions (long threshold)
1160        let chains = vec![
1161            make_chain((0..20).map(|i| ("x", i * 10, i * 10 + 1)).collect()),
1162            make_chain((0..25).map(|i| ("y", i * 10 + 5, i * 10 + 6)).collect()),
1163        ];
1164
1165        let (long, short, single) = analyzer.stratify_chains(&chains);
1166
1167        assert_eq!(long.len(), 2);
1168        assert!(short.is_empty());
1169        assert!(single.is_empty());
1170    }
1171
1172    #[test]
1173    fn test_scores_default() {
1174        let scores = Scores::default();
1175        assert!((scores.precision - 0.0).abs() < 0.001);
1176        assert!((scores.recall - 0.0).abs() < 0.001);
1177        assert!((scores.f1 - 0.0).abs() < 0.001);
1178    }
1179
1180    #[test]
1181    fn test_coref_eval_scores_conll_average() {
1182        let eval = CorefEvalScores {
1183            muc: Scores {
1184                precision: 0.8,
1185                recall: 0.8,
1186                f1: 0.8,
1187            },
1188            b_cubed: Scores {
1189                precision: 0.7,
1190                recall: 0.7,
1191                f1: 0.7,
1192            },
1193            ceaf_e: Scores {
1194                precision: 0.6,
1195                recall: 0.6,
1196                f1: 0.6,
1197            },
1198            ceaf_m: Scores::default(),
1199            lea: Scores::default(),
1200            conll_f1: 0.7, // (0.8 + 0.7 + 0.6) / 3 = 0.7
1201        };
1202
1203        // CoNLL F1 is typically average of MUC, B³, CEAF-e
1204        let expected_conll = (0.8 + 0.7 + 0.6) / 3.0;
1205        assert!((eval.conll_f1 - expected_conll).abs() < 0.001);
1206    }
1207
1208    #[test]
1209    fn test_windowed_evaluation_performance_drop() {
1210        let windowed = WindowedEvaluation {
1211            num_windows: 5,
1212            window_size: 1000,
1213            avg_conll_f1: 0.80,
1214            std_conll_f1: 0.03,
1215            performance_drop: 0.15,
1216            window_evals: vec![],
1217        };
1218
1219        // Performance drop should be positive when full-doc is worse
1220        assert!(windowed.performance_drop > 0.0);
1221        assert_eq!(windowed.num_windows, 5);
1222    }
1223
1224    #[test]
1225    fn test_diagnostics_no_issues_for_short_doc() {
1226        let config = BookScaleConfig::default();
1227        let analyzer = BookScaleAnalyzer::new(config);
1228
1229        // Perfect scores - no issues expected
1230        let eval = CorefEvalScores {
1231            muc: Scores {
1232                precision: 0.85,
1233                recall: 0.85,
1234                f1: 0.85,
1235            },
1236            b_cubed: Scores {
1237                precision: 0.82,
1238                recall: 0.82,
1239                f1: 0.82,
1240            },
1241            ceaf_e: Scores {
1242                precision: 0.80,
1243                recall: 0.80,
1244                f1: 0.80,
1245            },
1246            ceaf_m: Scores::default(),
1247            lea: Scores::default(),
1248            conll_f1: 0.82,
1249        };
1250
1251        let stratified = StratifiedEvaluation::default();
1252        let diagnostics = analyzer.generate_diagnostics(
1253            &eval,
1254            None, // No windowed evaluation
1255            &stratified,
1256            DocumentScale::Short,
1257        );
1258
1259        // Small divergence for short docs - may or may not have issues
1260        // Just verify it doesn't panic and has expected fields
1261        let _ = diagnostics.high_metric_divergence;
1262        let _ = diagnostics.has_issues();
1263    }
1264
1265    #[test]
1266    fn test_multi_book_report_empty() {
1267        let books: Vec<PerBookEvaluation> = vec![];
1268        let report = MultiBookReport::from_books(books);
1269
1270        assert_eq!(report.aggregate.total_books, 0);
1271        assert!(report.books.is_empty());
1272    }
1273
1274    #[test]
1275    fn test_per_book_evaluation_scale() {
1276        let book = PerBookEvaluation {
1277            book_id: "test".to_string(),
1278            title: Some("Test Book".to_string()),
1279            author: None,
1280            token_count: 200000,
1281            full_doc: CorefEvalScores::default(),
1282            windowed: None,
1283            scale: DocumentScale::BookScale,
1284        };
1285
1286        assert!(book.token_count > 100000);
1287        assert_eq!(book.scale, DocumentScale::BookScale);
1288    }
1289}