Skip to main content

anno_eval/eval/
report.rs

1//! Unified evaluation report.
2//!
3//! Provides a single cohesive report structure that aggregates results from
4//! all evaluation modules. This is the primary output type users should work with.
5//!
6//! # Example
7//!
8//! ```rust
9//! use anno_eval::eval::report::{EvalReport, ReportBuilder};
10//! use anno::RegexNER;
11//!
12//! let model = RegexNER::new();
13//! let report = ReportBuilder::new("RegexNER")
14//!     .with_core_metrics(true)
15//!     .with_bias_analysis(false)  // Skip if no PER/ORG support
16//!     .with_error_analysis(true)
17//!     .build(&model);
18//!
19//! println!("{}", report.summary());
20//! ```
21
22use anno::{Model, Result};
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::fmt;
26
27// =============================================================================
28// Core Report Structure
29// =============================================================================
30
31/// Unified evaluation report aggregating all analysis results.
32///
33/// Instead of 16 different Results structs, this provides one cohesive view.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct EvalReport {
36    /// Model/system identifier
37    pub model_name: String,
38
39    /// Timestamp of evaluation
40    pub timestamp: String,
41
42    /// Core NER metrics (always present)
43    pub core: CoreMetrics,
44
45    /// Per-entity-type breakdown
46    pub per_type: HashMap<String, TypeMetrics>,
47
48    /// Error analysis (if enabled)
49    pub errors: Option<ErrorSummary>,
50
51    /// Bias analysis (if enabled and applicable)
52    pub bias: Option<BiasSummary>,
53
54    /// Data quality findings (if enabled)
55    pub data_quality: Option<DataQualitySummary>,
56
57    /// Calibration analysis (if model provides confidence)
58    pub calibration: Option<CalibrationSummary>,
59
60    /// Recommendations based on findings
61    pub recommendations: Vec<Recommendation>,
62
63    /// Raw warnings/notes generated during evaluation
64    pub warnings: Vec<String>,
65}
66
67/// Core precision/recall/F1 metrics.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct CoreMetrics {
70    /// Micro-averaged precision (total_correct / total_predicted)
71    pub precision: f64,
72    /// Micro-averaged recall (total_correct / total_gold)
73    pub recall: f64,
74    /// Micro-averaged F1
75    pub f1: f64,
76    /// Total entities in gold standard
77    pub total_gold: usize,
78    /// Total entities predicted
79    pub total_predicted: usize,
80    /// Total correct predictions
81    pub total_correct: usize,
82    /// Macro-averaged F1 (for comparison)
83    pub macro_f1: Option<f64>,
84}
85
86/// Per-type metrics breakdown.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct TypeMetrics {
89    /// Precision for this type
90    pub precision: f64,
91    /// Recall for this type
92    pub recall: f64,
93    /// F1 for this type
94    pub f1: f64,
95    /// Number of gold entities of this type
96    pub support: usize,
97    /// Number of predicted entities of this type
98    pub predicted: usize,
99    /// Number of correctly predicted entities
100    pub correct: usize,
101}
102
103/// Error analysis summary.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ErrorSummary {
106    /// Total errors
107    pub total_errors: usize,
108    /// Boundary errors (correct type, wrong span)
109    pub boundary_errors: usize,
110    /// Type errors (correct span, wrong type)
111    pub type_errors: usize,
112    /// False positives (spurious entities)
113    pub false_positives: usize,
114    /// False negatives (missed entities)
115    pub false_negatives: usize,
116    /// Most common error patterns
117    pub top_patterns: Vec<String>,
118}
119
120/// Bias analysis summary.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct BiasSummary {
123    /// Whether significant bias was detected
124    pub bias_detected: bool,
125    /// Gender bias metrics (if applicable)
126    pub gender: Option<GenderBiasMetrics>,
127    /// Demographic bias metrics (if applicable)
128    pub demographic: Option<DemographicBiasMetrics>,
129    /// Length bias (short vs long entities)
130    pub length: Option<LengthBiasMetrics>,
131}
132
133/// Gender bias metrics from WinoBias-style evaluation.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct GenderBiasMetrics {
136    /// Accuracy on pro-stereotypical examples
137    pub pro_stereotype_accuracy: f64,
138    /// Accuracy on anti-stereotypical examples
139    pub anti_stereotype_accuracy: f64,
140    /// Gap between pro and anti (lower is better)
141    pub gap: f64,
142    /// Human-readable verdict
143    pub verdict: String,
144}
145
146/// Demographic bias metrics.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct DemographicBiasMetrics {
149    /// Performance gap between best and worst demographic groups
150    pub max_gap: f64,
151    /// Groups with notably lower performance
152    pub underperforming_groups: Vec<String>,
153}
154
155/// Entity length bias metrics.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct LengthBiasMetrics {
158    /// F1 on short entities (1-2 tokens)
159    pub short_entity_f1: f64,
160    /// F1 on long entities (4+ tokens)
161    pub long_entity_f1: f64,
162    /// Gap between short and long (lower is better)
163    pub gap: f64,
164}
165
166/// Data quality findings.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct DataQualitySummary {
169    /// Potential train/test leakage detected
170    pub leakage_detected: bool,
171    /// Percentage of redundant examples
172    pub redundancy_rate: f64,
173    /// Ambiguous entity annotations found
174    pub ambiguous_count: usize,
175}
176
177/// Calibration analysis.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct CalibrationSummary {
180    /// Expected Calibration Error
181    pub ece: f64,
182    /// Maximum Calibration Error
183    pub mce: f64,
184    /// Optimal confidence threshold
185    pub optimal_threshold: f64,
186    /// Grade (A/B/C/D/F)
187    pub grade: char,
188}
189
190/// Actionable recommendation.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct Recommendation {
193    /// Priority: high, medium, low
194    pub priority: Priority,
195    /// Category of recommendation
196    pub category: RecommendationCategory,
197    /// Human-readable recommendation
198    pub message: String,
199    /// Estimated impact if addressed
200    pub estimated_impact: Option<String>,
201}
202
203/// Priority level for recommendations.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205pub enum Priority {
206    /// Urgent - blocks deployment
207    High,
208    /// Important - should be addressed
209    Medium,
210    /// Nice to have
211    Low,
212}
213
214/// Category of recommendation.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216pub enum RecommendationCategory {
217    /// Core performance issues (F1/P/R)
218    Performance,
219    /// Bias-related issues
220    Bias,
221    /// Dataset quality issues
222    DataQuality,
223    /// Confidence calibration issues
224    Calibration,
225    /// Entity type coverage issues
226    Coverage,
227}
228
229// =============================================================================
230// Report Builder
231// =============================================================================
232
233/// Builder for constructing evaluation reports.
234pub 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
244/// A single test case for evaluation.
245pub struct TestCase {
246    /// Input text
247    pub text: String,
248    /// Gold standard entities
249    pub gold_entities: Vec<SimpleGoldEntity>,
250}
251
252/// Simplified gold entity for report generation.
253///
254/// This is a simplified version with string-based entity types,
255/// designed for report generation where type normalization is handled externally.
256///
257/// For evaluation code, use [`super::datasets::GoldEntity`] instead.
258#[derive(Debug, Clone)]
259pub struct SimpleGoldEntity {
260    /// Entity text
261    pub text: String,
262    /// Entity type label (e.g., "PER", "ORG", "DATE")
263    pub entity_type: String,
264    /// Start character offset
265    pub start: usize,
266    /// End character offset (exclusive)
267    pub end: usize,
268}
269
270impl SimpleGoldEntity {
271    /// Safely extract text from source using character offsets.
272    ///
273    /// SimpleGoldEntity stores character offsets, not byte offsets. This method
274    /// correctly extracts text by iterating over characters.
275    ///
276    /// # Arguments
277    /// * `source_text` - The original text from which this entity was extracted
278    ///
279    /// # Returns
280    /// The extracted text, or empty string if offsets are invalid
281    #[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    /// Create a new report builder.
297    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    /// Include core metrics (default: true).
310    pub fn with_core_metrics(mut self, include: bool) -> Self {
311        self.include_core = include;
312        self
313    }
314
315    /// Include error analysis (default: true).
316    pub fn with_error_analysis(mut self, include: bool) -> Self {
317        self.include_errors = include;
318        self
319    }
320
321    /// Include bias analysis (default: false).
322    /// Only meaningful for models that detect PER/ORG entities.
323    pub fn with_bias_analysis(mut self, include: bool) -> Self {
324        self.include_bias = include;
325        self
326    }
327
328    /// Include data quality checks (default: false).
329    pub fn with_data_quality(mut self, include: bool) -> Self {
330        self.include_data_quality = include;
331        self
332    }
333
334    /// Include calibration analysis (default: false).
335    /// Only meaningful for models that provide confidence scores.
336    pub fn with_calibration(mut self, include: bool) -> Self {
337        self.include_calibration = include;
338        self
339    }
340
341    /// Set test data for evaluation.
342    pub fn with_test_data(mut self, data: Vec<TestCase>) -> Self {
343        self.test_data = Some(data);
344        self
345    }
346
347    /// Run bias analysis using EvalSystem.
348    #[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        // Run demographic bias analysis
357        let names = create_diverse_name_dataset();
358        let evaluator = DemographicBiasEvaluator::new(true);
359        let demo_results = evaluator.evaluate_ner(model, &names);
360
361        // Gender bias (coreference)
362        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        // Determine if bias was detected
368        let bias_detected =
369            gender_results.bias_gap > 0.1 || demo_results.ethnicity_parity_gap > 0.1;
370
371        // Find underperforming groups
372        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, // Can be added if needed
398        })
399    }
400
401    /// Run calibration analysis.
402    #[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        // Collect predictions with confidence scores
410        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                // Check if this entity's extraction method is calibrated
420                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; // Skip uncalibrated entities
428                }
429
430                has_calibrated_entities = true;
431
432                // Match to gold to determine correctness
433                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 no calibrated entities found, return a warning summary
444        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: '?', // Unknown - no calibrated predictions
450            });
451        }
452
453        // Compute calibration metrics
454        let results = CalibrationEvaluator::compute(&predictions);
455
456        // Find optimal threshold (highest accuracy with reasonable coverage)
457        let optimal_threshold = results
458            .threshold_accuracy
459            .iter()
460            .filter(|(_, metrics)| metrics.coverage >= 0.1) // At least 10% coverage
461            .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        // Convert ECE to grade
470        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    /// Run data quality checks.
491    #[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        // Convert test cases to format expected by DatasetQualityAnalyzer
504        // Since we only have test data (no train), we check for redundancy within test set
505        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        // Check for redundancy (duplicates within test set)
518        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        // Check for ambiguous entities (same text, different types)
533        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        // Note: We can't check for train-test leakage since we only have test data
548        // This would require access to training data, which is not available in this context
549
550        Ok(DataQualitySummary {
551            leakage_detected: false, // Cannot determine without train data
552            redundancy_rate,
553            ambiguous_count,
554        })
555    }
556
557    /// Build the report by running the model on test data.
558    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        // Get test data (use synthetic if none provided)
564        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        // Run model predictions
570        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            // Match predictions to gold
588            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; // gold count
592
593                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; // correct count
602                } 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; // predicted count
611            }
612        }
613
614        // Compute core metrics
615        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, // Computed below if needed
639        };
640
641        // Per-type metrics
642        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        // Generate recommendations
675        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        // Error summary
698        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, // Would need span comparison
704                type_errors: 0,     // Would need type comparison
705                false_positives,
706                false_negatives,
707                top_patterns: all_errors.into_iter().take(5).collect(),
708            })
709        } else {
710            None
711        };
712
713        // Bias analysis (if enabled) - use EvalSystem
714        let bias = if self.include_bias {
715            #[cfg(feature = "eval-bias")]
716            {
717                // Create a boxed model for EvalSystem
718                // Note: This requires cloning or wrapping the model
719                // For now, we'll use a simplified approach
720                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        // Calibration (if enabled)
737        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        // Data quality (if enabled)
757        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
791// =============================================================================
792// Display Implementation
793// =============================================================================
794
795impl EvalReport {
796    /// Generate a human-readable summary.
797    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        // Core metrics
804        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        // Per-type breakdown
817        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        // Error summary
835        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        // Recommendations
850        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        // Warnings
864        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    /// Export report as JSON.
875    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
887// =============================================================================
888// Helpers
889// =============================================================================
890
891fn chrono_lite_timestamp() -> String {
892    // Simple timestamp without chrono dependency
893    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    // Minimal synthetic test set for quick evaluation
902    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// =============================================================================
950// Tests
951// =============================================================================
952
953#[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%")); // precision
995        assert!(summary.contains("75.0%")); // recall
996    }
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        // Create test data that will result in low F1
1032        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        // RegexNER can't detect PER/ORG, so F1 should be low
1055        // and recommendations should be generated
1056        assert!(
1057            report.core.f1 < 0.5
1058                || !report.recommendations.is_empty()
1059                || report.core.total_gold == 0
1060        );
1061    }
1062}