1use anno::{Model, Result};
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::fmt;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct EvalReport {
36 pub model_name: String,
38
39 pub timestamp: String,
41
42 pub core: CoreMetrics,
44
45 pub per_type: HashMap<String, TypeMetrics>,
47
48 pub errors: Option<ErrorSummary>,
50
51 pub bias: Option<BiasSummary>,
53
54 pub data_quality: Option<DataQualitySummary>,
56
57 pub calibration: Option<CalibrationSummary>,
59
60 pub recommendations: Vec<Recommendation>,
62
63 pub warnings: Vec<String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct CoreMetrics {
70 pub precision: f64,
72 pub recall: f64,
74 pub f1: f64,
76 pub total_gold: usize,
78 pub total_predicted: usize,
80 pub total_correct: usize,
82 pub macro_f1: Option<f64>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct TypeMetrics {
89 pub precision: f64,
91 pub recall: f64,
93 pub f1: f64,
95 pub support: usize,
97 pub predicted: usize,
99 pub correct: usize,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ErrorSummary {
106 pub total_errors: usize,
108 pub boundary_errors: usize,
110 pub type_errors: usize,
112 pub false_positives: usize,
114 pub false_negatives: usize,
116 pub top_patterns: Vec<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct BiasSummary {
123 pub bias_detected: bool,
125 pub gender: Option<GenderBiasMetrics>,
127 pub demographic: Option<DemographicBiasMetrics>,
129 pub length: Option<LengthBiasMetrics>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct GenderBiasMetrics {
136 pub pro_stereotype_accuracy: f64,
138 pub anti_stereotype_accuracy: f64,
140 pub gap: f64,
142 pub verdict: String,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct DemographicBiasMetrics {
149 pub max_gap: f64,
151 pub underperforming_groups: Vec<String>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct LengthBiasMetrics {
158 pub short_entity_f1: f64,
160 pub long_entity_f1: f64,
162 pub gap: f64,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct DataQualitySummary {
169 pub leakage_detected: bool,
171 pub redundancy_rate: f64,
173 pub ambiguous_count: usize,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct CalibrationSummary {
180 pub ece: f64,
182 pub mce: f64,
184 pub optimal_threshold: f64,
186 pub grade: char,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct Recommendation {
193 pub priority: Priority,
195 pub category: RecommendationCategory,
197 pub message: String,
199 pub estimated_impact: Option<String>,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205pub enum Priority {
206 High,
208 Medium,
210 Low,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216pub enum RecommendationCategory {
217 Performance,
219 Bias,
221 DataQuality,
223 Calibration,
225 Coverage,
227}
228
229pub struct ReportBuilder {
235 model_name: String,
236 include_core: bool,
237 include_errors: bool,
238 include_bias: bool,
239 include_data_quality: bool,
240 include_calibration: bool,
241 test_data: Option<Vec<TestCase>>,
242}
243
244pub struct TestCase {
246 pub text: String,
248 pub gold_entities: Vec<SimpleGoldEntity>,
250}
251
252#[derive(Debug, Clone)]
259pub struct SimpleGoldEntity {
260 pub text: String,
262 pub entity_type: String,
264 pub start: usize,
266 pub end: usize,
268}
269
270impl SimpleGoldEntity {
271 #[must_use]
282 pub fn extract_text(&self, source_text: &str) -> String {
283 let char_count = source_text.chars().count();
284 if self.start >= char_count || self.end > char_count || self.start >= self.end {
285 return String::new();
286 }
287 source_text
288 .chars()
289 .skip(self.start)
290 .take(self.end - self.start)
291 .collect()
292 }
293}
294
295impl ReportBuilder {
296 pub fn new(model_name: &str) -> Self {
298 Self {
299 model_name: model_name.to_string(),
300 include_core: true,
301 include_errors: true,
302 include_bias: false,
303 include_data_quality: false,
304 include_calibration: false,
305 test_data: None,
306 }
307 }
308
309 pub fn with_core_metrics(mut self, include: bool) -> Self {
311 self.include_core = include;
312 self
313 }
314
315 pub fn with_error_analysis(mut self, include: bool) -> Self {
317 self.include_errors = include;
318 self
319 }
320
321 pub fn with_bias_analysis(mut self, include: bool) -> Self {
324 self.include_bias = include;
325 self
326 }
327
328 pub fn with_data_quality(mut self, include: bool) -> Self {
330 self.include_data_quality = include;
331 self
332 }
333
334 pub fn with_calibration(mut self, include: bool) -> Self {
337 self.include_calibration = include;
338 self
339 }
340
341 pub fn with_test_data(mut self, data: Vec<TestCase>) -> Self {
343 self.test_data = Some(data);
344 self
345 }
346
347 #[cfg(feature = "eval-bias")]
349 fn run_bias_analysis<M: Model>(model: &M) -> Result<BiasSummary> {
350 use crate::eval::coref_resolver::SimpleCorefResolver;
351 use crate::eval::demographic_bias::{
352 create_diverse_name_dataset, DemographicBiasEvaluator,
353 };
354 use crate::eval::gender_bias::{create_winobias_templates, GenderBiasEvaluator};
355
356 let names = create_diverse_name_dataset();
358 let evaluator = DemographicBiasEvaluator::new(true);
359 let demo_results = evaluator.evaluate_ner(model, &names);
360
361 let resolver = SimpleCorefResolver::default();
363 let templates = create_winobias_templates();
364 let gender_evaluator = GenderBiasEvaluator::new(true);
365 let gender_results = gender_evaluator.evaluate_resolver(&resolver, &templates);
366
367 let bias_detected =
369 gender_results.bias_gap > 0.1 || demo_results.ethnicity_parity_gap > 0.1;
370
371 let mut underperforming_groups = Vec::new();
373 for (ethnicity, rate) in &demo_results.by_ethnicity {
374 if *rate < demo_results.overall_recognition_rate - 0.1 {
375 underperforming_groups.push(ethnicity.clone());
376 }
377 }
378
379 Ok(BiasSummary {
380 bias_detected,
381 gender: Some(GenderBiasMetrics {
382 pro_stereotype_accuracy: gender_results.pro_stereotype_accuracy,
383 anti_stereotype_accuracy: gender_results.anti_stereotype_accuracy,
384 gap: gender_results.bias_gap,
385 verdict: if gender_results.bias_gap > 0.1 {
386 "Significant gender bias detected".to_string()
387 } else {
388 "No significant gender bias".to_string()
389 },
390 }),
391 demographic: Some(DemographicBiasMetrics {
392 max_gap: demo_results
393 .ethnicity_parity_gap
394 .max(demo_results.script_bias_gap),
395 underperforming_groups,
396 }),
397 length: None, })
399 }
400
401 #[cfg(feature = "eval")]
403 fn run_calibration_analysis<M: Model>(
404 model: &M,
405 test_cases: &[TestCase],
406 ) -> Result<CalibrationSummary> {
407 use crate::eval::calibration::CalibrationEvaluator;
408
409 let mut predictions = Vec::new();
411 let mut has_calibrated_entities = false;
412
413 for case in test_cases {
414 let entities = model
415 .extract_entities(&case.text, None)
416 .unwrap_or_else(|_| Vec::new());
417
418 for entity in &entities {
419 let is_calibrated = entity
421 .provenance
422 .as_ref()
423 .map(|p| p.method.is_calibrated())
424 .unwrap_or(false);
425
426 if !is_calibrated {
427 continue; }
429
430 has_calibrated_entities = true;
431
432 let is_correct = case.gold_entities.iter().any(|gold| {
434 entity.start() == gold.start
435 && entity.end() == gold.end
436 && entity.entity_type.as_label() == gold.entity_type
437 });
438
439 predictions.push((entity.confidence.into(), is_correct));
440 }
441 }
442
443 if !has_calibrated_entities || predictions.is_empty() {
445 return Ok(CalibrationSummary {
446 ece: 0.0,
447 mce: 0.0,
448 optimal_threshold: 0.5,
449 grade: '?', });
451 }
452
453 let results = CalibrationEvaluator::compute(&predictions);
455
456 let optimal_threshold = results
458 .threshold_accuracy
459 .iter()
460 .filter(|(_, metrics)| metrics.coverage >= 0.1) .max_by(|(_, a), (_, b)| {
462 a.accuracy
463 .partial_cmp(&b.accuracy)
464 .unwrap_or(std::cmp::Ordering::Equal)
465 })
466 .and_then(|(thresh_str, _)| thresh_str.parse::<f64>().ok())
467 .unwrap_or(0.5);
468
469 let grade = if results.ece < 0.05 {
471 'A'
472 } else if results.ece < 0.10 {
473 'B'
474 } else if results.ece < 0.15 {
475 'C'
476 } else if results.ece < 0.25 {
477 'D'
478 } else {
479 'F'
480 };
481
482 Ok(CalibrationSummary {
483 ece: results.ece,
484 mce: results.mce,
485 optimal_threshold,
486 grade,
487 })
488 }
489
490 #[cfg(feature = "eval")]
492 fn run_data_quality_checks(test_cases: &[TestCase]) -> Result<DataQualitySummary> {
493 use std::collections::{HashMap, HashSet};
494
495 if test_cases.is_empty() {
496 return Ok(DataQualitySummary {
497 leakage_detected: false,
498 redundancy_rate: 0.0,
499 ambiguous_count: 0,
500 });
501 }
502
503 let test_data: Vec<(&str, Vec<(&str, &str)>)> = test_cases
506 .iter()
507 .map(|case| {
508 let entities: Vec<(&str, &str)> = case
509 .gold_entities
510 .iter()
511 .map(|e| (e.text.as_str(), e.entity_type.as_str()))
512 .collect();
513 (case.text.as_str(), entities)
514 })
515 .collect();
516
517 let mut seen_texts = HashSet::new();
519 let mut duplicate_count = 0;
520 for (text, _) in &test_data {
521 let normalized = text.to_lowercase();
522 if !seen_texts.insert(normalized) {
523 duplicate_count += 1;
524 }
525 }
526 let redundancy_rate = if test_data.is_empty() {
527 0.0
528 } else {
529 duplicate_count as f64 / test_data.len() as f64
530 };
531
532 let mut text_to_types: HashMap<String, HashSet<String>> = HashMap::new();
534 for (_, entities) in &test_data {
535 for (text, entity_type) in entities {
536 text_to_types
537 .entry(text.to_lowercase())
538 .or_default()
539 .insert(entity_type.to_string());
540 }
541 }
542 let ambiguous_count = text_to_types
543 .values()
544 .filter(|types| types.len() > 1)
545 .count();
546
547 Ok(DataQualitySummary {
551 leakage_detected: false, redundancy_rate,
553 ambiguous_count,
554 })
555 }
556
557 pub fn build<M: Model>(self, model: &M) -> EvalReport {
559 let timestamp = chrono_lite_timestamp();
560 let mut warnings = Vec::new();
561 let mut recommendations = Vec::new();
562
563 let test_cases = self.test_data.unwrap_or_else(|| {
565 warnings.push("Using synthetic test data (no custom data provided)".into());
566 default_synthetic_cases()
567 });
568
569 let mut total_gold = 0;
571 let mut total_predicted = 0;
572 let mut total_correct = 0;
573 let mut per_type_stats: HashMap<String, (usize, usize, usize)> = HashMap::new();
574 let mut all_errors = Vec::new();
575
576 for case in &test_cases {
577 let predictions = model
578 .extract_entities(&case.text, None)
579 .unwrap_or_else(|e| {
580 warnings.push(format!("Failed to extract entities for test case: {}", e));
581 Vec::new()
582 });
583
584 total_gold += case.gold_entities.len();
585 total_predicted += predictions.len();
586
587 for gold in &case.gold_entities {
589 let type_key = gold.entity_type.clone();
590 let entry = per_type_stats.entry(type_key.clone()).or_insert((0, 0, 0));
591 entry.0 += 1; let matched = predictions.iter().any(|p| {
594 p.start() == gold.start
595 && p.end() == gold.end
596 && p.entity_type.as_label() == gold.entity_type
597 });
598
599 if matched {
600 total_correct += 1;
601 entry.2 += 1; } else {
603 all_errors.push(format!("Missed: {} ({})", gold.text, gold.entity_type));
604 }
605 }
606
607 for pred in &predictions {
608 let type_key = pred.entity_type.as_label().to_string();
609 let entry = per_type_stats.entry(type_key).or_insert((0, 0, 0));
610 entry.1 += 1; }
612 }
613
614 let precision = if total_predicted > 0 {
616 total_correct as f64 / total_predicted as f64
617 } else {
618 0.0
619 };
620 let recall = if total_gold > 0 {
621 total_correct as f64 / total_gold as f64
622 } else {
623 0.0
624 };
625 let f1 = if precision + recall > 0.0 {
626 2.0 * precision * recall / (precision + recall)
627 } else {
628 0.0
629 };
630
631 let core = CoreMetrics {
632 precision,
633 recall,
634 f1,
635 total_gold,
636 total_predicted,
637 total_correct,
638 macro_f1: None, };
640
641 let mut per_type = HashMap::new();
643 let mut type_f1s = Vec::new();
644 for (type_name, (gold, pred, correct)) in &per_type_stats {
645 let p = if *pred > 0 {
646 *correct as f64 / *pred as f64
647 } else {
648 0.0
649 };
650 let r = if *gold > 0 {
651 *correct as f64 / *gold as f64
652 } else {
653 0.0
654 };
655 let f = if p + r > 0.0 {
656 2.0 * p * r / (p + r)
657 } else {
658 0.0
659 };
660 type_f1s.push(f);
661 per_type.insert(
662 type_name.clone(),
663 TypeMetrics {
664 precision: p,
665 recall: r,
666 f1: f,
667 support: *gold,
668 predicted: *pred,
669 correct: *correct,
670 },
671 );
672 }
673
674 if f1 < 0.5 {
676 recommendations.push(Recommendation {
677 priority: Priority::High,
678 category: RecommendationCategory::Performance,
679 message: format!(
680 "F1 score ({:.1}%) is below acceptable threshold",
681 f1 * 100.0
682 ),
683 estimated_impact: Some("Core functionality compromised".into()),
684 });
685 }
686
687 if recall < precision * 0.7 {
688 recommendations.push(Recommendation {
689 priority: Priority::Medium,
690 category: RecommendationCategory::Coverage,
691 message: "Recall significantly lower than precision - model is too conservative"
692 .into(),
693 estimated_impact: Some("Missing many valid entities".into()),
694 });
695 }
696
697 let errors = if self.include_errors {
699 let false_negatives = total_gold - total_correct;
700 let false_positives = total_predicted - total_correct;
701 Some(ErrorSummary {
702 total_errors: false_negatives + false_positives,
703 boundary_errors: 0, type_errors: 0, false_positives,
706 false_negatives,
707 top_patterns: all_errors.into_iter().take(5).collect(),
708 })
709 } else {
710 None
711 };
712
713 let bias = if self.include_bias {
715 #[cfg(feature = "eval-bias")]
716 {
717 match Self::run_bias_analysis(model) {
721 Ok(bias_results) => Some(bias_results),
722 Err(e) => {
723 warnings.push(format!("Bias analysis failed: {}", e));
724 None
725 }
726 }
727 }
728 #[cfg(not(feature = "eval-bias"))]
729 {
730 None
731 }
732 } else {
733 None
734 };
735
736 let calibration = if self.include_calibration {
738 #[cfg(feature = "eval")]
739 {
740 match Self::run_calibration_analysis(model, &test_cases) {
741 Ok(cal_results) => Some(cal_results),
742 Err(e) => {
743 warnings.push(format!("Calibration analysis failed: {}", e));
744 None
745 }
746 }
747 }
748 #[cfg(not(feature = "eval"))]
749 {
750 None
751 }
752 } else {
753 None
754 };
755
756 let data_quality = if self.include_data_quality {
758 #[cfg(feature = "eval")]
759 {
760 match Self::run_data_quality_checks(&test_cases) {
761 Ok(quality_results) => Some(quality_results),
762 Err(e) => {
763 warnings.push(format!("Data quality checks failed: {}", e));
764 None
765 }
766 }
767 }
768 #[cfg(not(feature = "eval"))]
769 {
770 None
771 }
772 } else {
773 None
774 };
775
776 EvalReport {
777 model_name: self.model_name,
778 timestamp,
779 core,
780 per_type,
781 errors,
782 bias,
783 data_quality,
784 calibration,
785 recommendations,
786 warnings,
787 }
788 }
789}
790
791impl EvalReport {
796 pub fn summary(&self) -> String {
798 let mut out = String::new();
799
800 out.push_str(&format!("=== Evaluation Report: {} ===\n", self.model_name));
801 out.push_str(&format!("Generated: {}\n\n", self.timestamp));
802
803 out.push_str("## Core Metrics\n");
805 out.push_str(&format!(
806 " Precision: {:.1}%\n",
807 self.core.precision * 100.0
808 ));
809 out.push_str(&format!(" Recall: {:.1}%\n", self.core.recall * 100.0));
810 out.push_str(&format!(" F1: {:.1}%\n", self.core.f1 * 100.0));
811 out.push_str(&format!(
812 " ({} correct / {} predicted / {} gold)\n\n",
813 self.core.total_correct, self.core.total_predicted, self.core.total_gold
814 ));
815
816 if !self.per_type.is_empty() {
818 out.push_str("## Per-Type Breakdown\n");
819 let mut types: Vec<_> = self.per_type.iter().collect();
820 types.sort_by_key(|b| std::cmp::Reverse(b.1.support));
821 for (type_name, metrics) in types {
822 out.push_str(&format!(
823 " {:12} P={:.0}% R={:.0}% F1={:.0}% (n={})\n",
824 type_name,
825 metrics.precision * 100.0,
826 metrics.recall * 100.0,
827 metrics.f1 * 100.0,
828 metrics.support
829 ));
830 }
831 out.push('\n');
832 }
833
834 if let Some(ref errors) = self.errors {
836 out.push_str("## Error Analysis\n");
837 out.push_str(&format!(" Total errors: {}\n", errors.total_errors));
838 out.push_str(&format!(" False positives: {}\n", errors.false_positives));
839 out.push_str(&format!(" False negatives: {}\n", errors.false_negatives));
840 if !errors.top_patterns.is_empty() {
841 out.push_str(" Sample errors:\n");
842 for pattern in &errors.top_patterns {
843 out.push_str(&format!(" - {}\n", pattern));
844 }
845 }
846 out.push('\n');
847 }
848
849 if !self.recommendations.is_empty() {
851 out.push_str("## Recommendations\n");
852 for rec in &self.recommendations {
853 let priority = match rec.priority {
854 Priority::High => "[HIGH]",
855 Priority::Medium => "[MED]",
856 Priority::Low => "[LOW]",
857 };
858 out.push_str(&format!(" {} {}\n", priority, rec.message));
859 }
860 out.push('\n');
861 }
862
863 if !self.warnings.is_empty() {
865 out.push_str("## Warnings\n");
866 for warning in &self.warnings {
867 out.push_str(&format!(" - {}\n", warning));
868 }
869 }
870
871 out
872 }
873
874 pub fn to_json(&self) -> Result<String> {
876 serde_json::to_string_pretty(self)
877 .map_err(|e| crate::Error::InvalidInput(format!("JSON serialization failed: {}", e)))
878 }
879}
880
881impl fmt::Display for EvalReport {
882 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
883 write!(f, "{}", self.summary())
884 }
885}
886
887fn chrono_lite_timestamp() -> String {
892 use std::time::{SystemTime, UNIX_EPOCH};
894 let duration = SystemTime::now()
895 .duration_since(UNIX_EPOCH)
896 .unwrap_or_default();
897 format!("{}s since epoch", duration.as_secs())
898}
899
900fn default_synthetic_cases() -> Vec<TestCase> {
901 vec![
903 TestCase {
904 text: "Meeting on January 15, 2024 at 3:00 PM".into(),
905 gold_entities: vec![
906 SimpleGoldEntity {
907 text: "January 15, 2024".into(),
908 entity_type: "DATE".into(),
909 start: 11,
910 end: 27,
911 },
912 SimpleGoldEntity {
913 text: "3:00 PM".into(),
914 entity_type: "TIME".into(),
915 start: 31,
916 end: 38,
917 },
918 ],
919 },
920 TestCase {
921 text: "Contact: user@example.com or call 555-1234".into(),
922 gold_entities: vec![
923 SimpleGoldEntity {
924 text: "user@example.com".into(),
925 entity_type: "EMAIL".into(),
926 start: 9,
927 end: 25,
928 },
929 SimpleGoldEntity {
930 text: "555-1234".into(),
931 entity_type: "PHONE".into(),
932 start: 34,
933 end: 42,
934 },
935 ],
936 },
937 TestCase {
938 text: "Invoice total: $1,234.56 USD".into(),
939 gold_entities: vec![SimpleGoldEntity {
940 text: "$1,234.56".into(),
941 entity_type: "MONEY".into(),
942 start: 15,
943 end: 24,
944 }],
945 },
946 ]
947}
948
949#[cfg(test)]
954mod tests {
955 use super::*;
956
957 #[test]
958 fn test_report_builder_basic() {
959 use crate::RegexNER;
960 let model = RegexNER::new();
961 let report = ReportBuilder::new("RegexNER")
962 .with_error_analysis(true)
963 .build(&model);
964
965 assert_eq!(report.model_name, "RegexNER");
966 assert!(report.core.total_gold > 0);
967 }
968
969 #[test]
970 fn test_report_summary_format() {
971 let report = EvalReport {
972 model_name: "TestModel".into(),
973 timestamp: "2024-01-01".into(),
974 core: CoreMetrics {
975 precision: 0.85,
976 recall: 0.75,
977 f1: 0.80,
978 total_gold: 100,
979 total_predicted: 90,
980 total_correct: 75,
981 macro_f1: None,
982 },
983 per_type: HashMap::new(),
984 errors: None,
985 bias: None,
986 data_quality: None,
987 calibration: None,
988 recommendations: vec![],
989 warnings: vec![],
990 };
991
992 let summary = report.summary();
993 assert!(summary.contains("TestModel"));
994 assert!(summary.contains("85.0%")); assert!(summary.contains("75.0%")); }
997
998 #[test]
999 fn test_report_json_export() {
1000 let report = EvalReport {
1001 model_name: "TestModel".into(),
1002 timestamp: "test".into(),
1003 core: CoreMetrics {
1004 precision: 0.9,
1005 recall: 0.8,
1006 f1: 0.85,
1007 total_gold: 10,
1008 total_predicted: 10,
1009 total_correct: 8,
1010 macro_f1: None,
1011 },
1012 per_type: HashMap::new(),
1013 errors: None,
1014 bias: None,
1015 data_quality: None,
1016 calibration: None,
1017 recommendations: vec![],
1018 warnings: vec![],
1019 };
1020
1021 let json = report.to_json().unwrap();
1022 assert!(json.contains("\"model_name\": \"TestModel\""));
1023 assert!(json.contains("\"f1\": 0.85"));
1024 }
1025
1026 #[test]
1027 fn test_recommendations_generated() {
1028 use crate::RegexNER;
1029 let model = RegexNER::new();
1030
1031 let test_data = vec![TestCase {
1033 text: "John Smith works at Google".into(),
1034 gold_entities: vec![
1035 SimpleGoldEntity {
1036 text: "John Smith".into(),
1037 entity_type: "PER".into(),
1038 start: 0,
1039 end: 10,
1040 },
1041 SimpleGoldEntity {
1042 text: "Google".into(),
1043 entity_type: "ORG".into(),
1044 start: 20,
1045 end: 26,
1046 },
1047 ],
1048 }];
1049
1050 let report = ReportBuilder::new("RegexNER")
1051 .with_test_data(test_data)
1052 .build(&model);
1053
1054 assert!(
1057 report.core.f1 < 0.5
1058 || !report.recommendations.is_empty()
1059 || report.core.total_gold == 0
1060 );
1061 }
1062}