Skip to main content

anno_eval/eval/
evaluator.rs

1//! NER evaluation trait and implementations.
2//!
3//! Provides trait-based evaluation matching the RetrievalEvaluator pattern
4//! for consistency and extensibility.
5
6use super::datasets::GoldEntity;
7use super::types::{GoalCheckResult, MetricValue};
8use super::TypeMetrics;
9use anno::{Error, Model, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Per-test-case NER evaluation metrics.
14///
15/// Type-safe metrics using `MetricValue` for compile-time guarantees.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct NERQueryMetrics {
18    /// Test case text
19    pub text: String,
20    /// Optional test case ID
21    pub test_case_id: Option<String>,
22    /// Precision (type-safe, bounded 0.0-1.0)
23    pub precision: MetricValue,
24    /// Recall (type-safe, bounded 0.0-1.0)
25    pub recall: MetricValue,
26    /// F1 score (type-safe, bounded 0.0-1.0)
27    pub f1: MetricValue,
28    /// Per-entity-type metrics
29    pub per_type: HashMap<String, TypeMetrics>,
30    /// Number of entities found
31    pub found: usize,
32    /// Number of entities expected
33    pub expected: usize,
34    /// Number of correct predictions
35    pub correct: usize,
36    /// Processing speed (tokens per second)
37    pub tokens_per_second: f64,
38}
39
40/// Averaging mode for NER metrics.
41///
42/// Following seqeval conventions:
43/// - Micro: Calculate metrics globally by counting total TP, FP, FN (default, recommended)
44/// - Macro: Calculate metrics per test case, then average (gives equal weight to each case)
45/// - Weighted: Like macro, but weighted by support (number of expected entities per case)
46#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
47pub enum AveragingMode {
48    /// Calculate globally: total_correct / total_predicted (default, standard for NER)
49    #[default]
50    Micro,
51    /// Average per-case metrics (equal weight to each test case, regardless of size)
52    Macro,
53    /// Average per-case metrics weighted by support (number of expected entities)
54    Weighted,
55}
56
57/// Aggregated NER evaluation metrics with statistical measures.
58///
59/// Provides mean, standard deviation, and confidence intervals
60/// for comprehensive analysis.
61///
62/// # Micro vs Macro Averaging
63///
64/// By default, we compute **micro-averaged** metrics (total_correct / total_found),
65/// which is the standard for NER evaluation and matches seqeval's default.
66///
67/// Macro-averaging (average of per-case metrics) can inflate scores when test
68/// cases have different sizes. A test case with 1 entity getting 100% F1 shouldn't
69/// boost overall metrics as much as a case with 100 entities getting 50% F1.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct NERAggregateMetrics {
72    /// Micro-averaged precision (total_correct / total_found)
73    pub precision: MetricValue,
74    /// Micro-averaged recall (total_correct / total_expected)
75    pub recall: MetricValue,
76    /// Micro-averaged F1 score
77    pub f1: MetricValue,
78    /// Macro-averaged precision (for comparison)
79    pub macro_precision: MetricValue,
80    /// Macro-averaged recall (for comparison)
81    pub macro_recall: MetricValue,
82    /// Macro-averaged F1 (for comparison)
83    pub macro_f1: MetricValue,
84    /// Precision standard deviation (of per-case metrics)
85    pub precision_std: f64,
86    /// Recall standard deviation (of per-case metrics)
87    pub recall_std: f64,
88    /// F1 standard deviation (of per-case metrics)
89    pub f1_std: f64,
90    /// Precision 95% confidence interval (lower, upper)
91    pub precision_ci_95: Option<(f64, f64)>,
92    /// Recall 95% confidence interval (lower, upper)
93    pub recall_ci_95: Option<(f64, f64)>,
94    /// F1 95% confidence interval (lower, upper)
95    pub f1_ci_95: Option<(f64, f64)>,
96    /// Per-entity-type aggregated metrics (micro-averaged)
97    pub per_type: HashMap<String, TypeMetrics>,
98    /// Mean tokens per second
99    pub tokens_per_second: f64,
100    /// Number of test cases evaluated
101    pub num_test_cases: usize,
102    /// Total entities found
103    pub total_found: usize,
104    /// Total entities expected
105    pub total_expected: usize,
106    /// Total correct predictions
107    pub total_correct: usize,
108}
109
110/// Type-safe NER evaluation goals.
111///
112/// Allows setting minimum thresholds for metrics with compile-time guarantees.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct NERMetricGoals {
115    /// Minimum precision threshold
116    pub min_precision: Option<MetricValue>,
117    /// Minimum recall threshold
118    pub min_recall: Option<MetricValue>,
119    /// Minimum F1 threshold
120    pub min_f1: Option<MetricValue>,
121    /// Per-entity-type goals
122    pub per_type_goals: HashMap<String, TypeMetricGoals>,
123}
124
125impl NERMetricGoals {
126    /// Create new empty goals.
127    #[must_use]
128    pub fn new() -> Self {
129        Self {
130            min_precision: None,
131            min_recall: None,
132            min_f1: None,
133            per_type_goals: HashMap::new(),
134        }
135    }
136
137    /// Set minimum precision goal.
138    pub fn with_min_precision(mut self, value: f64) -> Result<Self> {
139        self.min_precision = Some(MetricValue::try_new(value)?);
140        Ok(self)
141    }
142
143    /// Set minimum recall goal.
144    pub fn with_min_recall(mut self, value: f64) -> Result<Self> {
145        self.min_recall = Some(MetricValue::try_new(value)?);
146        Ok(self)
147    }
148
149    /// Set minimum F1 goal.
150    pub fn with_min_f1(mut self, value: f64) -> Result<Self> {
151        self.min_f1 = Some(MetricValue::try_new(value)?);
152        Ok(self)
153    }
154
155    /// Add per-type goal.
156    #[must_use]
157    pub fn with_type_goal(mut self, entity_type: String, goal: TypeMetricGoals) -> Self {
158        self.per_type_goals.insert(entity_type, goal);
159        self
160    }
161}
162
163impl Default for NERMetricGoals {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169/// Per-entity-type metric goals.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct TypeMetricGoals {
172    /// Minimum precision for this entity type
173    pub min_precision: Option<MetricValue>,
174    /// Minimum recall for this entity type
175    pub min_recall: Option<MetricValue>,
176    /// Minimum F1 for this entity type
177    pub min_f1: Option<MetricValue>,
178}
179
180impl TypeMetricGoals {
181    /// Create new type goals.
182    #[must_use]
183    pub fn new() -> Self {
184        Self {
185            min_precision: None,
186            min_recall: None,
187            min_f1: None,
188        }
189    }
190
191    /// Set minimum precision.
192    pub fn with_min_precision(mut self, value: f64) -> Result<Self> {
193        self.min_precision = Some(MetricValue::try_new(value)?);
194        Ok(self)
195    }
196
197    /// Set minimum recall.
198    pub fn with_min_recall(mut self, value: f64) -> Result<Self> {
199        self.min_recall = Some(MetricValue::try_new(value)?);
200        Ok(self)
201    }
202
203    /// Set minimum F1.
204    pub fn with_min_f1(mut self, value: f64) -> Result<Self> {
205        self.min_f1 = Some(MetricValue::try_new(value)?);
206        Ok(self)
207    }
208}
209
210impl Default for TypeMetricGoals {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216/// Trait for NER evaluation strategies.
217///
218/// Allows plugging in different evaluation implementations:
219/// - Standard evaluator (exact match)
220/// - Partial match evaluator (overlap-based)
221/// - Custom evaluators (for research, special metrics)
222///
223/// # Example
224///
225/// ```rust
226/// use anno_eval::eval::{GoldEntity, StandardNEREvaluator, NEREvaluator};
227/// use anno::{RegexNER, Model, EntityType};
228///
229/// let evaluator = StandardNEREvaluator::new();
230/// let model = RegexNER::new();
231/// let ground_truth = vec![
232///     GoldEntity::new("$100", EntityType::Money, 6),
233/// ];
234///
235/// let metrics = evaluator.evaluate_test_case(
236///     &model,
237///     "Cost: $100",
238///     &ground_truth,
239///     Some("test-1"),
240/// ).unwrap();
241///
242/// assert!(metrics.precision.get() > 0.0);
243/// ```
244pub trait NEREvaluator: Send + Sync {
245    /// Evaluate a single test case.
246    ///
247    /// # Arguments
248    /// * `model` - NER model to evaluate
249    /// * `text` - Text to extract entities from (must not be empty)
250    /// * `ground_truth` - Expected entities
251    /// * `test_case_id` - Optional test case identifier
252    ///
253    /// # Returns
254    /// Per-test-case metrics with precision, recall, F1, and per-type breakdowns
255    ///
256    /// # Errors
257    /// Returns `Error::InvalidInput` if:
258    /// - Text is empty
259    /// - Ground truth entities are invalid (overlapping, out of bounds)
260    /// - Metrics are invalid (NaN or Inf)
261    fn evaluate_test_case(
262        &self,
263        model: &dyn Model,
264        text: &str,
265        ground_truth: &[GoldEntity],
266        test_case_id: Option<&str>,
267    ) -> Result<NERQueryMetrics>;
268
269    /// Aggregate metrics across multiple test cases.
270    ///
271    /// # Arguments
272    /// * `query_metrics` - Per-test-case metrics
273    ///
274    /// # Returns
275    /// Aggregate metrics with statistical measures
276    fn aggregate(&self, query_metrics: &[NERQueryMetrics]) -> Result<NERAggregateMetrics>;
277
278    /// Check if metrics meet goals.
279    ///
280    /// # Arguments
281    /// * `metrics` - Aggregate metrics to check
282    /// * `goals` - Goals to check against
283    ///
284    /// # Returns
285    /// Goal check result with pass/fail status
286    fn check_goals(
287        &self,
288        metrics: &NERAggregateMetrics,
289        goals: &NERMetricGoals,
290    ) -> Result<GoalCheckResult>;
291}
292
293/// Standard NER evaluator implementation.
294///
295/// Computes standard NER metrics: Precision, Recall, F1 (exact match).
296pub struct StandardNEREvaluator;
297
298impl StandardNEREvaluator {
299    /// Create a new standard evaluator.
300    #[must_use]
301    pub fn new() -> Self {
302        Self
303    }
304}
305
306impl Default for StandardNEREvaluator {
307    fn default() -> Self {
308        Self::new()
309    }
310}
311
312impl NEREvaluator for StandardNEREvaluator {
313    fn evaluate_test_case(
314        &self,
315        model: &dyn Model,
316        text: &str,
317        ground_truth: &[GoldEntity],
318        test_case_id: Option<&str>,
319    ) -> Result<NERQueryMetrics> {
320        // Validate input
321        if text.is_empty() {
322            return Err(Error::InvalidInput(
323                "Text cannot be empty for NER evaluation".to_string(),
324            ));
325        }
326
327        // Validate ground truth entities
328        let validation = crate::eval::validation::validate_ground_truth_entities(
329            text,
330            ground_truth,
331            false, // Warnings for overlaps, not errors
332        );
333        if !validation.is_valid {
334            return Err(Error::InvalidInput(format!(
335                "Invalid ground truth entities: {}",
336                validation.errors.join("; ")
337            )));
338        }
339        // Log warnings if any (using eprintln! for now, can be upgraded to proper logging)
340        if !validation.warnings.is_empty() {
341            eprintln!(
342                "WARNING: Ground truth validation warnings: {}",
343                validation.warnings.join("; ")
344            );
345        }
346
347        let start_time = std::time::Instant::now();
348
349        // Extract entities using model
350        let predicted = model.extract_entities(text, None)?;
351
352        let elapsed = start_time.elapsed().as_secs_f64();
353        let tokens = text.split_whitespace().count();
354        let tokens_per_second = if elapsed > 0.0 {
355            tokens as f64 / elapsed
356        } else {
357            0.0
358        };
359
360        // Count correct predictions (exact match: same span and type)
361        // Track which gold entities have been matched to prevent double-counting
362        // This ensures each gold entity can only be matched once, even if multiple
363        // predictions match it (duplicate predictions should not inflate precision)
364        let mut gold_matched = vec![false; ground_truth.len()];
365        let mut correct = 0;
366        for pred in &predicted {
367            for (gt_idx, gt) in ground_truth.iter().enumerate() {
368                if gold_matched[gt_idx] {
369                    continue; // This gold entity already matched
370                }
371                if pred.start() == gt.start
372                    && pred.end() == gt.end
373                    && super::entity_type_matches(&pred.entity_type, &gt.entity_type)
374                {
375                    gold_matched[gt_idx] = true;
376                    correct += 1;
377                    break;
378                }
379            }
380        }
381
382        // Calculate per-type statistics
383        let mut per_type_stats: HashMap<String, (usize, usize, usize)> = HashMap::new(); // (found, expected, correct)
384
385        // Count expected per type and check matches
386        // Reuse gold_matched tracking to ensure each gold entity counted once per type
387        let mut gold_matched_per_type = vec![false; ground_truth.len()];
388        for (gt_idx, gt) in ground_truth.iter().enumerate() {
389            let type_key = super::entity_type_to_string(&gt.entity_type);
390            let stats = per_type_stats.entry(type_key.clone()).or_insert((0, 0, 0));
391            stats.1 += 1; // expected
392
393            // Check if this ground truth entity was found (only count once per gold entity)
394            if !gold_matched_per_type[gt_idx] {
395                for pred in &predicted {
396                    if pred.start() == gt.start
397                        && pred.end() == gt.end
398                        && super::entity_type_matches(&pred.entity_type, &gt.entity_type)
399                    {
400                        gold_matched_per_type[gt_idx] = true;
401                        stats.2 += 1; // correct
402                        break;
403                    }
404                }
405            }
406        }
407
408        // Count found per type
409        for pred in &predicted {
410            let type_key = super::entity_type_to_string(&pred.entity_type);
411            let stats = per_type_stats.entry(type_key).or_insert((0, 0, 0));
412            stats.0 += 1; // found
413        }
414
415        // Calculate overall metrics
416        let found = predicted.len();
417        let expected = ground_truth.len();
418
419        let precision = if found > 0 {
420            correct as f64 / found as f64
421        } else {
422            0.0
423        };
424        let recall = if expected > 0 {
425            correct as f64 / expected as f64
426        } else {
427            0.0
428        };
429        let f1 = if precision + recall > 0.0 {
430            2.0 * precision * recall / (precision + recall)
431        } else {
432            0.0
433        };
434
435        // Validate metrics are finite (not NaN or Inf)
436        if !precision.is_finite() || !recall.is_finite() || !f1.is_finite() {
437            return Err(Error::InvalidInput(format!(
438                "Invalid metric values: precision={}, recall={}, f1={}",
439                precision, recall, f1
440            )));
441        }
442
443        // Calculate per-type metrics
444        let mut per_type = HashMap::new();
445        for (type_name, (found_count, expected_count, correct_count)) in per_type_stats {
446            let type_precision = if found_count > 0 {
447                correct_count as f64 / found_count as f64
448            } else {
449                0.0
450            };
451            let type_recall = if expected_count > 0 {
452                correct_count as f64 / expected_count as f64
453            } else {
454                0.0
455            };
456            let type_f1 = if type_precision + type_recall > 0.0 {
457                2.0 * type_precision * type_recall / (type_precision + type_recall)
458            } else {
459                0.0
460            };
461
462            per_type.insert(
463                type_name,
464                TypeMetrics {
465                    precision: type_precision,
466                    recall: type_recall,
467                    f1: type_f1,
468                    found: found_count,
469                    expected: expected_count,
470                    correct: correct_count,
471                },
472            );
473        }
474
475        Ok(NERQueryMetrics {
476            text: text.to_string(),
477            test_case_id: test_case_id.map(|s| s.to_string()),
478            precision: MetricValue::new(precision),
479            recall: MetricValue::new(recall),
480            f1: MetricValue::new(f1),
481            per_type,
482            found,
483            expected,
484            correct,
485            tokens_per_second,
486        })
487    }
488
489    fn aggregate(&self, query_metrics: &[NERQueryMetrics]) -> Result<NERAggregateMetrics> {
490        if query_metrics.is_empty() {
491            return Err(Error::InvalidInput(
492                "Cannot aggregate empty metrics".to_string(),
493            ));
494        }
495
496        // Calculate totals for micro-averaging
497        let total_found: usize = query_metrics.iter().map(|m| m.found).sum();
498        let total_expected: usize = query_metrics.iter().map(|m| m.expected).sum();
499        let total_correct: usize = query_metrics.iter().map(|m| m.correct).sum();
500
501        // MICRO-averaged metrics (standard for NER, matches seqeval default)
502        // precision = total_correct / total_found
503        // recall = total_correct / total_expected
504        let micro_precision = if total_found > 0 {
505            total_correct as f64 / total_found as f64
506        } else {
507            0.0 // Note: seqeval would optionally warn here
508        };
509        let micro_recall = if total_expected > 0 {
510            total_correct as f64 / total_expected as f64
511        } else {
512            0.0
513        };
514        let micro_f1 = if micro_precision + micro_recall > 0.0 {
515            2.0 * micro_precision * micro_recall / (micro_precision + micro_recall)
516        } else {
517            0.0
518        };
519
520        // MACRO-averaged metrics (for comparison, equal weight per test case)
521        let precisions: Vec<f64> = query_metrics.iter().map(|m| m.precision.get()).collect();
522        let recalls: Vec<f64> = query_metrics.iter().map(|m| m.recall.get()).collect();
523        let f1s: Vec<f64> = query_metrics.iter().map(|m| m.f1.get()).collect();
524        let tokens_per_second: Vec<f64> =
525            query_metrics.iter().map(|m| m.tokens_per_second).collect();
526
527        // Defensive checks for division by zero (shouldn't happen due to earlier check, but be safe)
528        let macro_precision = if precisions.is_empty() {
529            0.0
530        } else {
531            precisions.iter().sum::<f64>() / precisions.len() as f64
532        };
533        let macro_recall = if recalls.is_empty() {
534            0.0
535        } else {
536            recalls.iter().sum::<f64>() / recalls.len() as f64
537        };
538        let macro_f1 = if f1s.is_empty() {
539            0.0
540        } else {
541            f1s.iter().sum::<f64>() / f1s.len() as f64
542        };
543        let mean_tokens_per_second = if tokens_per_second.is_empty() {
544            0.0
545        } else {
546            tokens_per_second.iter().sum::<f64>() / tokens_per_second.len() as f64
547        };
548
549        // Validate metrics are finite
550        if !micro_precision.is_finite()
551            || !micro_recall.is_finite()
552            || !micro_f1.is_finite()
553            || !mean_tokens_per_second.is_finite()
554        {
555            return Err(Error::InvalidInput(format!(
556                "Invalid aggregate metric values: precision={}, recall={}, f1={}, tps={}",
557                micro_precision, micro_recall, micro_f1, mean_tokens_per_second
558            )));
559        }
560
561        // Calculate standard deviations (of per-case metrics, for variability analysis)
562        let precision_std = calculate_std_dev(&precisions, macro_precision);
563        let recall_std = calculate_std_dev(&recalls, macro_recall);
564        let f1_std = calculate_std_dev(&f1s, macro_f1);
565
566        // Calculate 95% confidence intervals (based on per-case variability)
567        let precision_ci_95 = calculate_ci_95(&precisions, macro_precision, precision_std);
568        let recall_ci_95 = calculate_ci_95(&recalls, macro_recall, recall_std);
569        let f1_ci_95 = calculate_ci_95(&f1s, macro_f1, f1_std);
570
571        // Aggregate per-type metrics using MICRO-averaging
572        let mut per_type_totals: HashMap<String, (usize, usize, usize)> = HashMap::new();
573        for metric in query_metrics {
574            for (type_name, type_metric) in &metric.per_type {
575                let entry = per_type_totals
576                    .entry(type_name.clone())
577                    .or_insert((0, 0, 0));
578                entry.0 += type_metric.found;
579                entry.1 += type_metric.expected;
580                entry.2 += type_metric.correct;
581            }
582        }
583
584        let mut per_type = HashMap::new();
585        for (type_name, (type_found, type_expected, type_correct)) in per_type_totals {
586            // Micro-averaged per-type metrics
587            let type_precision = if type_found > 0 {
588                type_correct as f64 / type_found as f64
589            } else {
590                0.0
591            };
592            let type_recall = if type_expected > 0 {
593                type_correct as f64 / type_expected as f64
594            } else {
595                0.0
596            };
597            let type_f1 = if type_precision + type_recall > 0.0 {
598                2.0 * type_precision * type_recall / (type_precision + type_recall)
599            } else {
600                0.0
601            };
602
603            per_type.insert(
604                type_name,
605                TypeMetrics {
606                    precision: type_precision,
607                    recall: type_recall,
608                    f1: type_f1,
609                    found: type_found,
610                    expected: type_expected,
611                    correct: type_correct,
612                },
613            );
614        }
615
616        Ok(NERAggregateMetrics {
617            // Primary metrics: micro-averaged (standard for NER)
618            precision: MetricValue::new(micro_precision),
619            recall: MetricValue::new(micro_recall),
620            f1: MetricValue::new(micro_f1),
621            // Secondary metrics: macro-averaged (for comparison)
622            macro_precision: MetricValue::new(macro_precision),
623            macro_recall: MetricValue::new(macro_recall),
624            macro_f1: MetricValue::new(macro_f1),
625            precision_std,
626            recall_std,
627            f1_std,
628            precision_ci_95,
629            recall_ci_95,
630            f1_ci_95,
631            per_type,
632            tokens_per_second: mean_tokens_per_second,
633            num_test_cases: query_metrics.len(),
634            total_found,
635            total_expected,
636            total_correct,
637        })
638    }
639
640    fn check_goals(
641        &self,
642        metrics: &NERAggregateMetrics,
643        goals: &NERMetricGoals,
644    ) -> Result<GoalCheckResult> {
645        let mut result = GoalCheckResult::new();
646
647        // Check overall goals
648        if let Some(min_precision) = goals.min_precision {
649            let actual = metrics.precision.get();
650            let goal = min_precision.get();
651            if actual < goal {
652                result.add_failure("precision".to_string(), actual, goal);
653            }
654        }
655
656        if let Some(min_recall) = goals.min_recall {
657            let actual = metrics.recall.get();
658            let goal = min_recall.get();
659            if actual < goal {
660                result.add_failure("recall".to_string(), actual, goal);
661            }
662        }
663
664        if let Some(min_f1) = goals.min_f1 {
665            let actual = metrics.f1.get();
666            let goal = min_f1.get();
667            if actual < goal {
668                result.add_failure("f1".to_string(), actual, goal);
669            }
670        }
671
672        // Check per-type goals
673        for (type_name, type_goals) in &goals.per_type_goals {
674            if let Some(type_metrics) = metrics.per_type.get(type_name) {
675                if let Some(min_precision) = type_goals.min_precision {
676                    let actual = type_metrics.precision;
677                    let goal = min_precision.get();
678                    if actual < goal {
679                        result.add_failure(format!("{}.precision", type_name), actual, goal);
680                    }
681                }
682
683                if let Some(min_recall) = type_goals.min_recall {
684                    let actual = type_metrics.recall;
685                    let goal = min_recall.get();
686                    if actual < goal {
687                        result.add_failure(format!("{}.recall", type_name), actual, goal);
688                    }
689                }
690
691                if let Some(min_f1) = type_goals.min_f1 {
692                    let actual = type_metrics.f1;
693                    let goal = min_f1.get();
694                    if actual < goal {
695                        result.add_failure(format!("{}.f1", type_name), actual, goal);
696                    }
697                }
698            }
699        }
700
701        Ok(result)
702    }
703}
704
705/// Calculate standard deviation.
706fn calculate_std_dev(values: &[f64], mean: f64) -> f64 {
707    if values.len() < 2 {
708        return 0.0;
709    }
710
711    let variance =
712        values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (values.len() - 1) as f64;
713
714    variance.sqrt()
715}
716
717/// Calculate 95% confidence interval.
718///
719/// Uses t-distribution approximation (z-score for large samples).
720///
721/// # Note
722/// Confidence intervals may extend beyond [0.0, 1.0] for small samples or high variance.
723/// This is statistically valid and indicates uncertainty in the estimate.
724/// For display purposes, you may want to clamp bounds to [0.0, 1.0], but the raw
725/// intervals provide more accurate statistical information.
726fn calculate_ci_95(values: &[f64], mean: f64, std_dev: f64) -> Option<(f64, f64)> {
727    if values.len() < 2 {
728        return None;
729    }
730
731    // Use z-score for 95% CI (1.96 for large samples)
732    // For small samples, should use t-distribution, but z-score is acceptable approximation
733    let z_score = 1.96;
734    let margin = z_score * std_dev / (values.len() as f64).sqrt();
735
736    // Clamp CI bounds to [0.0, 1.0] for metrics (precision, recall, F1)
737    // Note: For very small samples, CI may extend beyond [0, 1], but we clamp
738    // to maintain valid metric bounds. This is a reasonable approximation.
739    let lower = (mean - margin).clamp(0.0, 1.0);
740    let upper = (mean + margin).clamp(0.0, 1.0);
741
742    Some((lower, upper))
743}
744
745// Tests moved to tests/ directory