1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
59pub struct Scores {
60 pub precision: f64,
62 pub recall: f64,
64 pub f1: f64,
66}
67
68impl Scores {
69 pub fn from_tuple((precision, recall, f1): (f64, f64, f64)) -> Self {
71 Self {
72 precision,
73 recall,
74 f1,
75 }
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct BookScaleConfig {
86 pub window_size: usize,
88 pub window_overlap: usize,
90 pub long_chain_threshold: usize,
92 pub short_chain_threshold: usize,
94 pub divergence_threshold: f64,
96 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, short_chain_threshold: 2, divergence_threshold: 0.30, performance_drop_threshold: 0.15, }
110 }
111}
112
113#[derive(Debug, Clone, Default, Serialize, Deserialize)]
121pub struct CorefEvalScores {
122 pub muc: Scores,
124 pub b_cubed: Scores,
126 pub ceaf_e: Scores,
128 pub ceaf_m: Scores,
130 pub lea: Scores,
132 pub conll_f1: f64,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct BookScaleAnalysis {
139 pub full_doc_eval: CorefEvalScores,
141 pub windowed_eval: Option<WindowedEvaluation>,
143 pub stratified: StratifiedEvaluation,
145 pub doc_stats: CorefDocStats,
147 pub reliability: MetricReliability,
149 pub scale: DocumentScale,
151 pub diagnostics: BookScaleDiagnostics,
153}
154
155impl BookScaleAnalysis {
156 pub fn has_scale_issues(&self) -> bool {
158 self.diagnostics.has_issues()
159 }
160
161 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct WindowedEvaluation {
278 pub num_windows: usize,
280 pub window_size: usize,
282 pub avg_conll_f1: f64,
284 pub std_conll_f1: f64,
286 pub performance_drop: f64,
288 pub window_evals: Vec<CorefEvalScores>,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, Default)]
294pub struct StratifiedEvaluation {
295 pub long_chains: Scores,
297 pub short_chains: Scores,
299 pub singletons: Scores,
301 pub long_chain_count: usize,
303 pub short_chain_count: usize,
305 pub singleton_count: usize,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct MetricReliability {
312 pub muc_reliability: ReliabilityLevel,
314 pub muc_note: String,
316 pub b_cubed_reliability: ReliabilityLevel,
318 pub b_cubed_note: String,
320 pub ceaf_e_reliability: ReliabilityLevel,
322 pub ceaf_e_note: String,
324 pub lea_reliability: ReliabilityLevel,
326 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
347pub enum ReliabilityLevel {
348 High,
350 Medium,
352 Low,
354 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
371pub struct BookScaleDiagnostics {
372 pub high_metric_divergence: bool,
374 pub large_performance_drop: bool,
376 pub long_chain_dominance: bool,
378 pub singleton_neglect: bool,
380 pub recommendations: Vec<String>,
382}
383
384impl BookScaleDiagnostics {
385 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
394pub 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 pub fn new(config: BookScaleConfig) -> Self {
412 Self { config }
413 }
414
415 pub fn analyze(
417 &self,
418 predicted: &[CorefChain],
419 gold: &[CorefChain],
420 doc_length: usize,
421 ) -> BookScaleAnalysis {
422 let full_doc_eval = self.evaluate_chains(predicted, gold);
424
425 let mut doc_stats = CorefDocStats::from_chains(gold);
427 doc_stats.doc_length = doc_length;
428
429 let scale = doc_stats.scale_classification();
431
432 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 let stratified = self.compute_stratified_eval(predicted, gold);
441
442 let reliability = self.assess_reliability(&full_doc_eval, &stratified, scale);
444
445 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
772pub struct PerBookEvaluation {
773 pub book_id: String,
775 pub title: Option<String>,
777 pub author: Option<String>,
779 pub token_count: usize,
781 pub full_doc: CorefEvalScores,
783 pub windowed: Option<WindowedEvaluation>,
785 pub scale: DocumentScale,
787}
788
789#[derive(Debug, Clone, Serialize, Deserialize)]
791pub struct MultiBookReport {
792 pub books: Vec<PerBookEvaluation>,
794 pub aggregate: AggregateStats,
796}
797
798#[derive(Debug, Clone, Serialize, Deserialize)]
800pub struct AggregateStats {
801 pub total_books: usize,
803 pub mean_conll_f1: f64,
805 pub std_conll_f1: f64,
807 pub mean_performance_drop: f64,
809 pub books_with_issues: usize,
811}
812
813impl MultiBookReport {
814 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 pub fn format_table(&self) -> String {
873 let mut table = String::new();
874
875 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 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 table.push_str(&format!("{}\n", "-".repeat(78)));
905
906 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#[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)]), make_chain(vec![("b", 0, 1), ("c", 2, 3), ("d", 4, 5)]), make_chain((0..15).map(|i| ("x", i * 10, i * 10 + 1)).collect()), ];
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 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 #[test]
1111 fn test_document_scale_classification() {
1112 assert_eq!(DocumentScale::from_tokens(100), DocumentScale::Short);
1114 assert_eq!(DocumentScale::from_tokens(2000), DocumentScale::Short);
1115 assert_eq!(DocumentScale::from_tokens(5000), DocumentScale::Medium);
1117 assert_eq!(DocumentScale::from_tokens(30000), DocumentScale::Long);
1119 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 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, };
1202
1203 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 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 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, &stratified,
1256 DocumentScale::Short,
1257 );
1258
1259 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}