Skip to main content

anno_eval/eval/
mod.rs

1//! NER and Coreference evaluation framework.
2//!
3//! # Feature Gating
4//!
5//! This module lives in the `anno-eval` crate (which is **not** intended for crates.io
6//! publishing right now) and intentionally pulls in a large amount of evaluation
7//! infrastructure: datasets, registries, harnesses, and reporting.
8//!
9//! For a minimal dependency surface, prefer the `anno` crate without evaluation tooling.
10//! For evaluation workflows, depend on `anno-eval` (or use the `anno` CLI).
11//!
12//! # Relation to `anno::eval`
13//!
14//! The `anno` crate also exposes a small `anno::eval` module behind the legacy `eval`
15//! feature (and the preferred alias `analysis`). That module contains **only** light,
16//! analysis-oriented primitives (e.g. coref metrics and cross-context clustering helpers).
17//! The full evaluation harness remains here in `anno-eval::eval`.
18//!
19//! # Overview
20//!
21//! This module provides comprehensive evaluation tools for:
22//! - **Named Entity Recognition (NER)**: Standard metrics, error analysis, significance testing
23//! - **Coreference Resolution**: MUC, B³, CEAF, LEA, BLANC, CoNLL F1
24//!
25//! # Why Multiple Coreference Metrics?
26//!
27//! No single metric captures all aspects of coreference quality:
28//!
29//! | Metric | Measures | Blind to |
30//! |--------|----------|----------|
31//! | **MUC** | Link recall/precision | Singletons, entity count |
32//! | **B³** | Mention-level overlap | Link structure |
33//! | **CEAF** | Optimal entity alignment | Within-cluster structure |
34//! | **LEA** | Links weighted by entity size | Nothing (comprehensive) |
35//! | **BLANC** | Rand index (coreference + non-coref) | Entity semantics |
36//!
37//! The **CoNLL F1** (average of MUC, B³, CEAF-e) is the standard benchmark metric.
38//!
39//! # Metric Divergence: A Diagnostic Tool
40//!
41//! When metrics disagree significantly, it reveals systematic model behaviors:
42//!
43//! - **High MUC, Low B³**: Model makes too few links (conservative)
44//! - **Low MUC, High B³**: Model over-clusters (aggressive)
45//! - **High CEAF variance**: Inconsistent entity boundaries
46//!
47//! Use [`MetricDivergence`] to quantify this and diagnose model behavior.
48//!
49//! # NER Evaluation
50//!
51//! ```rust,ignore
52//! use anno_eval::eval::{evaluate_ner_model, GoldEntity, ErrorAnalysis};
53//! use anno::RegexNER;
54//!
55//! let model = RegexNER::new();
56//! let test_cases = vec![
57//!     ("Meeting on January 15".to_string(), vec![
58//!         GoldEntity::new("January 15", anno::EntityType::Date, 11),
59//!     ]),
60//! ];
61//!
62//! let results = evaluate_ner_model(&model, &test_cases)?;
63//! println!("F1: {:.1}%", results.f1 * 100.0);
64//! ```
65//!
66//! # Coreference Evaluation
67//!
68//! ```rust,ignore
69//! use anno_eval::eval::{CorefChain, Mention, conll_f1, muc_score, b_cubed_score};
70//!
71//! let gold = vec![
72//!     CorefChain::new(0, vec![Mention::new("John", 0, 4), Mention::new("he", 20, 22)]),
73//! ];
74//! let pred = gold.clone(); // Perfect match
75//!
76//! let (p, r, f1) = conll_f1(&gold, &pred);
77//! assert!((f1 - 1.0).abs() < 0.001);
78//! ```
79//!
80//! # Dataset Support
81//!
82//! | Dataset | Type | Size | Format |
83//! |---------|------|------|--------|
84//! | CoNLL-2003 | NER | ~22k sentences | BIO tags |
85//! | WikiGold | NER | 145 docs | CoNLL |
86//! | WNUT-17 | NER | ~5k tweets | CoNLL |
87//! | MultiNERD | NER | ~50k examples | JSONL |
88//! | GAP | Coref | 4.5k examples | TSV |
89//! | PreCo | Coref | 12k docs | JSON |
90//!
91//! # Metrics
92//!
93//! **NER Metrics:**
94//! - Precision, Recall, F1 (micro/macro)
95//! - Per-entity-type breakdown
96//! - Partial match (boundary overlap)
97//! - Confidence threshold analysis
98//!
99//! **Coreference Metrics:**
100//! - MUC (link-based)
101//! - B³ (mention-based)
102//! - CEAF-e/m (entity/mention alignment)
103//! - LEA (link-based entity-aware)
104//! - BLANC (rand-index based)
105//! - CoNLL F1 (average of MUC, B³, CEAF-e)
106//!
107//! **Error Analysis:**
108//! - Confusion matrix
109//! - Error categorization (type, boundary, spurious, missed)
110//! - Statistical significance testing (paired t-test)
111
112use anno::{EntityCategory, EntityType};
113use anno::{Error, Model, Result};
114use serde::{Deserialize, Serialize};
115use std::collections::HashMap;
116use std::path::Path;
117
118// =============================================================================
119// Evaluation Task Enum
120// =============================================================================
121
122/// Evaluation task type.
123///
124/// Clarifies what capability is being evaluated, since the same model
125/// may support multiple tasks with different metrics.
126///
127/// # Example
128///
129/// ```rust
130/// use anno_eval::eval::{EvalTask, EvalMode};
131///
132/// let task = EvalTask::NER {
133///     labels: vec!["PER", "ORG", "LOC"].into_iter().map(String::from).collect(),
134///     mode: EvalMode::Strict,
135/// };
136///
137/// match task {
138///     EvalTask::NER { labels, mode } => {
139///         println!("NER with {} entity types, {:?} mode", labels.len(), mode);
140///     }
141///     _ => {}
142/// }
143/// ```
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub enum EvalTask {
146    /// Named Entity Recognition: span extraction with type classification.
147    ///
148    /// Input: Text → Output: `Vec<Entity>`
149    ///
150    /// Metrics: Precision, Recall, F1 (Strict/Exact/Partial/Type modes)
151    NER {
152        /// Entity type labels expected (e.g., ["PER", "ORG", "LOC"])
153        labels: Vec<String>,
154        /// Evaluation mode (Strict, Exact, Partial, Type)
155        mode: EvalMode,
156    },
157
158    /// Relation Extraction: entity pairs with relation types.
159    ///
160    /// Input: Text → Output: `Vec<(Entity, Relation, Entity)>`
161    ///
162    /// Metrics: Relation F1 (with/without entity correctness)
163    RelationExtraction {
164        /// Relation types expected (e.g., ["WORKS_AT", "BORN_IN"])
165        relations: Vec<String>,
166        /// Whether to require correct entity spans for relation credit
167        require_entity_match: bool,
168    },
169
170    /// Coreference Resolution: mention clustering.
171    ///
172    /// Input: Text → Output: `Vec<CorefChain>`
173    ///
174    /// Metrics: MUC, B³, CEAF-e/m, LEA, BLANC, CoNLL F1
175    Coreference {
176        /// Which coreference metrics to compute
177        metrics: Vec<CorefMetric>,
178    },
179
180    /// Discontinuous NER: non-contiguous span extraction.
181    ///
182    /// Input: Text → Output: `Vec<DiscontinuousEntity>`
183    ///
184    /// Metrics: Same as NER but with discontinuous span matching
185    DiscontinuousNER {
186        /// Entity type labels expected
187        labels: Vec<String>,
188    },
189
190    /// Event Extraction: event triggers and arguments.
191    ///
192    /// Input: Text → Output: Events with trigger and argument spans
193    ///
194    /// Metrics: Trigger F1, Argument F1, Event F1
195    EventExtraction {
196        /// Event types (e.g., ["ATTACK", "MOVEMENT", "TRANSACTION"])
197        event_types: Vec<String>,
198        /// Argument roles (e.g., ["AGENT", "PATIENT", "LOCATION"])
199        argument_roles: Vec<String>,
200    },
201}
202
203impl Default for EvalTask {
204    fn default() -> Self {
205        EvalTask::NER {
206            labels: vec![
207                "PER".to_string(),
208                "ORG".to_string(),
209                "LOC".to_string(),
210                "MISC".to_string(),
211            ],
212            mode: EvalMode::Strict,
213        }
214    }
215}
216
217/// Coreference evaluation metrics.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219pub enum CorefMetric {
220    /// MUC (link-based)
221    MUC,
222    /// B-cubed (mention-based)
223    BCubed,
224    /// CEAF entity-based
225    CEAFe,
226    /// CEAF mention-based
227    CEAFm,
228    /// LEA (link-based entity-aware)
229    LEA,
230    /// BLANC (rand-index based)
231    BLANC,
232    /// CoNLL F1 (average of MUC, B³, CEAF-e)
233    CoNLL,
234}
235
236/// BIO tagging scheme for sequence labeling.
237pub use bio_adapter::BioScheme;
238/// Evaluation configuration for modes (overlap thresholds, etc).
239/// Note: This is separate from `harness::EvalConfig` for benchmarks.
240pub use modes::EvalConfig as ModeConfig;
241/// Evaluation mode for NER (re-exported from modes).
242pub use modes::EvalMode;
243
244/// Cross-context clustering primitives (shared via `anno-metrics`).
245pub mod cluster_encoder {
246    pub use anno::metrics::cluster_encoder::*;
247}
248
249/// Coreference metrics (shared via `anno-metrics`).
250pub mod coref_metrics {
251    pub use anno::metrics::coref_metrics::*;
252}
253
254// =============================================================================
255// CORE MODULES (always available with `eval` feature)
256// Basic P/R/F1, datasets, coreference metrics
257// =============================================================================
258#[cfg(feature = "discourse")]
259pub mod abstract_anaphora;
260pub mod advanced_evaluator;
261pub mod advanced_harness;
262pub mod analysis;
263pub mod backend_factory;
264pub mod benchmark;
265pub mod bio_adapter;
266pub mod book_scale;
267pub mod cdcr;
268pub mod coref;
269pub mod coref_loader;
270pub mod cross_context_eval;
271
272/// Coreference resolvers (shared via `anno::metrics`).
273pub mod coref_resolver {
274    pub use anno::metrics::coref_resolver::*;
275}
276pub mod dataset;
277pub mod dataset_metadata;
278pub mod dataset_registry;
279pub mod dataset_spec;
280pub mod datasets;
281pub mod discontinuous;
282#[cfg(feature = "discourse")]
283pub mod discourse_deixis;
284pub mod evaluator;
285pub mod harness;
286pub mod history;
287pub mod incremental_coref;
288pub mod inter_doc_coref;
289pub mod loader;
290pub mod metrics;
291pub mod modes;
292pub mod ner_metrics;
293pub mod neural_cluster_encoder;
294pub mod prelude;
295pub mod relation;
296pub mod report;
297pub mod sampling;
298pub mod shell_nouns;
299pub mod synthetic;
300pub mod synthetic_gen;
301pub mod task_evaluator;
302
303#[cfg(feature = "eval-profiling")]
304pub mod profiling;
305pub mod task_mapping;
306pub mod types;
307pub mod validation;
308pub mod visual;
309
310// =============================================================================
311// BIAS MODULES (available with `eval-bias` feature)
312// Gender, demographic, temporal, and length bias analysis
313// =============================================================================
314#[cfg(feature = "eval-bias")]
315pub mod bias_config;
316#[cfg(feature = "eval-bias")]
317pub mod demographic_bias;
318#[cfg(feature = "eval-bias")]
319pub mod gender_bias;
320#[cfg(feature = "eval-bias")]
321pub mod length_bias;
322#[cfg(feature = "eval-bias")]
323pub mod temporal_bias;
324
325// =============================================================================
326// ADVANCED MODULES (available with `eval` feature)
327// Calibration, robustness, active learning, specialized analysis
328// =============================================================================
329#[cfg(feature = "eval")]
330pub mod active_learning;
331#[cfg(feature = "eval")]
332pub mod calibration;
333#[cfg(feature = "eval")]
334pub mod dataset_comparison;
335#[cfg(feature = "eval")]
336pub mod dataset_quality;
337#[cfg(feature = "eval")]
338pub mod drift;
339#[cfg(feature = "eval")]
340pub mod ensemble;
341#[cfg(feature = "eval")]
342pub mod error_analysis;
343#[cfg(feature = "eval")]
344pub mod few_shot;
345#[cfg(feature = "eval")]
346pub mod learning_curve;
347#[cfg(feature = "eval")]
348pub mod long_tail;
349#[cfg(feature = "eval")]
350pub mod low_resource;
351#[cfg(feature = "eval")]
352pub mod ood_detection;
353#[cfg(feature = "eval")]
354pub mod robustness;
355#[cfg(feature = "eval")]
356pub mod threshold_analysis;
357
358// Specialized analysis modules (eval)
359#[cfg(feature = "eval")]
360pub mod annotator;
361#[cfg(feature = "eval")]
362pub mod bridging;
363#[cfg(feature = "eval")]
364pub mod multi_run;
365#[cfg(feature = "eval")]
366pub mod ranking;
367
368#[cfg(all(feature = "eval", test))]
369mod property_tests;
370
371// Re-exports
372pub use datasets::GoldEntity;
373
374// Dataset loading and registry API
375//
376// - `RegistryDatasetId` (`dataset_registry::DatasetId`): full metadata catalog
377// - `LoadableDatasetId` (`loader::LoadableDatasetId`): subset guaranteed to be loadable
378//
379// Use `LoadableDatasetId` + `DatasetLoader` when you want to actually load data.
380// Use `RegistryDatasetId` when browsing/filtering the full catalog by metadata.
381pub use dataset_registry::{AnnotationScheme, DataFormat, DatasetId as RegistryDatasetId};
382pub use datasets::DatasetMetadata;
383pub use loader::{DatasetLoader, LoadableDatasetId, LoadedDataset};
384
385#[cfg(test)]
386mod registry_exports;
387
388// Dataset API re-exports (new structured dataset interface)
389pub use dataset::{AnnotatedExample, DatasetStats, Difficulty, Domain, NERDataset};
390pub use evaluator::*;
391pub use harness::{
392    BackendAggregateResult, BackendDatasetResult, BackendRegistry, DatasetStatsSummary, EvalConfig,
393    EvalHarness, EvalResults,
394};
395pub use metrics::*;
396pub use types::{
397    CorefChainStats, CorefDocStats, DocumentScale, GoalCheck, GoalCheckResult, LabelShift,
398    MetricDivergence, MetricValue, MetricWithVariance,
399};
400pub use validation::*;
401
402// Coreference re-exports
403pub use coref::{CorefChain, CorefDocument, Mention, MentionType};
404pub use coref_loader::{
405    adversarial_coref_examples, synthetic_coref_dataset, CorefLoader, GapExample,
406};
407pub use coref_metrics::{
408    b_cubed_score, blanc_score, ceaf_e_score, ceaf_m_score, compare_systems, conll_f1, lea_score,
409    muc_score, AggregateCorefEvaluation, CorefEvaluation, CorefScores, SignificanceTest,
410};
411
412// Book-scale coreference analysis (Bourgois & Poibeau 2025)
413pub use book_scale::{
414    BookScaleAnalysis, BookScaleAnalyzer, BookScaleConfig, BookScaleDiagnostics, CorefEvalScores,
415    MetricReliability, MultiBookReport, PerBookEvaluation, ReliabilityLevel, Scores,
416    StratifiedEvaluation, WindowedEvaluation,
417};
418
419// Coreference resolution
420pub use coref_resolver::{CorefConfig, CoreferenceResolver, SimpleCorefResolver};
421
422// Cross-document coreference resolution (CDCR)
423pub use inter_doc_coref::InterDocCorefMetrics;
424
425pub use cdcr::{
426    comprehensive_cdcr_dataset,
427    financial_news_dataset,
428    political_news_dataset,
429    science_news_dataset,
430    sports_news_dataset,
431    // Domain-specific CDCR datasets
432    tech_news_dataset,
433    CDCRConfig,
434    CDCRMetrics,
435    CDCRResolver,
436    CrossDocCluster,
437    Document,
438    LSHBlocker,
439    MentionRef,
440};
441
442// Discontinuous NER evaluation
443pub use discontinuous::{
444    evaluate_discontinuous_ner, DiscontinuousEvalConfig, DiscontinuousGold,
445    DiscontinuousNERMetrics, TypeMetrics as DiscontinuousTypeMetrics,
446};
447
448// Relation extraction evaluation
449pub use relation::{
450    evaluate_relations, RelationEvalConfig, RelationGold, RelationMetrics, RelationPrediction,
451    RelationTypeMetrics,
452};
453
454// Advanced evaluators for specialized tasks
455pub use advanced_evaluator::{
456    evaluator_for_task, DiscontinuousEvaluator, EvalResults as AdvancedEvalResults,
457    RelationEvaluator, TaskEvaluator,
458};
459
460// Visual/multimodal NER evaluation
461pub use visual::{
462    evaluate_visual_ner, synthetic_visual_examples, BoundingBox, VisualEvalConfig, VisualGold,
463    VisualNERMetrics, VisualPrediction, VisualTypeMetrics,
464};
465
466// Advanced harness for specialized tasks
467pub use advanced_harness::{
468    evaluate_discontinuous_gold_vs_gold, evaluate_discontinuous_synthetic,
469    evaluate_relations_gold_vs_gold, evaluate_relations_synthetic, evaluate_visual_gold_vs_gold,
470    synthetic_dataset_stats, AdvancedTaskResults, ModelResult, SyntheticDatasetStats,
471};
472
473// =============================================================================
474// BIAS MODULE RE-EXPORTS (eval-bias feature)
475// =============================================================================
476#[cfg(feature = "eval-bias")]
477pub use gender_bias::{
478    create_comprehensive_bias_templates, create_neopronoun_templates, create_winobias_templates,
479    occupation_stereotype, GenderBiasEvaluator, GenderBiasResults, OccupationBiasMetrics,
480    PronounGender, StereotypeType, WinoBiasExample,
481};
482
483#[cfg(feature = "eval-bias")]
484pub use bias_config::{
485    BiasDatasetConfig, DistributionValidation, FrequencyWeightedResults, StatisticalBiasResults,
486};
487#[cfg(feature = "eval-bias")]
488pub use demographic_bias::{
489    create_diverse_location_dataset, create_diverse_name_dataset, DemographicBiasEvaluator,
490    DemographicBiasResults, Ethnicity, Gender, LocationExample, LocationType, NameExample,
491    NameFrequency, NameResult, Region, RegionalBiasResults, Script,
492};
493
494#[cfg(feature = "eval-bias")]
495pub use temporal_bias::{
496    create_temporal_name_dataset, Decade, TemporalBiasEvaluator, TemporalBiasResults,
497    TemporalGender, TemporalNameExample,
498};
499
500#[cfg(feature = "eval-bias")]
501pub use length_bias::{
502    create_length_varied_dataset, EntityLengthEvaluator, LengthBiasResults, LengthBucket,
503    LengthTestExample, WordCountBucket,
504};
505
506// =============================================================================
507// ADVANCED MODULE RE-EXPORTS (eval feature)
508// =============================================================================
509#[cfg(feature = "eval")]
510pub use calibration::{
511    calibration_grade, confidence_entropy, confidence_gap_grade, confidence_variance,
512    CalibrationEvaluator, CalibrationResults, EntropyFilter, ReliabilityBin, ThresholdMetrics,
513};
514
515#[cfg(feature = "eval")]
516pub use robustness::{
517    robustness_grade, Perturbation, PerturbationMetrics, RobustnessEvaluator, RobustnessResults,
518};
519
520#[cfg(feature = "eval")]
521pub use ood_detection::{
522    ood_rate_grade, OODAnalysisResults, OODConfig, OODDetector, OODStatus, VocabCoverageStats,
523};
524
525#[cfg(feature = "eval")]
526pub use dataset_quality::{
527    check_leakage, entity_imbalance_ratio, DatasetQualityAnalyzer, DifficultyMetrics,
528    QualityReport, ReliabilityMetrics, ValidityMetrics,
529};
530
531#[cfg(feature = "eval")]
532pub use learning_curve::{
533    suggested_train_sizes, CurveFitParams, DataPoint, LearningCurveAnalysis, LearningCurveAnalyzer,
534    SampleEfficiencyMetrics,
535};
536
537#[cfg(feature = "eval")]
538pub use ensemble::{
539    agreement_grade, kappa_interpretation, DisagreementDetail, EnsembleAnalysisResults,
540    EnsembleAnalyzer, ModelPrediction, SingleExampleAnalysis,
541};
542
543#[cfg(feature = "eval")]
544pub use dataset_comparison::{
545    compare_datasets, compute_stats, estimate_difficulty, DatasetComparison,
546    DatasetStats as ComparisonStats, DifficultyEstimate, LengthStats,
547};
548
549#[cfg(feature = "eval")]
550pub use drift::{
551    ConfidenceDrift, DistributionDrift, DriftConfig, DriftDetector, DriftReport, DriftWindow,
552    VocabularyDrift,
553};
554
555#[cfg(feature = "eval")]
556pub use active_learning::{
557    entities_to_candidates, estimate_budget, export_annotation_priority, rank_for_annotation,
558    select_for_annotation, ActiveLearner, Candidate, SamplingStrategy, ScoreStats, SelectionResult,
559};
560
561#[cfg(feature = "eval")]
562pub use error_analysis::{
563    EntityInfo, ErrorAnalyzer, ErrorCategory, ErrorInstance, ErrorPattern, ErrorReport,
564    PredictedEntity, TypeErrorStats,
565};
566
567#[cfg(feature = "eval")]
568pub use threshold_analysis::{
569    format_threshold_table, interpret_curve, PredictionWithConfidence, ThresholdAnalyzer,
570    ThresholdCurve, ThresholdPoint,
571};
572
573// Unified evaluation report (always available - uses what's enabled)
574pub use report::{
575    BiasSummary, CalibrationSummary, CoreMetrics, DataQualitySummary, DemographicBiasMetrics,
576    ErrorSummary, EvalReport, GenderBiasMetrics, LengthBiasMetrics, Priority, Recommendation,
577    RecommendationCategory, ReportBuilder, SimpleGoldEntity, TestCase,
578    TypeMetrics as ReportTypeMetrics,
579};
580
581// Unified evaluation system (recommended entry point)
582pub mod unified_evaluator;
583pub use unified_evaluator::{EvalMetadata, EvalSystem, UnifiedEvalResults};
584
585#[cfg(feature = "eval-bias")]
586pub use unified_evaluator::BiasEvalResults;
587#[cfg(feature = "eval")]
588pub use unified_evaluator::CalibrationEvalResults;
589#[cfg(feature = "eval")]
590pub use unified_evaluator::DataQualityEvalResults;
591#[cfg(feature = "eval")]
592pub use unified_evaluator::StandardEvalResults;
593
594// Type-safe backend names
595pub mod backend_name;
596pub use backend_name::BackendName;
597
598// Configuration builders
599pub mod config_builder;
600#[cfg(feature = "eval-bias")]
601pub use config_builder::BiasDatasetConfigBuilder;
602#[cfg(feature = "eval")]
603pub use config_builder::TaskEvalConfigBuilder;
604
605#[cfg(feature = "eval")]
606pub use few_shot::{
607    simulate_few_shot_task, FewShotEvaluator, FewShotGold, FewShotPrediction, FewShotResults,
608    FewShotTask, FewShotTaskResults, SupportExample,
609};
610
611#[cfg(feature = "eval")]
612pub use long_tail::{
613    format_long_tail_results, EntityFrequency, FrequencyBucket, FrequencySplit, LongTailAnalyzer,
614    LongTailResults, TypePerformance,
615};
616
617// Analysis re-exports
618pub use analysis::{
619    build_confusion_matrix, compare_ner_systems, ConfusionMatrix, ErrorAnalysis, ErrorType,
620    NERError, NERSignificanceTest,
621};
622
623// Sampling re-exports
624pub use sampling::{multi_seed_eval, stratified_sample, stratified_sample_ner};
625
626/// Per-entity-type metrics.
627#[derive(Debug, Clone, Serialize, Deserialize)]
628pub struct TypeMetrics {
629    /// Precision for this entity type.
630    pub precision: f64,
631    /// Recall for this entity type.
632    pub recall: f64,
633    /// F1 score for this entity type.
634    pub f1: f64,
635    /// Number of entities found by the model.
636    pub found: usize,
637    /// Number of entities expected (ground truth).
638    pub expected: usize,
639    /// Number of correctly identified entities.
640    pub correct: usize,
641}
642
643/// NER evaluation results.
644///
645/// Contains both micro and macro F1 scores:
646/// - **Micro F1**: Treats all entities as one pool (good for overall performance)
647/// - **Macro F1**: Averages per-type scores (good for fairness across types)
648#[derive(Debug, Clone, Serialize, Deserialize)]
649pub struct NEREvaluationResults {
650    /// Overall precision (micro-averaged)
651    pub precision: f64,
652    /// Overall recall (micro-averaged)
653    pub recall: f64,
654    /// Overall F1 (micro-averaged) - treats all entities as one pool
655    pub f1: f64,
656    /// Macro F1 - average of per-type F1 scores (equal weight to each type)
657    #[serde(default)]
658    pub macro_f1: Option<f64>,
659    /// Weighted F1 - per-type F1 weighted by support (entity count)
660    #[serde(default)]
661    pub weighted_f1: Option<f64>,
662    /// Per-entity-type metrics
663    pub per_type: HashMap<String, TypeMetrics>,
664    /// Speed metrics
665    pub tokens_per_second: f64,
666    /// Total entities found by the model.
667    pub found: usize,
668    /// Total entities expected (ground truth).
669    pub expected: usize,
670    /// Additional metadata
671    #[serde(default)]
672    pub metadata: Option<EvaluationMetadata>,
673}
674
675/// Additional evaluation metadata for reproducibility.
676#[derive(Debug, Clone, Serialize, Deserialize, Default)]
677pub struct EvaluationMetadata {
678    /// Dataset name
679    pub dataset_name: Option<String>,
680    /// Dataset format (e.g., "CoNLL", "JSONL", "synthetic")
681    pub dataset_format: Option<String>,
682    /// Dataset version or checksum for integrity verification
683    pub dataset_version: Option<String>,
684    /// Number of test cases evaluated
685    pub num_test_cases: usize,
686    /// Total number of gold entities in the dataset
687    pub total_gold_entities: Option<usize>,
688    /// Evaluation timestamp (ISO 8601)
689    pub timestamp: Option<String>,
690    /// Model name/identifier
691    pub model_info: Option<String>,
692    /// Model version (if applicable)
693    pub model_version: Option<String>,
694    /// Matching mode used (e.g., "exact", "partial_0.5")
695    pub matching_mode: Option<String>,
696    /// anno version
697    pub anno_version: Option<String>,
698}
699
700/// Convert EntityType to string label.
701///
702/// Used for evaluation metrics and dataset compatibility.
703pub fn entity_type_to_string(et: &EntityType) -> String {
704    et.as_label().to_string()
705}
706
707/// Entity type matching for evaluation.
708///
709/// Handles fuzzy matching for evaluation:
710/// 1. Exact match (Person == Person)
711/// 2. Normalized match (normalize both to canonical form)
712/// 3. Semantic equivalents (corporation → organization)
713pub fn entity_type_matches(a: &EntityType, b: &EntityType) -> bool {
714    // Fast path: exact match
715    if a == b {
716        return true;
717    }
718
719    // Normalize both to canonical form and compare
720    let a_label = a.as_label().to_uppercase();
721    let b_label = b.as_label().to_uppercase();
722
723    if a_label == b_label {
724        return true;
725    }
726
727    // Semantic equivalents (both directions)
728    matches!(
729        (a_label.as_str(), b_label.as_str()),
730        // Person variations
731        ("PERSON", "PER") | ("PER", "PERSON")
732        // Organization variations (including WNUT corporation)
733        | ("ORGANIZATION", "ORG") | ("ORG", "ORGANIZATION")
734        | ("ORGANIZATION", "CORPORATION") | ("CORPORATION", "ORGANIZATION")
735        | ("ORG", "CORPORATION") | ("CORPORATION", "ORG")
736        | ("ORGANIZATION", "COMPANY") | ("COMPANY", "ORGANIZATION")
737        // Location variations
738        | ("LOCATION", "LOC") | ("LOC", "LOCATION")
739        | ("LOCATION", "GPE") | ("GPE", "LOCATION")
740        | ("LOC", "GPE") | ("GPE", "LOC")
741        // MISC variations
742        | ("MISC", "MISCELLANEOUS") | ("MISCELLANEOUS", "MISC")
743        | ("MISC", "OTHER") | ("OTHER", "MISC")
744    )
745}
746
747/// Load CoNLL-2003 format dataset.
748///
749/// Format: Each line contains: word POS-tag chunk-tag NER-tag
750/// Empty lines separate sentences.
751/// NER tags: B-PER, I-PER, B-ORG, I-ORG, B-LOC, I-LOC, B-MISC, I-MISC, O
752pub fn load_conll2003<P: AsRef<Path>>(path: P) -> Result<Vec<(String, Vec<GoldEntity>)>> {
753    let content = std::fs::read_to_string(path.as_ref()).map_err(Error::Io)?;
754
755    let mut test_cases: Vec<(String, Vec<GoldEntity>)> = Vec::new();
756    let mut current_text = String::new();
757    let mut current_entities: Vec<GoldEntity> = Vec::new();
758    let mut char_offset = 0;
759
760    for line in content.lines() {
761        if line.trim().is_empty() {
762            // End of sentence
763            if !current_text.is_empty() {
764                test_cases.push((current_text.clone(), current_entities.clone()));
765            }
766            current_text.clear();
767            current_entities.clear();
768            char_offset = 0;
769            continue;
770        }
771
772        let parts: Vec<&str> = line.split_whitespace().collect();
773        if parts.len() < 4 {
774            continue; // Skip malformed lines
775        }
776
777        let word = parts[0];
778        let ner_tag = parts[3];
779
780        // Add word to text
781        if !current_text.is_empty() {
782            current_text.push(' ');
783            char_offset += 1;
784        }
785        let word_start = char_offset;
786        current_text.push_str(word);
787        // Offsets in anno are character offsets (Unicode scalar values), not bytes.
788        char_offset += word.chars().count();
789        let word_end = char_offset;
790
791        // Parse NER tag
792        if ner_tag != "O" {
793            let (prefix, entity_type_str) = if let Some(dash_pos) = ner_tag.find('-') {
794                (&ner_tag[..dash_pos], &ner_tag[dash_pos + 1..])
795            } else {
796                continue;
797            };
798
799            let entity_type = match entity_type_str {
800                "PER" => EntityType::Person,
801                "ORG" => EntityType::Organization,
802                "LOC" => EntityType::Location,
803                "MISC" => EntityType::custom("misc", EntityCategory::Misc),
804                "DATE" => EntityType::Date,
805                "MONEY" => EntityType::Money,
806                "PERCENT" => EntityType::Percent,
807                _ => continue,
808            };
809
810            if prefix == "B" {
811                // Beginning of entity - start new entity
812                current_entities.push(GoldEntity::with_span(
813                    word,
814                    entity_type,
815                    word_start,
816                    word_end,
817                ));
818            } else if prefix == "I" {
819                // Inside entity - extend last entity if same type
820                if let Some(last) = current_entities.last_mut() {
821                    if entity_type_matches(&last.entity_type, &entity_type) {
822                        // Extend entity
823                        last.text.push(' ');
824                        last.text.push_str(word);
825                        last.end = word_end;
826                    } else {
827                        // Different type - start new entity
828                        current_entities.push(GoldEntity::with_span(
829                            word,
830                            entity_type,
831                            word_start,
832                            word_end,
833                        ));
834                    }
835                }
836            }
837        }
838    }
839
840    // Handle last sentence if file doesn't end with newline
841    if !current_text.is_empty() {
842        test_cases.push((current_text, current_entities));
843    }
844
845    // Validate all loaded entities
846    for (text, entities) in &test_cases {
847        let validation_result = validation::validate_ground_truth_entities(text, entities, false);
848        if !validation_result.is_valid {
849            return Err(Error::InvalidInput(format!(
850                "Invalid entities in CoNLL dataset: {}",
851                validation_result.errors.join("; ")
852            )));
853        }
854    }
855
856    Ok(test_cases)
857}
858
859/// Evaluate NER model on a dataset.
860pub fn evaluate_ner_model(
861    model: &dyn Model,
862    test_cases: &[(String, Vec<GoldEntity>)],
863) -> Result<NEREvaluationResults> {
864    evaluate_ner_model_with_mapper(model, test_cases, None)
865}
866
867/// Evaluate NER model with optional type normalization.
868///
869/// # Arguments
870/// * `model` - NER model to evaluate
871/// * `test_cases` - Test cases with text and gold entities
872/// * `type_mapper` - Optional TypeMapper to normalize domain-specific types
873///
874/// # Example
875///
876/// ```rust,ignore
877/// use anno::{TypeMapper, RegexNER, Model};
878/// use anno_eval::eval::{evaluate_ner_model_with_mapper, GoldEntity};
879///
880/// // MIT Movie dataset - normalize ACTOR/DIRECTOR to Person
881/// let mapper = TypeMapper::mit_movie();
882/// let test_cases = vec![
883///     ("Tom Hanks directed the movie".to_string(), vec![
884///         GoldEntity::with_label("Tom Hanks", "ACTOR", 0),
885///     ]),
886/// ];
887///
888/// let model = RegexNER::new();
889/// let results = evaluate_ner_model_with_mapper(&model, &test_cases, Some(&mapper));
890/// ```
891pub fn evaluate_ner_model_with_mapper(
892    model: &dyn Model,
893    test_cases: &[(String, Vec<GoldEntity>)],
894    type_mapper: Option<&anno::TypeMapper>,
895) -> Result<NEREvaluationResults> {
896    let evaluator = evaluator::StandardNEREvaluator::new();
897
898    if test_cases.is_empty() {
899        return Ok(NEREvaluationResults {
900            precision: 0.0,
901            recall: 0.0,
902            f1: 0.0,
903            macro_f1: None,
904            weighted_f1: None,
905            per_type: HashMap::new(),
906            tokens_per_second: 0.0,
907            found: 0,
908            expected: 0,
909            metadata: Some(EvaluationMetadata {
910                num_test_cases: 0,
911                total_gold_entities: Some(0),
912                timestamp: Some(chrono::Utc::now().to_rfc3339()),
913                anno_version: Some(env!("CARGO_PKG_VERSION").to_string()),
914                ..Default::default()
915            }),
916        });
917    }
918
919    // Evaluate each test case
920    let mut query_metrics = Vec::new();
921    for (i, (text, ground_truth)) in test_cases.iter().enumerate() {
922        let test_case_id = format!("test_case_{}", i);
923
924        // Apply type normalization if mapper provided
925        let normalized_truth: Vec<GoldEntity>;
926        let truth_ref = if let Some(mapper) = type_mapper {
927            normalized_truth = ground_truth
928                .iter()
929                .map(|e| GoldEntity {
930                    text: e.text.clone(),
931                    entity_type: mapper.normalize(e.entity_type.as_label()),
932                    original_label: e.original_label.clone(), // Preserve original for debugging
933                    start: e.start,
934                    end: e.end,
935                })
936                .collect();
937            &normalized_truth
938        } else {
939            ground_truth
940        };
941
942        let metrics = evaluator.evaluate_test_case(model, text, truth_ref, Some(&test_case_id))?;
943        query_metrics.push(metrics);
944    }
945
946    // Aggregate metrics
947    let aggregate = evaluator.aggregate(&query_metrics)?;
948
949    // Compute macro F1
950    let macro_f1 = if aggregate.per_type.is_empty() {
951        None
952    } else {
953        let sum: f64 = aggregate.per_type.values().map(|m| m.f1).sum();
954        Some(sum / aggregate.per_type.len() as f64)
955    };
956
957    // Compute weighted F1
958    let weighted_f1 = if aggregate.per_type.is_empty() || aggregate.total_expected == 0 {
959        None
960    } else {
961        let weighted_sum: f64 = aggregate
962            .per_type
963            .values()
964            .map(|m| m.f1 * m.expected as f64)
965            .sum();
966        Some(weighted_sum / aggregate.total_expected as f64)
967    };
968
969    Ok(NEREvaluationResults {
970        precision: aggregate.precision.get(),
971        recall: aggregate.recall.get(),
972        f1: aggregate.f1.get(),
973        macro_f1,
974        weighted_f1,
975        per_type: aggregate.per_type,
976        tokens_per_second: aggregate.tokens_per_second,
977        found: aggregate.total_found,
978        expected: aggregate.total_expected,
979        metadata: Some(EvaluationMetadata {
980            num_test_cases: aggregate.num_test_cases,
981            total_gold_entities: Some(aggregate.total_expected),
982            timestamp: Some(chrono::Utc::now().to_rfc3339()),
983            anno_version: Some(env!("CARGO_PKG_VERSION").to_string()),
984            ..Default::default()
985        }),
986    })
987}
988
989/// Compare multiple NER models on the same dataset.
990pub fn compare_ner_models(
991    models: &[(&str, &dyn Model)],
992    test_cases: &[(String, Vec<GoldEntity>)],
993) -> Result<HashMap<String, NEREvaluationResults>> {
994    let mut results = HashMap::new();
995
996    for (name, model) in models {
997        log::info!("Evaluating {}...", name);
998        let result = evaluate_ner_model(*model, test_cases)?;
999        results.insert(name.to_string(), result);
1000    }
1001
1002    Ok(results)
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008
1009    #[test]
1010    fn test_entity_type_to_string() {
1011        assert_eq!(entity_type_to_string(&EntityType::Person), "PER");
1012        assert_eq!(entity_type_to_string(&EntityType::Organization), "ORG");
1013        assert_eq!(entity_type_to_string(&EntityType::Location), "LOC");
1014    }
1015
1016    #[test]
1017    fn test_entity_type_matches() {
1018        assert!(entity_type_matches(
1019            &EntityType::Person,
1020            &EntityType::Person
1021        ));
1022        assert!(!entity_type_matches(
1023            &EntityType::Person,
1024            &EntityType::Organization
1025        ));
1026    }
1027}