Skip to main content

ipfrs_semantic/
feedback_loop.rs

1//! Semantic Feedback Loop — relevance feedback collection and query re-ranking.
2//!
3//! Collects explicit and implicit relevance signals from users and uses them
4//! to adjust future search rankings through query expansion and score boosting.
5
6use std::collections::{HashMap, HashSet};
7
8// ---------------------------------------------------------------------------
9// FNV-1a helper (no external dep needed for a simple hash)
10// ---------------------------------------------------------------------------
11
12/// Compute the FNV-1a 64-bit hash of an arbitrary byte slice.
13pub 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
25/// Compute the query-id (FNV-1a hash) for an arbitrary query string.
26pub fn query_id_for(query_text: &str) -> u64 {
27    fnv1a_64(query_text.as_bytes())
28}
29
30// ---------------------------------------------------------------------------
31// FeedbackType — user-facing relevance judgment
32// ---------------------------------------------------------------------------
33
34/// Classification of a search result's relevance as judged by a user.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FeedbackType {
37    /// The result was relevant to the query.
38    Relevant,
39    /// The result was not relevant to the query.
40    Irrelevant,
41    /// The result was somewhat relevant but not a perfect match.
42    PartiallyRelevant,
43}
44
45// ---------------------------------------------------------------------------
46// FeedbackEntry — a single user-feedback record
47// ---------------------------------------------------------------------------
48
49/// A single feedback record submitted by a user for a query–document pair.
50#[derive(Debug, Clone)]
51pub struct FeedbackEntry {
52    /// Identifier of the query this feedback pertains to.
53    pub query_id: String,
54    /// Identifier of the document this feedback pertains to.
55    pub doc_id: String,
56    /// The user's relevance judgment.
57    pub feedback: FeedbackType,
58    /// Monotonic tick at which this entry was recorded.
59    pub tick: u64,
60    /// User-reported confidence in their judgment (0.0–1.0).
61    pub confidence: f64,
62}
63
64// ---------------------------------------------------------------------------
65// QueryFeedbackSummary — aggregated feedback for a single query
66// ---------------------------------------------------------------------------
67
68/// Aggregated feedback statistics for a single query.
69#[derive(Debug, Clone)]
70pub struct QueryFeedbackSummary {
71    /// The query this summary pertains to.
72    pub query_id: String,
73    /// Number of documents marked `Relevant`.
74    pub relevant_count: usize,
75    /// Number of documents marked `Irrelevant`.
76    pub irrelevant_count: usize,
77    /// Number of documents marked `PartiallyRelevant`.
78    pub partial_count: usize,
79    /// Mean confidence across all feedback entries for this query.
80    pub avg_confidence: f64,
81    /// Precision: `relevant / (relevant + irrelevant)`.  `0.0` when denominator is zero.
82    pub precision: f64,
83}
84
85// ---------------------------------------------------------------------------
86// FeedbackLoopStats — global loop statistics
87// ---------------------------------------------------------------------------
88
89/// Aggregate statistics across all feedback entries in the loop.
90#[derive(Debug, Clone)]
91pub struct FeedbackLoopStats {
92    /// Total number of feedback entries currently stored.
93    pub total_entries: usize,
94    /// Number of unique query IDs with at least one entry.
95    pub unique_queries: usize,
96    /// Overall precision across all queries (`None` if no relevant+irrelevant entries).
97    pub overall_precision: Option<f64>,
98    /// Mean confidence across all entries (`0.0` when empty).
99    pub avg_confidence: f64,
100}
101
102// ---------------------------------------------------------------------------
103// FeedbackSignal
104// ---------------------------------------------------------------------------
105
106/// A single relevance signal emitted by a user (explicit or implicit).
107#[derive(Clone, Debug, PartialEq)]
108pub enum FeedbackSignal {
109    /// The user explicitly confirmed that a result was relevant.
110    Relevant { result_id: u64, rank: usize },
111    /// The user explicitly marked a result as not relevant.
112    Irrelevant { result_id: u64, rank: usize },
113    /// Implicit positive signal: the user clicked a result and dwelt on it.
114    Clicked {
115        result_id: u64,
116        rank: usize,
117        dwell_ms: u64,
118    },
119}
120
121impl FeedbackSignal {
122    /// Return the `result_id` carried by any variant.
123    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    /// Return the rank of the result that triggered this signal.
132    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    /// Return true if the signal conveys a positive relevance judgment.
141    pub fn is_positive(&self) -> bool {
142        matches!(self, Self::Relevant { .. } | Self::Clicked { .. })
143    }
144}
145
146// ---------------------------------------------------------------------------
147// QueryFeedback
148// ---------------------------------------------------------------------------
149
150/// All feedback signals collected for a single query.
151#[derive(Clone, Debug)]
152pub struct QueryFeedback {
153    /// FNV-1a hash of the query text.
154    pub query_id: u64,
155    /// Signals gathered for this query (in insertion order).
156    pub signals: Vec<FeedbackSignal>,
157    /// Unix timestamp (seconds) when the first signal was collected.
158    pub collected_at_secs: u64,
159}
160
161impl QueryFeedback {
162    /// Create a new `QueryFeedback` with no signals yet.
163    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    /// Aggregate relevance score across all collected signals.
172    ///
173    /// Scoring:
174    /// * `Relevant`   → +1.0
175    /// * `Irrelevant` → -0.5
176    /// * `Clicked`    → +0.3 (dwell time is ignored here; see `BoostRecord`)
177    ///
178    /// The final value is clamped to `[-10.0, 10.0]`.
179    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    /// Return the `result_id`s of all positive signals (`Relevant` + `Clicked`).
193    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// ---------------------------------------------------------------------------
203// BoostRecord
204// ---------------------------------------------------------------------------
205
206/// Cumulative boost information for a single result document.
207#[derive(Clone, Debug)]
208pub struct BoostRecord {
209    /// The result this record belongs to.
210    pub result_id: u64,
211    /// Cumulative sum of all boost contributions from feedback signals.
212    pub boost_score: f64,
213    /// How many signals have contributed to `boost_score`.
214    pub feedback_count: u64,
215}
216
217impl BoostRecord {
218    /// Create a new, empty `BoostRecord`.
219    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    /// The per-signal average boost: `boost_score / max(feedback_count, 1)`.
228    pub fn effective_boost(&self) -> f64 {
229        self.boost_score / (self.feedback_count.max(1) as f64)
230    }
231}
232
233// ---------------------------------------------------------------------------
234// FeedbackStats
235// ---------------------------------------------------------------------------
236
237/// Aggregate statistics across all queries and signals.
238#[derive(Clone, Debug, Default)]
239pub struct FeedbackStats {
240    /// Total number of distinct queries that have received at least one signal.
241    pub total_queries: usize,
242    /// Grand total of signals recorded (all types).
243    pub total_signals: u64,
244    /// Number of `Relevant` signals recorded.
245    pub relevant_count: u64,
246    /// Number of `Irrelevant` signals recorded.
247    pub irrelevant_count: u64,
248    /// Number of `Clicked` signals recorded.
249    pub clicked_count: u64,
250}
251
252impl FeedbackStats {
253    /// Fraction of positive signals: `(relevant + clicked) / max(total_signals, 1)`.
254    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// ---------------------------------------------------------------------------
261// SemanticFeedbackLoop
262// ---------------------------------------------------------------------------
263
264/// Core feedback-loop engine.
265///
266/// Ingests relevance signals emitted during search sessions and uses them to
267/// boost (or suppress) future rankings of individual results.
268#[derive(Debug)]
269pub struct SemanticFeedbackLoop {
270    /// Per-query feedback records, keyed by `query_id`.
271    pub feedback: HashMap<u64, QueryFeedback>,
272    /// Per-result cumulative boost records, keyed by `result_id`.
273    pub boosts: HashMap<u64, BoostRecord>,
274    /// Global statistics.
275    pub stats: FeedbackStats,
276
277    // --- user-facing feedback entries ---
278    /// Ordered list of user feedback entries.
279    entries: Vec<FeedbackEntry>,
280    /// Monotonic tick counter for ordering entries.
281    current_tick: u64,
282    /// Maximum number of entries retained before oldest are evicted.
283    max_entries: usize,
284}
285
286impl SemanticFeedbackLoop {
287    /// Create a new, empty feedback loop with the default capacity of 50 000 entries.
288    pub fn new() -> Self {
289        Self::with_max_entries(50_000)
290    }
291
292    /// Create a new, empty feedback loop with the given maximum entry capacity.
293    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    // ------------------------------------------------------------------
305    // Signal recording
306    // ------------------------------------------------------------------
307
308    /// Record a single relevance signal for the given query.
309    ///
310    /// * Updates the per-query `QueryFeedback` record.
311    /// * Updates the per-result `BoostRecord`.
312    /// * Updates global `FeedbackStats`.
313    pub fn record_feedback(&mut self, query_id: u64, signal: FeedbackSignal, now_secs: u64) {
314        // -------- stats ------------------------------------------------
315        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        // -------- boost record -----------------------------------------
323        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        // -------- query feedback ---------------------------------------
333        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    /// Compute the boost delta contributed by a single signal to a `BoostRecord`.
346    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                // Base +0.3, scaled by dwell: +0.3 * (1 + dwell_s)
352                // No explicit cap is defined in the spec; we keep it unbounded
353                // here — `effective_boost` normalises across signal count.
354                let dwell_s = *dwell_ms as f64 / 1000.0;
355                0.3 * (1.0 + dwell_s)
356            }
357        }
358    }
359
360    // ------------------------------------------------------------------
361    // Score application
362    // ------------------------------------------------------------------
363
364    /// Re-score and re-rank a list of `(result_id, score)` pairs using boosts.
365    ///
366    /// For each entry: `new_score = score * (1.0 + effective_boost().max(-0.9))`
367    ///
368    /// The result list is returned sorted by `new_score` descending.
369    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        // Sort descending by new score; stable sort to preserve tie ordering.
384        boosted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
385        boosted
386    }
387
388    // ------------------------------------------------------------------
389    // Accessors
390    // ------------------------------------------------------------------
391
392    /// Return the top-`k` result IDs sorted by `effective_boost` descending.
393    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    /// Return the positive `result_id`s recorded for a specific query.
404    ///
405    /// Returns an empty `Vec` if the query has not been seen.
406    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    /// Reference to the global statistics.
414    pub fn stats(&self) -> &FeedbackStats {
415        &self.stats
416    }
417
418    // ------------------------------------------------------------------
419    // User-facing feedback entry API
420    // ------------------------------------------------------------------
421
422    /// Record a user feedback entry.  If the loop is at capacity the oldest
423    /// entry is evicted before inserting the new one.
424    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    /// Aggregate feedback for a specific query.  Returns `None` if the query
448    /// has no recorded entries.
449    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    /// Return document IDs marked `Relevant` for the given query.
491    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    /// Return document IDs marked `Irrelevant` for the given query.
500    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    /// Precision for a single query: `relevant / (relevant + irrelevant)`.
509    /// Returns `None` when the query has no relevant or irrelevant entries.
510    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    /// Overall precision across all queries: `total_relevant / (total_relevant + total_irrelevant)`.
531    /// Returns `None` when there are no relevant or irrelevant entries at all.
532    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    /// Total number of user feedback entries currently stored.
551    pub fn feedback_count(&self) -> usize {
552        self.entries.len()
553    }
554
555    /// Unique query IDs that have at least one feedback entry.
556    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    /// Advance the internal tick counter by one.
568    pub fn tick(&mut self) {
569        self.current_tick += 1;
570    }
571
572    /// Remove all user feedback entries and reset the tick counter.
573    pub fn clear_entries(&mut self) {
574        self.entries.clear();
575        self.current_tick = 0;
576    }
577
578    /// Compute aggregate [`FeedbackLoopStats`] from the current entries.
579    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// ---------------------------------------------------------------------------
607// Tests
608// ---------------------------------------------------------------------------
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    // ------------------------------------------------------------------
615    // Helper builders
616    // ------------------------------------------------------------------
617
618    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    // ------------------------------------------------------------------
635    // Test 1: record Relevant increments relevant_count
636    // ------------------------------------------------------------------
637    #[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    // ------------------------------------------------------------------
648    // Test 2: record Irrelevant increments irrelevant_count
649    // ------------------------------------------------------------------
650    #[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    // ------------------------------------------------------------------
660    // Test 3: record Clicked increments clicked_count
661    // ------------------------------------------------------------------
662    #[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    // ------------------------------------------------------------------
672    // Test 4: relevance_score — Relevant contributes +1.0
673    // ------------------------------------------------------------------
674    #[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    // ------------------------------------------------------------------
683    // Test 5: relevance_score — Irrelevant contributes -0.5
684    // ------------------------------------------------------------------
685    #[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    // ------------------------------------------------------------------
694    // Test 6: relevance_score — Clicked contributes +0.3
695    // ------------------------------------------------------------------
696    #[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    // ------------------------------------------------------------------
704    // Test 7: relevance_score — mixed signals accumulate correctly
705    // ------------------------------------------------------------------
706    #[test]
707    fn test_relevance_score_mixed() {
708        let mut qf = QueryFeedback::new(42, 0);
709        qf.signals.push(relevant(1, 0)); // +1.0
710        qf.signals.push(irrelevant(2, 1)); // -0.5
711        qf.signals.push(clicked(3, 2, 0)); // +0.3
712        let expected = 1.0 - 0.5 + 0.3;
713        assert!((qf.relevance_score() - expected).abs() < 1e-10);
714    }
715
716    // ------------------------------------------------------------------
717    // Test 8: relevance_score — clamped to [-10, 10]
718    // ------------------------------------------------------------------
719    #[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    // ------------------------------------------------------------------
738    // Test 9: positive_ids returns only Relevant + Clicked ids
739    // ------------------------------------------------------------------
740    #[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    // ------------------------------------------------------------------
754    // Test 10: apply_boosts re-sorts results by boosted score
755    // ------------------------------------------------------------------
756    #[test]
757    fn test_apply_boosts_resorts() {
758        let mut fl = SemanticFeedbackLoop::new();
759        // Result 99 gets a positive boost
760        fl.record_feedback(1, relevant(99, 1), 0);
761
762        // Initially result 100 has a higher raw score
763        let results = vec![(100u64, 0.9), (99u64, 0.5)];
764        let boosted = fl.apply_boosts(&results);
765
766        // result 99 should now rank first (score * (1 + 1.0) = 1.0 > 0.9)
767        assert_eq!(boosted[0].0, 99);
768        assert_eq!(boosted[1].0, 100);
769    }
770
771    // ------------------------------------------------------------------
772    // Test 11: apply_boosts — result with no boost record is unchanged
773    // ------------------------------------------------------------------
774    #[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        // Order must be preserved (descending by score, no boost applied)
780        assert_eq!(boosted[0].0, 1);
781        assert!((boosted[0].1 - 0.8).abs() < 1e-10);
782    }
783
784    // ------------------------------------------------------------------
785    // Test 12: effective_boost normalises by feedback_count
786    // ------------------------------------------------------------------
787    #[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        // feedback_count == 0 → max(0,1) = 1
799        assert!((r.effective_boost() - 0.0).abs() < 1e-10);
800    }
801
802    // ------------------------------------------------------------------
803    // Test 13: top_boosted_ids returns top-k by effective_boost desc
804    // ------------------------------------------------------------------
805    #[test]
806    fn test_top_boosted_ids() {
807        let mut fl = SemanticFeedbackLoop::new();
808        fl.record_feedback(1, relevant(10, 0), 0); // boost = +1.0
809        fl.record_feedback(1, relevant(20, 1), 0); // boost = +1.0
810        fl.record_feedback(1, relevant(20, 1), 0); // boost += 1.0 → 2.0 total, eff = 1.0
811        fl.record_feedback(1, irrelevant(30, 2), 0); // boost = -0.5
812
813        let top = fl.top_boosted_ids(2);
814        assert_eq!(top.len(), 2);
815        // 10 and 20 should be the top 2 (both effective 1.0, 30 is negative)
816        assert!(!top.contains(&30));
817    }
818
819    // ------------------------------------------------------------------
820    // Test 14: signal_ratio
821    // ------------------------------------------------------------------
822    #[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        // ratio = (1 + 1) / 3
829        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        // total_signals = 0 → max(0,1) = 1, ratio = 0/1 = 0
837        assert!((fl.stats().signal_ratio() - 0.0).abs() < 1e-10);
838    }
839
840    // ------------------------------------------------------------------
841    // Test 15: multiple signals same query accumulate
842    // ------------------------------------------------------------------
843    #[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); // still one unique query
852        assert_eq!(fl.stats().total_signals, 3);
853    }
854
855    // ------------------------------------------------------------------
856    // Test 16: boost from Clicked scales with dwell time
857    // ------------------------------------------------------------------
858    #[test]
859    fn test_boost_clicked_scales_with_dwell() {
860        let mut fl = SemanticFeedbackLoop::new();
861        // dwell = 1000 ms → delta = 0.3 * (1 + 1.0) = 0.6
862        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        // dwell = 0 ms → delta = 0.3 * (1 + 0.0) = 0.3
871        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    // ------------------------------------------------------------------
877    // Test 17: stats totals are correct across mixed queries
878    // ------------------------------------------------------------------
879    #[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); // 2nd signal for query 1
886
887        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    // ------------------------------------------------------------------
896    // Test 18: positive_ids_for_query — unknown query returns empty
897    // ------------------------------------------------------------------
898    #[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    // ------------------------------------------------------------------
905    // Test 19: positive_ids_for_query — returns correct ids
906    // ------------------------------------------------------------------
907    #[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    // ------------------------------------------------------------------
922    // Test 20: query_id_for produces consistent FNV-1a hashes
923    // ------------------------------------------------------------------
924    #[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    // ------------------------------------------------------------------
934    // Test 21: apply_boosts with Irrelevant reduces score
935    // ------------------------------------------------------------------
936    #[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        // effective_boost = -0.5, multiplier = 1 + max(-0.5, -0.9) = 0.5
941        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    // ======================================================================
947    // User-facing feedback entry API tests (22–50+)
948    // ======================================================================
949
950    // Test 22: record adds an entry
951    #[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 23: record multiple entries
959    #[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 24: get_summary aggregation
969    #[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        // precision = 1 / (1+1) = 0.5
981        assert!((s.precision - 0.5).abs() < 1e-10);
982        // avg confidence = (1.0 + 0.5 + 0.8) / 3
983        assert!((s.avg_confidence - (1.0 + 0.5 + 0.8) / 3.0).abs() < 1e-10);
984    }
985
986    // Test 25: get_summary returns None for unknown query
987    #[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 26: precision_at_query
994    #[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        // precision = 2 / (2+1) = 2/3
1001        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 27: precision_at_query — only partial entries gives None
1006    #[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 28: precision_at_query — unknown query
1014    #[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 29: overall_precision
1021    #[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        // overall = 2 / (2+1) = 2/3
1028        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 30: overall_precision None on empty
1035    #[test]
1036    fn test_overall_precision_empty() {
1037        let fl = SemanticFeedbackLoop::new();
1038        assert!(fl.overall_precision().is_none());
1039    }
1040
1041    // Test 31: overall_precision None on partial-only
1042    #[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 32: relevant_docs
1050    #[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 33: irrelevant_docs
1064    #[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 34: relevant_docs empty for unknown query
1078    #[test]
1079    fn test_relevant_docs_unknown() {
1080        let fl = SemanticFeedbackLoop::new();
1081        assert!(fl.relevant_docs("nope").is_empty());
1082    }
1083
1084    // Test 35: max_entries eviction
1085    #[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        // This should evict d1
1094        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 36: clear_entries
1103    #[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 37: queries_with_feedback returns unique query IDs
1116    #[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); // duplicate q1
1122
1123        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 38: loop_stats accuracy
1130    #[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        // overall precision = 2 / (2+1) = 2/3
1141        let p = s.overall_precision.expect("should have precision");
1142        assert!((p - 2.0 / 3.0).abs() < 1e-10);
1143        // avg confidence = (1.0 + 0.5 + 0.8) / 3
1144        assert!((s.avg_confidence - (1.0 + 0.5 + 0.8) / 3.0).abs() < 1e-10);
1145    }
1146
1147    // Test 39: loop_stats on empty loop
1148    #[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 40: tick advances counter
1159    #[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        // record increments tick internally, but we also called tick() twice
1171        // first record: tick=0, then current_tick=1
1172        // tick(): current_tick=2, tick(): current_tick=3
1173        // second record: tick=3, then current_tick=4
1174        assert_eq!(tick_before, Some(0));
1175        assert_eq!(tick_after, Some(3));
1176    }
1177
1178    // Test 41: confidence is clamped to [0, 1]
1179    #[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        // (1.0 + 0.0) / 2 = 0.5
1187        assert!((s.avg_confidence - 0.5).abs() < 1e-10);
1188    }
1189
1190    // Test 42: partial relevance does not affect precision
1191    #[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        // precision = 1 / (1+0) = 1.0 (partial is excluded)
1198        let p = fl.precision_at_query("q1").expect("should have precision");
1199        assert!((p - 1.0).abs() < 1e-10);
1200    }
1201
1202    // Test 43: multiple queries precision independence
1203    #[test]
1204    fn test_multiple_queries_precision_independence() {
1205        let mut fl = SemanticFeedbackLoop::new();
1206        // q1: 1 relevant, 1 irrelevant → 0.5
1207        fl.record("q1", "d1", FeedbackType::Relevant, 0.9);
1208        fl.record("q1", "d2", FeedbackType::Irrelevant, 0.8);
1209        // q2: all relevant → 1.0
1210        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 44: eviction preserves newest entries
1220    #[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 45: FeedbackType equality
1235    #[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 46: get_summary precision with all relevant
1243    #[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 47: get_summary precision with all irrelevant
1254    #[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 48: with_max_entries constructor
1265    #[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 49: irrelevant_docs for unknown query
1273    #[test]
1274    fn test_irrelevant_docs_unknown() {
1275        let fl = SemanticFeedbackLoop::new();
1276        assert!(fl.irrelevant_docs("nope").is_empty());
1277    }
1278
1279    // Test 50: large-scale eviction maintains count
1280    #[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        // Only the last 10 docs should remain (d90..d99)
1289        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 51: clear_entries does not affect signal-based stats
1297    #[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        // Signal-based stats remain intact
1312        assert_eq!(fl.stats().relevant_count, 1);
1313        // But entries are gone
1314        assert_eq!(fl.feedback_count(), 0);
1315    }
1316
1317    // Test 52: summary with only partial entries has precision 0.0
1318    #[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 53: default constructor uses 50_000 max_entries
1332    #[test]
1333    fn test_default_max_entries() {
1334        let fl = SemanticFeedbackLoop::new();
1335        assert_eq!(fl.max_entries, 50_000);
1336    }
1337
1338    // Test 54: FeedbackLoopStats clone and debug
1339    #[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 55: FeedbackEntry clone
1353    #[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 56: QueryFeedbackSummary clone
1368    #[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}