Skip to main content

anno_eval/eval/
active_learning.rs

1//! Active learning utilities for NER annotation.
2//!
3//! Helps identify which examples to annotate next for maximum model improvement.
4//!
5//! # Sampling Strategies
6//!
7//! - **Uncertainty Sampling**: Low-confidence predictions (requires confidence scores)
8//! - **Diversity Sampling**: Examples most different from each other (requires embeddings)
9//! - **Query-by-Committee**: High model disagreement (requires multiple model predictions)
10//! - **Hybrid**: Combine uncertainty and committee signals
11//!
12//! # Strategy Requirements and Fallbacks
13//!
14//! Each strategy has specific data requirements. When requirements aren't met,
15//! the strategy falls back as follows:
16//!
17//! | Strategy | Requires | Falls back to |
18//! |----------|----------|---------------|
19//! | Uncertainty | `confidence` | Always works (uses 0.5 if missing) |
20//! | Diversity | `embedding` | Uncertainty (with warning) |
21//! | QueryByCommittee | `committee_predictions` (≥2) | Uncertainty (with warning) |
22//! | Hybrid | Both confidence and committee | Uncertainty if committee missing |
23//!
24//! # Example
25//!
26//! ```rust
27//! use anno_eval::eval::active_learning::{ActiveLearner, SamplingStrategy, Candidate};
28//!
29//! let learner = ActiveLearner::new(SamplingStrategy::Uncertainty);
30//!
31//! let candidates = vec![
32//!     Candidate::new("John works at Google.", 0.95),
33//!     Candidate::new("Xiangjun joined Alibaba.", 0.45),  // Low confidence
34//! ];
35//!
36//! let to_annotate = learner.select(&candidates, 1);
37//! assert_eq!(to_annotate[0].text, "Xiangjun joined Alibaba.");
38//! ```
39
40use serde::{Deserialize, Serialize};
41use std::collections::HashSet;
42
43// =============================================================================
44// Data Structures
45// =============================================================================
46
47/// A candidate example for annotation.
48#[derive(Debug, Clone)]
49pub struct Candidate {
50    /// Text to potentially annotate
51    pub text: String,
52    /// Model's confidence on this example (lower = more uncertain)
53    pub confidence: f64,
54    /// Optional: entity types predicted
55    pub predicted_types: Vec<String>,
56    /// Optional: multiple model predictions for committee sampling
57    pub committee_predictions: Vec<Vec<String>>,
58    /// Optional: embedding for diversity sampling (required for Diversity strategy)
59    pub embedding: Option<Vec<f64>>,
60}
61
62impl Candidate {
63    /// Create a simple candidate with text and confidence.
64    pub fn new(text: impl Into<String>, confidence: f64) -> Self {
65        Self {
66            text: text.into(),
67            confidence,
68            predicted_types: Vec::new(),
69            committee_predictions: Vec::new(),
70            embedding: None,
71        }
72    }
73
74    /// Create candidate with predicted types.
75    pub fn with_types(mut self, types: Vec<String>) -> Self {
76        self.predicted_types = types;
77        self
78    }
79
80    /// Create candidate with committee predictions.
81    pub fn with_committee(mut self, predictions: Vec<Vec<String>>) -> Self {
82        self.committee_predictions = predictions;
83        self
84    }
85
86    /// Create candidate with embedding.
87    pub fn with_embedding(mut self, embedding: Vec<f64>) -> Self {
88        self.embedding = Some(embedding);
89        self
90    }
91
92    /// Check if this candidate has valid committee predictions (≥2 models).
93    pub fn has_committee(&self) -> bool {
94        self.committee_predictions.len() >= 2
95    }
96
97    /// Check if this candidate has an embedding.
98    pub fn has_embedding(&self) -> bool {
99        self.embedding.is_some()
100    }
101}
102
103/// Sampling strategy for active learning.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105pub enum SamplingStrategy {
106    /// Select examples with lowest model confidence.
107    /// Always works - uses confidence field directly.
108    Uncertainty,
109    /// Select examples most different from existing data.
110    /// **Requires embeddings** - falls back to Uncertainty if missing.
111    Diversity,
112    /// Select examples where model committee disagrees most.
113    /// **Requires committee_predictions with ≥2 models** - falls back to Uncertainty.
114    QueryByCommittee,
115    /// Combine uncertainty and committee disagreement (0.7 uncertainty + 0.3 committee).
116    /// Falls back to pure Uncertainty if no committee data.
117    Hybrid,
118    /// Random baseline (deterministic given seed).
119    Random,
120}
121
122/// Result of active learning selection.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct SelectionResult {
125    /// Selected candidates with their scores
126    pub selected: Vec<(String, f64)>,
127    /// Total candidates considered
128    pub total_candidates: usize,
129    /// Strategy used
130    pub strategy: SamplingStrategy,
131    /// Actual strategy used (may differ if fallback occurred)
132    pub actual_strategy: SamplingStrategy,
133    /// Score statistics
134    pub score_stats: ScoreStats,
135    /// Warnings about strategy fallbacks or data issues
136    pub warnings: Vec<String>,
137}
138
139/// Statistics about selection scores.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ScoreStats {
142    /// Mean score of selected candidates
143    pub mean_selected: f64,
144    /// Mean score of all candidates
145    pub mean_all: f64,
146    /// Score of best candidate
147    pub max_score: f64,
148    /// Score of worst candidate
149    pub min_score: f64,
150}
151
152// =============================================================================
153// Active Learner
154// =============================================================================
155
156/// Active learning selector.
157#[derive(Debug, Clone)]
158pub struct ActiveLearner {
159    /// Sampling strategy
160    strategy: SamplingStrategy,
161    /// Seed for random sampling
162    seed: u64,
163    /// Weight for uncertainty in hybrid mode (0-1)
164    uncertainty_weight: f64,
165}
166
167impl ActiveLearner {
168    /// Create a new active learner with given strategy.
169    pub fn new(strategy: SamplingStrategy) -> Self {
170        Self {
171            strategy,
172            seed: 42,
173            uncertainty_weight: 0.7,
174        }
175    }
176
177    /// Set random seed.
178    pub fn with_seed(mut self, seed: u64) -> Self {
179        self.seed = seed;
180        self
181    }
182
183    /// Set uncertainty weight for hybrid mode.
184    pub fn with_uncertainty_weight(mut self, weight: f64) -> Self {
185        self.uncertainty_weight = weight.clamp(0.0, 1.0);
186        self
187    }
188
189    /// Select top-k candidates for annotation.
190    pub fn select<'a>(&self, candidates: &'a [Candidate], k: usize) -> Vec<&'a Candidate> {
191        if candidates.is_empty() || k == 0 {
192            return Vec::new();
193        }
194
195        let k = k.min(candidates.len());
196        let (actual_strategy, _warnings) = self.resolve_strategy(candidates);
197
198        match actual_strategy {
199            SamplingStrategy::Uncertainty => self.select_by_uncertainty(candidates, k),
200            SamplingStrategy::Diversity => self.select_by_diversity(candidates, k),
201            SamplingStrategy::QueryByCommittee => self.select_by_committee(candidates, k),
202            SamplingStrategy::Hybrid => self.select_hybrid(candidates, k),
203            SamplingStrategy::Random => self.select_random(candidates, k),
204        }
205    }
206
207    /// Select with detailed results including warnings about fallbacks.
208    pub fn select_with_scores(&self, candidates: &[Candidate], k: usize) -> SelectionResult {
209        let (actual_strategy, warnings) = self.resolve_strategy(candidates);
210        let scores = self.compute_scores_with_strategy(candidates, actual_strategy);
211
212        let mut indexed: Vec<(usize, f64)> = scores.into_iter().enumerate().collect();
213        indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
214
215        let k = k.min(candidates.len());
216        let selected: Vec<(String, f64)> = indexed
217            .iter()
218            .take(k)
219            .map(|(i, s)| (candidates[*i].text.clone(), *s))
220            .collect();
221
222        let all_scores: Vec<f64> = indexed.iter().map(|(_, s)| *s).collect();
223        let mean_all = all_scores.iter().sum::<f64>() / all_scores.len().max(1) as f64;
224        let mean_selected = selected.iter().map(|(_, s)| s).sum::<f64>() / k.max(1) as f64;
225
226        SelectionResult {
227            selected,
228            total_candidates: candidates.len(),
229            strategy: self.strategy,
230            actual_strategy,
231            score_stats: ScoreStats {
232                mean_selected,
233                mean_all,
234                max_score: all_scores.first().copied().unwrap_or(0.0),
235                min_score: all_scores.last().copied().unwrap_or(0.0),
236            },
237            warnings,
238        }
239    }
240
241    /// Resolve the actual strategy to use, considering data availability.
242    fn resolve_strategy(&self, candidates: &[Candidate]) -> (SamplingStrategy, Vec<String>) {
243        let mut warnings = Vec::new();
244
245        match self.strategy {
246            SamplingStrategy::Diversity => {
247                let has_all_embeddings = candidates.iter().all(|c| c.has_embedding());
248                if !has_all_embeddings {
249                    let missing = candidates.iter().filter(|c| !c.has_embedding()).count();
250                    warnings.push(format!(
251                        "Diversity sampling requires embeddings: {}/{} candidates missing embeddings. Falling back to Uncertainty.",
252                        missing, candidates.len()
253                    ));
254                    return (SamplingStrategy::Uncertainty, warnings);
255                }
256            }
257            SamplingStrategy::QueryByCommittee => {
258                let has_all_committees = candidates.iter().all(|c| c.has_committee());
259                if !has_all_committees {
260                    let missing = candidates.iter().filter(|c| !c.has_committee()).count();
261                    warnings.push(format!(
262                        "Query-by-Committee requires committee predictions (≥2 models): {}/{} candidates missing. Falling back to Uncertainty.",
263                        missing, candidates.len()
264                    ));
265                    return (SamplingStrategy::Uncertainty, warnings);
266                }
267            }
268            SamplingStrategy::Hybrid => {
269                let has_any_committees = candidates.iter().any(|c| c.has_committee());
270                if !has_any_committees {
271                    warnings.push(
272                        "Hybrid mode has no committee data. Using pure Uncertainty.".to_string(),
273                    );
274                    // Still use Hybrid, but it will effectively be Uncertainty
275                }
276            }
277            _ => {}
278        }
279
280        (self.strategy, warnings)
281    }
282
283    fn compute_scores_with_strategy(
284        &self,
285        candidates: &[Candidate],
286        strategy: SamplingStrategy,
287    ) -> Vec<f64> {
288        match strategy {
289            SamplingStrategy::Uncertainty => {
290                candidates.iter().map(|c| 1.0 - c.confidence).collect()
291            }
292            SamplingStrategy::QueryByCommittee => candidates
293                .iter()
294                .map(|c| self.committee_disagreement(c))
295                .collect(),
296            SamplingStrategy::Diversity => {
297                // For compute_scores, we return uncertainty-weighted diversity
298                // The actual diversity selection uses greedy farthest-point
299                self.compute_diversity_scores(candidates)
300            }
301            SamplingStrategy::Hybrid => {
302                let uncertainty: Vec<f64> = candidates.iter().map(|c| 1.0 - c.confidence).collect();
303                let committee: Vec<f64> = candidates
304                    .iter()
305                    .map(|c| self.committee_disagreement(c))
306                    .collect();
307
308                uncertainty
309                    .iter()
310                    .zip(committee.iter())
311                    .map(|(u, c)| self.uncertainty_weight * u + (1.0 - self.uncertainty_weight) * c)
312                    .collect()
313            }
314            SamplingStrategy::Random => {
315                // Pseudo-random scores based on text hash
316                candidates
317                    .iter()
318                    .enumerate()
319                    .map(|(i, c)| {
320                        let hash = c.text.bytes().fold(self.seed, |acc, b| {
321                            acc.wrapping_mul(31).wrapping_add(b as u64)
322                        });
323                        (hash.wrapping_add(i as u64) % 1000) as f64 / 1000.0
324                    })
325                    .collect()
326            }
327        }
328    }
329
330    /// Compute diversity scores based on embedding distances.
331    ///
332    /// Uses average distance to all other candidates as the diversity score.
333    /// Higher scores indicate more diverse (distant) candidates.
334    fn compute_diversity_scores(&self, candidates: &[Candidate]) -> Vec<f64> {
335        let n = candidates.len();
336        if n == 0 {
337            return Vec::new();
338        }
339
340        // Compute pairwise distances and use mean distance as diversity score
341        let mut scores = vec![0.0; n];
342
343        for i in 0..n {
344            let emb_i = match &candidates[i].embedding {
345                Some(e) => e,
346                None => {
347                    // No embedding - use uncertainty as fallback
348                    scores[i] = 1.0 - candidates[i].confidence;
349                    continue;
350                }
351            };
352
353            let mut total_dist = 0.0;
354            let mut count = 0;
355
356            for (j, candidate) in candidates.iter().enumerate() {
357                if i == j {
358                    continue;
359                }
360                if let Some(emb_j) = &candidate.embedding {
361                    total_dist += self.embedding_distance(emb_i, emb_j);
362                    count += 1;
363                }
364            }
365
366            scores[i] = if count > 0 {
367                total_dist / count as f64
368            } else {
369                0.0
370            };
371        }
372
373        // Normalize scores to [0, 1]
374        let max_score = scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
375        let min_score = scores.iter().cloned().fold(f64::INFINITY, f64::min);
376        let range = max_score - min_score;
377
378        if range > 0.0 {
379            scores
380                .iter_mut()
381                .for_each(|s| *s = (*s - min_score) / range);
382        }
383
384        scores
385    }
386
387    fn select_by_uncertainty<'a>(
388        &self,
389        candidates: &'a [Candidate],
390        k: usize,
391    ) -> Vec<&'a Candidate> {
392        let mut indexed: Vec<(usize, f64)> = candidates
393            .iter()
394            .enumerate()
395            .map(|(i, c)| (i, c.confidence))
396            .collect();
397
398        // Sort by confidence ascending (lowest = most uncertain)
399        indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
400
401        indexed
402            .iter()
403            .take(k)
404            .map(|(i, _)| &candidates[*i])
405            .collect()
406    }
407
408    fn select_by_diversity<'a>(&self, candidates: &'a [Candidate], k: usize) -> Vec<&'a Candidate> {
409        // Greedy farthest-point sampling using embeddings.
410        // This maximizes the minimum distance between selected points.
411
412        // Verify embeddings exist (should have been checked by resolve_strategy)
413        let has_embeddings = candidates.iter().all(|c| c.embedding.is_some());
414        if !has_embeddings {
415            return self.select_by_uncertainty(candidates, k);
416        }
417
418        let mut selected_indices = Vec::with_capacity(k);
419        let mut remaining: HashSet<usize> = (0..candidates.len()).collect();
420
421        // Start with the most uncertain candidate (combines uncertainty with diversity)
422        let first_idx = candidates
423            .iter()
424            .enumerate()
425            .min_by(|a, b| {
426                a.1.confidence
427                    .partial_cmp(&b.1.confidence)
428                    .unwrap_or(std::cmp::Ordering::Equal)
429            })
430            .map(|(i, _)| i)
431            .unwrap_or(0);
432
433        selected_indices.push(first_idx);
434        remaining.remove(&first_idx);
435
436        // Greedily add candidate that maximizes minimum distance to selected set
437        while selected_indices.len() < k && !remaining.is_empty() {
438            let mut best_idx = 0;
439            let mut best_min_dist = f64::NEG_INFINITY;
440
441            for &idx in &remaining {
442                // Skip candidates without embeddings
443                let Some(emb_idx) = candidates[idx].embedding.as_ref() else {
444                    continue;
445                };
446
447                // Find minimum distance to any already-selected candidate
448                let min_dist = selected_indices
449                    .iter()
450                    .filter_map(|&sel_idx| {
451                        let emb_sel = candidates[sel_idx].embedding.as_ref()?;
452                        Some(self.embedding_distance(emb_idx, emb_sel))
453                    })
454                    .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
455                    .unwrap_or(0.0);
456
457                if min_dist > best_min_dist {
458                    best_min_dist = min_dist;
459                    best_idx = idx;
460                }
461            }
462
463            selected_indices.push(best_idx);
464            remaining.remove(&best_idx);
465        }
466
467        selected_indices.iter().map(|&i| &candidates[i]).collect()
468    }
469
470    fn select_by_committee<'a>(&self, candidates: &'a [Candidate], k: usize) -> Vec<&'a Candidate> {
471        let mut indexed: Vec<(usize, f64)> = candidates
472            .iter()
473            .enumerate()
474            .map(|(i, c)| (i, self.committee_disagreement(c)))
475            .collect();
476
477        // Sort by disagreement descending
478        indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
479
480        indexed
481            .iter()
482            .take(k)
483            .map(|(i, _)| &candidates[*i])
484            .collect()
485    }
486
487    fn select_hybrid<'a>(&self, candidates: &'a [Candidate], k: usize) -> Vec<&'a Candidate> {
488        let scores = self.compute_scores_with_strategy(candidates, SamplingStrategy::Hybrid);
489        let mut indexed: Vec<(usize, f64)> = scores.into_iter().enumerate().collect();
490        indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
491        indexed
492            .iter()
493            .take(k)
494            .map(|(i, _)| &candidates[*i])
495            .collect()
496    }
497
498    fn select_random<'a>(&self, candidates: &'a [Candidate], k: usize) -> Vec<&'a Candidate> {
499        let scores = self.compute_scores_with_strategy(candidates, SamplingStrategy::Random);
500        let mut indexed: Vec<(usize, f64)> = scores.into_iter().enumerate().collect();
501        indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
502        indexed
503            .iter()
504            .take(k)
505            .map(|(i, _)| &candidates[*i])
506            .collect()
507    }
508
509    fn committee_disagreement(&self, candidate: &Candidate) -> f64 {
510        if candidate.committee_predictions.len() < 2 {
511            // No committee - fall back to uncertainty
512            return 1.0 - candidate.confidence;
513        }
514
515        // Count agreement on each entity type using vote entropy
516        let all_types: HashSet<&String> = candidate
517            .committee_predictions
518            .iter()
519            .flat_map(|p| p.iter())
520            .collect();
521
522        if all_types.is_empty() {
523            return 0.0;
524        }
525
526        let num_models = candidate.committee_predictions.len();
527        let mut total_disagreement = 0.0;
528
529        let num_types = all_types.len();
530        for entity_type in &all_types {
531            let count = candidate
532                .committee_predictions
533                .iter()
534                .filter(|p| p.contains(*entity_type))
535                .count();
536
537            // Disagreement is highest when count is closest to num_models/2
538            // Using variance of binary votes: p(1-p) where p = count/num_models
539            let agreement_ratio = count as f64 / num_models as f64;
540            let disagreement = 4.0 * agreement_ratio * (1.0 - agreement_ratio);
541            total_disagreement += disagreement;
542        }
543
544        total_disagreement / num_types as f64
545    }
546
547    fn embedding_distance(&self, a: &[f64], b: &[f64]) -> f64 {
548        // Euclidean distance
549        if a.len() != b.len() {
550            return 0.0;
551        }
552
553        a.iter()
554            .zip(b.iter())
555            .map(|(x, y)| (x - y).powi(2))
556            .sum::<f64>()
557            .sqrt()
558    }
559}
560
561impl Default for ActiveLearner {
562    fn default() -> Self {
563        Self::new(SamplingStrategy::Uncertainty)
564    }
565}
566
567// =============================================================================
568// Utility Functions
569// =============================================================================
570
571/// Estimate annotation budget needed for target performance.
572///
573/// This is a simple linear extrapolation based on observed learning rate.
574/// For more accurate estimates, use `LearningCurveAnalyzer`.
575pub fn estimate_budget(
576    current_f1: f64,
577    target_f1: f64,
578    _current_samples: usize,
579    f1_per_100_samples: f64,
580) -> Option<usize> {
581    if target_f1 <= current_f1 || f1_per_100_samples <= 0.0 {
582        return Some(0);
583    }
584
585    let f1_needed = target_f1 - current_f1;
586    let hundreds_needed = f1_needed / f1_per_100_samples;
587    Some((hundreds_needed * 100.0).ceil() as usize)
588}
589
590// =============================================================================
591// Entity bridge
592// =============================================================================
593
594/// Convert extracted entities into active learning candidates.
595///
596/// Each entity's text and confidence score become a [`Candidate`].
597/// The entity type is recorded in `predicted_types`.
598pub fn entities_to_candidates(entities: &[anno::Entity]) -> Vec<Candidate> {
599    entities
600        .iter()
601        .map(|e| {
602            Candidate::new(e.text.clone(), e.confidence.value())
603                .with_types(vec![e.entity_type.to_string()])
604        })
605        .collect()
606}
607
608/// Rank entities by annotation priority (lowest confidence first).
609///
610/// Returns `(entity_index, uncertainty_score)` pairs sorted by
611/// descending uncertainty (most uncertain first), limited to `k`.
612pub fn rank_for_annotation(entities: &[anno::Entity], k: usize) -> Vec<(usize, f64)> {
613    let mut scored: Vec<(usize, f64)> = entities
614        .iter()
615        .enumerate()
616        .map(|(i, e)| (i, 1.0 - e.confidence.value()))
617        .collect();
618    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
619    scored.truncate(k);
620    scored
621}
622
623/// Export uncertainty-ranked entities as JSONL for annotation tools.
624///
625/// Each line is a JSON object with `text`, `entity_type`, `confidence`,
626/// `uncertainty`, and `rank` fields, sorted by descending uncertainty.
627/// Run the full active learning pipeline on extracted entities.
628///
629/// Converts entities to candidates, applies the given sampling strategy,
630/// and returns a [`SelectionResult`] with scores, warnings, and statistics.
631pub fn select_for_annotation(
632    entities: &[anno::Entity],
633    strategy: SamplingStrategy,
634    k: usize,
635) -> SelectionResult {
636    let candidates = entities_to_candidates(entities);
637    let learner = ActiveLearner::new(strategy);
638    learner.select_with_scores(&candidates, k)
639}
640
641/// Export uncertainty-ranked entities as JSONL for annotation tools.
642///
643/// Each line is a JSON object with `text`, `entity_type`, `confidence`,
644/// `uncertainty`, and `rank` fields, sorted by descending uncertainty.
645pub fn export_annotation_priority(entities: &[anno::Entity], k: usize) -> Vec<String> {
646    let ranked = rank_for_annotation(entities, k);
647    ranked
648        .iter()
649        .enumerate()
650        .map(|(rank, &(idx, uncertainty))| {
651            let e = &entities[idx];
652            serde_json::json!({
653                "rank": rank + 1,
654                "text": e.text,
655                "entity_type": e.entity_type.to_string(),
656                "confidence": e.confidence.value(),
657                "uncertainty": uncertainty,
658                "start": e.start(),
659                "end": e.end(),
660            })
661            .to_string()
662        })
663        .collect()
664}
665
666// =============================================================================
667// Tests
668// =============================================================================
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    #[test]
675    fn test_uncertainty_sampling() {
676        let candidates = vec![
677            Candidate::new("High confidence", 0.95),
678            Candidate::new("Low confidence", 0.30),
679            Candidate::new("Medium confidence", 0.60),
680        ];
681
682        let learner = ActiveLearner::new(SamplingStrategy::Uncertainty);
683        let selected = learner.select(&candidates, 2);
684
685        assert_eq!(selected.len(), 2);
686        assert_eq!(selected[0].text, "Low confidence");
687        assert_eq!(selected[1].text, "Medium confidence");
688    }
689
690    #[test]
691    fn test_committee_sampling() {
692        let mut low_agreement = Candidate::new("Disagreement", 0.5);
693        low_agreement.committee_predictions =
694            vec![vec!["PER".into()], vec!["ORG".into()], vec!["LOC".into()]];
695
696        let mut high_agreement = Candidate::new("Agreement", 0.5);
697        high_agreement.committee_predictions =
698            vec![vec!["PER".into()], vec!["PER".into()], vec!["PER".into()]];
699
700        let candidates = vec![low_agreement, high_agreement];
701        let learner = ActiveLearner::new(SamplingStrategy::QueryByCommittee);
702        let selected = learner.select(&candidates, 1);
703
704        assert_eq!(selected[0].text, "Disagreement");
705    }
706
707    #[test]
708    fn test_diversity_sampling_with_embeddings() {
709        // Create candidates with embeddings at different points
710        let candidates = vec![
711            Candidate::new("Near origin", 0.5).with_embedding(vec![0.0, 0.0]),
712            Candidate::new("Far positive", 0.5).with_embedding(vec![10.0, 10.0]),
713            Candidate::new("Far negative", 0.5).with_embedding(vec![-10.0, -10.0]),
714            Candidate::new("Near origin 2", 0.5).with_embedding(vec![0.1, 0.1]),
715        ];
716
717        let learner = ActiveLearner::new(SamplingStrategy::Diversity);
718        let selected = learner.select(&candidates, 3);
719
720        // Should select diverse points, not clustered ones
721        assert_eq!(selected.len(), 3);
722        let texts: Vec<&str> = selected.iter().map(|c| c.text.as_str()).collect();
723        assert!(texts.contains(&"Far positive"));
724        assert!(texts.contains(&"Far negative"));
725    }
726
727    #[test]
728    fn test_diversity_fallback_without_embeddings() {
729        let candidates = vec![
730            Candidate::new("No embedding 1", 0.9),
731            Candidate::new("No embedding 2", 0.3), // Most uncertain
732        ];
733
734        let learner = ActiveLearner::new(SamplingStrategy::Diversity);
735        let result = learner.select_with_scores(&candidates, 1);
736
737        // Should fall back to uncertainty
738        assert_eq!(result.actual_strategy, SamplingStrategy::Uncertainty);
739        assert!(!result.warnings.is_empty());
740        assert_eq!(result.selected[0].0, "No embedding 2");
741    }
742
743    #[test]
744    fn test_committee_fallback_without_predictions() {
745        let candidates = vec![
746            Candidate::new("No committee 1", 0.9),
747            Candidate::new("No committee 2", 0.3),
748        ];
749
750        let learner = ActiveLearner::new(SamplingStrategy::QueryByCommittee);
751        let result = learner.select_with_scores(&candidates, 1);
752
753        // Should fall back to uncertainty
754        assert_eq!(result.actual_strategy, SamplingStrategy::Uncertainty);
755        assert!(!result.warnings.is_empty());
756    }
757
758    #[test]
759    fn test_select_with_scores() {
760        let candidates = vec![
761            Candidate::new("A", 0.90),
762            Candidate::new("B", 0.40),
763            Candidate::new("C", 0.70),
764        ];
765
766        let learner = ActiveLearner::new(SamplingStrategy::Uncertainty);
767        let result = learner.select_with_scores(&candidates, 2);
768
769        assert_eq!(result.selected.len(), 2);
770        assert_eq!(result.total_candidates, 3);
771        assert!(result.score_stats.mean_selected > result.score_stats.mean_all);
772        assert!(result.warnings.is_empty());
773    }
774
775    #[test]
776    fn test_estimate_budget() {
777        let budget = estimate_budget(0.70, 0.85, 1000, 0.01);
778        assert!(budget.is_some());
779        assert!(budget.unwrap() > 0);
780    }
781
782    #[test]
783    fn test_empty_candidates() {
784        let learner = ActiveLearner::default();
785        let selected = learner.select(&[], 5);
786        assert!(selected.is_empty());
787    }
788
789    #[test]
790    fn test_entities_to_candidates() {
791        use anno::EntityType;
792
793        let entities = vec![
794            anno::Entity::new("Alice", EntityType::Person, 0, 5, 0.9),
795            anno::Entity::new("Acme Corp", EntityType::Organization, 10, 19, 0.4),
796        ];
797
798        let candidates = entities_to_candidates(&entities);
799        assert_eq!(candidates.len(), 2);
800        assert_eq!(candidates[0].text, "Alice");
801        assert!((candidates[0].confidence - 0.9).abs() < 1e-10);
802        assert_eq!(
803            candidates[0].predicted_types,
804            vec![EntityType::Person.to_string()]
805        );
806        assert_eq!(candidates[1].text, "Acme Corp");
807        assert_eq!(
808            candidates[1].predicted_types,
809            vec![EntityType::Organization.to_string()]
810        );
811    }
812
813    #[test]
814    fn test_rank_for_annotation() {
815        use anno::EntityType;
816
817        let entities = vec![
818            anno::Entity::new("High", EntityType::Person, 0, 4, 0.95),
819            anno::Entity::new("Low", EntityType::Person, 5, 8, 0.2),
820            anno::Entity::new("Mid", EntityType::Person, 9, 12, 0.6),
821        ];
822
823        let ranked = rank_for_annotation(&entities, 2);
824        assert_eq!(ranked.len(), 2);
825        assert_eq!(ranked[0].0, 1); // "Low" at index 1
826        assert!((ranked[0].1 - 0.8).abs() < 1e-10);
827        assert_eq!(ranked[1].0, 2); // "Mid" at index 2
828    }
829
830    #[test]
831    fn test_export_annotation_priority() {
832        use anno::EntityType;
833
834        let entities = vec![
835            anno::Entity::new("Sure", EntityType::Person, 0, 4, 0.99),
836            anno::Entity::new("Unsure", EntityType::Organization, 5, 11, 0.3),
837        ];
838
839        let lines = export_annotation_priority(&entities, 2);
840        assert_eq!(lines.len(), 2);
841
842        let first: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
843        assert_eq!(first["rank"], 1);
844        assert_eq!(first["text"], "Unsure");
845        assert_eq!(first["entity_type"], EntityType::Organization.to_string());
846        assert!((first["uncertainty"].as_f64().unwrap() - 0.7).abs() < 1e-10);
847    }
848
849    #[test]
850    fn test_select_for_annotation() {
851        use anno::EntityType;
852
853        let entities = vec![
854            anno::Entity::new("Certain", EntityType::Person, 0, 7, 0.98),
855            anno::Entity::new("Uncertain", EntityType::Organization, 8, 17, 0.15),
856            anno::Entity::new("Medium", EntityType::Location, 18, 24, 0.55),
857        ];
858
859        let result = select_for_annotation(&entities, SamplingStrategy::Uncertainty, 2);
860
861        assert_eq!(result.selected.len(), 2);
862        assert_eq!(result.total_candidates, 3);
863        assert_eq!(result.strategy, SamplingStrategy::Uncertainty);
864        assert_eq!(result.actual_strategy, SamplingStrategy::Uncertainty);
865        // Most uncertain first.
866        assert_eq!(result.selected[0].0, "Uncertain");
867        assert_eq!(result.selected[1].0, "Medium");
868        assert!(result.warnings.is_empty());
869    }
870}