Skip to main content

anno_eval/eval/
task_evaluator.rs

1//! Comprehensive Task-Dataset-Backend Evaluation System
2//!
3//! This module provides a unified evaluation framework that:
4//! - Maps tasks to suitable datasets
5//! - Maps datasets to compatible backends
6//! - Runs evaluations across all valid combinations
7//! - Generates comprehensive reports
8//!
9//! # Design Philosophy
10//!
11//! - **Trait-based**: Backend capabilities detected via trait implementations
12//! - **Many-to-many**: Each task can use multiple datasets, each dataset can evaluate multiple tasks
13//! - **Comprehensive**: Evaluates all valid task-dataset-backend combinations
14//! - **Extensible**: Easy to add new tasks, datasets, or backends
15
16use crate::eval::backend_factory::BackendFactory;
17use crate::eval::loader::{DatasetId, DatasetLoader, LoadedDataset};
18#[cfg(feature = "eval-profiling")]
19use crate::eval::profiling;
20use crate::eval::task_mapping::{
21    dataset_tasks, get_task_backends, get_task_datasets, Task, TaskMapping,
22};
23use anno::backends::inference::ZeroShotNER;
24use anno::{Entity, Model, Result};
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27use std::sync::Mutex;
28use std::time::Instant;
29
30/// Lock a std::sync::Mutex, recovering from poisoning.
31fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
32    mutex.lock().unwrap_or_else(|e| e.into_inner())
33}
34
35// Type aliases for complex types
36type PerExampleScores = Vec<(Vec<anno::Entity>, Vec<anno::Entity>, String)>;
37
38// Constants for evaluation
39/// 95% confidence interval z-score (normal distribution)
40const DEFAULT_Z_SCORE_95: f64 = 1.96;
41/// Fallback standard deviation when actual variance cannot be computed.
42///
43/// This value (0.05, or 5%) is used as a conservative estimate when we cannot compute
44/// actual variance from per-example scores. It represents a typical standard deviation
45/// for evaluation metrics, providing a reasonable CI width for reporting purposes.
46///
47/// Note: This is a fallback - prefer computing actual variance from per-example scores
48/// when available via `compute_confidence_intervals_from_scores()`.
49const DEFAULT_FALLBACK_STD_DEV: f64 = 0.05;
50/// Maximum sample size for confidence interval computation (to avoid expensive recomputation)
51const MAX_CI_SAMPLE_SIZE: usize = 100;
52/// Minimum sample size for confidence interval computation
53///
54/// Set to 2 because confidence intervals require at least 2 samples for meaningful variance estimation.
55const MIN_CI_SAMPLE_SIZE: usize = 2;
56/// Maximum number of examples for robustness testing (performance limit)
57///
58/// Used in `compute_robustness()` to limit the number of test cases processed.
59#[cfg(feature = "eval")]
60const ROBUSTNESS_TEST_LIMIT: usize = 50;
61
62/// Stratified metrics across multiple dimensions.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct StratifiedMetrics {
65    /// Metrics by entity type
66    pub by_entity_type: HashMap<String, MetricWithCI>,
67    /// Metrics by temporal stratum (if available)
68    pub by_temporal_stratum: Option<HashMap<String, MetricWithCI>>,
69    /// Metrics by surface form type (proper noun, common noun, pronoun)
70    pub by_surface_form: Option<HashMap<String, MetricWithCI>>,
71    /// Metrics by mention characteristics (capitalized, partial name, etc.)
72    pub by_mention_char: Option<HashMap<String, MetricWithCI>>,
73}
74
75/// Metrics with confidence intervals.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct MetricWithCI {
78    /// Mean value
79    pub mean: f64,
80    /// Standard deviation
81    pub std_dev: f64,
82    /// 95% confidence interval (lower, upper)
83    pub ci_95: (f64, f64),
84    /// Sample size
85    pub n: usize,
86}
87
88/// Confidence intervals for key metrics.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ConfidenceIntervals {
91    /// F1 score CI
92    pub f1_ci: (f64, f64),
93    /// Precision CI
94    pub precision_ci: (f64, f64),
95    /// Recall CI
96    pub recall_ci: (f64, f64),
97}
98
99/// Cached backend enum for thread-local storage (avoids Box<dyn Any> downcast issues).
100#[allow(clippy::large_enum_variant)]
101#[cfg(feature = "eval-parallel")]
102enum CachedBackend {
103    #[cfg(feature = "onnx")]
104    NuNER(anno::backends::nuner::NuNER),
105    #[cfg(feature = "onnx")]
106    GLiNEROnnx(anno::backends::gliner_onnx::GLiNEROnnx),
107    #[cfg(feature = "onnx")]
108    GLiNERMultitaskOnnx(anno::backends::gliner_multitask::GLiNERMultitaskOnnx),
109    #[cfg(feature = "candle")]
110    GLiNERCandle(anno::backends::gliner_candle::GLiNERCandle),
111    #[cfg(feature = "onnx")]
112    GLiNERPoly(anno::backends::gliner_poly::GLiNERPoly),
113    UniversalNER(anno::backends::universal_ner::UniversalNER),
114}
115
116/// Configuration for task evaluation.
117#[derive(Serialize, Deserialize)]
118pub struct TaskEvalConfig {
119    /// Which tasks to evaluate
120    pub tasks: Vec<Task>,
121    /// Which datasets to use (if empty, uses all suitable datasets for each task)
122    pub datasets: Vec<DatasetId>,
123    /// Which backends to test (if empty, uses all compatible backends)
124    pub backends: Vec<String>,
125    /// Maximum number of examples per dataset (for quick testing)
126    pub max_examples: Option<usize>,
127    /// Random seed for sampling (for reproducibility and varied testing)
128    pub seed: Option<u64>,
129    /// Whether to skip datasets that aren't cached
130    pub require_cached: bool,
131    /// Confidence threshold for relation extraction (default: 0.5)
132    pub relation_threshold: f32,
133    /// Whether to run robustness testing (perturbations)
134    pub robustness: bool,
135    /// Whether to compute familiarity scores for zero-shot evaluations
136    pub compute_familiarity: bool,
137    /// Whether to compute temporal stratification (if dataset supports it)
138    pub temporal_stratification: bool,
139    /// Whether to compute confidence intervals for metrics
140    pub confidence_intervals: bool,
141    /// Optional custom coreference resolver (for use with matryoshka-box trained models)
142    /// If None, resolver is created from backend_name using create_coref_resolver()
143    /// Uses Arc to allow sharing across multiple evaluation calls
144    #[serde(skip)]
145    pub custom_coref_resolver:
146        Option<std::sync::Arc<dyn crate::eval::coref_resolver::CoreferenceResolver>>,
147
148    /// Coreference evaluation mode:
149    /// - `false` (default): run NER to get mentions, then resolve coref on those mentions.
150    /// - `true`: use GOLD mentions from the coref dataset and evaluate clustering only.
151    ///
152    /// This is critical for datasets like CorefUD where mentions include pronouns/nominals
153    /// and empty nodes (zero anaphora) that typical NER backends do not emit.
154    pub coref_use_gold_mentions: bool,
155}
156
157impl Default for TaskEvalConfig {
158    fn default() -> Self {
159        Self {
160            tasks: Task::all().to_vec(),
161            datasets: vec![],
162            backends: vec![],
163            max_examples: None,
164            seed: Some(42),
165            require_cached: false,
166            relation_threshold: 0.5,
167            robustness: false,
168            compute_familiarity: true, // Default to true for zero-shot awareness
169            temporal_stratification: false,
170            confidence_intervals: true, // Default to true for better reporting
171            custom_coref_resolver: None,
172            coref_use_gold_mentions: false,
173        }
174    }
175}
176
177impl std::fmt::Debug for TaskEvalConfig {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        f.debug_struct("TaskEvalConfig")
180            .field("tasks", &self.tasks)
181            .field("datasets", &self.datasets)
182            .field("backends", &self.backends)
183            .field("max_examples", &self.max_examples)
184            .field("seed", &self.seed)
185            .field("require_cached", &self.require_cached)
186            .field("relation_threshold", &self.relation_threshold)
187            .field("robustness", &self.robustness)
188            .field("compute_familiarity", &self.compute_familiarity)
189            .field("temporal_stratification", &self.temporal_stratification)
190            .field("confidence_intervals", &self.confidence_intervals)
191            .field("coref_use_gold_mentions", &self.coref_use_gold_mentions)
192            .field(
193                "custom_coref_resolver",
194                &if self.custom_coref_resolver.is_some() {
195                    "Some(...)"
196                } else {
197                    "None"
198                },
199            )
200            .finish()
201    }
202}
203
204/// Results from evaluating a task-dataset-backend combination.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct TaskEvalResult {
207    /// Task being evaluated
208    pub task: Task,
209    /// Dataset used
210    pub dataset: DatasetId,
211    /// Backend name
212    pub backend: String,
213    /// Backend display name (may include composition details, e.g. `stacked(regex+heuristic)`).
214    ///
215    /// Best-effort: when absent, callers should fall back to `backend`.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub backend_display: Option<String>,
218    /// Random seed used for sampling/examples.
219    pub seed: u64,
220    /// Whether evaluation succeeded
221    pub success: bool,
222    /// Error message if failed
223    pub error: Option<String>,
224    /// Metrics (task-specific, stored as JSON-serializable map)
225    pub metrics: HashMap<String, f64>,
226    /// Number of examples evaluated
227    pub num_examples: usize,
228    /// Time taken in milliseconds (if available)
229    pub duration_ms: Option<f64>,
230    /// Label shift/familiarity metrics (if computed for zero-shot)
231    pub label_shift: Option<super::types::LabelShift>,
232    /// Robustness scores (if robustness testing was enabled)
233    #[cfg(feature = "eval")]
234    pub robustness: Option<super::robustness::RobustnessResults>,
235    #[cfg(not(feature = "eval"))]
236    /// Robustness testing results (only available with `eval` feature).
237    #[cfg(not(feature = "eval"))]
238    pub robustness: Option<()>, // Stub when `eval` feature not enabled
239    /// Stratified metrics by various dimensions
240    pub stratified: Option<StratifiedMetrics>,
241    /// Confidence intervals for key metrics (if computed)
242    pub confidence_intervals: Option<ConfidenceIntervals>,
243    /// KB version used (if available from dataset metadata)
244    pub kb_version: Option<String>,
245}
246
247#[derive(Debug)]
248struct BackendEvalOk {
249    metrics: HashMap<String, f64>,
250    backend_display: Option<String>,
251}
252
253impl TaskEvalResult {
254    /// Check if this is a "skipped" result (feature not available or incompatible) vs actual failure
255    pub fn is_skipped(&self) -> bool {
256        if self.success {
257            return false;
258        }
259        if let Some(ref err) = self.error {
260            err.starts_with("incompatible:")
261                || err.contains("Feature not available")
262                || err.contains("requires '")
263                || err.contains("Incompatible entity types")
264        } else {
265            false
266        }
267    }
268
269    /// Get primary F1 metric for ranking
270    pub fn primary_f1(&self) -> Option<f64> {
271        self.metrics
272            .get("f1")
273            .or_else(|| self.metrics.get("conll_f1"))
274            .or_else(|| self.metrics.get("strict_f1"))
275            .copied()
276    }
277}
278
279/// Comprehensive evaluation results across all combinations.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct ComprehensiveEvalResults {
282    /// Individual evaluation results
283    pub results: Vec<TaskEvalResult>,
284    /// Summary statistics
285    pub summary: EvalSummary,
286}
287
288/// Summary statistics for comprehensive evaluation.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct EvalSummary {
291    /// Total combinations evaluated
292    pub total_combinations: usize,
293    /// Successful evaluations
294    pub successful: usize,
295    /// Failed evaluations (actual errors, not skipped)
296    pub failed: usize,
297    /// Skipped evaluations (feature not available, etc.)
298    pub skipped: usize,
299    /// Tasks evaluated
300    pub tasks: Vec<Task>,
301    /// Datasets used
302    pub datasets: Vec<DatasetId>,
303    /// Backends tested
304    pub backends: Vec<String>,
305}
306
307/// Evaluator for task-dataset-backend combinations.
308pub struct TaskEvaluator {
309    loader: DatasetLoader,
310    #[allow(dead_code)] // Reserved for future use
311    mapping: TaskMapping,
312    // Temporary storage for per-example scores (used during evaluation)
313    // Cloned when needed to avoid borrow checker issues
314    per_example_scores_cache: Mutex<Option<PerExampleScores>>,
315    /// Evaluation history tracker (optional, for persistent result storage)
316    history: Option<super::history::EvalHistory>,
317}
318
319impl TaskEvaluator {
320    /// Access the evaluation history (if initialized).
321    pub fn history(&self) -> Option<&super::history::EvalHistory> {
322        self.history.as_ref()
323    }
324
325    /// True if this task has a real end-to-end evaluation path implemented in `TaskEvaluator`.
326    ///
327    /// Tasks may be "catalogued" (present in the dataset registry and task mapping) without
328    /// having an evaluation pipeline yet; those tasks should not be scheduled by the matrix.
329    pub fn is_task_supported(task: Task) -> bool {
330        matches!(
331            task,
332            Task::NER
333                | Task::DiscontinuousNER
334                | Task::RelationExtraction
335                | Task::IntraDocCoref
336                | Task::InterDocCoref
337                | Task::AbstractAnaphora
338                | Task::TextClassification
339                | Task::EventExtraction
340                | Task::SpeechActClassification
341                | Task::Temporal
342                | Task::DiscourseRelations
343                | Task::DiscourseSegmentation
344        )
345    }
346
347    /// Create a new task evaluator.
348    pub fn new() -> Result<Self> {
349        // Resolution order (mirrors eval_history_jsonl_path() in matrix_muxer_ci):
350        //   1. ANNO_EVAL_HISTORY — explicit override
351        //   2. ANNO_CACHE_DIR   — CI-consistent cache root (avoids split-brain with the harness)
352        //   3. platform cache   — dirs::cache_dir()/anno/eval-results.jsonl
353        let history_path: std::path::PathBuf = std::env::var("ANNO_EVAL_HISTORY")
354            .map(std::path::PathBuf::from)
355            .or_else(|_| {
356                std::env::var("ANNO_CACHE_DIR")
357                    .map(|d| std::path::PathBuf::from(d).join("eval-results.jsonl"))
358            })
359            .unwrap_or_else(|_| {
360                dirs::cache_dir()
361                    .map(|d| d.join("anno").join("eval-results.jsonl"))
362                    .unwrap_or_else(|| std::path::PathBuf::from("eval-results.jsonl"))
363            });
364        let history = super::history::EvalHistory::new(&history_path)
365            .map_err(|e| {
366                log::warn!("Failed to initialize eval history: {}", e);
367                e
368            })
369            .ok();
370
371        Ok(Self {
372            loader: DatasetLoader::new()?,
373            mapping: TaskMapping::build(),
374            per_example_scores_cache: Mutex::new(None),
375            history,
376        })
377    }
378
379    /// Create a new task evaluator with a custom cache directory.
380    ///
381    /// Useful for testing with isolated caches.
382    pub fn with_cache_dir(cache_dir: impl AsRef<std::path::Path>) -> Result<Self> {
383        let cache_path = cache_dir.as_ref();
384
385        // Use same directory for history if cache_dir is provided
386        // If cache_dir is a file, use its parent; if it's a dir, use it directly
387        let history_path = if cache_path.is_file() {
388            cache_path
389                .parent()
390                .map(|p| p.join("eval-results.jsonl"))
391                .unwrap_or_else(|| cache_path.with_file_name("eval-results.jsonl"))
392        } else {
393            cache_path.join("eval-results.jsonl")
394        };
395        let history = super::history::EvalHistory::new(&history_path)
396            .map_err(|e| {
397                log::warn!("Failed to initialize eval history: {}", e);
398                e
399            })
400            .ok();
401
402        Ok(Self {
403            loader: DatasetLoader::new()?,
404            mapping: TaskMapping::build(),
405            per_example_scores_cache: Mutex::new(None),
406            history,
407        })
408    }
409
410    fn sample_dataset_for_task(
411        task: Task,
412        dataset_data: &LoadedDataset,
413        config: &TaskEvalConfig,
414    ) -> (LoadedDataset, usize) {
415        let total = dataset_data.sentences.len();
416        let (sampled_data, sentences_to_use) = if let Some(max) = config.max_examples {
417            if max >= total {
418                (dataset_data.clone(), total)
419            } else {
420                // Task-aware, deterministic sampling:
421                //
422                // For NER, prefer sentences that actually contain gold entities so tiny samples
423                // are less likely to be “all negatives”, which creates noisy 0.0-F1 outcomes.
424                let seed = config.seed.unwrap_or(42);
425                use std::collections::hash_map::DefaultHasher;
426                use std::hash::{Hash, Hasher};
427                let eligible_indices: Vec<usize> = match task {
428                    Task::NER | Task::DiscontinuousNER | Task::EventExtraction => dataset_data
429                        .sentences
430                        .iter()
431                        .enumerate()
432                        .filter_map(|(i, s)| {
433                            if s.entities().is_empty() {
434                                None
435                            } else {
436                                Some(i)
437                            }
438                        })
439                        .collect(),
440                    _ => (0..total).collect(),
441                };
442                let fallback_indices: Vec<usize>;
443                let base: &[usize] = if eligible_indices.is_empty() {
444                    // Fallback if we can’t detect positives / no gold entities.
445                    fallback_indices = (0..total).collect();
446                    &fallback_indices
447                } else {
448                    &eligible_indices
449                };
450
451                let mut indices: Vec<(usize, u64)> = base
452                    .iter()
453                    .copied()
454                    .map(|i| {
455                        let mut hasher = DefaultHasher::new();
456                        seed.hash(&mut hasher);
457                        i.hash(&mut hasher);
458                        (i, hasher.finish())
459                    })
460                    .collect();
461                indices.sort_by_key(|(_, hash)| *hash);
462                let selected_indices: Vec<usize> = indices
463                    .iter()
464                    .take(max.min(indices.len()))
465                    .map(|(i, _)| *i)
466                    .collect();
467                let sampled_sentences: Vec<_> = selected_indices
468                    .iter()
469                    .filter_map(|&i| dataset_data.sentences.get(i).cloned())
470                    .collect();
471                let sampled_dataset = LoadedDataset {
472                    id: dataset_data.id,
473                    sentences: sampled_sentences,
474                    loaded_at: dataset_data.loaded_at.clone(),
475                    source_url: dataset_data.source_url.clone(),
476                    data_source: dataset_data.data_source,
477                    temporal_metadata: dataset_data.temporal_metadata.clone(),
478                    metadata: dataset_data.metadata.clone(),
479                };
480                let n = sampled_dataset.sentences.len();
481                (sampled_dataset, n)
482            }
483        } else {
484            (dataset_data.clone(), total)
485        };
486
487        (sampled_data, sentences_to_use)
488    }
489
490    fn evaluate_backend_on_loaded(
491        &self,
492        task: Task,
493        dataset: DatasetId,
494        backend_name: &str,
495        sampled_data: &LoadedDataset,
496        sentences_to_use: usize,
497        config: &TaskEvalConfig,
498    ) -> TaskEvalResult {
499        let seed = config.seed.unwrap_or(42);
500        // Try to evaluate backend (handles backend creation internally)
501        let start = Instant::now();
502        match self.try_evaluate_backend(task, dataset, backend_name, sampled_data, config) {
503            Ok(ok) => {
504                let metrics = ok.metrics;
505                let duration = start.elapsed().as_secs_f64() * 1000.0;
506                let num_examples = if task.is_coref_family() {
507                    metrics
508                        .get("num_docs")
509                        .copied()
510                        .map(|n| n.max(0.0) as usize)
511                        .unwrap_or(sentences_to_use)
512                } else {
513                    sentences_to_use
514                };
515
516                // Compute familiarity for zero-shot backends
517                let label_shift = if config.compute_familiarity {
518                    self.compute_familiarity_if_zero_shot(backend_name, sampled_data)
519                } else {
520                    None
521                };
522
523                // Run robustness testing if enabled
524                #[cfg(feature = "eval")]
525                let robustness_result: Option<
526                    super::robustness::RobustnessResults,
527                > = if config.robustness && matches!(task, Task::NER | Task::DiscontinuousNER) {
528                    self.compute_robustness(backend_name, sampled_data, config)
529                } else {
530                    None
531                };
532
533                // Compute stratified metrics (use per-example scores if available)
534                // Extract per-example scores once and reuse for both stratified metrics and confidence intervals
535                let per_example_opt =
536                    { lock::<Option<PerExampleScores>>(&self.per_example_scores_cache).clone() };
537
538                let stratified = if matches!(task, Task::NER | Task::DiscontinuousNER) {
539                    if let Some(per_example) = per_example_opt.as_ref() {
540                        self.compute_stratified_metrics_from_scores(
541                            sampled_data,
542                            &metrics,
543                            Some(per_example),
544                        )
545                    } else {
546                        self.compute_stratified_metrics(sampled_data, &metrics)
547                    }
548                } else {
549                    None
550                };
551
552                // Compute confidence intervals if requested (use per-example scores if available)
553                let confidence_intervals = if config.confidence_intervals {
554                    if let Some(per_example) = per_example_opt.as_ref() {
555                        self.compute_confidence_intervals_from_scores(per_example)
556                    } else {
557                        self.compute_confidence_intervals(
558                            sampled_data,
559                            task,
560                            backend_name,
561                            &metrics,
562                            config,
563                        )
564                    }
565                } else {
566                    None
567                };
568
569                // Clear cache after use
570                let mut cache = lock(&self.per_example_scores_cache);
571                *cache = None;
572
573                // Extract KB version if available
574                let kb_version = Self::extract_kb_version(sampled_data);
575
576                TaskEvalResult {
577                    task,
578                    dataset,
579                    backend: backend_name.to_string(),
580                    backend_display: ok.backend_display,
581                    seed,
582                    success: true,
583                    error: None,
584                    metrics,
585                    num_examples,
586                    duration_ms: Some(duration),
587                    label_shift,
588                    #[cfg(feature = "eval")]
589                    robustness: robustness_result,
590                    #[cfg(not(feature = "eval"))]
591                    robustness: None,
592                    stratified,
593                    confidence_intervals,
594                    kb_version,
595                }
596            }
597            Err(e) => {
598                let duration = start.elapsed().as_secs_f64() * 1000.0;
599                TaskEvalResult {
600                    task,
601                    dataset,
602                    backend: backend_name.to_string(),
603                    backend_display: None,
604                    seed,
605                    success: false,
606                    error: Some(format!("{}", e)),
607                    metrics: HashMap::new(),
608                    num_examples: sentences_to_use,
609                    duration_ms: Some(duration),
610                    label_shift: None,
611                    #[cfg(feature = "eval")]
612                    robustness: None,
613                    #[cfg(not(feature = "eval"))]
614                    robustness: None,
615                    stratified: None,
616                    confidence_intervals: None,
617                    kb_version: None,
618                }
619            }
620        }
621    }
622
623    /// Run comprehensive evaluation across all valid combinations.
624    pub fn evaluate_all(&self, config: TaskEvalConfig) -> Result<ComprehensiveEvalResults> {
625        let seed = config.seed.unwrap_or(42);
626        let mut results = Vec::new();
627        let mut tasks_evaluated = Vec::new();
628        let mut datasets_used = Vec::new();
629        let mut backends_tested: Vec<String> = Vec::new();
630        let mut dataset_cache: HashMap<DatasetId, LoadedDataset> = HashMap::new();
631        let mut sampled_cache: HashMap<(Task, DatasetId), (LoadedDataset, usize)> = HashMap::new();
632
633        // Determine which tasks to evaluate
634        let tasks = if config.tasks.is_empty() {
635            Task::all().to_vec()
636        } else {
637            config.tasks.clone()
638        };
639
640        for task in &tasks {
641            tasks_evaluated.push(*task);
642
643            // Get suitable datasets for this task
644            let datasets = if config.datasets.is_empty() {
645                get_task_datasets(*task)
646            } else {
647                // Filter to datasets that support this task
648                config
649                    .datasets
650                    .iter()
651                    .filter(|d| dataset_tasks(**d).contains(task))
652                    .copied()
653                    .collect()
654            };
655
656            for dataset in &datasets {
657                if !datasets_used.contains(dataset) {
658                    datasets_used.push(*dataset);
659                }
660                // Note: `require_cached` is treated as “prefer cache” rather than “never download”.
661                // The loader’s strategy is:
662                // - local cache
663                // - S3 cache (if enabled)
664                // - URL download
665                //
666                // CI uses S3 (when configured) to keep matrix runs stable, but it should still
667                // attempt downloads when caches are cold to avoid a no-op run.
668
669                // Get compatible backends for this task
670                let backends: Vec<String> = if config.backends.is_empty() {
671                    get_task_backends(*task)
672                        .iter()
673                        .map(|s| s.to_string())
674                        .collect()
675                } else {
676                    // If the caller specifies explicit backends, still filter them per-task.
677                    // Otherwise we waste time evaluating impossible combinations and inflate
678                    // "expected failures" (which reduces signal from matrix sampling).
679                    let allowed: std::collections::HashSet<&'static str> =
680                        get_task_backends(*task).into_iter().collect();
681                    config
682                        .backends
683                        .iter()
684                        .filter(|b| allowed.contains(b.as_str()))
685                        .cloned()
686                        .collect()
687                };
688
689                // Further filter by dataset-level compatibility (entity types, etc.).
690                // Track incompatible backends for better error reporting.
691                let (compatible_backends, incompatible_backends): (Vec<String>, Vec<String>) =
692                    backends
693                        .into_iter()
694                        .partition(|b| Self::is_backend_compatible(b, *dataset));
695
696                // Add incompatible backends as results with clear error message
697                for backend_name in &incompatible_backends {
698                    if !backends_tested.contains(backend_name) {
699                        backends_tested.push(backend_name.clone());
700                    }
701                    let dataset_entity_types = dataset.entity_types();
702                    results.push(TaskEvalResult {
703                        task: *task,
704                        dataset: *dataset,
705                        backend: backend_name.to_string(),
706                        backend_display: None,
707                        seed,
708                        success: false,
709                        error: Some(format!(
710                            "incompatible: backend '{}' doesn't support dataset entity types: {:?}",
711                            backend_name, dataset_entity_types
712                        )),
713                        metrics: HashMap::new(),
714                        num_examples: 0,
715                        duration_ms: None,
716                        label_shift: None,
717                        #[cfg(feature = "eval")]
718                        robustness: None,
719                        #[cfg(not(feature = "eval"))]
720                        robustness: None,
721                        stratified: None,
722                        confidence_intervals: None,
723                        kb_version: None,
724                    });
725                }
726
727                if compatible_backends.is_empty() {
728                    continue;
729                }
730
731                let backends = compatible_backends;
732
733                // Load dataset once per dataset id and reuse across backends.
734                if !dataset_cache.contains_key(dataset) {
735                    let loaded: Result<LoadedDataset> = {
736                        #[cfg(feature = "eval")]
737                        {
738                            let loadable = crate::eval::LoadableDatasetId::try_from(*dataset)
739                                .map_err(|e| crate::Error::InvalidInput(format!("{}", e)))?;
740                            self.loader.load_or_download(loadable)
741                        }
742                        #[cfg(not(feature = "eval"))]
743                        {
744                            let loadable = crate::eval::LoadableDatasetId::try_from(*dataset)
745                                .map_err(|e| crate::Error::InvalidInput(format!("{}", e)))?;
746                            self.loader.load(loadable)
747                        }
748                    };
749                    match loaded {
750                        Ok(d) => {
751                            dataset_cache.insert(*dataset, d);
752                        }
753                        Err(e) => {
754                            for backend_name in &backends {
755                                if !backends_tested.contains(backend_name) {
756                                    backends_tested.push(backend_name.clone());
757                                }
758                                results.push(TaskEvalResult {
759                                    task: *task,
760                                    dataset: *dataset,
761                                    backend: backend_name.to_string(),
762                                    backend_display: None,
763                                    seed,
764                                    success: false,
765                                    error: Some(format!("Failed to load dataset: {}", e)),
766                                    metrics: HashMap::new(),
767                                    num_examples: 0,
768                                    duration_ms: None,
769                                    label_shift: None,
770                                    #[cfg(feature = "eval")]
771                                    robustness: None,
772                                    #[cfg(not(feature = "eval"))]
773                                    robustness: None,
774                                    stratified: None,
775                                    confidence_intervals: None,
776                                    kb_version: None,
777                                });
778                            }
779                            continue;
780                        }
781                    }
782                }
783
784                let dataset_data = dataset_cache.get(dataset).expect("cache populated");
785
786                if dataset_data.sentences.is_empty() {
787                    for backend_name in &backends {
788                        if !backends_tested.contains(backend_name) {
789                            backends_tested.push(backend_name.clone());
790                        }
791                        results.push(TaskEvalResult {
792                            task: *task,
793                            dataset: *dataset,
794                            backend: backend_name.to_string(),
795                            backend_display: None,
796                            seed,
797                            success: false,
798                            error: Some(format!(
799                                "Dataset '{}' is empty (no sentences found)",
800                                dataset.name()
801                            )),
802                            metrics: HashMap::new(),
803                            num_examples: 0,
804                            duration_ms: None,
805                            label_shift: None,
806                            #[cfg(feature = "eval")]
807                            robustness: None,
808                            #[cfg(not(feature = "eval"))]
809                            robustness: None,
810                            stratified: None,
811                            confidence_intervals: None,
812                            kb_version: None,
813                        });
814                    }
815                    continue;
816                }
817
818                sampled_cache.entry((*task, *dataset)).or_insert_with(|| {
819                    let (sampled, n) = Self::sample_dataset_for_task(*task, dataset_data, &config);
820                    (sampled, n)
821                });
822                let (sampled_data, sentences_to_use) = sampled_cache
823                    .get(&(*task, *dataset))
824                    .expect("sampled cache populated");
825
826                for backend_name in &backends {
827                    if !backends_tested.contains(backend_name) {
828                        backends_tested.push(backend_name.clone());
829                    }
830                    results.push(self.evaluate_backend_on_loaded(
831                        *task,
832                        *dataset,
833                        backend_name,
834                        sampled_data,
835                        *sentences_to_use,
836                        &config,
837                    ));
838                }
839            }
840        }
841
842        let skipped = results.iter().filter(|r| r.is_skipped()).count();
843        let failed = results
844            .iter()
845            .filter(|r| !r.success && !r.is_skipped())
846            .count();
847        let summary = EvalSummary {
848            total_combinations: results.len(),
849            successful: results.iter().filter(|r| r.success).count(),
850            failed,
851            skipped,
852            tasks: tasks_evaluated,
853            datasets: datasets_used,
854            backends: backends_tested,
855        };
856
857        #[cfg(feature = "eval-profiling")]
858        profiling::print_summary();
859
860        // Store results in history if available
861        if let Some(ref history) = self.history {
862            for result in &results {
863                let entry = super::history::EvalHistoryEntry::from(result);
864                if let Err(e) = history.append_entry(&entry) {
865                    log::warn!("Failed to store result in history: {}", e);
866                }
867            }
868        }
869
870        Ok(ComprehensiveEvalResults { results, summary })
871    }
872
873    /// Check if backend is compatible with dataset entity types.
874    ///
875    /// - `stacked`: Compatible with most types (combines pattern+heuristic)
876    /// - ML backends: Always compatible (zero-shot or trained)
877    /// - `pattern`: Only structured entities (not named entities)
878    /// - `heuristic`: Only Person, Organization, Location
879    pub(crate) fn is_backend_compatible(backend_name: &str, dataset: DatasetId) -> bool {
880        let entity_types = dataset.entity_types();
881        let normalized_types: Vec<String> = entity_types.iter().map(|t| t.to_lowercase()).collect();
882
883        match backend_name {
884            // Stacked combines pattern+heuristic, so it's compatible with most types
885            "stacked" => true,
886            // Classical backends in this repo are trained/implemented for CoNLL-style tags.
887            "crf" | "hmm" => {
888                let supported = [
889                    "person",
890                    "per",
891                    "organization",
892                    "org",
893                    "location",
894                    "loc",
895                    "misc",
896                ];
897                normalized_types
898                    .iter()
899                    .all(|t| supported.iter().any(|s| t == s || t.starts_with(s)))
900            }
901            // ML backends are zero-shot or trained, so compatible
902            "bert_onnx" | "candle_ner" | "nuner" | "nuner_4k" | "b2ner" | "gliner_onnx"
903            | "gliner_candle" | "gliner_multitask" | "gliner_pii" | "gliner_relex" | "w2ner"
904            | "gliner_poly" | "deberta_v3" | "albert" | "universal_ner" => true,
905            // Pattern only does structured entities (not named entities)
906            "pattern" => {
907                // RegexNER only extracts: Date, Time, Money, Percent, Email, URL, Phone
908                // Not compatible with named entity datasets
909                false
910            }
911            // Heuristic only does Person, Organization, Location
912            "heuristic" => {
913                let supported = [
914                    "person",
915                    "per",
916                    "organization",
917                    "org",
918                    "location",
919                    "loc",
920                    "misc",
921                ];
922                normalized_types
923                    .iter()
924                    .all(|t| supported.iter().any(|s| t == s || t.starts_with(s)))
925            }
926            _ => true, // Unknown backends - assume compatible
927        }
928    }
929
930    /// Evaluate a backend on a task with actual inference and metrics.
931    ///
932    /// This implementation:
933    /// 1. Creates backend instance via `BackendFactory`
934    /// 2. Runs inference on dataset examples
935    /// 3. Computes task-specific metrics (P/R/F1 for NER, MUC/B³/CEAF for coref, etc.)
936    /// 4. Returns metrics as a map
937    fn try_evaluate_backend(
938        &self,
939        task: Task,
940        dataset: DatasetId,
941        backend_name: &str,
942        dataset_data: &LoadedDataset,
943        config: &TaskEvalConfig,
944    ) -> Result<BackendEvalOk> {
945        // Validate task-dataset compatibility
946        let dataset_tasks = dataset_tasks(dataset);
947        if !dataset_tasks.contains(&task) {
948            return Err(crate::Error::InvalidInput(format!(
949                "Dataset {:?} does not support task {:?}",
950                dataset, task
951            )));
952        }
953
954        // Validate task-backend compatibility
955        let backend_tasks: Vec<String> = get_task_backends(task)
956            .iter()
957            .map(|s| s.to_string())
958            .collect();
959        if !backend_tasks.contains(&backend_name.to_string()) {
960            return Err(crate::Error::InvalidInput(format!(
961                "Backend '{}' does not support task {:?}",
962                backend_name, task
963            )));
964        }
965
966        // Run task-specific evaluation
967        // Note: Coref tasks don't use BackendFactory (they use create_coref_resolver)
968        match task {
969            Task::NER
970            | Task::DiscontinuousNER
971            | Task::EventExtraction
972            | Task::Temporal
973            | Task::DiscourseSegmentation => {
974                let backend = BackendFactory::create(backend_name)?;
975                let backend_display = {
976                    let n = backend.name().trim();
977                    if n.is_empty() || n.eq_ignore_ascii_case("unknown") {
978                        Some(backend_name.to_string())
979                    } else {
980                        Some(n.to_string())
981                    }
982                };
983                // Check availability before evaluation
984                if !backend.is_available() {
985                    return Err(crate::Error::FeatureNotAvailable(format!(
986                        "Backend '{}' is not available (feature not enabled or model not loaded)",
987                        backend_name
988                    )));
989                }
990                let metrics =
991                    self.evaluate_ner_task(backend_name, &*backend, dataset, dataset_data, config)?;
992                Ok(BackendEvalOk {
993                    metrics,
994                    backend_display,
995                })
996            }
997            Task::IntraDocCoref | Task::InterDocCoref | Task::AbstractAnaphora => {
998                // Coref tasks use create_coref_resolver, not BackendFactory
999                // Skip BackendFactory::create() to avoid "Unknown backend" error
1000                let metrics = self.evaluate_coref_task(task, backend_name, dataset_data, config)?;
1001                Ok(BackendEvalOk {
1002                    metrics,
1003                    backend_display: None,
1004                })
1005            }
1006            Task::RelationExtraction => {
1007                // Relation extraction requires a Model backend
1008                let backend = BackendFactory::create(backend_name)?;
1009                let backend_display = {
1010                    let n = backend.name().trim();
1011                    if n.is_empty() || n.eq_ignore_ascii_case("unknown") {
1012                        Some(backend_name.to_string())
1013                    } else {
1014                        Some(n.to_string())
1015                    }
1016                };
1017                // Check availability before evaluation
1018                if !backend.is_available() {
1019                    return Err(crate::Error::FeatureNotAvailable(format!(
1020                        "Backend '{}' is not available (feature not enabled or model not loaded)",
1021                        backend_name
1022                    )));
1023                }
1024                let metrics =
1025                    self.evaluate_relation_task(backend_name, &*backend, dataset_data, config)?;
1026                Ok(BackendEvalOk {
1027                    metrics,
1028                    backend_display,
1029                })
1030            }
1031            Task::TextClassification | Task::SpeechActClassification | Task::DiscourseRelations => {
1032                let metrics = self.evaluate_text_classification_task(
1033                    backend_name,
1034                    dataset,
1035                    dataset_data,
1036                    config,
1037                )?;
1038                Ok(BackendEvalOk {
1039                    metrics,
1040                    backend_display: None,
1041                })
1042            }
1043            _ => Err(crate::Error::InvalidInput(format!(
1044                "Task {} is catalogued but not yet supported by TaskEvaluator",
1045                task.code()
1046            ))),
1047        }
1048    }
1049
1050    /// Evaluate NER task with actual inference.
1051    fn evaluate_ner_task(
1052        &self,
1053        backend_name: &str,
1054        backend: &dyn Model,
1055        dataset: DatasetId,
1056        dataset_data: &LoadedDataset,
1057        _config: &TaskEvalConfig,
1058    ) -> Result<HashMap<String, f64>> {
1059        use crate::eval::metrics::compute_extraction_quality_metrics;
1060        use crate::eval::ner_metrics::evaluate_entities;
1061
1062        #[cfg(feature = "eval-profiling")]
1063        profiling::start("evaluate_ner_task");
1064
1065        // Pre-allocate vectors with estimated capacity to reduce reallocations
1066        let estimated_entities = dataset_data.sentences.len() * 3; // Rough estimate: ~3 entities per sentence
1067        let mut all_gold = Vec::with_capacity(estimated_entities);
1068        let mut all_predicted = Vec::with_capacity(estimated_entities);
1069        let mut total_chars = 0;
1070        let start_time = Instant::now();
1071
1072        // Track per-example scores for stratified metrics and confidence intervals
1073        // Always track for NER tasks (needed for per-type metrics)
1074        // Note: This function is only called for NER/DiscontinuousNER tasks
1075        let track_per_example = true;
1076        let mut per_example_scores: Vec<(Vec<Entity>, Vec<Entity>, String)> = Vec::new();
1077
1078        // Extract dataset entity types and map to model-compatible labels
1079        let dataset_labels = dataset.entity_types();
1080        let mapped_labels = Self::map_dataset_labels_to_model(dataset_labels, backend_name);
1081
1082        // Debug: log mapped labels for zero-shot models
1083        if std::env::var("ANNO_DEBUG_LABELS").is_ok() {
1084            eprintln!(
1085                "DEBUG [{}]: dataset_labels={:?} mapped_labels={:?}",
1086                backend_name, dataset_labels, mapped_labels
1087            );
1088        }
1089
1090        // Check if this is a zero-shot backend that needs custom labels
1091        let is_zero_shot = matches!(
1092            backend_name.to_lowercase().as_str(),
1093            "nuner"
1094                | "gliner_onnx"
1095                | "gliner_candle"
1096                | "gliner_multitask"
1097                | "gliner_poly"
1098                | "universal_ner"
1099        );
1100
1101        // Process sentences (parallel if rayon is available, sequential otherwise)
1102        let total_sentences = dataset_data.sentences.len();
1103
1104        #[cfg(feature = "eval-parallel")]
1105        {
1106            use rayon::prelude::*;
1107            use std::cell::RefCell;
1108            use std::sync::atomic::{AtomicUsize, Ordering};
1109            use std::sync::Arc;
1110
1111            // For parallel processing, use thread-local storage to cache backends per thread
1112            // This avoids the need to share state across threads while still caching per thread
1113            // Using CachedBackend enum instead of Box<dyn Any> to avoid downcast issues
1114            thread_local! {
1115                // Store (normalized_name, backend_name_used_for_creation, backend)
1116                // Using enum instead of Box<dyn Any> for type safety
1117                static THREAD_CACHED_BACKEND: RefCell<Option<(String, String, CachedBackend)>> = const { RefCell::new(None) };
1118            }
1119
1120            // Normalize backend name to lowercase for consistent caching
1121            let backend_name_normalized = backend_name.to_lowercase();
1122            let backend_name_arc = Arc::new(backend_name_normalized);
1123            let mapped_labels_arc = Arc::new(mapped_labels.clone());
1124            let is_zero_shot_flag = is_zero_shot;
1125
1126            let progress_counter = AtomicUsize::new(0);
1127            let last_progress_percent = Arc::new(Mutex::new(0));
1128            let start_time_arc = Arc::new(Mutex::new(start_time));
1129
1130            let all_results: Vec<_> = dataset_data.sentences
1131                .par_iter()
1132                .enumerate()
1133                .map(|(_idx, sentence)| {
1134                    let text = sentence.text();
1135                    let chars_count = text.chars().count();
1136
1137                    // Extract gold entities (clone necessary for parallel processing)
1138                    let gold_entities: Vec<Entity> = sentence.entities().iter().map(|g| {
1139                        let mut entity = Entity::new(
1140                            g.text.clone(), // Clone necessary: sentence.entities() returns references
1141                            g.entity_type.clone(), // Clone necessary: sentence.entities() returns references
1142                            g.start,
1143                            g.end,
1144                            1.0,
1145                        );
1146                        entity.provenance = Some(crate::Provenance::ml("gold", 1.0));
1147                        entity
1148                    }).collect();
1149
1150                    // Run inference - use thread-local cached backend for zero-shot models
1151                    let entities_result = if is_zero_shot_flag && !mapped_labels_arc.is_empty() {
1152                        THREAD_CACHED_BACKEND.with(|cache| {
1153                            let mut cached = cache.borrow_mut();
1154                            // Check if we have a cached backend for this backend_name (case-insensitive)
1155                            let backend_name_lower = backend_name_arc.as_str().to_lowercase();
1156                            if let Some((ref cached_name, ref _creation_name, ref backend)) = *cached {
1157                                if cached_name.to_lowercase() == backend_name_lower {
1158                                    // Use cached backend - no downcast needed, enum is type-safe
1159                                    return Self::extract_with_cached_backend(
1160                                        backend,
1161                                        &text,
1162                                        &mapped_labels_arc,
1163                                    );
1164                                }
1165                            }
1166                            // Create and cache new backend for this thread
1167                            let creation_name = backend_name_arc.as_str().to_string();
1168                            match Self::create_zero_shot_backend(backend_name_arc.as_str()) {
1169                                Ok(new_backend) => {
1170                                    let result = Self::extract_with_cached_backend(
1171                                        &new_backend,
1172                                        &text,
1173                                        &mapped_labels_arc,
1174                                    );
1175                                    // Store normalized (lowercase) name for matching, and creation name for reference
1176                                    *cached = Some((backend_name_lower, creation_name, new_backend));
1177                                    result
1178                                }
1179                                Err(e) => Err(e),
1180                            }
1181                        })
1182                    } else {
1183                        backend.extract_entities(&text, None)
1184                    };
1185
1186                    // Update progress with time estimates
1187                    let processed = progress_counter.fetch_add(1, Ordering::Relaxed) + 1;
1188                    let current_percent = (processed * 100) / total_sentences;
1189                    let mut last_percent = lock(&last_progress_percent);
1190                    if current_percent >= *last_percent + 10 || processed.is_multiple_of(10) {
1191                        let elapsed = lock(&start_time_arc).elapsed();
1192                        let elapsed_secs = elapsed.as_secs_f64();
1193                        let rate = if elapsed_secs > 0.0 {
1194                            processed as f64 / elapsed_secs
1195                        } else {
1196                            0.0
1197                        };
1198                        let remaining = if rate > 0.0 {
1199                            ((total_sentences - processed) as f64 / rate) as u64
1200                        } else {
1201                            0
1202                        };
1203                        let remaining_str = if remaining > 0 {
1204                            format!(" (~{}s remaining)", remaining)
1205                        } else {
1206                            String::new()
1207                        };
1208                        eprint!("\rProcessing: {}/{} sentences ({:.0}%) for backend '{}' on dataset '{}'{}\x1b[K",
1209                            processed, total_sentences, current_percent, backend_name, dataset, remaining_str);
1210                        *last_percent = current_percent;
1211                    }
1212
1213                    let text = sentence.text();
1214                    (chars_count, gold_entities, entities_result, text.to_string())
1215                })
1216                .collect();
1217
1218            // Final progress update with timing
1219            let total_elapsed = start_time.elapsed();
1220            let total_secs = total_elapsed.as_secs_f64();
1221            let (time_str, rate_str) = if total_secs >= 0.01 {
1222                (
1223                    format!("{:.2}s", total_secs),
1224                    format!("{:.1} sentences/s", total_sentences as f64 / total_secs),
1225                )
1226            } else {
1227                let ms = total_elapsed.as_millis();
1228                let time_str = if ms == 0 {
1229                    "<1ms".to_string()
1230                } else {
1231                    format!("{ms}ms")
1232                };
1233                (time_str, "n/a".to_string())
1234            };
1235            eprint!(
1236                "\rProcessing: {}/{} sentences (100.0%) for backend '{}' on dataset '{}' (completed in {}, {})\x1b[K",
1237                total_sentences,
1238                total_sentences,
1239                backend_name,
1240                dataset,
1241                time_str,
1242                rate_str
1243            );
1244            eprintln!(); // Newline after progress
1245
1246            // Aggregate results and track per-example scores if needed
1247            for (chars_count, gold_entities, entities_result, text) in all_results {
1248                total_chars += chars_count;
1249
1250                match entities_result {
1251                    Ok(entities) => {
1252                        if track_per_example {
1253                            // Clone when tracking per-example (need to store in cache)
1254                            all_gold.extend(gold_entities.clone());
1255                            all_predicted.extend(entities.clone());
1256                            per_example_scores.push((gold_entities, entities, text));
1257                        } else {
1258                            // Move when not tracking (more efficient)
1259                            all_gold.extend(gold_entities);
1260                            all_predicted.extend(entities);
1261                        }
1262                    }
1263                    Err(e) => {
1264                        // Still need to extend all_gold even on error (for metrics)
1265                        if track_per_example {
1266                            all_gold.extend(gold_entities.clone());
1267                        } else {
1268                            all_gold.extend(gold_entities);
1269                        }
1270                        eprintln!("\nWarning: Backend inference failed: {}", e);
1271                    }
1272                }
1273            }
1274        }
1275
1276        #[cfg(not(feature = "eval-parallel"))]
1277        {
1278            // For zero-shot backends, create a cached instance once to avoid recreating for each sentence
1279            // Non-parallel path still uses Box<dyn Any> for backward compatibility
1280            let zero_shot_backend: Option<Box<dyn std::any::Any>> =
1281                if is_zero_shot && !mapped_labels.is_empty() {
1282                    Some(Self::create_zero_shot_backend_any(backend_name)?)
1283                } else {
1284                    None
1285                };
1286
1287            // Sequential processing (fallback when rayon not available)
1288            for (idx, sentence) in dataset_data.sentences.iter().enumerate() {
1289                // Progress reporting every 10% or every 10 sentences, whichever is more frequent
1290                if idx % 10 == 0 || idx == total_sentences - 1 {
1291                    let progress = ((idx + 1) as f64 / total_sentences as f64) * 100.0;
1292                    let elapsed = start_time.elapsed();
1293                    let elapsed_secs = elapsed.as_secs_f64();
1294                    let rate = if elapsed_secs > 0.0 {
1295                        (idx + 1) as f64 / elapsed_secs
1296                    } else {
1297                        0.0
1298                    };
1299                    let remaining = if rate > 0.0 {
1300                        ((total_sentences.saturating_sub(idx).saturating_sub(1)) as f64 / rate)
1301                            as u64
1302                    } else {
1303                        0
1304                    };
1305                    let remaining_str = if remaining > 0 {
1306                        format!(" (~{}s remaining)", remaining)
1307                    } else {
1308                        String::new()
1309                    };
1310                    eprint!("\rProcessing: {}/{} sentences ({:.1}%) for backend '{}' on dataset '{}'{}\x1b[K",
1311                        idx + 1, total_sentences, progress, backend_name, dataset, remaining_str);
1312                }
1313
1314                let text = sentence.text();
1315                total_chars += text.chars().count();
1316
1317                #[cfg(feature = "eval-profiling")]
1318                profiling::start("extract_gold_entities");
1319                // Extract gold entities from sentence
1320                let gold_entities = sentence.entities();
1321                all_gold.extend(gold_entities.iter().map(|g| {
1322                    let mut entity =
1323                        Entity::new(g.text.clone(), g.entity_type.clone(), g.start, g.end, 1.0);
1324                    entity.provenance = Some(crate::Provenance::ml("gold", 1.0));
1325                    entity
1326                }));
1327                #[cfg(feature = "eval-profiling")]
1328                profiling::stop("extract_gold_entities");
1329
1330                #[cfg(feature = "eval-profiling")]
1331                profiling::start("backend_inference");
1332
1333                // Run inference (no prediction cache).
1334                let entities = {
1335                    let inference_start = Instant::now();
1336                    let result = if let Some(ref cached) = zero_shot_backend {
1337                        // Dereference Box to get &dyn Any (not &Box<dyn Any>)
1338                        Self::extract_with_cached_backend_any(
1339                            backend_name,
1340                            cached.as_ref(),
1341                            &text,
1342                            &mapped_labels,
1343                        )
1344                    } else {
1345                        backend.extract_entities(&text, None)
1346                    };
1347                    let _ = inference_start; // reserved for future profiling
1348                    result
1349                };
1350
1351                #[cfg(feature = "eval-profiling")]
1352                profiling::stop("backend_inference");
1353
1354                match entities {
1355                    Ok(entities) => {
1356                        if track_per_example {
1357                            // Clone when tracking per-example (need to store in cache)
1358                            let gold: Vec<Entity> = gold_entities
1359                                .iter()
1360                                .map(|g| {
1361                                    let mut entity = Entity::new(
1362                                        g.text.clone(),
1363                                        g.entity_type.clone(),
1364                                        g.start,
1365                                        g.end,
1366                                        1.0,
1367                                    );
1368                                    entity.provenance = Some(crate::Provenance::ml("gold", 1.0));
1369                                    entity
1370                                })
1371                                .collect();
1372                            all_predicted.extend(entities.clone());
1373                            per_example_scores.push((gold, entities, text.to_string()));
1374                        } else {
1375                            // Move when not tracking (more efficient)
1376                            all_predicted.extend(entities);
1377                        }
1378                    }
1379                    Err(e) => {
1380                        // Log error with more context but continue with other sentences
1381                        let error_msg = format!("{}", e);
1382                        // Categorize errors for better reporting
1383                        let error_type = if error_msg.contains("ONNX")
1384                            || error_msg.contains("GatherElements")
1385                            || error_msg.contains("span_idx")
1386                        {
1387                            "ONNX inference error"
1388                        } else if error_msg.contains("Mutex lock failed") {
1389                            "Thread synchronization error"
1390                        } else if error_msg.contains("Retrieval error") {
1391                            "Model loading error"
1392                        } else {
1393                            "Backend error"
1394                        };
1395                        eprintln!("\nWarning: {} for sentence {}: {}", error_type, idx + 1, e);
1396                        // Log to debug channel for detailed analysis
1397                        log::debug!(
1398                            "Backend '{}' failed on sentence {}: {}",
1399                            backend_name,
1400                            idx + 1,
1401                            e
1402                        );
1403                    }
1404                }
1405            }
1406
1407            // Final progress update with timing
1408            let total_elapsed = start_time.elapsed();
1409            let total_secs = total_elapsed.as_secs_f64();
1410            let (time_str, rate_str) = if total_secs >= 0.01 {
1411                (
1412                    format!("{:.2}s", total_secs),
1413                    format!("{:.1} sentences/s", total_sentences as f64 / total_secs),
1414                )
1415            } else {
1416                let ms = total_elapsed.as_millis();
1417                let time_str = if ms == 0 {
1418                    "<1ms".to_string()
1419                } else {
1420                    format!("{ms}ms")
1421                };
1422                (time_str, "n/a".to_string())
1423            };
1424            eprint!(
1425                "\rProcessing: {}/{} sentences (100.0%) for backend '{}' on dataset '{}' (completed in {}, {})\x1b[K",
1426                total_sentences, total_sentences, backend_name, dataset, time_str, rate_str
1427            );
1428            eprintln!(); // Newline after progress
1429        }
1430
1431        #[cfg(feature = "eval-profiling")]
1432        profiling::stop("evaluate_ner_task");
1433
1434        #[cfg(feature = "eval-profiling")]
1435        profiling::start("compute_metrics");
1436
1437        let elapsed = start_time.elapsed();
1438        let chars_per_second = if elapsed.as_secs_f64() > 0.0 {
1439            total_chars as f64 / elapsed.as_secs_f64()
1440        } else {
1441            0.0
1442        };
1443
1444        // Compute metrics
1445        let eval_results = evaluate_entities(&all_gold, &all_predicted);
1446
1447        #[cfg(feature = "eval-profiling")]
1448        profiling::stop("compute_metrics");
1449        let summary = eval_results.summary();
1450
1451        // Build metrics map
1452        let mut metrics = HashMap::new();
1453        metrics.insert("precision".to_string(), summary.strict_precision);
1454        metrics.insert("recall".to_string(), summary.strict_recall);
1455        metrics.insert("f1".to_string(), summary.strict_f1);
1456        metrics.insert("exact_precision".to_string(), summary.exact_precision);
1457        metrics.insert("exact_recall".to_string(), summary.exact_recall);
1458        metrics.insert("exact_f1".to_string(), summary.exact_f1);
1459        metrics.insert("partial_precision".to_string(), summary.partial_precision);
1460        metrics.insert("partial_recall".to_string(), summary.partial_recall);
1461        metrics.insert("partial_f1".to_string(), summary.partial_f1);
1462        metrics.insert("type_precision".to_string(), summary.type_precision);
1463        metrics.insert("type_recall".to_string(), summary.type_recall);
1464        metrics.insert("type_f1".to_string(), summary.type_f1);
1465        metrics.insert("chars_per_second".to_string(), chars_per_second);
1466        metrics.insert("num_gold".to_string(), all_gold.len() as f64);
1467        metrics.insert("num_predicted".to_string(), all_predicted.len() as f64);
1468
1469        // CORE-KG-inspired diagnostics (heuristic): duplication + noise in predictions.
1470        let q = compute_extraction_quality_metrics(&all_predicted);
1471        metrics.insert("pred_duplication_rate".to_string(), q.duplication_rate);
1472        metrics.insert("pred_noise_rate".to_string(), q.noise_rate);
1473        metrics.insert("pred_duplicates".to_string(), q.duplicates as f64);
1474        metrics.insert("pred_noisy".to_string(), q.noisy as f64);
1475
1476        // Store per-example scores for later use in stratified metrics and confidence intervals
1477        {
1478            // Use blocking lock for cache - it's not critical path and avoids "would block" errors
1479            // If lock fails (poisoned), just skip caching rather than failing the evaluation
1480            let mut cache_guard = lock(&self.per_example_scores_cache);
1481            if !per_example_scores.is_empty() {
1482                *cache_guard = Some(per_example_scores);
1483            } else {
1484                *cache_guard = None;
1485            }
1486            // If lock fails, continue without caching (non-critical)
1487        }
1488
1489        Ok(metrics)
1490    }
1491
1492    /// Map dataset entity type labels to model-compatible labels.
1493    ///
1494    /// Handles common label variations (e.g., "PER" → "person", "PERSON" → "person").
1495    /// Also handles domain-specific mappings (e.g., MIT Movie "Actor" → "person").
1496    /// Also limits labels for backends with restrictions (e.g., NuNER only supports 3 labels).
1497    /// Public for testing purposes.
1498    pub fn map_dataset_labels_to_model(dataset_labels: &[&str], backend_name: &str) -> Vec<String> {
1499        let backend_lower = backend_name.to_lowercase();
1500
1501        // NuNER has a limitation - it fails with GatherElements errors when using more than
1502        // its default 3 labels. Always use the exact default labels in the exact order.
1503        // The order matters because the model internally maps label index to entity type.
1504        if backend_lower == "nuner" {
1505            // Must match NuNER::from_pretrained default_labels exactly: person, organization, location
1506            return vec![
1507                "person".to_string(),
1508                "organization".to_string(),
1509                "location".to_string(),
1510            ];
1511        }
1512
1513        dataset_labels
1514            .iter()
1515            .map(|label| {
1516                // Normalize label to lowercase for matching
1517                let normalized = label.to_lowercase();
1518                match normalized.as_str() {
1519                    // Person variations
1520                    "per" | "person" => "person".to_string(),
1521                    // Organization variations
1522                    "org" | "organization" | "organisation" | "corporation" | "company" => {
1523                        "organization".to_string()
1524                    }
1525                    // Location variations (including WNUT geo-loc)
1526                    "loc" | "location" | "place" | "gpe" | "geo-loc" => "location".to_string(),
1527                    // Other common types
1528                    "misc" | "miscellaneous" | "other" => "misc".to_string(),
1529                    "date" => "date".to_string(),
1530                    "time" => "time".to_string(),
1531                    "money" | "currency" => "money".to_string(),
1532                    "percent" | "percentage" => "percent".to_string(),
1533                    "product" | "prod" => "product".to_string(),
1534                    "event" => "event".to_string(),
1535                    "facility" | "fac" => "facility".to_string(),
1536                    "work_of_art" | "workofart" => "work_of_art".to_string(),
1537                    "law" => "law".to_string(),
1538                    "language" => "language".to_string(),
1539                    "norp" => "norp".to_string(),
1540                    // Domain-specific mappings (MIT Movie, MIT Restaurant, etc.)
1541                    "actor" | "character" | "director" | "producer" | "writer" | "cast" => {
1542                        "person".to_string()
1543                    }
1544                    "restaurant_name" | "restaurant" | "cuisine" | "dish" | "food" => {
1545                        "organization".to_string()
1546                    }
1547                    "disease" | "disorder" | "syndrome" => "disease".to_string(),
1548                    "chemical" | "drug" | "medication" | "compound" => "chemical".to_string(),
1549                    // For zero-shot backends, preserve original labels (they can handle any type)
1550                    _ if matches!(
1551                        backend_lower.as_str(),
1552                        "gliner_onnx"
1553                            | "gliner_candle"
1554                            | "gliner_multitask"
1555                            | "gliner_poly"
1556                            | "universal_ner"
1557                    ) =>
1558                    {
1559                        label.to_lowercase()
1560                    }
1561                    // For other backends, try to map or use original
1562                    _ => label.to_lowercase(),
1563                }
1564            })
1565            .collect()
1566    }
1567
1568    /// Create a zero-shot backend instance (returns Box<dyn Any> for non-parallel path).
1569    ///
1570    /// This avoids recreating the model for every sentence, which causes ONNX errors.
1571    #[cfg(not(feature = "eval-parallel"))]
1572    fn create_zero_shot_backend_any(backend_name: &str) -> Result<Box<dyn std::any::Any>> {
1573        Self::create_zero_shot_backend_impl(backend_name)
1574    }
1575
1576    /// Create a zero-shot backend instance (returns enum for type safety).
1577    ///
1578    /// This avoids recreating the model for every sentence, which causes ONNX errors.
1579    #[cfg(feature = "eval-parallel")]
1580    fn create_zero_shot_backend(backend_name: &str) -> Result<CachedBackend> {
1581        match backend_name.to_lowercase().as_str() {
1582            #[cfg(feature = "onnx")]
1583            "nuner" => {
1584                use crate::DEFAULT_NUNER_MODEL;
1585                use anno::backends::nuner::NuNER;
1586                let nuner = NuNER::from_pretrained(DEFAULT_NUNER_MODEL)?;
1587                Ok(CachedBackend::NuNER(nuner))
1588            }
1589            #[cfg(not(feature = "onnx"))]
1590            "nuner" => Err(crate::Error::FeatureNotAvailable(
1591                "NuNER requires the 'onnx' feature".to_string(),
1592            )),
1593            #[cfg(feature = "onnx")]
1594            "gliner_onnx" | "gliner" => {
1595                use crate::DEFAULT_GLINER_MODEL;
1596                use anno::backends::gliner_onnx::GLiNEROnnx;
1597                let gliner = GLiNEROnnx::new(DEFAULT_GLINER_MODEL)?;
1598                Ok(CachedBackend::GLiNEROnnx(gliner))
1599            }
1600            #[cfg(not(feature = "onnx"))]
1601            "gliner_onnx" | "gliner" => Err(crate::Error::FeatureNotAvailable(
1602                "GLiNER requires the 'onnx' feature".to_string(),
1603            )),
1604            #[cfg(feature = "onnx")]
1605            "gliner_multitask" => {
1606                use crate::DEFAULT_GLINER_MULTITASK_MODEL;
1607                use anno::backends::gliner_multitask::GLiNERMultitaskOnnx;
1608                let gliner_multitask =
1609                    GLiNERMultitaskOnnx::from_pretrained(DEFAULT_GLINER_MULTITASK_MODEL)?;
1610                Ok(CachedBackend::GLiNERMultitaskOnnx(gliner_multitask))
1611            }
1612            #[cfg(not(feature = "onnx"))]
1613            "gliner_multitask" => Err(crate::Error::FeatureNotAvailable(
1614                "GLiNER multi-task requires the 'onnx' feature".to_string(),
1615            )),
1616            #[cfg(feature = "candle")]
1617            "gliner_candle" => {
1618                use crate::DEFAULT_GLINER_MODEL;
1619                use anno::backends::gliner_candle::GLiNERCandle;
1620                let gliner = GLiNERCandle::from_pretrained(DEFAULT_GLINER_MODEL)?;
1621                Ok(CachedBackend::GLiNERCandle(gliner))
1622            }
1623            #[cfg(not(feature = "candle"))]
1624            "gliner_candle" => Err(crate::Error::FeatureNotAvailable(
1625                "GLiNER Candle requires the 'candle' feature".to_string(),
1626            )),
1627            #[cfg(feature = "onnx")]
1628            "gliner_poly" => {
1629                use anno::backends::gliner_poly::GLiNERPoly;
1630                use anno::DEFAULT_GLINER_POLY_MODEL;
1631                let gliner_poly = GLiNERPoly::new(DEFAULT_GLINER_POLY_MODEL)?;
1632                Ok(CachedBackend::GLiNERPoly(gliner_poly))
1633            }
1634            #[cfg(not(feature = "onnx"))]
1635            "gliner_poly" => Err(crate::Error::FeatureNotAvailable(
1636                "GLiNER Poly requires the 'onnx' feature".to_string(),
1637            )),
1638            "universal_ner" => {
1639                use anno::backends::universal_ner::UniversalNER;
1640                let universal_ner = UniversalNER::new()?;
1641                Ok(CachedBackend::UniversalNER(universal_ner))
1642            }
1643            _ => Err(crate::Error::InvalidInput(format!(
1644                "Unknown zero-shot backend: {}",
1645                backend_name
1646            ))),
1647        }
1648    }
1649
1650    /// Internal implementation that creates backend as Box<dyn Any> (for non-parallel path).
1651    #[cfg(not(feature = "eval-parallel"))]
1652    fn create_zero_shot_backend_impl(backend_name: &str) -> Result<Box<dyn std::any::Any>> {
1653        match backend_name.to_lowercase().as_str() {
1654            "nuner" => {
1655                #[cfg(feature = "onnx")]
1656                {
1657                    use crate::DEFAULT_NUNER_MODEL;
1658                    use anno::backends::nuner::NuNER;
1659                    let nuner = NuNER::from_pretrained(DEFAULT_NUNER_MODEL)?;
1660                    Ok(Box::new(nuner))
1661                }
1662                #[cfg(not(feature = "onnx"))]
1663                {
1664                    Err(crate::Error::FeatureNotAvailable(
1665                        "NuNER requires the 'onnx' feature".to_string(),
1666                    ))
1667                }
1668            }
1669            "gliner_onnx" | "gliner" => {
1670                #[cfg(feature = "onnx")]
1671                {
1672                    use crate::DEFAULT_GLINER_MODEL;
1673                    use anno::backends::gliner_onnx::GLiNEROnnx;
1674                    let gliner = GLiNEROnnx::new(DEFAULT_GLINER_MODEL)?;
1675                    Ok(Box::new(gliner))
1676                }
1677                #[cfg(not(feature = "onnx"))]
1678                {
1679                    Err(crate::Error::FeatureNotAvailable(
1680                        "GLiNER requires the 'onnx' feature".to_string(),
1681                    ))
1682                }
1683            }
1684            "gliner_multitask" => {
1685                #[cfg(feature = "onnx")]
1686                {
1687                    use crate::DEFAULT_GLINER_MULTITASK_MODEL;
1688                    use anno::backends::gliner_multitask::GLiNERMultitaskOnnx;
1689                    let gliner_multitask =
1690                        GLiNERMultitaskOnnx::from_pretrained(DEFAULT_GLINER_MULTITASK_MODEL)?;
1691                    Ok(Box::new(gliner_multitask))
1692                }
1693                #[cfg(not(feature = "onnx"))]
1694                {
1695                    Err(crate::Error::FeatureNotAvailable(
1696                        "GLiNER multi-task requires the 'onnx' feature".to_string(),
1697                    ))
1698                }
1699            }
1700            "gliner_candle" => {
1701                #[cfg(feature = "candle")]
1702                {
1703                    use crate::DEFAULT_GLINER_MODEL;
1704                    use anno::backends::gliner_candle::GLiNERCandle;
1705                    let gliner = GLiNERCandle::from_pretrained(DEFAULT_GLINER_MODEL)?;
1706                    Ok(Box::new(gliner))
1707                }
1708                #[cfg(not(feature = "candle"))]
1709                {
1710                    Err(crate::Error::FeatureNotAvailable(
1711                        "GLiNER Candle requires the 'candle' feature".to_string(),
1712                    ))
1713                }
1714            }
1715            "gliner_poly" => {
1716                #[cfg(feature = "onnx")]
1717                {
1718                    use anno::backends::gliner_poly::GLiNERPoly;
1719                    use anno::DEFAULT_GLINER_POLY_MODEL;
1720                    let gliner_poly = GLiNERPoly::new(DEFAULT_GLINER_POLY_MODEL)?;
1721                    Ok(Box::new(gliner_poly))
1722                }
1723                #[cfg(not(feature = "onnx"))]
1724                {
1725                    Err(crate::Error::FeatureNotAvailable(
1726                        "GLiNER Poly requires the 'onnx' feature".to_string(),
1727                    ))
1728                }
1729            }
1730            "universal_ner" => {
1731                use anno::backends::universal_ner::UniversalNER;
1732                let universal_ner = UniversalNER::new()?;
1733                Ok(Box::new(universal_ner))
1734            }
1735            _ => Err(crate::Error::InvalidInput(format!(
1736                "Unknown zero-shot backend: {}",
1737                backend_name
1738            ))),
1739        }
1740    }
1741
1742    /// Extract entities using cached zero-shot backend instance.
1743    #[allow(unused_variables)] // False positives - variables are used in feature-gated code
1744    #[cfg(feature = "eval-parallel")]
1745    fn extract_with_cached_backend(
1746        cached: &CachedBackend,
1747        text: &str,
1748        labels: &[String],
1749    ) -> Result<Vec<Entity>> {
1750        // Convert labels to &str slice
1751        let label_strs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
1752
1753        match cached {
1754            #[cfg(feature = "onnx")]
1755            CachedBackend::NuNER(nuner) => nuner.extract(text, &label_strs, 0.5),
1756            #[cfg(feature = "onnx")]
1757            CachedBackend::GLiNEROnnx(gliner) => {
1758                let result = gliner.extract(text, &label_strs, 0.5);
1759                if std::env::var("ANNO_DEBUG_EXTRACT").is_ok() {
1760                    eprintln!(
1761                        "DEBUG gliner result: {:?}",
1762                        result.as_ref().map(|v| v.len())
1763                    );
1764                }
1765                result
1766            }
1767            #[cfg(feature = "onnx")]
1768            CachedBackend::GLiNERMultitaskOnnx(gliner_multitask) => {
1769                use anno::backends::gliner_multitask::TaskSchema;
1770                let schema = TaskSchema::new().with_entities(&label_strs);
1771                let result = gliner_multitask.extract(text, &schema)?;
1772                Ok(result.entities)
1773            }
1774            #[cfg(feature = "candle")]
1775            CachedBackend::GLiNERCandle(gliner) => gliner.extract(text, &label_strs, 0.5),
1776            #[cfg(feature = "onnx")]
1777            CachedBackend::GLiNERPoly(gliner_poly) => {
1778                gliner_poly.extract_with_types(text, &label_strs, 0.5)
1779            }
1780            CachedBackend::UniversalNER(universal_ner) => {
1781                universal_ner.extract_with_types(text, &label_strs, 0.5)
1782            }
1783        }
1784    }
1785
1786    /// Extract entities using cached zero-shot backend instance (Box<dyn Any> version for non-parallel path).
1787    #[allow(unused_variables)] // False positives - variables are used in feature-gated code
1788    #[cfg(not(feature = "eval-parallel"))]
1789    fn extract_with_cached_backend_any(
1790        backend_name: &str,
1791        cached: &dyn std::any::Any,
1792        text: &str,
1793        labels: &[String],
1794    ) -> Result<Vec<Entity>> {
1795        // Convert labels to &str slice
1796        let label_strs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
1797
1798        match backend_name.to_lowercase().as_str() {
1799            "nuner" => {
1800                #[cfg(feature = "onnx")]
1801                {
1802                    if let Some(nuner) = cached.downcast_ref::<anno::backends::nuner::NuNER>() {
1803                        let result = nuner.extract(text, &label_strs, 0.5);
1804                        if std::env::var("ANNO_DEBUG_NUNER").is_ok() {
1805                            eprintln!(
1806                                "DEBUG nuner: text={:?} labels={:?} result={:?}",
1807                                text.chars().take(30).collect::<String>(),
1808                                label_strs,
1809                                result.as_ref().map(|v| v.len())
1810                            );
1811                        }
1812                        result
1813                    } else {
1814                        Err(crate::Error::InvalidInput(
1815                            "Failed to downcast cached NuNER backend".to_string(),
1816                        ))
1817                    }
1818                }
1819                #[cfg(not(feature = "onnx"))]
1820                {
1821                    Err(crate::Error::FeatureNotAvailable(
1822                        "NuNER requires the 'onnx' feature".to_string(),
1823                    ))
1824                }
1825            }
1826            "gliner_onnx" | "gliner" => {
1827                #[cfg(feature = "onnx")]
1828                {
1829                    if let Some(gliner) =
1830                        cached.downcast_ref::<anno::backends::gliner_onnx::GLiNEROnnx>()
1831                    {
1832                        gliner.extract(text, &label_strs, 0.5)
1833                    } else {
1834                        Err(crate::Error::InvalidInput(
1835                            "Failed to downcast cached GLiNER backend".to_string(),
1836                        ))
1837                    }
1838                }
1839                #[cfg(not(feature = "onnx"))]
1840                {
1841                    Err(crate::Error::FeatureNotAvailable(
1842                        "GLiNER requires the 'onnx' feature".to_string(),
1843                    ))
1844                }
1845            }
1846            "gliner_multitask" => {
1847                #[cfg(feature = "onnx")]
1848                {
1849                    use anno::backends::gliner_multitask::TaskSchema;
1850                    if let Some(gliner_multitask) =
1851                        cached
1852                            .downcast_ref::<anno::backends::gliner_multitask::GLiNERMultitaskOnnx>()
1853                    {
1854                        let schema = TaskSchema::new().with_entities(&label_strs);
1855                        let result = gliner_multitask.extract(text, &schema);
1856                        if std::env::var("ANNO_DEBUG_GLINER_MULTITASK").is_ok() {
1857                            eprintln!(
1858                                "DEBUG gliner_multitask: text={:?} labels={:?} result={:?}",
1859                                &text[..text.len().min(50)],
1860                                label_strs,
1861                                result.as_ref().map(|r| r.entities.len())
1862                            );
1863                        }
1864                        Ok(result?.entities)
1865                    } else {
1866                        if std::env::var("ANNO_DEBUG_GLINER_MULTITASK").is_ok() {
1867                            eprintln!("DEBUG gliner_multitask: downcast FAILED");
1868                        }
1869                        Err(crate::Error::InvalidInput(
1870                            "Failed to downcast cached GLiNER multi-task backend".to_string(),
1871                        ))
1872                    }
1873                }
1874                #[cfg(not(feature = "onnx"))]
1875                {
1876                    Err(crate::Error::FeatureNotAvailable(
1877                        "GLiNER multi-task requires the 'onnx' feature".to_string(),
1878                    ))
1879                }
1880            }
1881            "gliner_candle" => {
1882                #[cfg(feature = "candle")]
1883                {
1884                    if let Some(gliner) =
1885                        cached.downcast_ref::<anno::backends::gliner_candle::GLiNERCandle>()
1886                    {
1887                        gliner.extract(text, &label_strs, 0.5)
1888                    } else {
1889                        Err(crate::Error::InvalidInput(
1890                            "Failed to downcast cached GLiNER Candle backend".to_string(),
1891                        ))
1892                    }
1893                }
1894                #[cfg(not(feature = "candle"))]
1895                {
1896                    Err(crate::Error::FeatureNotAvailable(
1897                        "GLiNER Candle requires the 'candle' feature".to_string(),
1898                    ))
1899                }
1900            }
1901            "gliner_poly" => {
1902                #[cfg(feature = "onnx")]
1903                {
1904                    if let Some(gliner_poly) =
1905                        cached.downcast_ref::<anno::backends::gliner_poly::GLiNERPoly>()
1906                    {
1907                        gliner_poly.extract_with_types(text, &label_strs, 0.5)
1908                    } else {
1909                        Err(crate::Error::InvalidInput(
1910                            "Failed to downcast cached GLiNER Poly backend".to_string(),
1911                        ))
1912                    }
1913                }
1914                #[cfg(not(feature = "onnx"))]
1915                {
1916                    Err(crate::Error::FeatureNotAvailable(
1917                        "GLiNER Poly requires the 'onnx' feature".to_string(),
1918                    ))
1919                }
1920            }
1921            "universal_ner" => {
1922                if let Some(universal_ner) =
1923                    cached.downcast_ref::<anno::backends::universal_ner::UniversalNER>()
1924                {
1925                    universal_ner.extract_with_types(text, &label_strs, 0.5)
1926                } else {
1927                    Err(crate::Error::InvalidInput(
1928                        "Failed to downcast cached UniversalNER backend".to_string(),
1929                    ))
1930                }
1931            }
1932            _ => Err(crate::Error::InvalidInput(format!(
1933                "Unknown zero-shot backend: {}",
1934                backend_name
1935            ))),
1936        }
1937    }
1938
1939    /// Evaluate coreference task.
1940    ///
1941    /// For `IntraDocCoref` and `AbstractAnaphora`, runs per-document coreference.
1942    /// For `InterDocCoref`, groups documents by topic and runs cross-document clustering.
1943    fn evaluate_coref_task(
1944        &self,
1945        task: Task,
1946        backend_name: &str,
1947        dataset_data: &LoadedDataset,
1948        config: &TaskEvalConfig,
1949    ) -> Result<HashMap<String, f64>> {
1950        use crate::eval::backend_factory::create_coref_resolver;
1951        use crate::eval::coref::entities_to_chains;
1952        use crate::eval::coref_metrics::{CorefEvaluation, WindowFragmentationStats};
1953
1954        // Try to load coreference documents if dataset supports it
1955        let gold_docs = if dataset_data.id.is_coreference() {
1956            match self.loader.load_coref(dataset_data.id) {
1957                Ok(docs) => {
1958                    if docs.is_empty() {
1959                        // If load_coref returns empty, try downloading first
1960                        #[cfg(feature = "eval")]
1961                        {
1962                            if let Err(e) = self.loader.load_or_download_coref(dataset_data.id) {
1963                                return Err(crate::Error::InvalidInput(format!(
1964                                    "Failed to load coreference dataset {:?}: {}",
1965                                    dataset_data.id, e
1966                                )));
1967                            }
1968                            // Retry after download
1969                            self.loader.load_coref(dataset_data.id)?
1970                        }
1971                        #[cfg(not(feature = "eval"))]
1972                        {
1973                            return Err(crate::Error::InvalidInput(format!(
1974                                "Coreference dataset {:?} not cached. Enable eval feature to auto-download.",
1975                                dataset_data.id
1976                            )));
1977                        }
1978                    } else {
1979                        docs
1980                    }
1981                }
1982                Err(e) => {
1983                    // Try downloading if not cached
1984                    #[cfg(feature = "eval")]
1985                    {
1986                        if let Err(dl_err) = self.loader.load_or_download_coref(dataset_data.id) {
1987                            return Err(crate::Error::InvalidInput(format!(
1988                                "Failed to load/download coreference dataset {:?}: {} (original: {})",
1989                                dataset_data.id, dl_err, e
1990                            )));
1991                        }
1992                        // Retry after download
1993                        self.loader.load_coref(dataset_data.id)?
1994                    }
1995                    #[cfg(not(feature = "eval"))]
1996                    {
1997                        return Err(crate::Error::InvalidInput(format!(
1998                            "Coreference dataset {:?} not cached: {}. Enable eval feature to auto-download.",
1999                            dataset_data.id, e
2000                        )));
2001                    }
2002                }
2003            }
2004        } else {
2005            // Not a coreference dataset - return error metrics
2006            let mut metrics = HashMap::new();
2007            metrics.insert(
2008                "num_sentences".to_string(),
2009                dataset_data.sentences.len() as f64,
2010            );
2011            metrics.insert("error".to_string(), 1.0);
2012            return Ok(metrics);
2013        };
2014
2015        // IMPORTANT: `TaskEvalConfig.max_examples` is interpreted as *max documents* for
2016        // coreference datasets (not "sentences"). Without this, `benchmark --max-examples N`
2017        // still evaluates the full coref dataset, which can be extremely slow.
2018        let gold_docs = if let Some(max) = config.max_examples.filter(|m| *m > 0) {
2019            if max >= gold_docs.len() {
2020                gold_docs
2021            } else {
2022                let seed = config.seed.unwrap_or(42);
2023                use std::collections::hash_map::DefaultHasher;
2024                use std::hash::{Hash, Hasher};
2025
2026                let mut indices: Vec<(usize, u64)> = (0..gold_docs.len())
2027                    .map(|i| {
2028                        let mut hasher = DefaultHasher::new();
2029                        seed.hash(&mut hasher);
2030                        i.hash(&mut hasher);
2031                        (i, hasher.finish())
2032                    })
2033                    .collect();
2034                indices.sort_by_key(|(_, hash)| *hash);
2035
2036                let selected: std::collections::HashSet<usize> =
2037                    indices.into_iter().take(max).map(|(i, _)| i).collect();
2038
2039                gold_docs
2040                    .into_iter()
2041                    .enumerate()
2042                    .filter_map(|(i, doc)| selected.contains(&i).then_some(doc))
2043                    .collect()
2044            }
2045        } else {
2046            gold_docs
2047        };
2048
2049        // ---- InterDocCoref: cross-document clustering path ----
2050        if task == Task::InterDocCoref {
2051            return self.evaluate_inter_doc_coref(&gold_docs, backend_name, config);
2052        }
2053
2054        // ---- IntraDocCoref / AbstractAnaphora: per-document path (unchanged) ----
2055
2056        // Create coreference resolver (not a Model backend)
2057        // Use custom resolver if provided, otherwise create from backend_name
2058        let resolver: std::sync::Arc<dyn crate::eval::coref_resolver::CoreferenceResolver> =
2059            if let Some(ref custom_resolver) = config.custom_coref_resolver {
2060                // Use the custom resolver directly (e.g., TrainedBoxCorefResolver from matryoshka-box)
2061                custom_resolver.clone()
2062            } else {
2063                // Create resolver from backend_name (e.g., "coref_resolver", "box", etc.)
2064                std::sync::Arc::from(create_coref_resolver(backend_name)?)
2065            };
2066
2067        let mut all_predicted_chains = Vec::new();
2068        let mut all_gold_chains = Vec::new();
2069
2070        // Long-document stitching diagnostics (CorefInst-style window fragmentation).
2071        // We use a fixed default windowing scheme matching other long-doc configs in this repo.
2072        let frag_window_size: usize = 4000;
2073        let frag_window_overlap: usize = 256;
2074        let mut frag_multiwindow_gold_chains: usize = 0;
2075        let mut frag_fragmented_gold_chains: usize = 0;
2076        let mut frag_boundary_checks: usize = 0;
2077        let mut frag_boundary_splits: usize = 0;
2078        let mut frag_missing_mentions_in_multiwindow_chains: usize = 0;
2079
2080        // IMPORTANT: Coref metrics in `coref_metrics.rs` key mentions only by (start,end).
2081        // If we concatenate multiple documents without offsetting spans, identical spans across docs
2082        // collide and corrupt metrics. We avoid this by assigning a monotonically increasing
2083        // character base offset per document.
2084        let mut cumulative_char_base: usize = 0;
2085
2086        fn offset_chains(
2087            mut chains: Vec<crate::eval::coref::CorefChain>,
2088            base: usize,
2089        ) -> Vec<crate::eval::coref::CorefChain> {
2090            if base == 0 {
2091                return chains;
2092            }
2093            for chain in &mut chains {
2094                for m in &mut chain.mentions {
2095                    m.start = m.start.saturating_add(base);
2096                    m.end = m.end.saturating_add(base);
2097                    if let Some(hs) = m.head_start.as_mut() {
2098                        *hs = hs.saturating_add(base);
2099                    }
2100                    if let Some(he) = m.head_end.as_mut() {
2101                        *he = he.saturating_add(base);
2102                    }
2103                }
2104            }
2105            chains
2106        }
2107
2108        for doc in &gold_docs {
2109            let doc_base = cumulative_char_base;
2110            let doc_char_len = doc.text.chars().count();
2111            cumulative_char_base =
2112                cumulative_char_base.saturating_add(doc_char_len.saturating_add(1));
2113
2114            // Collect gold chains from the document
2115            all_gold_chains.extend(offset_chains(doc.chains.clone(), doc_base));
2116
2117            // Check if this is a text-based coref backend (CorefBackend)
2118            // rather than an entity-based resolver (CoreferenceResolver).
2119            let is_text_based_coref = matches!(backend_name, "fcoref" | "f-coref" | "fastcoref");
2120
2121            let predicted_chains = if is_text_based_coref {
2122                // Text-based coref: run directly on raw text, bypass NER extraction.
2123                // This is the proper path for neural coref models (FCoref, etc.)
2124                use crate::eval::backend_factory::create_coref_backend;
2125                match create_coref_backend(backend_name) {
2126                    Ok(coref_backend) => {
2127                        match coref_backend.resolve(&doc.text) {
2128                            Ok(clusters) => {
2129                                // Convert CorefCluster -> CorefChain
2130                                use crate::eval::coref::{CorefChain, Mention};
2131                                clusters
2132                                    .into_iter()
2133                                    .map(|cluster| {
2134                                        let mentions = cluster
2135                                            .spans
2136                                            .iter()
2137                                            .zip(cluster.mentions.iter())
2138                                            .map(|(&(start, end), text)| {
2139                                                Mention::new(text, start, end)
2140                                            })
2141                                            .collect();
2142                                        CorefChain {
2143                                            mentions,
2144                                            cluster_id: Some(anno::CanonicalId::new(
2145                                                cluster.id as u64,
2146                                            )),
2147                                            entity_type: None,
2148                                        }
2149                                    })
2150                                    .collect()
2151                            }
2152                            Err(e) => {
2153                                eprintln!(
2154                                    "Warning: CorefBackend inference failed for document: {}",
2155                                    e
2156                                );
2157                                Vec::new()
2158                            }
2159                        }
2160                    }
2161                    Err(e) => {
2162                        return Err(crate::Error::FeatureNotAvailable(format!(
2163                            "Failed to create coref backend '{}': {}",
2164                            backend_name, e
2165                        )));
2166                    }
2167                }
2168            } else if config.coref_use_gold_mentions {
2169                // Gold-mention mode: evaluate clustering only.
2170                //
2171                // We deliberately exclude zero-length mentions (CorefUD empty nodes) from the
2172                // resolver input because most resolvers operate on overt spans.
2173                let mut gold_entities: Vec<crate::Entity> = Vec::new();
2174                for chain in &doc.chains {
2175                    for m in &chain.mentions {
2176                        let is_zero =
2177                            m.mention_type == Some(anno::MentionType::Zero) || m.start == m.end;
2178                        if is_zero {
2179                            continue;
2180                        }
2181                        let et = m
2182                            .entity_type
2183                            .as_deref()
2184                            .map(|t| {
2185                                // Best-effort mapping from CorefUD etype (person/place/organization/...)
2186                                // to our coarse EntityType. Everything else becomes Other.
2187                                let tl = t.to_lowercase();
2188                                if tl.contains("person") {
2189                                    crate::EntityType::Person
2190                                } else if tl.contains("place") || tl.contains("loc") {
2191                                    crate::EntityType::Location
2192                                } else if tl.contains("org") {
2193                                    crate::EntityType::Organization
2194                                } else {
2195                                    crate::EntityType::custom(t, crate::EntityCategory::Misc)
2196                                }
2197                            })
2198                            .unwrap_or_else(|| {
2199                                crate::EntityType::custom("mention", crate::EntityCategory::Misc)
2200                            });
2201
2202                        gold_entities.push(crate::Entity::new(&m.text, et, m.start, m.end, 1.0));
2203                    }
2204                }
2205
2206                let resolved_entities = resolver.resolve(&gold_entities);
2207                entities_to_chains(&resolved_entities)
2208            } else {
2209                // End-to-end mode: extract mentions via NER backend, then cluster.
2210                // Use a NER backend to extract entities first (heuristic or stacked as default)
2211                let ner_backend_name = match backend_name {
2212                    // Coref resolvers are not NER backends. Pick a sensible default mention detector.
2213                    "coref_resolver" | "mention_ranking" | "box" => "stacked",
2214                    // If the user passed an actual NER backend name, allow it.
2215                    other => other,
2216                };
2217                let ner_backend = BackendFactory::create(ner_backend_name)?;
2218
2219                match ner_backend.extract_entities(&doc.text, None) {
2220                    Ok(entities) => {
2221                        let resolved_entities = resolver.resolve(&entities);
2222                        entities_to_chains(&resolved_entities)
2223                    }
2224                    Err(e) => {
2225                        eprintln!("Warning: NER backend inference failed for document: {}", e);
2226                        Vec::new()
2227                    }
2228                }
2229            };
2230
2231            if let Some(fs) = WindowFragmentationStats::compute(
2232                &predicted_chains,
2233                &doc.chains,
2234                frag_window_size,
2235                frag_window_overlap,
2236            ) {
2237                frag_multiwindow_gold_chains += fs.multiwindow_gold_chains;
2238                frag_fragmented_gold_chains += fs.fragmented_gold_chains;
2239                frag_boundary_checks += fs.boundary_checks;
2240                frag_boundary_splits += fs.boundary_splits;
2241                frag_missing_mentions_in_multiwindow_chains +=
2242                    fs.missing_mentions_in_multiwindow_chains;
2243            }
2244
2245            all_predicted_chains.extend(offset_chains(predicted_chains, doc_base));
2246        }
2247
2248        // Compute coreference metrics
2249        let eval = CorefEvaluation::compute(&all_predicted_chains, &all_gold_chains);
2250
2251        let mut metrics = HashMap::new();
2252        metrics.insert("num_docs".to_string(), gold_docs.len() as f64);
2253        metrics.insert("muc_precision".to_string(), eval.muc.precision);
2254        metrics.insert("muc_recall".to_string(), eval.muc.recall);
2255        metrics.insert("muc_f1".to_string(), eval.muc.f1);
2256        metrics.insert("b3_precision".to_string(), eval.b_cubed.precision);
2257        metrics.insert("b3_recall".to_string(), eval.b_cubed.recall);
2258        metrics.insert("b3_f1".to_string(), eval.b_cubed.f1);
2259        metrics.insert("ceaf_e_precision".to_string(), eval.ceaf_e.precision);
2260        metrics.insert("ceaf_e_recall".to_string(), eval.ceaf_e.recall);
2261        metrics.insert("ceaf_e_f1".to_string(), eval.ceaf_e.f1);
2262        metrics.insert("ceaf_m_precision".to_string(), eval.ceaf_m.precision);
2263        metrics.insert("ceaf_m_recall".to_string(), eval.ceaf_m.recall);
2264        metrics.insert("ceaf_m_f1".to_string(), eval.ceaf_m.f1);
2265
2266        // Add chain-length stratification metrics
2267        if let Some(ref chain_stats) = eval.chain_stats {
2268            metrics.insert(
2269                "chain_long_count".to_string(),
2270                chain_stats.long_chain_count as f64,
2271            );
2272            metrics.insert(
2273                "chain_short_count".to_string(),
2274                chain_stats.short_chain_count as f64,
2275            );
2276            metrics.insert(
2277                "chain_singleton_count".to_string(),
2278                chain_stats.singleton_count as f64,
2279            );
2280            metrics.insert("chain_long_f1".to_string(), chain_stats.long_chain_f1);
2281            metrics.insert("chain_short_f1".to_string(), chain_stats.short_chain_f1);
2282            metrics.insert("chain_singleton_f1".to_string(), chain_stats.singleton_f1);
2283        }
2284        metrics.insert("lea_precision".to_string(), eval.lea.precision);
2285        metrics.insert("lea_recall".to_string(), eval.lea.recall);
2286        metrics.insert("lea_f1".to_string(), eval.lea.f1);
2287        metrics.insert("blanc_precision".to_string(), eval.blanc.precision);
2288        metrics.insert("blanc_recall".to_string(), eval.blanc.recall);
2289        metrics.insert("blanc_f1".to_string(), eval.blanc.f1);
2290        metrics.insert("conll_f1".to_string(), eval.conll_f1);
2291
2292        if let Some(z) = eval.zero_anaphor {
2293            metrics.insert("zero_precision".to_string(), z.precision);
2294            metrics.insert("zero_recall".to_string(), z.recall);
2295            metrics.insert("zero_f1".to_string(), z.f1);
2296            metrics.insert("zero_tp".to_string(), z.tp as f64);
2297            metrics.insert("zero_wl".to_string(), z.wl as f64);
2298            metrics.insert("zero_fp".to_string(), z.fp as f64);
2299            metrics.insert("zero_fn".to_string(), z.fn_ as f64);
2300            metrics.insert("zero_gold_anaphors".to_string(), z.gold_anaphors as f64);
2301            metrics.insert("zero_pred_anaphors".to_string(), z.pred_anaphors as f64);
2302        }
2303
2304        if frag_multiwindow_gold_chains > 0 {
2305            metrics.insert(
2306                "window_multiwindow_gold_chains".to_string(),
2307                frag_multiwindow_gold_chains as f64,
2308            );
2309            metrics.insert(
2310                "window_fragmented_gold_chains".to_string(),
2311                frag_fragmented_gold_chains as f64,
2312            );
2313            metrics.insert(
2314                "window_fragmentation_rate".to_string(),
2315                frag_fragmented_gold_chains as f64 / frag_multiwindow_gold_chains as f64,
2316            );
2317            metrics.insert(
2318                "window_boundary_checks".to_string(),
2319                frag_boundary_checks as f64,
2320            );
2321            metrics.insert(
2322                "window_boundary_splits".to_string(),
2323                frag_boundary_splits as f64,
2324            );
2325            if frag_boundary_checks > 0 {
2326                metrics.insert(
2327                    "window_boundary_split_rate".to_string(),
2328                    frag_boundary_splits as f64 / frag_boundary_checks as f64,
2329                );
2330            }
2331            metrics.insert(
2332                "window_missing_mentions_in_multiwindow_chains".to_string(),
2333                frag_missing_mentions_in_multiwindow_chains as f64,
2334            );
2335            metrics.insert("window_size".to_string(), frag_window_size as f64);
2336            metrics.insert("window_overlap".to_string(), frag_window_overlap as f64);
2337        }
2338        metrics.insert("num_documents".to_string(), gold_docs.len() as f64);
2339        metrics.insert("num_gold_chains".to_string(), all_gold_chains.len() as f64);
2340        metrics.insert(
2341            "num_predicted_chains".to_string(),
2342            all_predicted_chains.len() as f64,
2343        );
2344
2345        Ok(metrics)
2346    }
2347
2348    /// Evaluate inter-document (cross-document) coreference.
2349    ///
2350    /// Groups `CorefDocument`s by topic (from metadata), builds `Topic` objects with
2351    /// gold `CrossDocCluster`s, and runs `evaluate_cross_document()`.
2352    fn evaluate_inter_doc_coref(
2353        &self,
2354        gold_docs: &[crate::eval::coref::CorefDocument],
2355        _backend_name: &str,
2356        _config: &TaskEvalConfig,
2357    ) -> Result<HashMap<String, f64>> {
2358        use crate::eval::cdcr::{CrossDocCluster, Document};
2359        use crate::eval::cluster_encoder::{CosineMergeScorer, HeuristicClusterEncoder};
2360        use crate::eval::cross_context_eval::{
2361            evaluate_cross_document, CrossContextEvalConfig, Topic,
2362        };
2363
2364        // Group docs by topic (from metadata, or treat each doc as its own topic)
2365        let mut topics_map: HashMap<String, Vec<&crate::eval::coref::CorefDocument>> =
2366            HashMap::new();
2367
2368        for doc in gold_docs {
2369            // Extract topic from doc_id (format: "topicN_fileM" from ECB+ parser)
2370            let topic_key = doc
2371                .doc_id
2372                .as_deref()
2373                .and_then(|id| id.split('_').next())
2374                .unwrap_or("default")
2375                .to_string();
2376            topics_map.entry(topic_key).or_default().push(doc);
2377        }
2378
2379        // Build Topic objects
2380        let mut topics: Vec<Topic> = Vec::new();
2381        let mut topic_keys: Vec<_> = topics_map.keys().cloned().collect();
2382        topic_keys.sort();
2383
2384        for topic_key in &topic_keys {
2385            let coref_docs = &topics_map[topic_key];
2386            let mut topic = Topic::new(topic_key);
2387
2388            // Convert CorefDocuments to cdcr::Documents and build gold clusters
2389            // Each CorefChain in each doc that shares a chain across docs becomes a cross-doc cluster.
2390            // For ECB+, the chain IDs encode cross-doc identity.
2391            let mut chain_to_mentions: HashMap<String, Vec<(String, usize)>> = HashMap::new();
2392
2393            for coref_doc in coref_docs {
2394                let doc_id = coref_doc
2395                    .doc_id
2396                    .clone()
2397                    .unwrap_or_else(|| format!("doc_{}", topic.documents.len()));
2398
2399                // Build cdcr::Document with entities from gold mentions
2400                let mut entities: Vec<anno::Entity> = Vec::new();
2401                for (chain_idx, chain) in coref_doc.chains.iter().enumerate() {
2402                    for mention in &chain.mentions {
2403                        let et = mention
2404                            .entity_type
2405                            .as_deref()
2406                            .map(|t| {
2407                                let tl = t.to_lowercase();
2408                                if tl.contains("person") {
2409                                    anno::EntityType::Person
2410                                } else if tl.contains("loc") || tl.contains("place") {
2411                                    anno::EntityType::Location
2412                                } else if tl.contains("org") {
2413                                    anno::EntityType::Organization
2414                                } else {
2415                                    anno::EntityType::custom(t, anno::EntityCategory::Misc)
2416                                }
2417                            })
2418                            .unwrap_or(anno::EntityType::custom(
2419                                "mention",
2420                                anno::EntityCategory::Misc,
2421                            ));
2422
2423                        let entity_idx = entities.len();
2424                        entities.push(anno::Entity::new(
2425                            &mention.text,
2426                            et,
2427                            mention.start,
2428                            mention.end,
2429                            1.0,
2430                        ));
2431
2432                        // Track chain membership for cross-doc clustering
2433                        let chain_key = format!("{}_{}", topic_key, chain_idx);
2434                        chain_to_mentions
2435                            .entry(chain_key)
2436                            .or_default()
2437                            .push((doc_id.clone(), entity_idx));
2438                    }
2439                }
2440
2441                let cdcr_doc = Document::new(&doc_id, &coref_doc.text).with_entities(entities);
2442                topic.add_document(cdcr_doc);
2443            }
2444
2445            // Build gold CrossDocClusters from chain_to_mentions
2446            for mentions in chain_to_mentions.values() {
2447                if mentions.len() < 2 {
2448                    continue; // Skip singletons for cross-doc
2449                }
2450                let mut cluster = CrossDocCluster::new(topic.gold_clusters.len() as u64, "");
2451                cluster.mentions = mentions.clone();
2452                topic.add_gold_cluster(cluster);
2453            }
2454
2455            topics.push(topic);
2456        }
2457
2458        // Run cross-document evaluation
2459        let encoder = HeuristicClusterEncoder::new(64);
2460        let scorer = CosineMergeScorer::new();
2461        let config = CrossContextEvalConfig::default();
2462
2463        let results = evaluate_cross_document(&topics, encoder, scorer, &config)?;
2464
2465        // Convert to flat metrics HashMap
2466        let mut metrics = HashMap::new();
2467        metrics.insert("conll_f1".to_string(), results.conll_f1);
2468        metrics.insert("muc_f1".to_string(), results.muc.f1);
2469        metrics.insert("muc_precision".to_string(), results.muc.precision);
2470        metrics.insert("muc_recall".to_string(), results.muc.recall);
2471        metrics.insert("b3_f1".to_string(), results.b_cubed.f1);
2472        metrics.insert("b3_precision".to_string(), results.b_cubed.precision);
2473        metrics.insert("b3_recall".to_string(), results.b_cubed.recall);
2474        metrics.insert("ceaf_e_f1".to_string(), results.ceaf_e.f1);
2475        metrics.insert("ceaf_e_precision".to_string(), results.ceaf_e.precision);
2476        metrics.insert("ceaf_e_recall".to_string(), results.ceaf_e.recall);
2477        metrics.insert("lea_f1".to_string(), results.lea.f1);
2478        metrics.insert("lea_precision".to_string(), results.lea.precision);
2479        metrics.insert("lea_recall".to_string(), results.lea.recall);
2480        metrics.insert("num_topics".to_string(), topics.len() as f64);
2481        metrics.insert("num_documents".to_string(), results.num_contexts as f64);
2482        metrics.insert(
2483            "num_gold_clusters".to_string(),
2484            results.num_gold_clusters as f64,
2485        );
2486        metrics.insert(
2487            "num_pred_clusters".to_string(),
2488            results.num_pred_clusters as f64,
2489        );
2490        metrics.insert("purity".to_string(), results.avg_cluster_size);
2491        metrics.insert("time_ms".to_string(), results.time_ms);
2492        metrics.insert("is_cross_doc".to_string(), 1.0);
2493
2494        Ok(metrics)
2495    }
2496
2497    /// Evaluate relation extraction task.
2498    fn evaluate_relation_task(
2499        &self,
2500        backend_name: &str,
2501        backend: &dyn Model,
2502        dataset_data: &LoadedDataset,
2503        config: &TaskEvalConfig,
2504    ) -> Result<HashMap<String, f64>> {
2505        use crate::eval::relation::{
2506            evaluate_relations, RelationEvalConfig, RelationGold, RelationPrediction,
2507        };
2508
2509        // Load gold relations from dataset (try download if not cached)
2510        let relation_docs = match self.loader.load_relation(dataset_data.id) {
2511            Ok(docs) => docs,
2512            Err(_) => {
2513                // If not cached, try downloading (if eval feature enabled)
2514                #[cfg(feature = "eval")]
2515                {
2516                    match self.loader.load_or_download_relation(dataset_data.id) {
2517                        Ok(docs) => docs,
2518                        Err(e) => {
2519                            eprintln!(
2520                                "Warning: Failed to load/download relations for {:?}: {}",
2521                                dataset_data.id, e
2522                            );
2523                            let mut metrics = HashMap::new();
2524                            metrics.insert("boundary_f1".to_string(), 0.0);
2525                            metrics.insert("strict_f1".to_string(), 0.0);
2526                            metrics.insert("num_gold_relations".to_string(), 0.0);
2527                            metrics.insert("num_predicted_relations".to_string(), 0.0);
2528                            metrics.insert(
2529                                "num_sentences".to_string(),
2530                                dataset_data.sentences.len() as f64,
2531                            );
2532                            return Ok(metrics);
2533                        }
2534                    }
2535                }
2536                #[cfg(not(feature = "eval"))]
2537                {
2538                    eprintln!(
2539                        "Warning: Relations for {:?} not cached and 'eval' feature not enabled (cannot download)",
2540                        dataset_data.id
2541                    );
2542                    let mut metrics = HashMap::new();
2543                    metrics.insert("boundary_f1".to_string(), 0.0);
2544                    metrics.insert("strict_f1".to_string(), 0.0);
2545                    metrics.insert("num_gold_relations".to_string(), 0.0);
2546                    metrics.insert("num_predicted_relations".to_string(), 0.0);
2547                    metrics.insert(
2548                        "num_sentences".to_string(),
2549                        dataset_data.sentences.len() as f64,
2550                    );
2551                    return Ok(metrics);
2552                }
2553            }
2554        };
2555
2556        // Collect all gold relations
2557        let mut all_gold_relations: Vec<RelationGold> = Vec::new();
2558        for doc in &relation_docs {
2559            all_gold_relations.extend(doc.relations.iter().cloned());
2560        }
2561
2562        // Extract predicted relations from backend
2563        let mut all_predicted_relations: Vec<RelationPrediction> = Vec::new();
2564
2565        // Extract relations using RelationExtractor if backend supports it
2566        // GLiNER multi-task backends implement RelationExtractor
2567        use anno::backends::inference::RelationExtractor;
2568
2569        // Try to create RelationExtractor instance for relation extraction backends
2570        let relation_extractor: Option<Box<dyn RelationExtractor>> = match backend_name {
2571            #[cfg(feature = "onnx")]
2572            "gliner_multitask" | "gliner_multitask_onnx" => {
2573                use crate::DEFAULT_GLINER_MULTITASK_MODEL;
2574                use anno::backends::gliner_multitask::GLiNERMultitaskOnnx;
2575                match GLiNERMultitaskOnnx::from_pretrained(DEFAULT_GLINER_MULTITASK_MODEL) {
2576                    Ok(extractor) => Some(Box::new(extractor) as Box<dyn RelationExtractor>),
2577                    Err(e) => {
2578                        eprintln!(
2579                            "Warning: Failed to create GLiNER multi-task (ONNX) for relation extraction: {}",
2580                            e
2581                        );
2582                        None
2583                    }
2584                }
2585            }
2586            #[cfg(all(feature = "candle", feature = "onnx"))]
2587            "gliner_multitask_candle" => {
2588                use crate::DEFAULT_GLINER_MULTITASK_MODEL;
2589                use anno::backends::gliner_multitask::GLiNERMultitaskCandle;
2590                match GLiNERMultitaskCandle::from_pretrained(DEFAULT_GLINER_MULTITASK_MODEL) {
2591                    Ok(extractor) => Some(Box::new(extractor) as Box<dyn RelationExtractor>),
2592                    Err(e) => {
2593                        eprintln!(
2594                            "Warning: Failed to create GLiNER multi-task (Candle) for relation extraction: {}",
2595                            e
2596                        );
2597                        None
2598                    }
2599                }
2600            }
2601            "tplinker" | "tplink" => {
2602                use anno::backends::tplinker::TPLinker;
2603                match TPLinker::new() {
2604                    Ok(extractor) => Some(Box::new(extractor) as Box<dyn RelationExtractor>),
2605                    Err(e) => {
2606                        eprintln!("Warning: Failed to create TPLinker: {e}");
2607                        None
2608                    }
2609                }
2610            }
2611            _ => None,
2612        };
2613
2614        // Extract relations from each document
2615        let allow_oracle_entities = std::env::var("ANNO_RELATION_ORACLE_ENTITIES")
2616            .ok()
2617            .map(|v| {
2618                let v = v.trim().to_lowercase();
2619                v == "1" || v == "true" || v == "yes" || v == "y"
2620            })
2621            .unwrap_or(true);
2622        // TPLinker uses ONNX neural inference when the `onnx` feature is enabled,
2623        // with a heuristic fallback otherwise. For relation datasets like DocRED/CHisIEC
2624        // that provide gold entity spans/types, allow an optional “oracle entities” mode
2625        // so the eval is not dominated by mention detection mismatch.
2626        let tplinker_oracle_entities = std::env::var("ANNO_RELATION_TPLINKER_ORACLE_ENTITIES")
2627            .ok()
2628            .map(|v| {
2629                let v = v.trim().to_lowercase();
2630                v == "1" || v == "true" || v == "yes" || v == "y"
2631            })
2632            .unwrap_or(true);
2633        let mut oracle_docs_used: usize = 0;
2634        let mut oracle_tplinker_docs_used: usize = 0;
2635
2636        for doc in &relation_docs {
2637            let text = &doc.text;
2638
2639            if let Some(ref rel_extractor) = relation_extractor {
2640                // Use RelationExtractor to extract relations
2641                // Get entity types and relation types from gold relations
2642                let entity_types: Vec<&str> = doc
2643                    .relations
2644                    .iter()
2645                    .flat_map(|r| vec![r.head_type.as_str(), r.tail_type.as_str()])
2646                    .collect::<std::collections::HashSet<_>>()
2647                    .into_iter()
2648                    .collect();
2649
2650                let relation_types: Vec<&str> = doc
2651                    .relations
2652                    .iter()
2653                    .map(|r| r.relation_type.as_str())
2654                    .collect::<std::collections::HashSet<_>>()
2655                    .into_iter()
2656                    .collect();
2657
2658                // Use configurable threshold from TaskEvalConfig
2659                match rel_extractor.extract_with_relations(
2660                    text,
2661                    &entity_types,
2662                    &relation_types,
2663                    config.relation_threshold,
2664                ) {
2665                    Ok(extraction) => {
2666                        // TPLinker baseline: optionally use gold entity spans/types as the candidate
2667                        // entity set, then run our lightweight relation heuristics. This avoids the
2668                        // baseline being “always junk” due purely to entity boundary mismatch.
2669                        if backend_name.starts_with("tplinker")
2670                            && allow_oracle_entities
2671                            && tplinker_oracle_entities
2672                            && !doc.relations.is_empty()
2673                        {
2674                            use anno::backends::inference::{
2675                                extract_relation_triples_simple, RelationExtractionConfig,
2676                            };
2677                            use anno::{Confidence, Entity as PredEntity, EntityType};
2678                            use std::collections::BTreeMap;
2679
2680                            // Dedup entities by (start,end,type,text) while preserving a stable order.
2681                            let mut by_key: BTreeMap<(usize, usize, String, String), PredEntity> =
2682                                BTreeMap::new();
2683                            for r in &doc.relations {
2684                                for (ty, span, txt) in [
2685                                    (&r.head_type, r.head_span, &r.head_text),
2686                                    (&r.tail_type, r.tail_span, &r.tail_text),
2687                                ] {
2688                                    let (start, end) = span;
2689                                    let text_fallback: String = if !txt.is_empty() {
2690                                        txt.clone()
2691                                    } else {
2692                                        text.chars()
2693                                            .skip(start)
2694                                            .take(end.saturating_sub(start))
2695                                            .collect()
2696                                    };
2697                                    let ent = PredEntity::new(
2698                                        text_fallback.clone(),
2699                                        EntityType::from_label(ty),
2700                                        start,
2701                                        end,
2702                                        1.0,
2703                                    );
2704                                    by_key
2705                                        .entry((start, end, ty.clone(), text_fallback))
2706                                        .or_insert(ent);
2707                                }
2708                            }
2709                            let oracle_entities: Vec<PredEntity> = by_key.into_values().collect();
2710
2711                            let rel_strs: Vec<&str> = relation_types.iter().map(|s| &**s).collect();
2712                            let rel_cfg = RelationExtractionConfig {
2713                                threshold: Confidence::new(config.relation_threshold as f64),
2714                                max_span_distance: 120,
2715                                extract_triggers: false,
2716                            };
2717                            let triples = extract_relation_triples_simple(
2718                                &oracle_entities,
2719                                text,
2720                                &rel_strs,
2721                                &rel_cfg,
2722                            );
2723                            for t in &triples {
2724                                if let (Some(head), Some(tail)) = (
2725                                    oracle_entities.get(t.head_idx),
2726                                    oracle_entities.get(t.tail_idx),
2727                                ) {
2728                                    all_predicted_relations.push(RelationPrediction {
2729                                        head_span: (head.start(), head.end()),
2730                                        head_type: head.entity_type.as_label().to_string(),
2731                                        tail_span: (tail.start(), tail.end()),
2732                                        tail_type: tail.entity_type.as_label().to_string(),
2733                                        relation_type: t.relation_type.clone(),
2734                                        confidence: t.confidence.value() as f32,
2735                                    });
2736                                }
2737                            }
2738                            oracle_docs_used += 1;
2739                            oracle_tplinker_docs_used += 1;
2740                            continue;
2741                        }
2742
2743                        // If the backend's NER produces no entities (common for cross-lingual
2744                        // datasets like CHisIEC when using an English GLiNER multi-task model), fall back to
2745                        // an “oracle entities” baseline: use the gold entity spans/types as the
2746                        // candidate entity set, then run our lightweight relation heuristics.
2747                        //
2748                        // This keeps the relation evaluation non-degenerate and makes the
2749                        // matrix/muxer signal usable, without pretending the NER step worked.
2750                        // Scope this fallback narrowly:
2751                        // - only for CHisIEC (cross-lingual classical Chinese)
2752                        // - only for GLiNER multi-task (English NER tends to produce zero entities there)
2753                        //
2754                        // This keeps the eval non-degenerate *without* collapsing backend
2755                        // differences for other arms (e.g. `tplinker`).
2756                        if dataset_data.id == DatasetId::CHisIEC
2757                            && backend_name.starts_with("gliner_multitask")
2758                            && allow_oracle_entities
2759                            && extraction.entities.is_empty()
2760                            && !doc.relations.is_empty()
2761                        {
2762                            use anno::backends::inference::{
2763                                extract_relation_triples_simple, RelationExtractionConfig,
2764                            };
2765                            use anno::{Confidence, Entity as PredEntity, EntityType};
2766                            use std::collections::BTreeMap;
2767
2768                            // Dedup entities by (start,end,type,text) while preserving a stable order.
2769                            let mut by_key: BTreeMap<(usize, usize, String, String), PredEntity> =
2770                                BTreeMap::new();
2771                            for r in &doc.relations {
2772                                for (ty, span, txt) in [
2773                                    (&r.head_type, r.head_span, &r.head_text),
2774                                    (&r.tail_type, r.tail_span, &r.tail_text),
2775                                ] {
2776                                    let (start, end) = span;
2777                                    let text_fallback: String = if !txt.is_empty() {
2778                                        txt.clone()
2779                                    } else {
2780                                        text.chars()
2781                                            .skip(start)
2782                                            .take(end.saturating_sub(start))
2783                                            .collect()
2784                                    };
2785                                    let ent = PredEntity::new(
2786                                        text_fallback.clone(),
2787                                        EntityType::from_label(ty),
2788                                        start,
2789                                        end,
2790                                        1.0,
2791                                    );
2792                                    by_key
2793                                        .entry((start, end, ty.clone(), text_fallback))
2794                                        .or_insert(ent);
2795                                }
2796                            }
2797                            let oracle_entities: Vec<PredEntity> = by_key.into_values().collect();
2798
2799                            let rel_strs: Vec<&str> = relation_types.iter().map(|s| &**s).collect();
2800                            let rel_cfg = RelationExtractionConfig {
2801                                threshold: Confidence::new(config.relation_threshold as f64),
2802                                max_span_distance: 120,
2803                                extract_triggers: false,
2804                            };
2805                            let triples = extract_relation_triples_simple(
2806                                &oracle_entities,
2807                                text,
2808                                &rel_strs,
2809                                &rel_cfg,
2810                            );
2811                            for t in &triples {
2812                                if let (Some(head), Some(tail)) = (
2813                                    oracle_entities.get(t.head_idx),
2814                                    oracle_entities.get(t.tail_idx),
2815                                ) {
2816                                    all_predicted_relations.push(RelationPrediction {
2817                                        head_span: (head.start(), head.end()),
2818                                        head_type: head.entity_type.as_label().to_string(),
2819                                        tail_span: (tail.start(), tail.end()),
2820                                        tail_type: tail.entity_type.as_label().to_string(),
2821                                        relation_type: t.relation_type.clone(),
2822                                        confidence: t.confidence.value() as f32,
2823                                    });
2824                                }
2825                            }
2826                            oracle_docs_used += 1;
2827                            continue;
2828                        }
2829
2830                        // Convert ExtractionWithRelations to RelationPrediction
2831                        for triple in &extraction.relations {
2832                            if let (Some(head), Some(tail)) = (
2833                                extraction.entities.get(triple.head_idx),
2834                                extraction.entities.get(triple.tail_idx),
2835                            ) {
2836                                all_predicted_relations.push(RelationPrediction {
2837                                    head_span: (head.start(), head.end()),
2838                                    head_type: head.entity_type.as_label().to_string(),
2839                                    tail_span: (tail.start(), tail.end()),
2840                                    tail_type: tail.entity_type.as_label().to_string(),
2841                                    relation_type: triple.relation_type.clone(),
2842                                    confidence: triple.confidence.value() as f32,
2843                                });
2844                            }
2845                        }
2846                    }
2847                    Err(e) => {
2848                        eprintln!("Warning: Relation extraction failed: {}", e);
2849                    }
2850                }
2851            } else {
2852                // Fallback: Extract entities and create proximity-based heuristic relations
2853                let entities = match backend.extract_entities(text, None) {
2854                    Ok(ents) => ents,
2855                    Err(e) => {
2856                        eprintln!("Warning: Entity extraction failed: {}", e);
2857                        continue;
2858                    }
2859                };
2860
2861                // Create proximity-based relations for nearby entity pairs
2862                if entities.len() >= 2 {
2863                    for i in 0..entities.len() {
2864                        for j in (i + 1)..entities.len().min(i + 3) {
2865                            let head = &entities[i];
2866                            let tail = &entities[j];
2867
2868                            all_predicted_relations.push(RelationPrediction {
2869                                head_span: (head.start(), head.end()),
2870                                head_type: head.entity_type.as_label().to_string(),
2871                                tail_span: (tail.start(), tail.end()),
2872                                tail_type: tail.entity_type.as_label().to_string(),
2873                                relation_type: "RELATED".to_string(), // Proximity heuristic
2874                                confidence: 0.5,
2875                            });
2876                        }
2877                    }
2878                }
2879            }
2880        }
2881
2882        // Evaluate relations
2883        // Relation datasets in `anno` (e.g. DocRED/CHisIEC) commonly use a richer entity-type
2884        // schema than our `EntityType` enum. Require span + relation-type agreement, but do not
2885        // hard-require entity-type string equality by default.
2886        let config = RelationEvalConfig {
2887            require_entity_type_match: false,
2888            ..RelationEvalConfig::default()
2889        };
2890        let metrics_result =
2891            evaluate_relations(&all_gold_relations, &all_predicted_relations, &config);
2892
2893        let mut metrics = HashMap::new();
2894        metrics.insert(
2895            "boundary_precision".to_string(),
2896            metrics_result.boundary_precision,
2897        );
2898        metrics.insert(
2899            "boundary_recall".to_string(),
2900            metrics_result.boundary_recall,
2901        );
2902        metrics.insert("boundary_f1".to_string(), metrics_result.boundary_f1);
2903        metrics.insert(
2904            "strict_precision".to_string(),
2905            metrics_result.strict_precision,
2906        );
2907        metrics.insert("strict_recall".to_string(), metrics_result.strict_recall);
2908        metrics.insert("strict_f1".to_string(), metrics_result.strict_f1);
2909        metrics.insert(
2910            "num_gold_relations".to_string(),
2911            all_gold_relations.len() as f64,
2912        );
2913        metrics.insert(
2914            "num_predicted_relations".to_string(),
2915            all_predicted_relations.len() as f64,
2916        );
2917        metrics.insert("oracle_docs_used".to_string(), oracle_docs_used as f64);
2918        metrics.insert(
2919            "oracle_tplinker_docs_used".to_string(),
2920            oracle_tplinker_docs_used as f64,
2921        );
2922        metrics.insert(
2923            "num_sentences".to_string(),
2924            dataset_data.sentences.len() as f64,
2925        );
2926
2927        Ok(metrics)
2928    }
2929
2930    /// Evaluate text classification task.
2931    ///
2932    /// Loader encodes the gold label as the `B-<LABEL>` tag on the single token for each example.
2933    fn evaluate_text_classification_task(
2934        &self,
2935        backend_name: &str,
2936        dataset: DatasetId,
2937        dataset_data: &LoadedDataset,
2938        _config: &TaskEvalConfig,
2939    ) -> Result<HashMap<String, f64>> {
2940        // For now, only GLiNER multi-task is wired for classification in this repo.
2941        let backend_name_norm = backend_name.to_lowercase();
2942        if backend_name_norm != "gliner_multitask"
2943            && backend_name_norm != "gliner_multitask_onnx"
2944            && backend_name_norm != "gliner_multitask_candle"
2945        {
2946            return Err(crate::Error::InvalidInput(format!(
2947                "Text classification currently only supports gliner_multitask backends (got {})",
2948                backend_name
2949            )));
2950        }
2951
2952        // Prefer registry class labels when available, otherwise derive from gold labels in the data.
2953        let mut labels: Vec<String> = dataset
2954            .entity_types()
2955            .iter()
2956            .map(|s| s.to_string())
2957            .collect();
2958        if labels.is_empty() {
2959            for s in &dataset_data.sentences {
2960                let tag = s.tokens.first().map(|t| t.ner_tag.as_str()).unwrap_or("O");
2961                let gold = tag
2962                    .strip_prefix("B-")
2963                    .or_else(|| tag.strip_prefix("I-"))
2964                    .unwrap_or(tag)
2965                    .trim();
2966                if gold.is_empty() || gold == "O" {
2967                    continue;
2968                }
2969                labels.push(gold.to_string());
2970            }
2971            labels.sort();
2972            labels.dedup();
2973        }
2974        if labels.is_empty() {
2975            return Err(crate::Error::InvalidInput(format!(
2976                "Dataset {:?} has no class labels (neither registry entity_types nor gold labels in loaded data)",
2977                dataset
2978            )));
2979        }
2980        // If we don't have any compiled gliner_multitask backend (neither onnx nor candle),
2981        // classification is not available even if `eval` is enabled.
2982        #[cfg(any(feature = "onnx", feature = "candle"))]
2983        {
2984            use crate::eval::metrics::ClassificationMetrics;
2985
2986            let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
2987
2988            // Create backend instance for classification.
2989            #[cfg(feature = "onnx")]
2990            let extractor = if backend_name_norm == "gliner_multitask"
2991                || backend_name_norm == "gliner_multitask_onnx"
2992            {
2993                use crate::DEFAULT_GLINER_MULTITASK_MODEL;
2994                use anno::backends::gliner_multitask::GLiNERMultitaskOnnx;
2995                Some(GLiNERMultitaskOnnx::from_pretrained(
2996                    DEFAULT_GLINER_MULTITASK_MODEL,
2997                )?)
2998            } else {
2999                None
3000            };
3001            #[cfg(not(feature = "onnx"))]
3002            let extractor: Option<()> = None;
3003
3004            #[cfg(all(feature = "candle", feature = "onnx"))]
3005            let extractor_candle = if backend_name_norm == "gliner_multitask_candle"
3006                || backend_name_norm == "gliner_multitask_candle"
3007            {
3008                use crate::DEFAULT_GLINER_MULTITASK_MODEL;
3009                use anno::backends::gliner_multitask::GLiNERMultitaskCandle;
3010                Some(GLiNERMultitaskCandle::from_pretrained(
3011                    DEFAULT_GLINER_MULTITASK_MODEL,
3012                )?)
3013            } else {
3014                None
3015            };
3016            #[cfg(not(all(feature = "candle", feature = "onnx")))]
3017            let extractor_candle: Option<()> = None;
3018
3019            if extractor.is_none() && extractor_candle.is_none() {
3020                return Err(crate::Error::FeatureNotAvailable(
3021                    "Text classification requires a gliner_multitask backend with 'onnx' (and optionally 'candle') enabled"
3022                        .to_string(),
3023                ));
3024            }
3025
3026            #[cfg(feature = "onnx")]
3027            let schema = anno::backends::gliner_multitask::TaskSchema::new().with_classification(
3028                "topic",
3029                &label_refs,
3030                false,
3031            );
3032            #[cfg(not(feature = "onnx"))]
3033            let schema = ();
3034            #[cfg(not(feature = "onnx"))]
3035            let _ = (&label_refs, &schema);
3036
3037            let mut m = ClassificationMetrics::new();
3038            for s in &dataset_data.sentences {
3039                let text = s.text();
3040                if text.trim().is_empty() {
3041                    continue;
3042                }
3043                let tag = s.tokens.first().map(|t| t.ner_tag.as_str()).unwrap_or("O");
3044                let gold = tag
3045                    .strip_prefix("B-")
3046                    .or_else(|| tag.strip_prefix("I-"))
3047                    .unwrap_or(tag)
3048                    .to_string();
3049                if gold.is_empty() || gold == "O" {
3050                    continue;
3051                }
3052
3053                #[cfg(feature = "onnx")]
3054                let pred_labels: Vec<String> = if let Some(ref gliner_multitask) = extractor {
3055                    let r = gliner_multitask.extract(&text, &schema)?;
3056                    r.classifications
3057                        .get("topic")
3058                        .map(|c| c.labels.clone())
3059                        .unwrap_or_default()
3060                } else {
3061                    Vec::new()
3062                };
3063                #[cfg(all(feature = "candle", feature = "onnx"))]
3064                let pred_labels: Vec<String> = if let Some(ref gliner_multitask) = extractor_candle
3065                {
3066                    let r = gliner_multitask.extract(&text, &schema)?;
3067                    r.classifications
3068                        .get("topic")
3069                        .map(|c| c.labels.clone())
3070                        .unwrap_or_default()
3071                } else {
3072                    pred_labels
3073                };
3074                #[cfg(not(any(feature = "onnx", all(feature = "candle", feature = "onnx"))))]
3075                let pred_labels: Vec<String> = Vec::new();
3076
3077                let pred = pred_labels
3078                    .first()
3079                    .cloned()
3080                    .unwrap_or_else(|| "Unknown".to_string());
3081                m.add(&pred, &gold);
3082            }
3083
3084            let mut metrics = HashMap::new();
3085            metrics.insert("accuracy".to_string(), m.accuracy());
3086            metrics.insert("macro_f1".to_string(), m.macro_f1());
3087            metrics.insert("micro_f1".to_string(), m.micro_f1());
3088            metrics.insert("weighted_f1".to_string(), m.weighted_f1());
3089            metrics.insert("num_examples".to_string(), m.total as f64);
3090            Ok(metrics)
3091        }
3092
3093        #[cfg(not(any(feature = "onnx", feature = "candle")))]
3094        {
3095            Err(crate::Error::FeatureNotAvailable(
3096                "Text classification requires a gliner_multitask backend with 'onnx' or 'candle' enabled"
3097                    .to_string(),
3098            ))
3099        }
3100    }
3101}
3102
3103impl Default for TaskEvaluator {
3104    /// Creates a default `TaskEvaluator`.
3105    ///
3106    /// # Panics
3107    ///
3108    /// This function will panic if `DatasetLoader::new()` fails.
3109    /// In production code, prefer using `TaskEvaluator::new()` which returns a `Result`.
3110    fn default() -> Self {
3111        Self::new().expect("Failed to create TaskEvaluator: DatasetLoader initialization failed. Use TaskEvaluator::new() for proper error handling.")
3112    }
3113}
3114
3115/// Generate a markdown report from evaluation results.
3116impl ComprehensiveEvalResults {
3117    /// Convert evaluation results to a markdown-formatted report.
3118    pub fn to_markdown(&self) -> String {
3119        let mut md = String::new();
3120        md.push_str("# Eval Report\n\n");
3121
3122        // Backend macro-averages by task (successful-only).
3123        //
3124        // This is intentionally “objective backing”: within a single run/config, report
3125        // mean primary metric per backend per task. (Do not mix tasks.)
3126        {
3127            use std::collections::HashMap;
3128            let mut by_task_backend: HashMap<(Task, String), Vec<f64>> = HashMap::new();
3129            for r in &self.results {
3130                if !r.success {
3131                    continue;
3132                }
3133                if let Some(v) = r.primary_f1() {
3134                    by_task_backend
3135                        .entry((r.task, r.backend.clone()))
3136                        .or_default()
3137                        .push(v * 100.0);
3138                }
3139            }
3140
3141            if !by_task_backend.is_empty() {
3142                md.push_str("## Backend macro averages (successful only)\n\n");
3143                md.push_str("| Task | Backend | Avg primary metric | n |\n");
3144                md.push_str("|------|---------|--------------------|---|\n");
3145
3146                let mut entries: Vec<(Task, String, f64, usize)> = by_task_backend
3147                    .into_iter()
3148                    .map(|((task, backend), vals)| {
3149                        let n = vals.len();
3150                        let avg = if n == 0 {
3151                            0.0
3152                        } else {
3153                            vals.iter().sum::<f64>() / (n as f64)
3154                        };
3155                        (task, backend, avg, n)
3156                    })
3157                    .collect();
3158
3159                // Sort by task name, then avg descending.
3160                entries.sort_by(|a, b| match a.0.name().cmp(b.0.name()) {
3161                    std::cmp::Ordering::Equal => {
3162                        b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)
3163                    }
3164                    other => other,
3165                });
3166
3167                for (task, backend, avg, n) in entries {
3168                    md.push_str(&format!(
3169                        "| {} | {} | {:.1} | {} |\n",
3170                        task.name(),
3171                        backend,
3172                        avg,
3173                        n
3174                    ));
3175                }
3176                md.push('\n');
3177            }
3178        }
3179
3180        // Dense summary line
3181        let avg_examples: f64 = self
3182            .results
3183            .iter()
3184            .filter(|r| r.success)
3185            .map(|r| r.num_examples as f64)
3186            .sum::<f64>()
3187            / self.summary.successful.max(1) as f64;
3188        let avg_time: f64 = self
3189            .results
3190            .iter()
3191            .filter_map(|r| r.duration_ms)
3192            .sum::<f64>()
3193            / self
3194                .results
3195                .iter()
3196                .filter(|r| r.duration_ms.is_some())
3197                .count()
3198                .max(1) as f64;
3199
3200        md.push_str(&format!(
3201            "Total: {} | ✓: {} | ⊘: {} | ✗: {} | Avg examples: {:.0} | Avg time: {:.0}ms\n\n",
3202            self.summary.total_combinations,
3203            self.summary.successful,
3204            self.summary.skipped,
3205            self.summary.failed,
3206            avg_examples,
3207            avg_time
3208        ));
3209
3210        // Failures first (most important for debugging)
3211        let failures: Vec<_> = self
3212            .results
3213            .iter()
3214            .filter(|r| !r.success && !r.is_skipped())
3215            .collect();
3216
3217        if !failures.is_empty() {
3218            md.push_str("## Failures\n\n");
3219            md.push_str("| Task | Dataset | Backend | Error |\n");
3220            md.push_str("|------|---------|---------|-------|\n");
3221            for result in &failures {
3222                let error = result
3223                    .error
3224                    .as_ref()
3225                    .map(|e| e.replace('|', "\\|").replace('\n', " "))
3226                    .unwrap_or_else(|| "N/A".to_string());
3227                md.push_str(&format!(
3228                    "| {} | {:?} | {} | {} |\n",
3229                    result.task.name(),
3230                    result.dataset,
3231                    result.backend,
3232                    error
3233                ));
3234            }
3235            md.push('\n');
3236        }
3237
3238        // Error patterns
3239        let mut error_patterns: HashMap<String, usize> = HashMap::new();
3240        for result in failures.iter() {
3241            if let Some(ref err) = result.error {
3242                // Extract error pattern (first 50 chars or key phrase)
3243                let pattern = if err.len() > 50 {
3244                    err.chars().take(50).collect::<String>() + "..."
3245                } else {
3246                    err.clone()
3247                };
3248                *error_patterns.entry(pattern).or_insert(0) += 1;
3249            }
3250        }
3251
3252        if !error_patterns.is_empty() {
3253            md.push_str("## Error Patterns\n\n");
3254            let mut patterns: Vec<_> = error_patterns.iter().collect();
3255            patterns.sort_by(|a, b| b.1.cmp(a.1));
3256            for (pattern, count) in patterns {
3257                md.push_str(&format!("- [{}x] {}\n", count, pattern));
3258            }
3259            md.push('\n');
3260        }
3261
3262        md.push_str("## Results\n\n");
3263
3264        // Filter out skipped entries for cleaner report (show summary instead)
3265        let skipped_count = self.results.iter().filter(|r| r.is_skipped()).count();
3266        if skipped_count > 0 {
3267            md.push_str(&format!(
3268                "**Note**: {} combinations skipped (features not enabled or incompatible). Showing successful and failed results only.\n\n",
3269                skipped_count
3270            ));
3271        }
3272
3273        // Add compatibility notes
3274        md.push_str("**Compatibility Notes**:\n");
3275        md.push_str("- `stacked`: Combines pattern+heuristic, supports structured entities (date/time/money/etc) and named entities (PER/ORG/LOC), but not biomedical types\n");
3276        md.push_str("- `pattern`: Only structured entities (date, time, money, percent, email, URL, phone)\n");
3277        md.push_str("- `heuristic`: Only named entities (Person, Organization, Location)\n");
3278        md.push_str("- `incompatible`: Backend doesn't support dataset entity types (expected for non-zero-shot backends on fine-grained datasets)\n");
3279        md.push_str("- `load-failed`: Dataset failed to download/load (HuggingFace API errors, network issues, etc.)\n");
3280        md.push_str("- `empty-dataset`: Dataset loaded but contains no sentences\n");
3281        md.push_str("- `0.0 F1` with N>0: Backend doesn't support dataset entity types\n");
3282        md.push_str("- `N=0` or `N=1`: Dataset parsing issue or insufficient data\n\n");
3283
3284        // Group results by task, filtering out skipped
3285        let mut by_task: HashMap<Task, Vec<&TaskEvalResult>> = HashMap::new();
3286        for result in &self.results {
3287            if !result.is_skipped() {
3288                by_task.entry(result.task).or_default().push(result);
3289            }
3290        }
3291
3292        for (task, mut results) in by_task {
3293            md.push_str(&format!("### {}\n\n", task.name()));
3294
3295            // Sort results: successful first (by F1 descending), then skipped, then failed
3296            results.sort_by(|a, b| match (a.success, b.success) {
3297                (true, true) => {
3298                    let a_f1 = a.primary_f1().unwrap_or(0.0);
3299                    let b_f1 = b.primary_f1().unwrap_or(0.0);
3300                    b_f1.partial_cmp(&a_f1).unwrap_or(std::cmp::Ordering::Equal)
3301                }
3302                (true, false) => std::cmp::Ordering::Less,
3303                (false, true) => std::cmp::Ordering::Greater,
3304                (false, false) => match (a.is_skipped(), b.is_skipped()) {
3305                    (true, false) => std::cmp::Ordering::Less,
3306                    (false, true) => std::cmp::Ordering::Greater,
3307                    _ => std::cmp::Ordering::Equal,
3308                },
3309            });
3310
3311            // Compact table headers
3312            let show_metrics = match task {
3313                Task::NER | Task::DiscontinuousNER => {
3314                    md.push_str("| Dataset | Backend | F1 | P | R | N | ms |\n");
3315                    md.push_str("|---------|---------|----|----|----|---|----|\n");
3316                    true
3317                }
3318                Task::IntraDocCoref | Task::InterDocCoref | Task::AbstractAnaphora => {
3319                    md.push_str("| Dataset | Backend | CoNLL | MUC | B³ | N | ms |\n");
3320                    md.push_str("|---------|---------|-------|-----|----|---|----|\n");
3321                    true
3322                }
3323                Task::RelationExtraction => {
3324                    md.push_str("| Dataset | Backend | Strict | Boundary | N | ms |\n");
3325                    md.push_str("|---------|---------|--------|----------|---|----|\n");
3326                    true
3327                }
3328                _ => {
3329                    md.push_str("| Dataset | Backend | N | ms |\n");
3330                    md.push_str("|---------|---------|---|----|\n");
3331                    false
3332                }
3333            };
3334
3335            for result in results {
3336                let time_str = result
3337                    .duration_ms
3338                    .map(|d| format!("{:.0}", d))
3339                    .unwrap_or_else(|| "-".to_string());
3340
3341                if show_metrics && result.success {
3342                    match task {
3343                        Task::NER | Task::DiscontinuousNER => {
3344                            let f1 = result.metrics.get("f1").map(|v| *v * 100.0).unwrap_or(0.0);
3345                            let p = result
3346                                .metrics
3347                                .get("precision")
3348                                .map(|v| *v * 100.0)
3349                                .unwrap_or(0.0);
3350                            let r = result
3351                                .metrics
3352                                .get("recall")
3353                                .map(|v| *v * 100.0)
3354                                .unwrap_or(0.0);
3355
3356                            // Add familiarity note for zero-shot backends
3357                            let mut note_parts = Vec::new();
3358                            if let Some(ref label_shift) = result.label_shift {
3359                                if label_shift.is_inflated() {
3360                                    note_parts.push(format!(
3361                                        "⚠ familiarity={:.0}%",
3362                                        label_shift.familiarity * 100.0
3363                                    ));
3364                                }
3365                            }
3366
3367                            // Add note for 0.0 F1 scores
3368                            let note = if f1 < 0.1 && result.num_examples > 0 {
3369                                // Check if it's an incompatible entity type issue
3370                                let dataset_entity_types = result.dataset.entity_types();
3371                                let backend_name = &result.backend;
3372                                if backend_name == "stacked"
3373                                    || backend_name == "heuristic"
3374                                    || backend_name == "pattern"
3375                                {
3376                                    // Stacked/heuristic/pattern have limited entity type support
3377                                    let normalized_types: Vec<String> = dataset_entity_types
3378                                        .iter()
3379                                        .map(|t| t.to_lowercase())
3380                                        .collect();
3381                                    let supports_structured = normalized_types.iter().any(|t| {
3382                                        t.contains("date")
3383                                            || t.contains("time")
3384                                            || t.contains("money")
3385                                            || t.contains("percent")
3386                                            || t.contains("email")
3387                                            || t.contains("url")
3388                                            || t.contains("phone")
3389                                    });
3390                                    let supports_named = normalized_types.iter().any(|t| {
3391                                        t.contains("person")
3392                                            || t.contains("organization")
3393                                            || t.contains("location")
3394                                    });
3395                                    let supports_biomedical = normalized_types.iter().any(|t| {
3396                                        t.contains("disease")
3397                                            || t.contains("chemical")
3398                                            || t.contains("gene")
3399                                            || t.contains("protein")
3400                                            || t.contains("anatomy")
3401                                    });
3402
3403                                    if backend_name == "pattern" && !supports_structured {
3404                                        " (pattern: no structured entities)"
3405                                    } else if backend_name == "heuristic" && !supports_named {
3406                                        " (heuristic: no PER/ORG/LOC)"
3407                                    } else if backend_name == "stacked"
3408                                        && !supports_structured
3409                                        && !supports_named
3410                                    {
3411                                        if supports_biomedical {
3412                                            " (stacked: biomedical not supported)"
3413                                        } else {
3414                                            " (stacked: incompatible types)"
3415                                        }
3416                                    } else {
3417                                        ""
3418                                    }
3419                                } else if result.num_examples == 0 {
3420                                    " (N=0: no data)"
3421                                } else {
3422                                    ""
3423                                }
3424                            } else {
3425                                ""
3426                            };
3427
3428                            md.push_str(&format!(
3429                                "| {:?} | {} | {:.1} | {:.1} | {:.1} | {} | {} |{}\n",
3430                                result.dataset,
3431                                result.backend,
3432                                f1,
3433                                p,
3434                                r,
3435                                result.num_examples,
3436                                time_str,
3437                                note
3438                            ));
3439
3440                            // Add stratified metrics section if available
3441                            if let Some(ref stratified) = result.stratified {
3442                                if !stratified.by_entity_type.is_empty() {
3443                                    md.push_str("\n#### Stratified by Entity Type\n\n");
3444                                    md.push_str("| Type | F1 | CI 95% | N |\n");
3445                                    md.push_str("|------|----|--------|---|\n");
3446                                    let mut types: Vec<_> =
3447                                        stratified.by_entity_type.iter().collect();
3448                                    types.sort_by_key(|(k, _)| *k);
3449                                    for (type_str, metric_ci) in types {
3450                                        let ci_str = format!(
3451                                            "[{:.2}, {:.2}]",
3452                                            metric_ci.ci_95.0, metric_ci.ci_95.1
3453                                        );
3454                                        md.push_str(&format!(
3455                                            "| {} | {:.2} | {} | {} |\n",
3456                                            type_str, metric_ci.mean, ci_str, metric_ci.n
3457                                        ));
3458                                    }
3459                                    md.push('\n');
3460                                }
3461                            }
3462
3463                            // Add temporal stratification if available
3464                            if let Some(ref stratified) = result.stratified {
3465                                if let Some(ref temporal) = stratified.by_temporal_stratum {
3466                                    if !temporal.is_empty() {
3467                                        md.push_str("\n#### Temporal Stratification\n\n");
3468                                        md.push_str("| Stratum | F1 | CI 95% | N |\n");
3469                                        md.push_str("|---------|----|--------|---|\n");
3470                                        for (stratum, metric) in temporal {
3471                                            md.push_str(&format!(
3472                                                "| {} | {:.2} | [{:.2}, {:.2}] | {} |\n",
3473                                                stratum,
3474                                                metric.mean,
3475                                                metric.ci_95.0,
3476                                                metric.ci_95.1,
3477                                                metric.n
3478                                            ));
3479                                        }
3480                                        md.push('\n');
3481                                    }
3482                                }
3483                            }
3484
3485                            // Add confidence intervals if available
3486                            if let Some(ref ci) = result.confidence_intervals {
3487                                md.push_str(&format!(
3488                                    "\n**Confidence Intervals (95%)**: F1: [{:.2}, {:.2}], P: [{:.2}, {:.2}], R: [{:.2}, {:.2}]\n\n",
3489                                    ci.f1_ci.0, ci.f1_ci.1,
3490                                    ci.precision_ci.0, ci.precision_ci.1,
3491                                    ci.recall_ci.0, ci.recall_ci.1
3492                                ));
3493                            }
3494                        }
3495                        Task::IntraDocCoref | Task::InterDocCoref | Task::AbstractAnaphora => {
3496                            let conll = result
3497                                .metrics
3498                                .get("conll_f1")
3499                                .map(|v| *v * 100.0)
3500                                .unwrap_or(0.0);
3501                            let muc = result
3502                                .metrics
3503                                .get("muc_f1")
3504                                .map(|v| *v * 100.0)
3505                                .unwrap_or(0.0);
3506                            let b3 = result
3507                                .metrics
3508                                .get("b3_f1")
3509                                .map(|v| *v * 100.0)
3510                                .unwrap_or(0.0);
3511
3512                            // Add note for 0.0 scores with low N
3513                            let note = if conll < 0.1 && result.num_examples <= 1 {
3514                                " (N≤1: insufficient data or parsing issue)"
3515                            } else {
3516                                ""
3517                            };
3518
3519                            md.push_str(&format!(
3520                                "| {:?} | {} | {:.1} | {:.1} | {:.1} | {} | {} |{}\n",
3521                                result.dataset,
3522                                result.backend,
3523                                conll,
3524                                muc,
3525                                b3,
3526                                result.num_examples,
3527                                time_str,
3528                                note
3529                            ));
3530
3531                            // Add chain-length stratification if available in metrics
3532                            if let Some(long_f1) = result.metrics.get("chain_long_f1") {
3533                                md.push_str("\n#### Chain-Length Stratification\n\n");
3534                                md.push_str("| Chain Type | Count | F1 |\n");
3535                                md.push_str("|------------|-------|----|\n");
3536                                if let Some(long_count) = result.metrics.get("chain_long_count") {
3537                                    md.push_str(&format!(
3538                                        "| Long (>10) | {:.0} | {:.2} |\n",
3539                                        long_count,
3540                                        long_f1 * 100.0
3541                                    ));
3542                                }
3543                                if let Some(short_f1) = result.metrics.get("chain_short_f1") {
3544                                    if let Some(short_count) =
3545                                        result.metrics.get("chain_short_count")
3546                                    {
3547                                        md.push_str(&format!(
3548                                            "| Short (2-10) | {:.0} | {:.2} |\n",
3549                                            short_count,
3550                                            short_f1 * 100.0
3551                                        ));
3552                                    }
3553                                }
3554                                if let Some(singleton_f1) = result.metrics.get("chain_singleton_f1")
3555                                {
3556                                    if let Some(singleton_count) =
3557                                        result.metrics.get("chain_singleton_count")
3558                                    {
3559                                        md.push_str(&format!(
3560                                            "| Singleton (1) | {:.0} | {:.2} |\n",
3561                                            singleton_count,
3562                                            singleton_f1 * 100.0
3563                                        ));
3564                                    }
3565                                }
3566                                md.push('\n');
3567                            }
3568                        }
3569                        Task::RelationExtraction => {
3570                            let strict = result
3571                                .metrics
3572                                .get("strict_f1")
3573                                .map(|v| *v * 100.0)
3574                                .unwrap_or(0.0);
3575                            let boundary = result
3576                                .metrics
3577                                .get("boundary_f1")
3578                                .map(|v| *v * 100.0)
3579                                .unwrap_or(0.0);
3580                            md.push_str(&format!(
3581                                "| {:?} | {} | {:.1} | {:.1} | {} | {} |\n",
3582                                result.dataset,
3583                                result.backend,
3584                                strict,
3585                                boundary,
3586                                result.num_examples,
3587                                time_str
3588                            ));
3589                        }
3590                        _ => {
3591                            md.push_str(&format!(
3592                                "| {:?} | {} | {} | {} |\n",
3593                                result.dataset, result.backend, result.num_examples, time_str
3594                            ));
3595                        }
3596                    }
3597                } else {
3598                    // Failed or skipped - show error
3599                    let status = if result.is_skipped() { "⊘" } else { "✗" };
3600                    let error_msg = if result.is_skipped() {
3601                        "no-feature".to_string()
3602                    } else {
3603                        result
3604                            .error
3605                            .as_ref()
3606                            .map(|e| {
3607                                // Categorize errors for better debugging
3608                                if e.starts_with("incompatible:") {
3609                                    "incompatible".to_string()
3610                                } else if e.contains("Unknown backend")
3611                                    || e.contains("unknown backend")
3612                                {
3613                                    "unknown-backend".to_string()
3614                                } else if e.contains("Failed to load")
3615                                    || e.contains("422")
3616                                    || e.contains("HuggingFace")
3617                                    || e.contains("API")
3618                                {
3619                                    "load-failed".to_string()
3620                                } else if e.contains("empty") || e.contains("no sentences") {
3621                                    "empty-dataset".to_string()
3622                                } else if e.contains("ONNX") || e.contains("onnx") {
3623                                    "onnx-error".to_string()
3624                                } else if e.contains("model")
3625                                    && (e.contains("not found") || e.contains("download"))
3626                                {
3627                                    "model-load-failed".to_string()
3628                                } else if e.contains("timeout") || e.contains("timed out") {
3629                                    "timeout".to_string()
3630                                } else if e.contains("not available")
3631                                    || e.contains("FeatureNotAvailable")
3632                                {
3633                                    "not-available".to_string()
3634                                } else if e.len() > 30 {
3635                                    e.chars().take(30).collect::<String>() + "..."
3636                                } else {
3637                                    e.clone()
3638                                }
3639                            })
3640                            .unwrap_or_else(|| "error".to_string())
3641                    };
3642                    md.push_str(&format!(
3643                        "| {:?} | {} | {} | {} | {} |\n",
3644                        result.dataset, result.backend, status, error_msg, time_str
3645                    ));
3646                }
3647            }
3648            md.push('\n');
3649        }
3650
3651        // Backend summary (compact)
3652        let mut backend_stats: HashMap<String, (usize, usize, usize, f64)> = HashMap::new();
3653        for result in &self.results {
3654            let entry = backend_stats
3655                .entry(result.backend.clone())
3656                .or_insert((0, 0, 0, 0.0));
3657            if result.success {
3658                entry.0 += 1;
3659                if let Some(f1) = result.primary_f1() {
3660                    entry.3 += f1;
3661                }
3662            } else if result.is_skipped() {
3663                entry.1 += 1;
3664            } else {
3665                entry.2 += 1;
3666            }
3667        }
3668
3669        if !backend_stats.is_empty() {
3670            md.push_str("## Backend Summary\n\n");
3671            md.push_str("| Backend | ✓ | ⊘ | ✗ | Avg F1 |\n");
3672            md.push_str("|---------|---|---|---|--------|\n");
3673            let mut backends: Vec<_> = backend_stats.iter().collect();
3674            backends.sort_by_key(|(_, (success, _, _, _))| *success);
3675            backends.reverse();
3676            for (backend, (success, skipped, failed, total_f1)) in backends {
3677                let avg_f1 = if *success > 0 {
3678                    total_f1 / *success as f64 * 100.0
3679                } else {
3680                    0.0
3681                };
3682                md.push_str(&format!(
3683                    "| {} | {} | {} | {} | {:.1} |\n",
3684                    backend, success, skipped, failed, avg_f1
3685                ));
3686            }
3687            md.push('\n');
3688        }
3689
3690        md
3691    }
3692}
3693
3694// =============================================================================
3695// Helper Functions for Advanced Evaluation Features
3696// =============================================================================
3697
3698impl TaskEvaluator {
3699    /// Extract KB version from dataset metadata if available.
3700    ///
3701    /// Returns KB version string if temporal metadata contains it.
3702    fn extract_kb_version(dataset_data: &super::loader::LoadedDataset) -> Option<String> {
3703        dataset_data.temporal_metadata.as_ref()?.kb_version.clone()
3704    }
3705
3706    /// Compute familiarity for zero-shot backends.
3707    ///
3708    /// Returns None if backend is not zero-shot or if familiarity cannot be computed.
3709    fn compute_familiarity_if_zero_shot(
3710        &self,
3711        backend_name: &str,
3712        dataset_data: &LoadedDataset,
3713    ) -> Option<super::types::LabelShift> {
3714        // Check if this is a zero-shot backend
3715        let is_zero_shot = matches!(
3716            backend_name.to_lowercase().as_str(),
3717            "nuner"
3718                | "gliner_onnx"
3719                | "gliner_candle"
3720                | "gliner_multitask"
3721                | "gliner_poly"
3722                | "universal_ner"
3723        );
3724
3725        if !is_zero_shot {
3726            return None;
3727        }
3728
3729        // Extract dataset entity types
3730        let eval_types: Vec<String> = dataset_data
3731            .sentences
3732            .iter()
3733            .flat_map(|s| s.entities())
3734            .map(|e| e.entity_type.as_label().to_string())
3735            .collect::<std::collections::HashSet<_>>()
3736            .into_iter()
3737            .collect();
3738
3739        // For zero-shot backends, we don't have training types, so we use a heuristic:
3740        // Common entity types that zero-shot models are typically trained on
3741        let common_train_types = vec![
3742            "person".to_string(),
3743            "organization".to_string(),
3744            "location".to_string(),
3745            "PER".to_string(),
3746            "ORG".to_string(),
3747            "LOC".to_string(),
3748            "PERSON".to_string(),
3749            "ORGANIZATION".to_string(),
3750        ];
3751
3752        Some(super::types::LabelShift::from_type_sets(
3753            &common_train_types,
3754            &eval_types,
3755        ))
3756    }
3757
3758    /// Compute confidence intervals from aggregate metrics (fallback method).
3759    ///
3760    /// Uses normal approximation: CI = mean +/- 1.96 * std_dev.
3761    /// Uses a fixed fallback std_dev since per-example variance is not available.
3762    /// Prefer `compute_confidence_intervals_from_scores` when per-example scores
3763    /// are available.
3764    fn compute_confidence_intervals_from_aggregate(
3765        &self,
3766        metrics: &HashMap<String, f64>,
3767    ) -> Option<ConfidenceIntervals> {
3768        let f1 = metrics.get("f1")?;
3769        let precision = metrics.get("precision")?;
3770        let recall = metrics.get("recall")?;
3771
3772        let std_dev = DEFAULT_FALLBACK_STD_DEV;
3773        let z = DEFAULT_Z_SCORE_95; // 95% CI
3774        let margin = z * std_dev;
3775
3776        Some(ConfidenceIntervals {
3777            f1_ci: ((f1 - margin).clamp(0.0, 1.0), (f1 + margin).clamp(0.0, 1.0)),
3778            precision_ci: (
3779                (precision - margin).clamp(0.0, 1.0),
3780                (precision + margin).clamp(0.0, 1.0),
3781            ),
3782            recall_ci: (
3783                (recall - margin).clamp(0.0, 1.0),
3784                (recall + margin).clamp(0.0, 1.0),
3785            ),
3786        })
3787    }
3788
3789    /// Compute confidence intervals from per-example scores (improved version).
3790    ///
3791    /// Computes variance from per-example F1, precision, recall scores.
3792    ///
3793    /// # Performance Note
3794    ///
3795    /// This function creates a new backend instance and re-runs inference on a sample
3796    /// of the dataset to compute per-example scores. This is intentional - proper CI
3797    /// computation requires per-example variance, which isn't available from aggregate
3798    /// metrics alone.
3799    ///
3800    /// # Limitations
3801    ///
3802    /// - Samples up to `MAX_CI_SAMPLE_SIZE` examples for performance
3803    /// - Creates a new backend instance (doesn't reuse from main evaluation)
3804    /// - For zero-shot backends, creates and uses zero-shot backend instance
3805    ///
3806    /// Compute confidence intervals from per-example scores or aggregate metrics.
3807    ///
3808    /// This is the primary method for computing confidence intervals.
3809    /// For NER tasks, it samples sentences and re-runs inference to get per-example scores.
3810    /// For other tasks, it falls back to aggregate metrics with a fixed fallback std_dev.
3811    fn compute_confidence_intervals(
3812        &self,
3813        dataset_data: &LoadedDataset,
3814        task: Task,
3815        backend_name: &str,
3816        aggregate_metrics: &HashMap<String, f64>,
3817        _config: &TaskEvalConfig,
3818    ) -> Option<ConfidenceIntervals> {
3819        // For NER tasks, compute per-example scores
3820        if !matches!(task, Task::NER | Task::DiscontinuousNER) {
3821            return self.compute_confidence_intervals_from_aggregate(aggregate_metrics);
3822        }
3823
3824        // Sample a subset for CI computation (to avoid expensive recomputation)
3825        // Ensure sample_size is at least MIN_CI_SAMPLE_SIZE and doesn't exceed dataset size
3826        let dataset_len = dataset_data.sentences.len();
3827        if dataset_len == 0 {
3828            return self.compute_confidence_intervals_from_aggregate(aggregate_metrics);
3829        }
3830        // If dataset is too small for meaningful CI, fall back to aggregate metrics
3831        if dataset_len < MIN_CI_SAMPLE_SIZE {
3832            return self.compute_confidence_intervals_from_aggregate(aggregate_metrics);
3833        }
3834        let sample_size = dataset_len.clamp(MIN_CI_SAMPLE_SIZE, MAX_CI_SAMPLE_SIZE);
3835        let sample: Vec<_> = dataset_data.sentences.iter().take(sample_size).collect();
3836
3837        // Compute per-example F1, precision, recall
3838        let mut f1_scores = Vec::new();
3839        let mut precision_scores = Vec::new();
3840        let mut recall_scores = Vec::new();
3841
3842        // Try to create backend for per-example evaluation
3843        let backend = match BackendFactory::create(backend_name) {
3844            Ok(b) => b,
3845            Err(_) => return self.compute_confidence_intervals_from_aggregate(aggregate_metrics),
3846        };
3847
3848        if !backend.is_available() {
3849            return self.compute_confidence_intervals_from_aggregate(aggregate_metrics);
3850        }
3851
3852        let dataset_labels = dataset_data.id.entity_types();
3853        let mapped_labels = Self::map_dataset_labels_to_model(dataset_labels, backend_name);
3854        let is_zero_shot = matches!(
3855            backend_name.to_lowercase().as_str(),
3856            "nuner"
3857                | "gliner_onnx"
3858                | "gliner_candle"
3859                | "gliner_multitask"
3860                | "gliner_poly"
3861                | "universal_ner"
3862        );
3863
3864        for sentence in sample {
3865            let text = sentence.text();
3866            let gold: Vec<Entity> = sentence
3867                .entities()
3868                .iter()
3869                .map(|g| {
3870                    let mut entity =
3871                        Entity::new(g.text.clone(), g.entity_type.clone(), g.start, g.end, 1.0);
3872                    entity.provenance = Some(crate::Provenance::ml("gold", 1.0));
3873                    entity
3874                })
3875                .collect();
3876
3877            let predicted = if is_zero_shot && !mapped_labels.is_empty() {
3878                // For zero-shot backends, use extract_with_types
3879                // Create zero-shot backend instance (reuse thread-local cache if available)
3880                #[cfg(feature = "eval-parallel")]
3881                {
3882                    match Self::create_zero_shot_backend(backend_name) {
3883                        Ok(zero_shot_backend) => {
3884                            match Self::extract_with_cached_backend(
3885                                &zero_shot_backend,
3886                                &text,
3887                                &mapped_labels,
3888                            ) {
3889                                Ok(entities) => entities,
3890                                Err(_) => continue,
3891                            }
3892                        }
3893                        Err(_) => continue,
3894                    }
3895                }
3896                #[cfg(not(feature = "eval-parallel"))]
3897                {
3898                    match Self::create_zero_shot_backend_any(backend_name) {
3899                        Ok(zero_shot_backend) => {
3900                            match Self::extract_with_cached_backend_any(
3901                                backend_name,
3902                                zero_shot_backend.as_ref(),
3903                                &text,
3904                                &mapped_labels,
3905                            ) {
3906                                Ok(entities) => entities,
3907                                Err(_) => continue,
3908                            }
3909                        }
3910                        Err(_) => continue,
3911                    }
3912                }
3913            } else {
3914                match backend.extract_entities(&text, None) {
3915                    Ok(e) => e,
3916                    Err(_) => continue,
3917                }
3918            };
3919
3920            // Compute per-example metrics
3921            use crate::eval::ner_metrics::evaluate_entities;
3922            let result = evaluate_entities(&gold, &predicted);
3923            let summary = result.summary();
3924            f1_scores.push(summary.strict_f1);
3925            precision_scores.push(summary.strict_precision);
3926            recall_scores.push(summary.strict_recall);
3927        }
3928
3929        if f1_scores.is_empty() {
3930            return self.compute_confidence_intervals_from_aggregate(aggregate_metrics);
3931        }
3932
3933        // Compute mean and std_dev
3934        let n = f1_scores.len() as f64;
3935        let f1_mean = f1_scores.iter().sum::<f64>() / n;
3936        let precision_mean = precision_scores.iter().sum::<f64>() / n;
3937        let recall_mean = recall_scores.iter().sum::<f64>() / n;
3938
3939        // Use sample variance (Bessel's correction: n-1) for unbiased estimate
3940        let f1_variance = if n > 1.0 {
3941            f1_scores
3942                .iter()
3943                .map(|&x| (x - f1_mean).powi(2))
3944                .sum::<f64>()
3945                / (n - 1.0)
3946        } else {
3947            0.0
3948        };
3949        let precision_variance = if n > 1.0 {
3950            precision_scores
3951                .iter()
3952                .map(|&x| (x - precision_mean).powi(2))
3953                .sum::<f64>()
3954                / (n - 1.0)
3955        } else {
3956            0.0
3957        };
3958        let recall_variance = if n > 1.0 {
3959            recall_scores
3960                .iter()
3961                .map(|&x| (x - recall_mean).powi(2))
3962                .sum::<f64>()
3963                / (n - 1.0)
3964        } else {
3965            0.0
3966        };
3967
3968        let f1_std_dev = f1_variance.sqrt();
3969        let precision_std_dev = precision_variance.sqrt();
3970        let recall_std_dev = recall_variance.sqrt();
3971
3972        // 95% CI: mean ± DEFAULT_Z_SCORE_95 * std_dev / sqrt(n)
3973        let z = DEFAULT_Z_SCORE_95;
3974        let f1_margin = z * f1_std_dev / n.sqrt();
3975        let precision_margin = z * precision_std_dev / n.sqrt();
3976        let recall_margin = z * recall_std_dev / n.sqrt();
3977
3978        Some(ConfidenceIntervals {
3979            f1_ci: (
3980                (f1_mean - f1_margin).clamp(0.0, 1.0),
3981                (f1_mean + f1_margin).clamp(0.0, 1.0),
3982            ),
3983            precision_ci: (
3984                (precision_mean - precision_margin).clamp(0.0, 1.0),
3985                (precision_mean + precision_margin).clamp(0.0, 1.0),
3986            ),
3987            recall_ci: (
3988                (recall_mean - recall_margin).clamp(0.0, 1.0),
3989                (recall_mean + recall_margin).clamp(0.0, 1.0),
3990            ),
3991        })
3992    }
3993
3994    /// Compute robustness testing results.
3995    ///
3996    /// # Performance Note
3997    ///
3998    /// This function creates a new backend instance and runs robustness tests on up to
3999    /// `ROBUSTNESS_TEST_LIMIT` examples. This is intentional - robustness testing requires
4000    /// running perturbations that may affect backend state.
4001    ///
4002    /// # Limitations
4003    ///
4004    /// - Limited to `ROBUSTNESS_TEST_LIMIT` examples for performance
4005    /// - Creates a new backend instance (doesn't reuse from main evaluation)
4006    #[cfg(feature = "eval")]
4007    pub fn compute_robustness(
4008        &self,
4009        backend_name: &str,
4010        dataset_data: &LoadedDataset,
4011        config: &TaskEvalConfig,
4012    ) -> Option<super::robustness::RobustnessResults> {
4013        use super::robustness::RobustnessEvaluator;
4014        use anno::Entity;
4015
4016        // Create backend for robustness testing
4017        // NOTE: We create a new backend instance here rather than reusing from main evaluation
4018        // because robustness testing may modify backend state through perturbations
4019        let backend = match BackendFactory::create(backend_name) {
4020            Ok(b) => b,
4021            Err(_) => return None,
4022        };
4023
4024        if !backend.is_available() {
4025            return None;
4026        }
4027
4028        // Prepare test cases (limit to ROBUSTNESS_TEST_LIMIT for performance)
4029        let test_cases: Vec<(String, Vec<Entity>)> = dataset_data
4030            .sentences
4031            .iter()
4032            .take(ROBUSTNESS_TEST_LIMIT)
4033            .map(|s| {
4034                let gold: Vec<Entity> = s
4035                    .entities()
4036                    .iter()
4037                    .map(|g| {
4038                        let mut entity =
4039                            Entity::new(g.text.clone(), g.entity_type.clone(), g.start, g.end, 1.0);
4040                        entity.provenance = Some(crate::Provenance::ml("gold", 1.0));
4041                        entity
4042                    })
4043                    .collect();
4044                (s.text().to_string(), gold)
4045            })
4046            .collect();
4047
4048        if test_cases.is_empty() {
4049            return None;
4050        }
4051
4052        // Create robustness evaluator
4053        let evaluator = RobustnessEvaluator {
4054            seed: config.seed.unwrap_or(42),
4055            ..Default::default()
4056        };
4057
4058        // Run robustness evaluation
4059        Some(evaluator.evaluate(backend.as_ref(), &test_cases))
4060    }
4061
4062    /// Compute stratified metrics from per-example scores.
4063    ///
4064    /// Uses actual per-example F1/precision/recall to compute per-type metrics.
4065    /// This is the primary method when per-example scores are available.
4066    fn compute_stratified_metrics_from_scores(
4067        &self,
4068        dataset_data: &LoadedDataset,
4069        aggregate_metrics: &HashMap<String, f64>,
4070        per_example_scores: Option<&PerExampleScores>,
4071    ) -> Option<StratifiedMetrics> {
4072        use crate::eval::ner_metrics::evaluate_entities;
4073
4074        // If we have per-example scores, use them for proper stratification
4075        if let Some(per_example) = per_example_scores {
4076            // Compute per-type metrics from per-example scores
4077            let mut by_type_scores: HashMap<String, Vec<(f64, f64, f64)>> = HashMap::new(); // (f1, precision, recall)
4078
4079            for (gold, predicted, _text) in per_example {
4080                // Group by entity type and compute per-type metrics
4081                let mut type_groups: HashMap<String, (Vec<Entity>, Vec<Entity>)> = HashMap::new();
4082
4083                // Group gold entities by type
4084                for entity in gold {
4085                    let type_str = entity.entity_type.as_label().to_string();
4086                    type_groups
4087                        .entry(type_str.clone())
4088                        .or_default()
4089                        .0
4090                        .push(entity.clone());
4091                }
4092
4093                // Group predicted entities by type
4094                for entity in predicted {
4095                    let type_str = entity.entity_type.as_label().to_string();
4096                    type_groups
4097                        .entry(type_str)
4098                        .or_default()
4099                        .1
4100                        .push(entity.clone());
4101                }
4102
4103                // Compute per-type metrics
4104                for (type_str, (type_gold, type_predicted)) in type_groups {
4105                    let result = evaluate_entities(&type_gold, &type_predicted);
4106                    let summary = result.summary();
4107                    by_type_scores.entry(type_str).or_default().push((
4108                        summary.strict_f1,
4109                        summary.strict_precision,
4110                        summary.strict_recall,
4111                    ));
4112                }
4113            }
4114
4115            // Compute mean and CI for each type
4116            let mut by_entity_type = HashMap::new();
4117            for (type_str, scores) in by_type_scores {
4118                if scores.is_empty() {
4119                    continue;
4120                }
4121
4122                let n = scores.len() as f64;
4123                let f1_mean = scores.iter().map(|(f1, _, _)| f1).sum::<f64>() / n;
4124                // Note: precision_mean and recall_mean computed but not used in CI (using F1 only for now)
4125                let _precision_mean = scores.iter().map(|(_, p, _)| p).sum::<f64>() / n;
4126                let _recall_mean = scores.iter().map(|(_, _, r)| r).sum::<f64>() / n;
4127
4128                // Use sample variance (Bessel's correction: n-1) for unbiased estimate
4129                let f1_variance = if n > 1.0 {
4130                    scores
4131                        .iter()
4132                        .map(|(f1, _, _)| (f1 - f1_mean).powi(2))
4133                        .sum::<f64>()
4134                        / (n - 1.0)
4135                } else {
4136                    0.0
4137                };
4138                let f1_std_dev = f1_variance.sqrt();
4139
4140                let z = DEFAULT_Z_SCORE_95;
4141                let margin = z * f1_std_dev / n.sqrt();
4142
4143                by_entity_type.insert(
4144                    type_str,
4145                    MetricWithCI {
4146                        mean: f1_mean,
4147                        std_dev: f1_std_dev,
4148                        ci_95: (
4149                            (f1_mean - margin).clamp(0.0, 1.0),
4150                            (f1_mean + margin).clamp(0.0, 1.0),
4151                        ),
4152                        n: scores.len(),
4153                    },
4154                );
4155            }
4156
4157            // Compute temporal stratification if metadata available
4158            let by_temporal_stratum = if let Some(ref temporal) = dataset_data.temporal_metadata {
4159                self.compute_temporal_stratification(per_example, temporal)
4160            } else {
4161                None
4162            };
4163
4164            return Some(StratifiedMetrics {
4165                by_entity_type,
4166                by_temporal_stratum,
4167                by_surface_form: None, // Would need proper noun detection
4168                by_mention_char: None, // Would need mention analysis
4169            });
4170        }
4171
4172        // Fallback to simplified version using aggregate metrics
4173        self.compute_stratified_metrics(dataset_data, aggregate_metrics)
4174    }
4175
4176    /// Compute temporal stratification from per-example scores and temporal metadata.
4177    fn compute_temporal_stratification(
4178        &self,
4179        per_example_scores: &[(Vec<Entity>, Vec<Entity>, String)],
4180        temporal_metadata: &super::loader::TemporalMetadata,
4181    ) -> Option<HashMap<String, MetricWithCI>> {
4182        use crate::eval::ner_metrics::evaluate_entities;
4183
4184        // If no temporal cutoff, can't stratify
4185        let cutoff = temporal_metadata.temporal_cutoff.as_ref()?;
4186
4187        // Parse cutoff date (ISO 8601 format: YYYY-MM-DD)
4188        // For now, we use a simple heuristic: all examples are pre-cutoff
4189        // Future: would need entity creation dates or document timestamps to properly stratify
4190        let _cutoff_date = cutoff.split('T').next()?; // Remove time if present
4191                                                      // Note: cutoff date parsing removed - not used in current heuristic implementation
4192
4193        // Group examples by temporal stratum
4194        let mut pre_cutoff_scores = Vec::new();
4195        let mut post_cutoff_scores = Vec::new();
4196
4197        // Heuristic: Split examples in half based on order
4198        // First half treated as pre-cutoff, second half as post-cutoff
4199        // This approximates temporal drift when entity creation dates are unavailable
4200        let total = per_example_scores.len();
4201        let cutoff_index = total / 2;
4202
4203        for (idx, (gold, predicted, _text)) in per_example_scores.iter().enumerate() {
4204            // Split data in half: first half = pre-cutoff, second half = post-cutoff
4205            // This is a heuristic approximation - proper temporal stratification would
4206            // require entity creation dates from entity linking or document timestamps
4207            let is_post_cutoff = idx >= cutoff_index;
4208
4209            // Compute per-example metrics
4210            let result = evaluate_entities(gold, predicted);
4211            let summary = result.summary();
4212
4213            if is_post_cutoff {
4214                post_cutoff_scores.push(summary.strict_f1);
4215            } else {
4216                pre_cutoff_scores.push(summary.strict_f1);
4217            }
4218        }
4219
4220        // Compute metrics for each stratum
4221        let mut by_temporal = HashMap::new();
4222
4223        if !pre_cutoff_scores.is_empty() {
4224            let n = pre_cutoff_scores.len() as f64;
4225            let mean = pre_cutoff_scores.iter().sum::<f64>() / n;
4226            // Use sample variance (Bessel's correction: n-1) for unbiased estimate
4227            let variance = if n > 1.0 {
4228                pre_cutoff_scores
4229                    .iter()
4230                    .map(|&x| (x - mean).powi(2))
4231                    .sum::<f64>()
4232                    / (n - 1.0)
4233            } else {
4234                0.0
4235            };
4236            let std_dev = variance.sqrt();
4237            let z = DEFAULT_Z_SCORE_95;
4238            let margin = z * std_dev / n.sqrt();
4239
4240            by_temporal.insert(
4241                "pre_cutoff".to_string(),
4242                MetricWithCI {
4243                    mean,
4244                    std_dev,
4245                    ci_95: (
4246                        (mean - margin).clamp(0.0, 1.0),
4247                        (mean + margin).clamp(0.0, 1.0),
4248                    ),
4249                    n: pre_cutoff_scores.len(),
4250                },
4251            );
4252        }
4253
4254        if !post_cutoff_scores.is_empty() {
4255            let n = post_cutoff_scores.len() as f64;
4256            let mean = post_cutoff_scores.iter().sum::<f64>() / n;
4257            // Use sample variance (Bessel's correction: n-1) for unbiased estimate
4258            let variance = if n > 1.0 {
4259                post_cutoff_scores
4260                    .iter()
4261                    .map(|&x| (x - mean).powi(2))
4262                    .sum::<f64>()
4263                    / (n - 1.0)
4264            } else {
4265                0.0
4266            };
4267            let std_dev = variance.sqrt();
4268            let z = DEFAULT_Z_SCORE_95;
4269            let margin = z * std_dev / n.sqrt();
4270
4271            by_temporal.insert(
4272                "post_cutoff".to_string(),
4273                MetricWithCI {
4274                    mean,
4275                    std_dev,
4276                    ci_95: (
4277                        (mean - margin).clamp(0.0, 1.0),
4278                        (mean + margin).clamp(0.0, 1.0),
4279                    ),
4280                    n: post_cutoff_scores.len(),
4281                },
4282            );
4283        }
4284
4285        if by_temporal.is_empty() {
4286            None
4287        } else {
4288            Some(by_temporal)
4289        }
4290    }
4291
4292    /// Compute confidence intervals from per-example scores.
4293    fn compute_confidence_intervals_from_scores(
4294        &self,
4295        per_example_scores: &[(Vec<Entity>, Vec<Entity>, String)],
4296    ) -> Option<ConfidenceIntervals> {
4297        use crate::eval::ner_metrics::evaluate_entities;
4298
4299        if per_example_scores.is_empty() {
4300            return None;
4301        }
4302
4303        let mut f1_scores = Vec::new();
4304        let mut precision_scores = Vec::new();
4305        let mut recall_scores = Vec::new();
4306
4307        for (gold, predicted, _text) in per_example_scores {
4308            let result = evaluate_entities(gold, predicted);
4309            let summary = result.summary();
4310            f1_scores.push(summary.strict_f1);
4311            precision_scores.push(summary.strict_precision);
4312            recall_scores.push(summary.strict_recall);
4313        }
4314
4315        // Compute mean and std_dev
4316        let n = f1_scores.len() as f64;
4317        let f1_mean = f1_scores.iter().sum::<f64>() / n;
4318        let precision_mean = precision_scores.iter().sum::<f64>() / n;
4319        let recall_mean = recall_scores.iter().sum::<f64>() / n;
4320
4321        // Use sample variance (Bessel's correction: n-1) for unbiased estimate
4322        let f1_variance = if n > 1.0 {
4323            f1_scores
4324                .iter()
4325                .map(|&x| (x - f1_mean).powi(2))
4326                .sum::<f64>()
4327                / (n - 1.0)
4328        } else {
4329            0.0
4330        };
4331        let precision_variance = if n > 1.0 {
4332            precision_scores
4333                .iter()
4334                .map(|&x| (x - precision_mean).powi(2))
4335                .sum::<f64>()
4336                / (n - 1.0)
4337        } else {
4338            0.0
4339        };
4340        let recall_variance = if n > 1.0 {
4341            recall_scores
4342                .iter()
4343                .map(|&x| (x - recall_mean).powi(2))
4344                .sum::<f64>()
4345                / (n - 1.0)
4346        } else {
4347            0.0
4348        };
4349
4350        let f1_std_dev = f1_variance.sqrt();
4351        let precision_std_dev = precision_variance.sqrt();
4352        let recall_std_dev = recall_variance.sqrt();
4353
4354        // 95% CI: mean ± 1.96 * std_dev / sqrt(n)
4355        let z = DEFAULT_Z_SCORE_95;
4356        let f1_margin = z * f1_std_dev / n.sqrt();
4357        let precision_margin = z * precision_std_dev / n.sqrt();
4358        let recall_margin = z * recall_std_dev / n.sqrt();
4359
4360        Some(ConfidenceIntervals {
4361            f1_ci: (
4362                (f1_mean - f1_margin).clamp(0.0, 1.0),
4363                (f1_mean + f1_margin).clamp(0.0, 1.0),
4364            ),
4365            precision_ci: (
4366                (precision_mean - precision_margin).clamp(0.0, 1.0),
4367                (precision_mean + precision_margin).clamp(0.0, 1.0),
4368            ),
4369            recall_ci: (
4370                (recall_mean - recall_margin).clamp(0.0, 1.0),
4371                (recall_mean + recall_margin).clamp(0.0, 1.0),
4372            ),
4373        })
4374    }
4375
4376    /// Compute stratified metrics across multiple dimensions.
4377    ///
4378    /// # Fallback Behavior
4379    ///
4380    /// This is a **fallback** when per-example predictions are not available.
4381    /// All entity types will show the same aggregate F1 metrics because we lack
4382    /// the per-prediction data needed for true per-type stratification.
4383    ///
4384    /// # Preferred Path
4385    ///
4386    /// For proper per-type stratification, use `Self::compute_stratified_metrics_from_scores`
4387    /// which computes actual per-type F1/precision/recall from per-example predictions.
4388    /// That method is automatically used when per-example scores are available via
4389    /// the evaluation pipeline (see `evaluate_ner_internal`).
4390    ///
4391    /// # When This Fallback Is Used
4392    ///
4393    /// - External evaluation without per-example tracking
4394    /// - Legacy integrations that only provide aggregate metrics
4395    /// - Quick estimates when full stratification isn't needed
4396    pub fn compute_stratified_metrics(
4397        &self,
4398        dataset_data: &LoadedDataset,
4399        metrics: &HashMap<String, f64>,
4400    ) -> Option<StratifiedMetrics> {
4401        // Extract entity types from dataset (single pass)
4402        let mut type_counts: HashMap<String, usize> = HashMap::new();
4403        for sentence in &dataset_data.sentences {
4404            for entity in sentence.entities() {
4405                let type_str = entity.entity_type.as_label().to_string();
4406                *type_counts.entry(type_str).or_insert(0) += 1;
4407            }
4408        }
4409
4410        if type_counts.is_empty() {
4411            return None;
4412        }
4413
4414        // Build per-type metrics (fallback: uses aggregate F1 for all types)
4415        // Proper per-type stratification is done by compute_stratified_metrics_from_scores
4416        // when per-example scores are available from the evaluation pipeline.
4417        let mut by_entity_type = HashMap::new();
4418        let aggregate_f1 = metrics.get("f1").copied().unwrap_or(0.0);
4419        for (type_str, count) in type_counts {
4420            // Fallback: all types get aggregate F1 (proper per-type metrics need per-example data)
4421            let mean = aggregate_f1;
4422            let std_dev = DEFAULT_FALLBACK_STD_DEV;
4423            let z = DEFAULT_Z_SCORE_95;
4424            let margin = z * std_dev;
4425            by_entity_type.insert(
4426                type_str,
4427                MetricWithCI {
4428                    mean,
4429                    std_dev,
4430                    ci_95: (
4431                        (mean - margin).clamp(0.0, 1.0),
4432                        (mean + margin).clamp(0.0, 1.0),
4433                    ),
4434                    n: count, // Use actual count from dataset
4435                },
4436            );
4437        }
4438
4439        Some(StratifiedMetrics {
4440            by_entity_type,
4441            by_temporal_stratum: None, // Would need temporal metadata
4442            by_surface_form: None,     // Would need proper noun detection
4443            by_mention_char: None,     // Would need mention analysis
4444        })
4445    }
4446}
4447
4448#[cfg(test)]
4449mod tests {
4450    use super::*;
4451    use crate::eval::loader::DatasetId;
4452
4453    #[test]
4454    fn test_task_mapping_build() {
4455        let mapping = TaskMapping::build();
4456        assert!(!mapping.task_to_datasets.is_empty());
4457        assert!(!mapping.dataset_to_tasks.is_empty());
4458        assert!(!mapping.backend_to_tasks.is_empty());
4459        assert!(!mapping.task_to_backends.is_empty());
4460    }
4461
4462    #[test]
4463    fn test_type_mapping_domain_specific() {
4464        // Test domain-specific type mappings (MIT Movie, MIT Restaurant, etc.)
4465        use super::TaskEvaluator;
4466
4467        // MIT Movie types should map Actor/Director → person
4468        let mit_movie_types = vec!["Actor", "Director", "Character"];
4469        let mapped = TaskEvaluator::map_dataset_labels_to_model(&mit_movie_types, "stacked");
4470        assert!(
4471            mapped.iter().any(|t| t == "person"),
4472            "MIT Movie Actor/Director should map to person"
4473        );
4474
4475        // MIT Restaurant types should map Restaurant_Name → organization
4476        let mit_restaurant_types = vec!["Restaurant_Name", "Cuisine", "Dish"];
4477        let mapped = TaskEvaluator::map_dataset_labels_to_model(&mit_restaurant_types, "stacked");
4478        assert!(
4479            mapped.iter().any(|t| t == "organization"),
4480            "MIT Restaurant Restaurant_Name should map to organization"
4481        );
4482
4483        // Biomedical types should map Disease → disease
4484        let bio_types = vec!["Disease", "Chemical", "Disorder"];
4485        let mapped = TaskEvaluator::map_dataset_labels_to_model(&bio_types, "stacked");
4486        assert!(
4487            mapped.iter().any(|t| t == "disease"),
4488            "Biomedical Disease should map to disease"
4489        );
4490        assert!(
4491            mapped.iter().any(|t| t == "chemical"),
4492            "Biomedical Chemical should map to chemical"
4493        );
4494    }
4495
4496    #[test]
4497    fn test_classical_backend_dataset_compatibility_gate() {
4498        // CRF/HMM in this repo are CoNLL-style: they should be compatible with PER/LOC/ORG/MISC
4499        // datasets, but excluded from datasets with different type inventories (e.g. WNUT-17).
4500        assert!(TaskEvaluator::is_backend_compatible(
4501            "crf",
4502            DatasetId::CoNLL2003Sample
4503        ));
4504        assert!(TaskEvaluator::is_backend_compatible(
4505            "hmm",
4506            DatasetId::CoNLL2003Sample
4507        ));
4508
4509        assert!(!TaskEvaluator::is_backend_compatible(
4510            "crf",
4511            DatasetId::Wnut17
4512        ));
4513        assert!(!TaskEvaluator::is_backend_compatible(
4514            "hmm",
4515            DatasetId::Wnut17
4516        ));
4517    }
4518
4519    #[test]
4520    fn test_gliner_multitask_capabilities() {
4521        let tasks = crate::eval::task_mapping::backend_tasks("gliner_multitask");
4522        assert!(tasks.contains(&Task::NER));
4523        assert!(tasks.contains(&Task::RelationExtraction));
4524        assert!(tasks.contains(&Task::TextClassification));
4525    }
4526
4527    #[test]
4528    fn test_event_extraction_can_be_scored_like_ner() {
4529        use crate::eval::loader::{
4530            AnnotatedSentence, AnnotatedToken, DataSource, DatasetMetadata, LoadedDataset,
4531        };
4532        use anno::{AnyModel, Entity, EntityType};
4533
4534        // One example with an "event type" label encoded as BIO, like the loader parsers do.
4535        let ds = LoadedDataset {
4536            id: DatasetId::MAVEN,
4537            sentences: vec![AnnotatedSentence {
4538                tokens: vec![AnnotatedToken {
4539                    text: "boom".to_string(),
4540                    ner_tag: "B-EventType".to_string(),
4541                }],
4542                source_dataset: DatasetId::MAVEN,
4543            }],
4544            loaded_at: "now".to_string(),
4545            source_url: "test".to_string(),
4546            data_source: DataSource::Embedded,
4547            temporal_metadata: None,
4548            metadata: DatasetMetadata::default(),
4549        };
4550
4551        // A trivial backend that predicts exactly that span/type.
4552        let ty = EntityType::from_label("EventType");
4553        let m = AnyModel::new(
4554            "event-dummy",
4555            "dummy event trigger extractor",
4556            vec![ty.clone()],
4557            move |_text, _lang| Ok(vec![Entity::new("boom", ty.clone(), 0, 4, 1.0)]),
4558        );
4559
4560        let eval = TaskEvaluator::new().expect("TaskEvaluator::new");
4561        let metrics = eval
4562            .evaluate_ner_task(
4563                "event-dummy",
4564                &m,
4565                DatasetId::MAVEN,
4566                &ds,
4567                &TaskEvalConfig::default(),
4568            )
4569            .expect("evaluate_ner_task");
4570
4571        assert!(metrics.get("f1").copied().unwrap_or(0.0) >= 0.99);
4572    }
4573
4574    // =========================================================================
4575    // MetricWithCI Tests
4576    // =========================================================================
4577
4578    #[test]
4579    fn test_metric_with_ci_structure() {
4580        let metric = MetricWithCI {
4581            mean: 0.8,
4582            std_dev: 0.05,
4583            ci_95: (0.75, 0.85),
4584            n: 10,
4585        };
4586
4587        assert!((metric.mean - 0.8).abs() < 0.001);
4588        assert_eq!(metric.n, 10);
4589        assert!(metric.ci_95.0 < metric.mean);
4590        assert!(metric.ci_95.1 > metric.mean);
4591    }
4592
4593    #[test]
4594    fn test_metric_with_ci_serialization() {
4595        let metric = MetricWithCI {
4596            mean: 0.75,
4597            std_dev: 0.1,
4598            ci_95: (0.65, 0.85),
4599            n: 50,
4600        };
4601
4602        // Should serialize/deserialize correctly
4603        let json = serde_json::to_string(&metric).unwrap();
4604        let parsed: MetricWithCI = serde_json::from_str(&json).unwrap();
4605
4606        assert!((parsed.mean - 0.75).abs() < 0.001);
4607        assert_eq!(parsed.n, 50);
4608    }
4609
4610    // =========================================================================
4611    // StratifiedMetrics Tests
4612    // =========================================================================
4613
4614    #[test]
4615    fn test_stratified_metrics_default() {
4616        let strat = StratifiedMetrics {
4617            by_entity_type: HashMap::new(),
4618            by_temporal_stratum: None,
4619            by_surface_form: None,
4620            by_mention_char: None,
4621        };
4622
4623        assert!(strat.by_entity_type.is_empty());
4624        assert!(strat.by_temporal_stratum.is_none());
4625    }
4626
4627    #[test]
4628    fn test_stratified_metrics_with_types() {
4629        let mut by_type = HashMap::new();
4630        by_type.insert(
4631            "person".to_string(),
4632            MetricWithCI {
4633                mean: 0.87,
4634                std_dev: 0.03,
4635                ci_95: (0.84, 0.90),
4636                n: 100,
4637            },
4638        );
4639        by_type.insert(
4640            "location".to_string(),
4641            MetricWithCI {
4642                mean: 0.78,
4643                std_dev: 0.05,
4644                ci_95: (0.73, 0.83),
4645                n: 80,
4646            },
4647        );
4648
4649        let strat = StratifiedMetrics {
4650            by_entity_type: by_type,
4651            by_temporal_stratum: None,
4652            by_surface_form: None,
4653            by_mention_char: None,
4654        };
4655
4656        assert_eq!(strat.by_entity_type.len(), 2);
4657        assert!(strat.by_entity_type.contains_key("person"));
4658        assert!(strat.by_entity_type.contains_key("location"));
4659    }
4660
4661    // =========================================================================
4662    // TaskEvalResult Tests
4663    // =========================================================================
4664
4665    fn make_test_result(success: bool, error: Option<&str>, f1: Option<f64>) -> TaskEvalResult {
4666        let mut metrics = HashMap::new();
4667        if let Some(f1_val) = f1 {
4668            metrics.insert("f1".to_string(), f1_val);
4669            metrics.insert("precision".to_string(), 0.8);
4670            metrics.insert("recall".to_string(), 0.75);
4671        }
4672
4673        TaskEvalResult {
4674            task: Task::NER,
4675            dataset: DatasetId::WikiGold,
4676            backend: "stacked".to_string(),
4677            backend_display: Some("stacked(regex+heuristic)".to_string()),
4678            seed: 42,
4679            success,
4680            error: error.map(|s| s.to_string()),
4681            metrics,
4682            num_examples: 100,
4683            duration_ms: Some(500.0),
4684            label_shift: None,
4685            robustness: None,
4686            stratified: None,
4687            confidence_intervals: None,
4688            kb_version: None,
4689        }
4690    }
4691
4692    #[test]
4693    fn test_task_eval_result_success() {
4694        let result = make_test_result(true, None, Some(0.85));
4695
4696        assert!(result.success);
4697        assert!(result.error.is_none());
4698        assert!(result.metrics.contains_key("f1"));
4699        assert!((result.metrics["f1"] - 0.85).abs() < 0.001);
4700    }
4701
4702    #[test]
4703    fn test_task_eval_result_failure() {
4704        let result = make_test_result(false, Some("Model failed to load"), None);
4705
4706        assert!(!result.success);
4707        assert!(result.error.is_some());
4708        assert_eq!(result.error.as_ref().unwrap(), "Model failed to load");
4709    }
4710
4711    #[test]
4712    fn test_task_eval_result_is_skipped() {
4713        let skipped = TaskEvalResult {
4714            task: Task::NER,
4715            dataset: DatasetId::WikiGold,
4716            backend: "missing".to_string(),
4717            backend_display: None,
4718            seed: 42,
4719            success: false,
4720            error: Some("Feature not available".to_string()),
4721            metrics: HashMap::new(),
4722            num_examples: 0,
4723            duration_ms: None,
4724            label_shift: None,
4725            robustness: None,
4726            stratified: None,
4727            confidence_intervals: None,
4728            kb_version: None,
4729        };
4730
4731        assert!(skipped.is_skipped());
4732    }
4733
4734    #[test]
4735    fn test_task_eval_result_not_skipped() {
4736        let not_skipped = TaskEvalResult {
4737            task: Task::NER,
4738            dataset: DatasetId::WikiGold,
4739            backend: "missing".to_string(),
4740            backend_display: None,
4741            seed: 42,
4742            success: false,
4743            error: Some("Connection timeout".to_string()),
4744            metrics: HashMap::new(),
4745            num_examples: 0,
4746            duration_ms: None,
4747            label_shift: None,
4748            robustness: None,
4749            stratified: None,
4750            confidence_intervals: None,
4751            kb_version: None,
4752        };
4753
4754        assert!(!not_skipped.is_skipped());
4755    }
4756
4757    #[test]
4758    fn test_task_eval_result_primary_f1() {
4759        let result = make_test_result(true, None, Some(0.824));
4760        assert_eq!(result.primary_f1(), Some(0.824));
4761    }
4762
4763    #[test]
4764    fn test_task_eval_result_primary_f1_missing() {
4765        let result = make_test_result(false, Some("Error"), None);
4766        assert_eq!(result.primary_f1(), None);
4767    }
4768
4769    // =========================================================================
4770    // Task Mapping Tests
4771    // =========================================================================
4772
4773    #[test]
4774    fn test_all_tasks_have_datasets() {
4775        let mapping = TaskMapping::build();
4776
4777        // Just check that the mapping was built successfully
4778        assert!(
4779            !mapping.task_to_datasets.is_empty(),
4780            "Task mapping should have some tasks"
4781        );
4782
4783        // Check that NER task has datasets (core task that should always have datasets)
4784        let ner_code = Task::NER.code();
4785        let datasets = mapping.datasets_for_task(ner_code);
4786        assert!(
4787            datasets.is_some() && !datasets.unwrap().is_empty(),
4788            "NER task should have at least one dataset"
4789        );
4790    }
4791
4792    #[test]
4793    fn test_get_task_datasets_ner() {
4794        let datasets = get_task_datasets(Task::NER);
4795        assert!(!datasets.is_empty(), "NER should have datasets");
4796    }
4797
4798    #[test]
4799    fn test_get_task_backends_ner() {
4800        let backends = get_task_backends(Task::NER);
4801        assert!(!backends.is_empty(), "NER should have backends");
4802    }
4803
4804    #[test]
4805    fn test_dataset_tasks_wikigold() {
4806        let tasks = dataset_tasks(DatasetId::WikiGold);
4807        assert!(
4808            tasks.contains(&Task::NER),
4809            "WikiGold should support NER task"
4810        );
4811    }
4812
4813    // =========================================================================
4814    // Type Mapping Edge Cases
4815    // =========================================================================
4816
4817    #[test]
4818    fn test_type_mapping_preserves_standard_types() {
4819        let standard_types = vec!["PER", "LOC", "ORG", "MISC"];
4820        let mapped = TaskEvaluator::map_dataset_labels_to_model(&standard_types, "stacked");
4821
4822        // Standard types should be recognized
4823        assert!(
4824            mapped.iter().any(|t| t == "person" || t == "PER"),
4825            "PER should map to person or stay as PER"
4826        );
4827    }
4828
4829    #[test]
4830    fn test_type_mapping_unknown_types() {
4831        let unknown_types = vec!["UNKNOWN_TYPE_XYZ"];
4832        let mapped = TaskEvaluator::map_dataset_labels_to_model(&unknown_types, "stacked");
4833
4834        // Unknown types should be preserved or mapped to misc/other
4835        assert!(!mapped.is_empty());
4836    }
4837
4838    #[test]
4839    fn test_type_mapping_empty_input() {
4840        let empty_types: Vec<&str> = vec![];
4841        let mapped = TaskEvaluator::map_dataset_labels_to_model(&empty_types, "stacked");
4842
4843        assert!(mapped.is_empty());
4844    }
4845
4846    #[test]
4847    fn test_type_mapping_case_insensitive() {
4848        // Test that mapping handles case variations
4849        let types1 = vec!["Person", "PERSON", "person"];
4850        let mapped1 = TaskEvaluator::map_dataset_labels_to_model(&types1, "stacked");
4851
4852        // All should map to the same canonical form
4853        assert!(mapped1.iter().all(|t| t.to_lowercase() == "person"));
4854    }
4855
4856    // =========================================================================
4857    // ComprehensiveEvalResults Tests
4858    // =========================================================================
4859
4860    #[test]
4861    fn test_comprehensive_eval_results_average_f1() {
4862        let results = [
4863            make_test_result(true, None, Some(0.8)),
4864            make_test_result(true, None, Some(0.6)),
4865        ];
4866
4867        // Compute average F1
4868        let avg_f1: f64 = results.iter().filter_map(|r| r.primary_f1()).sum::<f64>()
4869            / results.iter().filter(|r| r.primary_f1().is_some()).count() as f64;
4870        assert!((avg_f1 - 0.7).abs() < 0.001);
4871    }
4872
4873    #[test]
4874    fn test_comprehensive_eval_results_mixed_success() {
4875        let results = [
4876            make_test_result(true, None, Some(0.824)),
4877            make_test_result(false, Some("Backend unavailable"), None),
4878        ];
4879
4880        let success_count = results.iter().filter(|r| r.success).count();
4881        let failure_count = results.iter().filter(|r| !r.success).count();
4882
4883        assert_eq!(success_count, 1);
4884        assert_eq!(failure_count, 1);
4885    }
4886
4887    #[test]
4888    fn test_eval_summary_structure() {
4889        let summary = EvalSummary {
4890            total_combinations: 100,
4891            successful: 85,
4892            failed: 10,
4893            skipped: 5,
4894            tasks: vec![Task::NER],
4895            datasets: vec![DatasetId::WikiGold],
4896            backends: vec!["stacked".to_string()],
4897        };
4898
4899        assert_eq!(summary.total_combinations, 100);
4900        assert_eq!(summary.successful + summary.failed + summary.skipped, 100);
4901        assert!(!summary.tasks.is_empty());
4902        assert!(!summary.backends.is_empty());
4903    }
4904}