1use 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
30fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
32 mutex.lock().unwrap_or_else(|e| e.into_inner())
33}
34
35type PerExampleScores = Vec<(Vec<anno::Entity>, Vec<anno::Entity>, String)>;
37
38const DEFAULT_Z_SCORE_95: f64 = 1.96;
41const DEFAULT_FALLBACK_STD_DEV: f64 = 0.05;
50const MAX_CI_SAMPLE_SIZE: usize = 100;
52const MIN_CI_SAMPLE_SIZE: usize = 2;
56#[cfg(feature = "eval")]
60const ROBUSTNESS_TEST_LIMIT: usize = 50;
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct StratifiedMetrics {
65 pub by_entity_type: HashMap<String, MetricWithCI>,
67 pub by_temporal_stratum: Option<HashMap<String, MetricWithCI>>,
69 pub by_surface_form: Option<HashMap<String, MetricWithCI>>,
71 pub by_mention_char: Option<HashMap<String, MetricWithCI>>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct MetricWithCI {
78 pub mean: f64,
80 pub std_dev: f64,
82 pub ci_95: (f64, f64),
84 pub n: usize,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ConfidenceIntervals {
91 pub f1_ci: (f64, f64),
93 pub precision_ci: (f64, f64),
95 pub recall_ci: (f64, f64),
97}
98
99#[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#[derive(Serialize, Deserialize)]
118pub struct TaskEvalConfig {
119 pub tasks: Vec<Task>,
121 pub datasets: Vec<DatasetId>,
123 pub backends: Vec<String>,
125 pub max_examples: Option<usize>,
127 pub seed: Option<u64>,
129 pub require_cached: bool,
131 pub relation_threshold: f32,
133 pub robustness: bool,
135 pub compute_familiarity: bool,
137 pub temporal_stratification: bool,
139 pub confidence_intervals: bool,
141 #[serde(skip)]
145 pub custom_coref_resolver:
146 Option<std::sync::Arc<dyn crate::eval::coref_resolver::CoreferenceResolver>>,
147
148 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, temporal_stratification: false,
170 confidence_intervals: true, 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#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct TaskEvalResult {
207 pub task: Task,
209 pub dataset: DatasetId,
211 pub backend: String,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub backend_display: Option<String>,
218 pub seed: u64,
220 pub success: bool,
222 pub error: Option<String>,
224 pub metrics: HashMap<String, f64>,
226 pub num_examples: usize,
228 pub duration_ms: Option<f64>,
230 pub label_shift: Option<super::types::LabelShift>,
232 #[cfg(feature = "eval")]
234 pub robustness: Option<super::robustness::RobustnessResults>,
235 #[cfg(not(feature = "eval"))]
236 #[cfg(not(feature = "eval"))]
238 pub robustness: Option<()>, pub stratified: Option<StratifiedMetrics>,
241 pub confidence_intervals: Option<ConfidenceIntervals>,
243 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct ComprehensiveEvalResults {
282 pub results: Vec<TaskEvalResult>,
284 pub summary: EvalSummary,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct EvalSummary {
291 pub total_combinations: usize,
293 pub successful: usize,
295 pub failed: usize,
297 pub skipped: usize,
299 pub tasks: Vec<Task>,
301 pub datasets: Vec<DatasetId>,
303 pub backends: Vec<String>,
305}
306
307pub struct TaskEvaluator {
309 loader: DatasetLoader,
310 #[allow(dead_code)] mapping: TaskMapping,
312 per_example_scores_cache: Mutex<Option<PerExampleScores>>,
315 history: Option<super::history::EvalHistory>,
317}
318
319impl TaskEvaluator {
320 pub fn history(&self) -> Option<&super::history::EvalHistory> {
322 self.history.as_ref()
323 }
324
325 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 pub fn new() -> Result<Self> {
349 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 pub fn with_cache_dir(cache_dir: impl AsRef<std::path::Path>) -> Result<Self> {
383 let cache_path = cache_dir.as_ref();
384
385 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 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_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 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 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 #[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 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 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 let mut cache = lock(&self.per_example_scores_cache);
571 *cache = None;
572
573 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 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 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 let datasets = if config.datasets.is_empty() {
645 get_task_datasets(*task)
646 } else {
647 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 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 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 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 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 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 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 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" => true,
886 "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 "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" => {
907 false
910 }
911 "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, }
928 }
929
930 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 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 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 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 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 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 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 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 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 let estimated_entities = dataset_data.sentences.len() * 3; 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 let track_per_example = true;
1076 let mut per_example_scores: Vec<(Vec<Entity>, Vec<Entity>, String)> = Vec::new();
1077
1078 let dataset_labels = dataset.entity_types();
1080 let mapped_labels = Self::map_dataset_labels_to_model(dataset_labels, backend_name);
1081
1082 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 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 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 thread_local! {
1115 static THREAD_CACHED_BACKEND: RefCell<Option<(String, String, CachedBackend)>> = const { RefCell::new(None) };
1118 }
1119
1120 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 let gold_entities: Vec<Entity> = sentence.entities().iter().map(|g| {
1139 let mut entity = Entity::new(
1140 g.text.clone(), g.entity_type.clone(), 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 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 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 return Self::extract_with_cached_backend(
1160 backend,
1161 &text,
1162 &mapped_labels_arc,
1163 );
1164 }
1165 }
1166 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 *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 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 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!(); 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 all_gold.extend(gold_entities.clone());
1255 all_predicted.extend(entities.clone());
1256 per_example_scores.push((gold_entities, entities, text));
1257 } else {
1258 all_gold.extend(gold_entities);
1260 all_predicted.extend(entities);
1261 }
1262 }
1263 Err(e) => {
1264 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 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 for (idx, sentence) in dataset_data.sentences.iter().enumerate() {
1289 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 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 let entities = {
1335 let inference_start = Instant::now();
1336 let result = if let Some(ref cached) = zero_shot_backend {
1337 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; 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 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 all_predicted.extend(entities);
1377 }
1378 }
1379 Err(e) => {
1380 let error_msg = format!("{}", e);
1382 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::debug!(
1398 "Backend '{}' failed on sentence {}: {}",
1399 backend_name,
1400 idx + 1,
1401 e
1402 );
1403 }
1404 }
1405 }
1406
1407 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!(); }
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 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 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 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 {
1478 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 }
1488
1489 Ok(metrics)
1490 }
1491
1492 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 if backend_lower == "nuner" {
1505 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 let normalized = label.to_lowercase();
1518 match normalized.as_str() {
1519 "per" | "person" => "person".to_string(),
1521 "org" | "organization" | "organisation" | "corporation" | "company" => {
1523 "organization".to_string()
1524 }
1525 "loc" | "location" | "place" | "gpe" | "geo-loc" => "location".to_string(),
1527 "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 "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 _ 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 _ => label.to_lowercase(),
1563 }
1564 })
1565 .collect()
1566 }
1567
1568 #[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 #[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 #[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 #[allow(unused_variables)] #[cfg(feature = "eval-parallel")]
1745 fn extract_with_cached_backend(
1746 cached: &CachedBackend,
1747 text: &str,
1748 labels: &[String],
1749 ) -> Result<Vec<Entity>> {
1750 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 #[allow(unused_variables)] #[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 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 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 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 #[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 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 #[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 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 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 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 if task == Task::InterDocCoref {
2051 return self.evaluate_inter_doc_coref(&gold_docs, backend_name, config);
2052 }
2053
2054 let resolver: std::sync::Arc<dyn crate::eval::coref_resolver::CoreferenceResolver> =
2059 if let Some(ref custom_resolver) = config.custom_coref_resolver {
2060 custom_resolver.clone()
2062 } else {
2063 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 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 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 all_gold_chains.extend(offset_chains(doc.chains.clone(), doc_base));
2116
2117 let is_text_based_coref = matches!(backend_name, "fcoref" | "f-coref" | "fastcoref");
2120
2121 let predicted_chains = if is_text_based_coref {
2122 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 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 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 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 let ner_backend_name = match backend_name {
2212 "coref_resolver" | "mention_ranking" | "box" => "stacked",
2214 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 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 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 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 let mut topics_map: HashMap<String, Vec<&crate::eval::coref::CorefDocument>> =
2366 HashMap::new();
2367
2368 for doc in gold_docs {
2369 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 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 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 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 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 for mentions in chain_to_mentions.values() {
2447 if mentions.len() < 2 {
2448 continue; }
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 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 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 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 let relation_docs = match self.loader.load_relation(dataset_data.id) {
2511 Ok(docs) => docs,
2512 Err(_) => {
2513 #[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 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 let mut all_predicted_relations: Vec<RelationPrediction> = Vec::new();
2564
2565 use anno::backends::inference::RelationExtractor;
2568
2569 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 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 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 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 match rel_extractor.extract_with_relations(
2660 text,
2661 &entity_types,
2662 &relation_types,
2663 config.relation_threshold,
2664 ) {
2665 Ok(extraction) => {
2666 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 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 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 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 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 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 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(), confidence: 0.5,
2875 });
2876 }
2877 }
2878 }
2879 }
2880 }
2881
2882 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 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 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 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 #[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 #[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 fn default() -> Self {
3111 Self::new().expect("Failed to create TaskEvaluator: DatasetLoader initialization failed. Use TaskEvaluator::new() for proper error handling.")
3112 }
3113}
3114
3115impl ComprehensiveEvalResults {
3117 pub fn to_markdown(&self) -> String {
3119 let mut md = String::new();
3120 md.push_str("# Eval Report\n\n");
3121
3122 {
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 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 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 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 let mut error_patterns: HashMap<String, usize> = HashMap::new();
3240 for result in failures.iter() {
3241 if let Some(ref err) = result.error {
3242 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 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 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 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 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 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 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 let note = if f1 < 0.1 && result.num_examples > 0 {
3369 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 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 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 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 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 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 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 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 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 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
3694impl TaskEvaluator {
3699 fn extract_kb_version(dataset_data: &super::loader::LoadedDataset) -> Option<String> {
3703 dataset_data.temporal_metadata.as_ref()?.kb_version.clone()
3704 }
3705
3706 fn compute_familiarity_if_zero_shot(
3710 &self,
3711 backend_name: &str,
3712 dataset_data: &LoadedDataset,
3713 ) -> Option<super::types::LabelShift> {
3714 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 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 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 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; 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 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 if !matches!(task, Task::NER | Task::DiscontinuousNER) {
3821 return self.compute_confidence_intervals_from_aggregate(aggregate_metrics);
3822 }
3823
3824 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_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 let mut f1_scores = Vec::new();
3839 let mut precision_scores = Vec::new();
3840 let mut recall_scores = Vec::new();
3841
3842 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 #[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 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 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 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 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 #[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 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 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 let evaluator = RobustnessEvaluator {
4054 seed: config.seed.unwrap_or(42),
4055 ..Default::default()
4056 };
4057
4058 Some(evaluator.evaluate(backend.as_ref(), &test_cases))
4060 }
4061
4062 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 let Some(per_example) = per_example_scores {
4076 let mut by_type_scores: HashMap<String, Vec<(f64, f64, f64)>> = HashMap::new(); for (gold, predicted, _text) in per_example {
4080 let mut type_groups: HashMap<String, (Vec<Entity>, Vec<Entity>)> = HashMap::new();
4082
4083 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 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 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 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 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 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 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, by_mention_char: None, });
4170 }
4171
4172 self.compute_stratified_metrics(dataset_data, aggregate_metrics)
4174 }
4175
4176 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 let cutoff = temporal_metadata.temporal_cutoff.as_ref()?;
4186
4187 let _cutoff_date = cutoff.split('T').next()?; let mut pre_cutoff_scores = Vec::new();
4195 let mut post_cutoff_scores = Vec::new();
4196
4197 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 let is_post_cutoff = idx >= cutoff_index;
4208
4209 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 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 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 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 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 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 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 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 pub fn compute_stratified_metrics(
4397 &self,
4398 dataset_data: &LoadedDataset,
4399 metrics: &HashMap<String, f64>,
4400 ) -> Option<StratifiedMetrics> {
4401 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 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 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, },
4436 );
4437 }
4438
4439 Some(StratifiedMetrics {
4440 by_entity_type,
4441 by_temporal_stratum: None, by_surface_form: None, by_mention_char: None, })
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 use super::TaskEvaluator;
4466
4467 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 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 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 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 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 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 #[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 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 #[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 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 #[test]
4774 fn test_all_tasks_have_datasets() {
4775 let mapping = TaskMapping::build();
4776
4777 assert!(
4779 !mapping.task_to_datasets.is_empty(),
4780 "Task mapping should have some tasks"
4781 );
4782
4783 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 #[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 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 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 let types1 = vec!["Person", "PERSON", "person"];
4850 let mapped1 = TaskEvaluator::map_dataset_labels_to_model(&types1, "stacked");
4851
4852 assert!(mapped1.iter().all(|t| t.to_lowercase() == "person"));
4854 }
4855
4856 #[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 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}