1use serde::{Deserialize, Serialize};
41use std::collections::HashSet;
42
43#[derive(Debug, Clone)]
49pub struct Candidate {
50 pub text: String,
52 pub confidence: f64,
54 pub predicted_types: Vec<String>,
56 pub committee_predictions: Vec<Vec<String>>,
58 pub embedding: Option<Vec<f64>>,
60}
61
62impl Candidate {
63 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 pub fn with_types(mut self, types: Vec<String>) -> Self {
76 self.predicted_types = types;
77 self
78 }
79
80 pub fn with_committee(mut self, predictions: Vec<Vec<String>>) -> Self {
82 self.committee_predictions = predictions;
83 self
84 }
85
86 pub fn with_embedding(mut self, embedding: Vec<f64>) -> Self {
88 self.embedding = Some(embedding);
89 self
90 }
91
92 pub fn has_committee(&self) -> bool {
94 self.committee_predictions.len() >= 2
95 }
96
97 pub fn has_embedding(&self) -> bool {
99 self.embedding.is_some()
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105pub enum SamplingStrategy {
106 Uncertainty,
109 Diversity,
112 QueryByCommittee,
115 Hybrid,
118 Random,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct SelectionResult {
125 pub selected: Vec<(String, f64)>,
127 pub total_candidates: usize,
129 pub strategy: SamplingStrategy,
131 pub actual_strategy: SamplingStrategy,
133 pub score_stats: ScoreStats,
135 pub warnings: Vec<String>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ScoreStats {
142 pub mean_selected: f64,
144 pub mean_all: f64,
146 pub max_score: f64,
148 pub min_score: f64,
150}
151
152#[derive(Debug, Clone)]
158pub struct ActiveLearner {
159 strategy: SamplingStrategy,
161 seed: u64,
163 uncertainty_weight: f64,
165}
166
167impl ActiveLearner {
168 pub fn new(strategy: SamplingStrategy) -> Self {
170 Self {
171 strategy,
172 seed: 42,
173 uncertainty_weight: 0.7,
174 }
175 }
176
177 pub fn with_seed(mut self, seed: u64) -> Self {
179 self.seed = seed;
180 self
181 }
182
183 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 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 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 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 }
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 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 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 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 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 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 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 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 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 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 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 let Some(emb_idx) = candidates[idx].embedding.as_ref() else {
444 continue;
445 };
446
447 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 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 return 1.0 - candidate.confidence;
513 }
514
515 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 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 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
567pub 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
590pub 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
608pub 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
623pub 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
641pub 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#[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 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 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), ];
733
734 let learner = ActiveLearner::new(SamplingStrategy::Diversity);
735 let result = learner.select_with_scores(&candidates, 1);
736
737 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 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); assert!((ranked[0].1 - 0.8).abs() < 1e-10);
827 assert_eq!(ranked[1].0, 2); }
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 assert_eq!(result.selected[0].0, "Uncertain");
867 assert_eq!(result.selected[1].0, "Medium");
868 assert!(result.warnings.is_empty());
869 }
870}