1use std::collections::{HashMap, HashSet};
7
8pub fn fnv1a_64(data: &[u8]) -> u64 {
14 const OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
15 const PRIME: u64 = 1_099_511_628_211;
16
17 let mut hash = OFFSET_BASIS;
18 for &byte in data {
19 hash ^= byte as u64;
20 hash = hash.wrapping_mul(PRIME);
21 }
22 hash
23}
24
25pub fn query_id_for(query_text: &str) -> u64 {
27 fnv1a_64(query_text.as_bytes())
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FeedbackType {
37 Relevant,
39 Irrelevant,
41 PartiallyRelevant,
43}
44
45#[derive(Debug, Clone)]
51pub struct FeedbackEntry {
52 pub query_id: String,
54 pub doc_id: String,
56 pub feedback: FeedbackType,
58 pub tick: u64,
60 pub confidence: f64,
62}
63
64#[derive(Debug, Clone)]
70pub struct QueryFeedbackSummary {
71 pub query_id: String,
73 pub relevant_count: usize,
75 pub irrelevant_count: usize,
77 pub partial_count: usize,
79 pub avg_confidence: f64,
81 pub precision: f64,
83}
84
85#[derive(Debug, Clone)]
91pub struct FeedbackLoopStats {
92 pub total_entries: usize,
94 pub unique_queries: usize,
96 pub overall_precision: Option<f64>,
98 pub avg_confidence: f64,
100}
101
102#[derive(Clone, Debug, PartialEq)]
108pub enum FeedbackSignal {
109 Relevant { result_id: u64, rank: usize },
111 Irrelevant { result_id: u64, rank: usize },
113 Clicked {
115 result_id: u64,
116 rank: usize,
117 dwell_ms: u64,
118 },
119}
120
121impl FeedbackSignal {
122 pub fn result_id(&self) -> u64 {
124 match self {
125 Self::Relevant { result_id, .. } => *result_id,
126 Self::Irrelevant { result_id, .. } => *result_id,
127 Self::Clicked { result_id, .. } => *result_id,
128 }
129 }
130
131 pub fn rank(&self) -> usize {
133 match self {
134 Self::Relevant { rank, .. } => *rank,
135 Self::Irrelevant { rank, .. } => *rank,
136 Self::Clicked { rank, .. } => *rank,
137 }
138 }
139
140 pub fn is_positive(&self) -> bool {
142 matches!(self, Self::Relevant { .. } | Self::Clicked { .. })
143 }
144}
145
146#[derive(Clone, Debug)]
152pub struct QueryFeedback {
153 pub query_id: u64,
155 pub signals: Vec<FeedbackSignal>,
157 pub collected_at_secs: u64,
159}
160
161impl QueryFeedback {
162 pub fn new(query_id: u64, collected_at_secs: u64) -> Self {
164 Self {
165 query_id,
166 signals: Vec::new(),
167 collected_at_secs,
168 }
169 }
170
171 pub fn relevance_score(&self) -> f64 {
180 let raw: f64 = self
181 .signals
182 .iter()
183 .map(|s| match s {
184 FeedbackSignal::Relevant { .. } => 1.0,
185 FeedbackSignal::Irrelevant { .. } => -0.5,
186 FeedbackSignal::Clicked { .. } => 0.3,
187 })
188 .sum();
189 raw.clamp(-10.0, 10.0)
190 }
191
192 pub fn positive_ids(&self) -> Vec<u64> {
194 self.signals
195 .iter()
196 .filter(|s| s.is_positive())
197 .map(|s| s.result_id())
198 .collect()
199 }
200}
201
202#[derive(Clone, Debug)]
208pub struct BoostRecord {
209 pub result_id: u64,
211 pub boost_score: f64,
213 pub feedback_count: u64,
215}
216
217impl BoostRecord {
218 pub fn new(result_id: u64) -> Self {
220 Self {
221 result_id,
222 boost_score: 0.0,
223 feedback_count: 0,
224 }
225 }
226
227 pub fn effective_boost(&self) -> f64 {
229 self.boost_score / (self.feedback_count.max(1) as f64)
230 }
231}
232
233#[derive(Clone, Debug, Default)]
239pub struct FeedbackStats {
240 pub total_queries: usize,
242 pub total_signals: u64,
244 pub relevant_count: u64,
246 pub irrelevant_count: u64,
248 pub clicked_count: u64,
250}
251
252impl FeedbackStats {
253 pub fn signal_ratio(&self) -> f64 {
255 let positive = self.relevant_count + self.clicked_count;
256 positive as f64 / (self.total_signals.max(1) as f64)
257 }
258}
259
260#[derive(Debug)]
269pub struct SemanticFeedbackLoop {
270 pub feedback: HashMap<u64, QueryFeedback>,
272 pub boosts: HashMap<u64, BoostRecord>,
274 pub stats: FeedbackStats,
276
277 entries: Vec<FeedbackEntry>,
280 current_tick: u64,
282 max_entries: usize,
284}
285
286impl SemanticFeedbackLoop {
287 pub fn new() -> Self {
289 Self::with_max_entries(50_000)
290 }
291
292 pub fn with_max_entries(max_entries: usize) -> Self {
294 Self {
295 feedback: HashMap::new(),
296 boosts: HashMap::new(),
297 stats: FeedbackStats::default(),
298 entries: Vec::new(),
299 current_tick: 0,
300 max_entries,
301 }
302 }
303
304 pub fn record_feedback(&mut self, query_id: u64, signal: FeedbackSignal, now_secs: u64) {
314 self.stats.total_signals += 1;
316 match &signal {
317 FeedbackSignal::Relevant { .. } => self.stats.relevant_count += 1,
318 FeedbackSignal::Irrelevant { .. } => self.stats.irrelevant_count += 1,
319 FeedbackSignal::Clicked { .. } => self.stats.clicked_count += 1,
320 }
321
322 let boost_delta = Self::boost_delta_for(&signal);
324 let result_id = signal.result_id();
325 let record = self
326 .boosts
327 .entry(result_id)
328 .or_insert_with(|| BoostRecord::new(result_id));
329 record.boost_score += boost_delta;
330 record.feedback_count += 1;
331
332 let is_new_query = !self.feedback.contains_key(&query_id);
334 let qf = self
335 .feedback
336 .entry(query_id)
337 .or_insert_with(|| QueryFeedback::new(query_id, now_secs));
338 qf.signals.push(signal);
339
340 if is_new_query {
341 self.stats.total_queries += 1;
342 }
343 }
344
345 fn boost_delta_for(signal: &FeedbackSignal) -> f64 {
347 match signal {
348 FeedbackSignal::Relevant { .. } => 1.0,
349 FeedbackSignal::Irrelevant { .. } => -0.5,
350 FeedbackSignal::Clicked { dwell_ms, .. } => {
351 let dwell_s = *dwell_ms as f64 / 1000.0;
355 0.3 * (1.0 + dwell_s)
356 }
357 }
358 }
359
360 pub fn apply_boosts(&self, results: &[(u64, f64)]) -> Vec<(u64, f64)> {
370 let mut boosted: Vec<(u64, f64)> = results
371 .iter()
372 .map(|&(id, score)| {
373 let multiplier = 1.0
374 + self
375 .boosts
376 .get(&id)
377 .map(|r| r.effective_boost().max(-0.9))
378 .unwrap_or(0.0);
379 (id, score * multiplier)
380 })
381 .collect();
382
383 boosted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
385 boosted
386 }
387
388 pub fn top_boosted_ids(&self, k: usize) -> Vec<u64> {
394 let mut pairs: Vec<(u64, f64)> = self
395 .boosts
396 .values()
397 .map(|r| (r.result_id, r.effective_boost()))
398 .collect();
399 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
400 pairs.into_iter().take(k).map(|(id, _)| id).collect()
401 }
402
403 pub fn positive_ids_for_query(&self, query_id: u64) -> Vec<u64> {
407 self.feedback
408 .get(&query_id)
409 .map(|qf| qf.positive_ids())
410 .unwrap_or_default()
411 }
412
413 pub fn stats(&self) -> &FeedbackStats {
415 &self.stats
416 }
417
418 pub fn record(
425 &mut self,
426 query_id: &str,
427 doc_id: &str,
428 feedback: FeedbackType,
429 confidence: f64,
430 ) {
431 let clamped = confidence.clamp(0.0, 1.0);
432 let entry = FeedbackEntry {
433 query_id: query_id.to_string(),
434 doc_id: doc_id.to_string(),
435 feedback,
436 tick: self.current_tick,
437 confidence: clamped,
438 };
439 self.current_tick += 1;
440
441 if self.entries.len() >= self.max_entries {
442 self.entries.remove(0);
443 }
444 self.entries.push(entry);
445 }
446
447 pub fn get_summary(&self, query_id: &str) -> Option<QueryFeedbackSummary> {
450 let mut relevant_count: usize = 0;
451 let mut irrelevant_count: usize = 0;
452 let mut partial_count: usize = 0;
453 let mut conf_sum: f64 = 0.0;
454 let mut total: usize = 0;
455
456 for e in &self.entries {
457 if e.query_id == query_id {
458 total += 1;
459 conf_sum += e.confidence;
460 match e.feedback {
461 FeedbackType::Relevant => relevant_count += 1,
462 FeedbackType::Irrelevant => irrelevant_count += 1,
463 FeedbackType::PartiallyRelevant => partial_count += 1,
464 }
465 }
466 }
467
468 if total == 0 {
469 return None;
470 }
471
472 let avg_confidence = conf_sum / total as f64;
473 let denom = relevant_count + irrelevant_count;
474 let precision = if denom > 0 {
475 relevant_count as f64 / denom as f64
476 } else {
477 0.0
478 };
479
480 Some(QueryFeedbackSummary {
481 query_id: query_id.to_string(),
482 relevant_count,
483 irrelevant_count,
484 partial_count,
485 avg_confidence,
486 precision,
487 })
488 }
489
490 pub fn relevant_docs(&self, query_id: &str) -> Vec<String> {
492 self.entries
493 .iter()
494 .filter(|e| e.query_id == query_id && e.feedback == FeedbackType::Relevant)
495 .map(|e| e.doc_id.clone())
496 .collect()
497 }
498
499 pub fn irrelevant_docs(&self, query_id: &str) -> Vec<String> {
501 self.entries
502 .iter()
503 .filter(|e| e.query_id == query_id && e.feedback == FeedbackType::Irrelevant)
504 .map(|e| e.doc_id.clone())
505 .collect()
506 }
507
508 pub fn precision_at_query(&self, query_id: &str) -> Option<f64> {
511 let mut rel: usize = 0;
512 let mut irr: usize = 0;
513 for e in &self.entries {
514 if e.query_id == query_id {
515 match e.feedback {
516 FeedbackType::Relevant => rel += 1,
517 FeedbackType::Irrelevant => irr += 1,
518 FeedbackType::PartiallyRelevant => {}
519 }
520 }
521 }
522 let denom = rel + irr;
523 if denom == 0 {
524 None
525 } else {
526 Some(rel as f64 / denom as f64)
527 }
528 }
529
530 pub fn overall_precision(&self) -> Option<f64> {
533 let mut rel: usize = 0;
534 let mut irr: usize = 0;
535 for e in &self.entries {
536 match e.feedback {
537 FeedbackType::Relevant => rel += 1,
538 FeedbackType::Irrelevant => irr += 1,
539 FeedbackType::PartiallyRelevant => {}
540 }
541 }
542 let denom = rel + irr;
543 if denom == 0 {
544 None
545 } else {
546 Some(rel as f64 / denom as f64)
547 }
548 }
549
550 pub fn feedback_count(&self) -> usize {
552 self.entries.len()
553 }
554
555 pub fn queries_with_feedback(&self) -> Vec<String> {
557 let mut seen = HashSet::new();
558 let mut result = Vec::new();
559 for e in &self.entries {
560 if seen.insert(&e.query_id) {
561 result.push(e.query_id.clone());
562 }
563 }
564 result
565 }
566
567 pub fn tick(&mut self) {
569 self.current_tick += 1;
570 }
571
572 pub fn clear_entries(&mut self) {
574 self.entries.clear();
575 self.current_tick = 0;
576 }
577
578 pub fn loop_stats(&self) -> FeedbackLoopStats {
580 let total_entries = self.entries.len();
581 let unique_queries = self.queries_with_feedback().len();
582 let overall_precision = self.overall_precision();
583
584 let avg_confidence = if total_entries == 0 {
585 0.0
586 } else {
587 let sum: f64 = self.entries.iter().map(|e| e.confidence).sum();
588 sum / total_entries as f64
589 };
590
591 FeedbackLoopStats {
592 total_entries,
593 unique_queries,
594 overall_precision,
595 avg_confidence,
596 }
597 }
598}
599
600impl Default for SemanticFeedbackLoop {
601 fn default() -> Self {
602 Self::new()
603 }
604}
605
606#[cfg(test)]
611mod tests {
612 use super::*;
613
614 fn relevant(result_id: u64, rank: usize) -> FeedbackSignal {
619 FeedbackSignal::Relevant { result_id, rank }
620 }
621
622 fn irrelevant(result_id: u64, rank: usize) -> FeedbackSignal {
623 FeedbackSignal::Irrelevant { result_id, rank }
624 }
625
626 fn clicked(result_id: u64, rank: usize, dwell_ms: u64) -> FeedbackSignal {
627 FeedbackSignal::Clicked {
628 result_id,
629 rank,
630 dwell_ms,
631 }
632 }
633
634 #[test]
638 fn test_record_relevant_increments_relevant_count() {
639 let mut fl = SemanticFeedbackLoop::new();
640 fl.record_feedback(1, relevant(10, 0), 1000);
641 assert_eq!(fl.stats().relevant_count, 1);
642 assert_eq!(fl.stats().irrelevant_count, 0);
643 assert_eq!(fl.stats().clicked_count, 0);
644 assert_eq!(fl.stats().total_signals, 1);
645 }
646
647 #[test]
651 fn test_record_irrelevant_increments_irrelevant_count() {
652 let mut fl = SemanticFeedbackLoop::new();
653 fl.record_feedback(1, irrelevant(10, 0), 1000);
654 assert_eq!(fl.stats().irrelevant_count, 1);
655 assert_eq!(fl.stats().relevant_count, 0);
656 assert_eq!(fl.stats().total_signals, 1);
657 }
658
659 #[test]
663 fn test_record_clicked_increments_clicked_count() {
664 let mut fl = SemanticFeedbackLoop::new();
665 fl.record_feedback(1, clicked(10, 0, 500), 1000);
666 assert_eq!(fl.stats().clicked_count, 1);
667 assert_eq!(fl.stats().relevant_count, 0);
668 assert_eq!(fl.stats().total_signals, 1);
669 }
670
671 #[test]
675 fn test_relevance_score_relevant_only() {
676 let mut qf = QueryFeedback::new(42, 0);
677 qf.signals.push(relevant(1, 0));
678 qf.signals.push(relevant(2, 1));
679 assert!((qf.relevance_score() - 2.0).abs() < 1e-10);
680 }
681
682 #[test]
686 fn test_relevance_score_irrelevant_only() {
687 let mut qf = QueryFeedback::new(42, 0);
688 qf.signals.push(irrelevant(1, 0));
689 qf.signals.push(irrelevant(2, 1));
690 assert!((qf.relevance_score() - (-1.0)).abs() < 1e-10);
691 }
692
693 #[test]
697 fn test_relevance_score_clicked_only() {
698 let mut qf = QueryFeedback::new(42, 0);
699 qf.signals.push(clicked(1, 0, 5000));
700 assert!((qf.relevance_score() - 0.3).abs() < 1e-10);
701 }
702
703 #[test]
707 fn test_relevance_score_mixed() {
708 let mut qf = QueryFeedback::new(42, 0);
709 qf.signals.push(relevant(1, 0)); qf.signals.push(irrelevant(2, 1)); qf.signals.push(clicked(3, 2, 0)); let expected = 1.0 - 0.5 + 0.3;
713 assert!((qf.relevance_score() - expected).abs() < 1e-10);
714 }
715
716 #[test]
720 fn test_relevance_score_clamped_positive() {
721 let mut qf = QueryFeedback::new(42, 0);
722 for i in 0..20 {
723 qf.signals.push(relevant(i, i as usize));
724 }
725 assert!((qf.relevance_score() - 10.0).abs() < 1e-10);
726 }
727
728 #[test]
729 fn test_relevance_score_clamped_negative() {
730 let mut qf = QueryFeedback::new(42, 0);
731 for i in 0..30 {
732 qf.signals.push(irrelevant(i, i as usize));
733 }
734 assert!((qf.relevance_score() - (-10.0)).abs() < 1e-10);
735 }
736
737 #[test]
741 fn test_positive_ids() {
742 let mut qf = QueryFeedback::new(42, 0);
743 qf.signals.push(relevant(10, 0));
744 qf.signals.push(irrelevant(20, 1));
745 qf.signals.push(clicked(30, 2, 100));
746 let ids = qf.positive_ids();
747 assert!(ids.contains(&10));
748 assert!(!ids.contains(&20));
749 assert!(ids.contains(&30));
750 assert_eq!(ids.len(), 2);
751 }
752
753 #[test]
757 fn test_apply_boosts_resorts() {
758 let mut fl = SemanticFeedbackLoop::new();
759 fl.record_feedback(1, relevant(99, 1), 0);
761
762 let results = vec![(100u64, 0.9), (99u64, 0.5)];
764 let boosted = fl.apply_boosts(&results);
765
766 assert_eq!(boosted[0].0, 99);
768 assert_eq!(boosted[1].0, 100);
769 }
770
771 #[test]
775 fn test_apply_boosts_no_boost_unchanged() {
776 let fl = SemanticFeedbackLoop::new();
777 let results = vec![(1u64, 0.8), (2u64, 0.6)];
778 let boosted = fl.apply_boosts(&results);
779 assert_eq!(boosted[0].0, 1);
781 assert!((boosted[0].1 - 0.8).abs() < 1e-10);
782 }
783
784 #[test]
788 fn test_effective_boost() {
789 let mut r = BoostRecord::new(7);
790 r.boost_score = 3.0;
791 r.feedback_count = 3;
792 assert!((r.effective_boost() - 1.0).abs() < 1e-10);
793 }
794
795 #[test]
796 fn test_effective_boost_zero_count() {
797 let r = BoostRecord::new(7);
798 assert!((r.effective_boost() - 0.0).abs() < 1e-10);
800 }
801
802 #[test]
806 fn test_top_boosted_ids() {
807 let mut fl = SemanticFeedbackLoop::new();
808 fl.record_feedback(1, relevant(10, 0), 0); fl.record_feedback(1, relevant(20, 1), 0); fl.record_feedback(1, relevant(20, 1), 0); fl.record_feedback(1, irrelevant(30, 2), 0); let top = fl.top_boosted_ids(2);
814 assert_eq!(top.len(), 2);
815 assert!(!top.contains(&30));
817 }
818
819 #[test]
823 fn test_signal_ratio() {
824 let mut fl = SemanticFeedbackLoop::new();
825 fl.record_feedback(1, relevant(1, 0), 0);
826 fl.record_feedback(1, clicked(2, 1, 0), 0);
827 fl.record_feedback(1, irrelevant(3, 2), 0);
828 let ratio = fl.stats().signal_ratio();
830 assert!((ratio - (2.0 / 3.0)).abs() < 1e-10);
831 }
832
833 #[test]
834 fn test_signal_ratio_no_signals() {
835 let fl = SemanticFeedbackLoop::new();
836 assert!((fl.stats().signal_ratio() - 0.0).abs() < 1e-10);
838 }
839
840 #[test]
844 fn test_multiple_signals_same_query_accumulate() {
845 let mut fl = SemanticFeedbackLoop::new();
846 fl.record_feedback(42, relevant(1, 0), 1000);
847 fl.record_feedback(42, relevant(2, 1), 2000);
848 fl.record_feedback(42, irrelevant(3, 2), 3000);
849
850 assert_eq!(fl.feedback[&42].signals.len(), 3);
851 assert_eq!(fl.stats().total_queries, 1); assert_eq!(fl.stats().total_signals, 3);
853 }
854
855 #[test]
859 fn test_boost_clicked_scales_with_dwell() {
860 let mut fl = SemanticFeedbackLoop::new();
861 fl.record_feedback(1, clicked(55, 0, 1000), 0);
863 let boost = fl.boosts[&55].boost_score;
864 assert!((boost - 0.6).abs() < 1e-10);
865 }
866
867 #[test]
868 fn test_boost_clicked_zero_dwell() {
869 let mut fl = SemanticFeedbackLoop::new();
870 fl.record_feedback(1, clicked(66, 0, 0), 0);
872 let boost = fl.boosts[&66].boost_score;
873 assert!((boost - 0.3).abs() < 1e-10);
874 }
875
876 #[test]
880 fn test_stats_totals_across_queries() {
881 let mut fl = SemanticFeedbackLoop::new();
882 fl.record_feedback(1, relevant(10, 0), 100);
883 fl.record_feedback(2, irrelevant(20, 0), 200);
884 fl.record_feedback(3, clicked(30, 0, 500), 300);
885 fl.record_feedback(1, relevant(40, 1), 400); let s = fl.stats();
888 assert_eq!(s.total_queries, 3);
889 assert_eq!(s.total_signals, 4);
890 assert_eq!(s.relevant_count, 2);
891 assert_eq!(s.irrelevant_count, 1);
892 assert_eq!(s.clicked_count, 1);
893 }
894
895 #[test]
899 fn test_positive_ids_for_query_unknown() {
900 let fl = SemanticFeedbackLoop::new();
901 assert!(fl.positive_ids_for_query(9999).is_empty());
902 }
903
904 #[test]
908 fn test_positive_ids_for_query_known() {
909 let mut fl = SemanticFeedbackLoop::new();
910 fl.record_feedback(7, relevant(100, 0), 0);
911 fl.record_feedback(7, irrelevant(200, 1), 0);
912 fl.record_feedback(7, clicked(300, 2, 250), 0);
913
914 let ids = fl.positive_ids_for_query(7);
915 assert_eq!(ids.len(), 2);
916 assert!(ids.contains(&100));
917 assert!(ids.contains(&300));
918 assert!(!ids.contains(&200));
919 }
920
921 #[test]
925 fn test_query_id_for_deterministic() {
926 let id1 = query_id_for("rust semantic search");
927 let id2 = query_id_for("rust semantic search");
928 let id3 = query_id_for("different query");
929 assert_eq!(id1, id2);
930 assert_ne!(id1, id3);
931 }
932
933 #[test]
937 fn test_apply_boosts_irrelevant_reduces_score() {
938 let mut fl = SemanticFeedbackLoop::new();
939 fl.record_feedback(1, irrelevant(77, 0), 0);
940 let results = vec![(77u64, 1.0)];
942 let boosted = fl.apply_boosts(&results);
943 assert!((boosted[0].1 - 0.5).abs() < 1e-10);
944 }
945
946 #[test]
952 fn test_record_adds_entry() {
953 let mut fl = SemanticFeedbackLoop::new();
954 fl.record("q1", "d1", FeedbackType::Relevant, 0.9);
955 assert_eq!(fl.feedback_count(), 1);
956 }
957
958 #[test]
960 fn test_record_multiple_entries() {
961 let mut fl = SemanticFeedbackLoop::new();
962 fl.record("q1", "d1", FeedbackType::Relevant, 0.9);
963 fl.record("q1", "d2", FeedbackType::Irrelevant, 0.8);
964 fl.record("q2", "d3", FeedbackType::PartiallyRelevant, 0.5);
965 assert_eq!(fl.feedback_count(), 3);
966 }
967
968 #[test]
970 fn test_get_summary_aggregation() {
971 let mut fl = SemanticFeedbackLoop::new();
972 fl.record("q1", "d1", FeedbackType::Relevant, 1.0);
973 fl.record("q1", "d2", FeedbackType::Irrelevant, 0.5);
974 fl.record("q1", "d3", FeedbackType::PartiallyRelevant, 0.8);
975
976 let s = fl.get_summary("q1").expect("summary should exist");
977 assert_eq!(s.relevant_count, 1);
978 assert_eq!(s.irrelevant_count, 1);
979 assert_eq!(s.partial_count, 1);
980 assert!((s.precision - 0.5).abs() < 1e-10);
982 assert!((s.avg_confidence - (1.0 + 0.5 + 0.8) / 3.0).abs() < 1e-10);
984 }
985
986 #[test]
988 fn test_get_summary_unknown_query() {
989 let fl = SemanticFeedbackLoop::new();
990 assert!(fl.get_summary("nonexistent").is_none());
991 }
992
993 #[test]
995 fn test_precision_at_query() {
996 let mut fl = SemanticFeedbackLoop::new();
997 fl.record("q1", "d1", FeedbackType::Relevant, 0.9);
998 fl.record("q1", "d2", FeedbackType::Relevant, 0.8);
999 fl.record("q1", "d3", FeedbackType::Irrelevant, 0.7);
1000 let p = fl.precision_at_query("q1").expect("should have precision");
1002 assert!((p - 2.0 / 3.0).abs() < 1e-10);
1003 }
1004
1005 #[test]
1007 fn test_precision_at_query_partial_only() {
1008 let mut fl = SemanticFeedbackLoop::new();
1009 fl.record("q1", "d1", FeedbackType::PartiallyRelevant, 0.5);
1010 assert!(fl.precision_at_query("q1").is_none());
1011 }
1012
1013 #[test]
1015 fn test_precision_at_query_unknown() {
1016 let fl = SemanticFeedbackLoop::new();
1017 assert!(fl.precision_at_query("missing").is_none());
1018 }
1019
1020 #[test]
1022 fn test_overall_precision() {
1023 let mut fl = SemanticFeedbackLoop::new();
1024 fl.record("q1", "d1", FeedbackType::Relevant, 0.9);
1025 fl.record("q1", "d2", FeedbackType::Irrelevant, 0.8);
1026 fl.record("q2", "d3", FeedbackType::Relevant, 0.7);
1027 let p = fl
1029 .overall_precision()
1030 .expect("should have overall precision");
1031 assert!((p - 2.0 / 3.0).abs() < 1e-10);
1032 }
1033
1034 #[test]
1036 fn test_overall_precision_empty() {
1037 let fl = SemanticFeedbackLoop::new();
1038 assert!(fl.overall_precision().is_none());
1039 }
1040
1041 #[test]
1043 fn test_overall_precision_partial_only() {
1044 let mut fl = SemanticFeedbackLoop::new();
1045 fl.record("q1", "d1", FeedbackType::PartiallyRelevant, 0.5);
1046 assert!(fl.overall_precision().is_none());
1047 }
1048
1049 #[test]
1051 fn test_relevant_docs() {
1052 let mut fl = SemanticFeedbackLoop::new();
1053 fl.record("q1", "d1", FeedbackType::Relevant, 0.9);
1054 fl.record("q1", "d2", FeedbackType::Irrelevant, 0.8);
1055 fl.record("q1", "d3", FeedbackType::Relevant, 0.7);
1056
1057 let docs = fl.relevant_docs("q1");
1058 assert_eq!(docs.len(), 2);
1059 assert!(docs.contains(&"d1".to_string()));
1060 assert!(docs.contains(&"d3".to_string()));
1061 }
1062
1063 #[test]
1065 fn test_irrelevant_docs() {
1066 let mut fl = SemanticFeedbackLoop::new();
1067 fl.record("q1", "d1", FeedbackType::Relevant, 0.9);
1068 fl.record("q1", "d2", FeedbackType::Irrelevant, 0.8);
1069 fl.record("q1", "d3", FeedbackType::Irrelevant, 0.7);
1070
1071 let docs = fl.irrelevant_docs("q1");
1072 assert_eq!(docs.len(), 2);
1073 assert!(docs.contains(&"d2".to_string()));
1074 assert!(docs.contains(&"d3".to_string()));
1075 }
1076
1077 #[test]
1079 fn test_relevant_docs_unknown() {
1080 let fl = SemanticFeedbackLoop::new();
1081 assert!(fl.relevant_docs("nope").is_empty());
1082 }
1083
1084 #[test]
1086 fn test_max_entries_eviction() {
1087 let mut fl = SemanticFeedbackLoop::with_max_entries(3);
1088 fl.record("q1", "d1", FeedbackType::Relevant, 1.0);
1089 fl.record("q1", "d2", FeedbackType::Relevant, 1.0);
1090 fl.record("q1", "d3", FeedbackType::Relevant, 1.0);
1091 assert_eq!(fl.feedback_count(), 3);
1092
1093 fl.record("q1", "d4", FeedbackType::Relevant, 1.0);
1095 assert_eq!(fl.feedback_count(), 3);
1096
1097 let docs = fl.relevant_docs("q1");
1098 assert!(!docs.contains(&"d1".to_string()));
1099 assert!(docs.contains(&"d4".to_string()));
1100 }
1101
1102 #[test]
1104 fn test_clear_entries() {
1105 let mut fl = SemanticFeedbackLoop::new();
1106 fl.record("q1", "d1", FeedbackType::Relevant, 0.9);
1107 fl.record("q2", "d2", FeedbackType::Irrelevant, 0.8);
1108 assert_eq!(fl.feedback_count(), 2);
1109
1110 fl.clear_entries();
1111 assert_eq!(fl.feedback_count(), 0);
1112 assert!(fl.queries_with_feedback().is_empty());
1113 }
1114
1115 #[test]
1117 fn test_queries_with_feedback() {
1118 let mut fl = SemanticFeedbackLoop::new();
1119 fl.record("q1", "d1", FeedbackType::Relevant, 0.9);
1120 fl.record("q2", "d2", FeedbackType::Irrelevant, 0.8);
1121 fl.record("q1", "d3", FeedbackType::Relevant, 0.7); let queries = fl.queries_with_feedback();
1124 assert_eq!(queries.len(), 2);
1125 assert!(queries.contains(&"q1".to_string()));
1126 assert!(queries.contains(&"q2".to_string()));
1127 }
1128
1129 #[test]
1131 fn test_loop_stats_accuracy() {
1132 let mut fl = SemanticFeedbackLoop::new();
1133 fl.record("q1", "d1", FeedbackType::Relevant, 1.0);
1134 fl.record("q1", "d2", FeedbackType::Irrelevant, 0.5);
1135 fl.record("q2", "d3", FeedbackType::Relevant, 0.8);
1136
1137 let s = fl.loop_stats();
1138 assert_eq!(s.total_entries, 3);
1139 assert_eq!(s.unique_queries, 2);
1140 let p = s.overall_precision.expect("should have precision");
1142 assert!((p - 2.0 / 3.0).abs() < 1e-10);
1143 assert!((s.avg_confidence - (1.0 + 0.5 + 0.8) / 3.0).abs() < 1e-10);
1145 }
1146
1147 #[test]
1149 fn test_loop_stats_empty() {
1150 let fl = SemanticFeedbackLoop::new();
1151 let s = fl.loop_stats();
1152 assert_eq!(s.total_entries, 0);
1153 assert_eq!(s.unique_queries, 0);
1154 assert!(s.overall_precision.is_none());
1155 assert!((s.avg_confidence - 0.0).abs() < 1e-10);
1156 }
1157
1158 #[test]
1160 fn test_tick_advances_counter() {
1161 let mut fl = SemanticFeedbackLoop::new();
1162 fl.record("q1", "d1", FeedbackType::Relevant, 1.0);
1163 let tick_before = fl.entries.last().map(|e| e.tick);
1164
1165 fl.tick();
1166 fl.tick();
1167 fl.record("q1", "d2", FeedbackType::Relevant, 1.0);
1168 let tick_after = fl.entries.last().map(|e| e.tick);
1169
1170 assert_eq!(tick_before, Some(0));
1175 assert_eq!(tick_after, Some(3));
1176 }
1177
1178 #[test]
1180 fn test_confidence_clamped() {
1181 let mut fl = SemanticFeedbackLoop::new();
1182 fl.record("q1", "d1", FeedbackType::Relevant, 2.0);
1183 fl.record("q1", "d2", FeedbackType::Relevant, -1.0);
1184
1185 let s = fl.get_summary("q1").expect("summary should exist");
1186 assert!((s.avg_confidence - 0.5).abs() < 1e-10);
1188 }
1189
1190 #[test]
1192 fn test_partial_not_in_precision() {
1193 let mut fl = SemanticFeedbackLoop::new();
1194 fl.record("q1", "d1", FeedbackType::Relevant, 0.9);
1195 fl.record("q1", "d2", FeedbackType::PartiallyRelevant, 0.8);
1196
1197 let p = fl.precision_at_query("q1").expect("should have precision");
1199 assert!((p - 1.0).abs() < 1e-10);
1200 }
1201
1202 #[test]
1204 fn test_multiple_queries_precision_independence() {
1205 let mut fl = SemanticFeedbackLoop::new();
1206 fl.record("q1", "d1", FeedbackType::Relevant, 0.9);
1208 fl.record("q1", "d2", FeedbackType::Irrelevant, 0.8);
1209 fl.record("q2", "d3", FeedbackType::Relevant, 0.7);
1211 fl.record("q2", "d4", FeedbackType::Relevant, 0.6);
1212
1213 let p1 = fl.precision_at_query("q1").expect("q1 precision");
1214 let p2 = fl.precision_at_query("q2").expect("q2 precision");
1215 assert!((p1 - 0.5).abs() < 1e-10);
1216 assert!((p2 - 1.0).abs() < 1e-10);
1217 }
1218
1219 #[test]
1221 fn test_eviction_preserves_newest() {
1222 let mut fl = SemanticFeedbackLoop::with_max_entries(2);
1223 fl.record("q1", "old", FeedbackType::Relevant, 1.0);
1224 fl.record("q1", "mid", FeedbackType::Relevant, 1.0);
1225 fl.record("q1", "new", FeedbackType::Relevant, 1.0);
1226
1227 let docs = fl.relevant_docs("q1");
1228 assert_eq!(docs.len(), 2);
1229 assert!(!docs.contains(&"old".to_string()));
1230 assert!(docs.contains(&"mid".to_string()));
1231 assert!(docs.contains(&"new".to_string()));
1232 }
1233
1234 #[test]
1236 fn test_feedback_type_equality() {
1237 assert_eq!(FeedbackType::Relevant, FeedbackType::Relevant);
1238 assert_ne!(FeedbackType::Relevant, FeedbackType::Irrelevant);
1239 assert_ne!(FeedbackType::Irrelevant, FeedbackType::PartiallyRelevant);
1240 }
1241
1242 #[test]
1244 fn test_summary_all_relevant() {
1245 let mut fl = SemanticFeedbackLoop::new();
1246 fl.record("q1", "d1", FeedbackType::Relevant, 1.0);
1247 fl.record("q1", "d2", FeedbackType::Relevant, 0.9);
1248
1249 let s = fl.get_summary("q1").expect("summary");
1250 assert!((s.precision - 1.0).abs() < 1e-10);
1251 }
1252
1253 #[test]
1255 fn test_summary_all_irrelevant() {
1256 let mut fl = SemanticFeedbackLoop::new();
1257 fl.record("q1", "d1", FeedbackType::Irrelevant, 0.9);
1258 fl.record("q1", "d2", FeedbackType::Irrelevant, 0.8);
1259
1260 let s = fl.get_summary("q1").expect("summary");
1261 assert!((s.precision - 0.0).abs() < 1e-10);
1262 }
1263
1264 #[test]
1266 fn test_with_max_entries_constructor() {
1267 let fl = SemanticFeedbackLoop::with_max_entries(100);
1268 assert_eq!(fl.feedback_count(), 0);
1269 assert_eq!(fl.max_entries, 100);
1270 }
1271
1272 #[test]
1274 fn test_irrelevant_docs_unknown() {
1275 let fl = SemanticFeedbackLoop::new();
1276 assert!(fl.irrelevant_docs("nope").is_empty());
1277 }
1278
1279 #[test]
1281 fn test_large_scale_eviction() {
1282 let mut fl = SemanticFeedbackLoop::with_max_entries(10);
1283 for i in 0..100 {
1284 fl.record("q1", &format!("d{}", i), FeedbackType::Relevant, 0.5);
1285 }
1286 assert_eq!(fl.feedback_count(), 10);
1287
1288 let docs = fl.relevant_docs("q1");
1290 assert_eq!(docs.len(), 10);
1291 assert!(docs.contains(&"d90".to_string()));
1292 assert!(docs.contains(&"d99".to_string()));
1293 assert!(!docs.contains(&"d0".to_string()));
1294 }
1295
1296 #[test]
1298 fn test_clear_entries_does_not_affect_signals() {
1299 let mut fl = SemanticFeedbackLoop::new();
1300 fl.record_feedback(
1301 1,
1302 FeedbackSignal::Relevant {
1303 result_id: 10,
1304 rank: 0,
1305 },
1306 0,
1307 );
1308 fl.record("q1", "d1", FeedbackType::Relevant, 0.9);
1309
1310 fl.clear_entries();
1311 assert_eq!(fl.stats().relevant_count, 1);
1313 assert_eq!(fl.feedback_count(), 0);
1315 }
1316
1317 #[test]
1319 fn test_summary_partial_only_precision_zero() {
1320 let mut fl = SemanticFeedbackLoop::new();
1321 fl.record("q1", "d1", FeedbackType::PartiallyRelevant, 0.6);
1322 fl.record("q1", "d2", FeedbackType::PartiallyRelevant, 0.4);
1323
1324 let s = fl.get_summary("q1").expect("summary");
1325 assert_eq!(s.partial_count, 2);
1326 assert_eq!(s.relevant_count, 0);
1327 assert_eq!(s.irrelevant_count, 0);
1328 assert!((s.precision - 0.0).abs() < 1e-10);
1329 }
1330
1331 #[test]
1333 fn test_default_max_entries() {
1334 let fl = SemanticFeedbackLoop::new();
1335 assert_eq!(fl.max_entries, 50_000);
1336 }
1337
1338 #[test]
1340 fn test_feedback_loop_stats_clone_debug() {
1341 let s = FeedbackLoopStats {
1342 total_entries: 10,
1343 unique_queries: 3,
1344 overall_precision: Some(0.75),
1345 avg_confidence: 0.85,
1346 };
1347 let s2 = s.clone();
1348 assert_eq!(s2.total_entries, 10);
1349 let _ = format!("{:?}", s2);
1350 }
1351
1352 #[test]
1354 fn test_feedback_entry_clone() {
1355 let e = FeedbackEntry {
1356 query_id: "q1".to_string(),
1357 doc_id: "d1".to_string(),
1358 feedback: FeedbackType::Relevant,
1359 tick: 42,
1360 confidence: 0.95,
1361 };
1362 let e2 = e.clone();
1363 assert_eq!(e2.query_id, "q1");
1364 assert_eq!(e2.tick, 42);
1365 }
1366
1367 #[test]
1369 fn test_query_feedback_summary_clone() {
1370 let s = QueryFeedbackSummary {
1371 query_id: "q1".to_string(),
1372 relevant_count: 5,
1373 irrelevant_count: 2,
1374 partial_count: 1,
1375 avg_confidence: 0.8,
1376 precision: 5.0 / 7.0,
1377 };
1378 let s2 = s.clone();
1379 assert_eq!(s2.relevant_count, 5);
1380 assert!((s2.precision - 5.0 / 7.0).abs() < 1e-10);
1381 }
1382}