Skip to main content

ipfrs_semantic/
relevance_feedback.rs

1//! Rocchio-style semantic relevance feedback for iterative query refinement.
2//!
3//! This module implements the classic Rocchio algorithm adapted for dense vector embeddings.
4//! Users mark search results as relevant or non-relevant; the algorithm shifts the query
5//! embedding toward relevant documents and away from non-relevant ones.
6
7use std::collections::HashMap;
8
9// ---------------------------------------------------------------------------
10// Public types
11// ---------------------------------------------------------------------------
12
13/// Classification label assigned to a retrieved document by the user.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum FeedbackLabel {
16    /// Document is relevant to the user's information need.
17    Relevant,
18    /// Document is not relevant to the user's information need.
19    NonRelevant,
20    /// Document is ignored in the Rocchio computation.
21    Neutral,
22}
23
24/// A single feedback item pairing a document with its user-assigned label.
25#[derive(Clone, Debug)]
26pub struct FeedbackItem {
27    /// Opaque document identifier.
28    pub doc_id: u64,
29    /// Dense embedding vector for this document.
30    pub embedding: Vec<f32>,
31    /// User-assigned relevance label.
32    pub label: FeedbackLabel,
33}
34
35/// Rocchio algorithm hyper-parameters.
36#[derive(Clone, Debug)]
37pub struct RocchioConfig {
38    /// Weight applied to the current query vector (α).
39    pub alpha: f32,
40    /// Weight applied to the centroid of relevant documents (β).
41    pub beta: f32,
42    /// Weight applied to the centroid of non-relevant documents (γ).
43    pub gamma: f32,
44    /// When `true` the updated query is L2-normalised before being stored.
45    pub normalize_result: bool,
46}
47
48impl Default for RocchioConfig {
49    fn default() -> Self {
50        Self {
51            alpha: 1.0,
52            beta: 0.75,
53            gamma: 0.25,
54            normalize_result: true,
55        }
56    }
57}
58
59/// State maintained for a single interactive feedback session.
60#[derive(Clone, Debug)]
61pub struct FeedbackSession {
62    /// Unique identifier for this session.
63    pub session_id: u64,
64    /// The query vector supplied when the session was created.
65    pub original_query: Vec<f32>,
66    /// The query vector after the most recent Rocchio update (or equal to
67    /// `original_query` when no rounds have been applied yet).
68    pub current_query: Vec<f32>,
69    /// Number of times [`SemanticRelevanceFeedback::apply_rocchio`] has been
70    /// called successfully for this session.
71    pub rounds: u32,
72    /// All feedback items accumulated across every round.
73    pub feedback_items: Vec<FeedbackItem>,
74}
75
76impl FeedbackSession {
77    /// Number of items labelled [`FeedbackLabel::Relevant`].
78    pub fn relevant_count(&self) -> usize {
79        self.feedback_items
80            .iter()
81            .filter(|i| i.label == FeedbackLabel::Relevant)
82            .count()
83    }
84
85    /// Number of items labelled [`FeedbackLabel::NonRelevant`].
86    pub fn non_relevant_count(&self) -> usize {
87        self.feedback_items
88            .iter()
89            .filter(|i| i.label == FeedbackLabel::NonRelevant)
90            .count()
91    }
92
93    /// Cosine distance (1 − cosine_similarity) between the original query and
94    /// the current query.  Returns `0.0` when no rounds have been applied.
95    pub fn query_shift(&self) -> f32 {
96        1.0 - cosine_similarity(&self.original_query, &self.current_query)
97    }
98}
99
100/// Aggregate statistics across all sessions managed by a
101/// [`SemanticRelevanceFeedback`] instance.
102#[derive(Clone, Debug)]
103pub struct FeedbackStats {
104    /// Number of sessions ever created.
105    pub total_sessions: usize,
106    /// Total Rocchio rounds applied across all sessions.
107    pub total_rounds: u64,
108    /// Average number of rounds per session (`NaN` when `total_sessions == 0`).
109    pub avg_rounds_per_session: f64,
110}
111
112// ---------------------------------------------------------------------------
113// Core engine
114// ---------------------------------------------------------------------------
115
116/// Engine that manages multiple interactive relevance-feedback sessions and
117/// applies the Rocchio algorithm to refine query embeddings.
118pub struct SemanticRelevanceFeedback {
119    /// Active and completed sessions keyed by session id.
120    pub sessions: HashMap<u64, FeedbackSession>,
121    /// Counter used to assign the next session id (monotonically increasing).
122    pub next_session_id: u64,
123    /// Rocchio hyper-parameters shared across all sessions.
124    pub config: RocchioConfig,
125    /// Running total of Rocchio rounds applied across all sessions.
126    pub total_rounds: u64,
127}
128
129impl SemanticRelevanceFeedback {
130    /// Create a new engine with the given configuration.
131    pub fn new(config: RocchioConfig) -> Self {
132        Self {
133            sessions: HashMap::new(),
134            next_session_id: 1,
135            config,
136            total_rounds: 0,
137        }
138    }
139
140    /// Start a new feedback session for `query`.
141    ///
142    /// Returns the freshly allocated session id.
143    pub fn create_session(&mut self, query: Vec<f32>) -> u64 {
144        let id = self.next_session_id;
145        self.next_session_id += 1;
146        let session = FeedbackSession {
147            session_id: id,
148            original_query: query.clone(),
149            current_query: query,
150            rounds: 0,
151            feedback_items: Vec::new(),
152        };
153        self.sessions.insert(id, session);
154        id
155    }
156
157    /// Append `item` to the feedback list of the session identified by
158    /// `session_id`.
159    ///
160    /// Returns `false` when no such session exists.
161    pub fn add_feedback(&mut self, session_id: u64, item: FeedbackItem) -> bool {
162        match self.sessions.get_mut(&session_id) {
163            Some(session) => {
164                session.feedback_items.push(item);
165                true
166            }
167            None => false,
168        }
169    }
170
171    /// Apply one Rocchio update round to the session identified by
172    /// `session_id`.
173    ///
174    /// The formula is:
175    /// ```text
176    /// new_query = α·q + β·centroid(R) − γ·centroid(NR)
177    /// ```
178    /// where the β or γ terms are omitted when the corresponding document set
179    /// is empty.
180    ///
181    /// Returns `Some(new_query)` on success, or `None` when `session_id` is
182    /// unknown.
183    pub fn apply_rocchio(&mut self, session_id: u64) -> Option<Vec<f32>> {
184        let session = self.sessions.get_mut(&session_id)?;
185
186        let dim = session.current_query.len();
187        if dim == 0 {
188            return Some(Vec::new());
189        }
190
191        // --- Collect relevant / non-relevant embeddings ---
192        let relevant: Vec<&Vec<f32>> = session
193            .feedback_items
194            .iter()
195            .filter(|i| i.label == FeedbackLabel::Relevant)
196            .map(|i| &i.embedding)
197            .collect();
198
199        let non_relevant: Vec<&Vec<f32>> = session
200            .feedback_items
201            .iter()
202            .filter(|i| i.label == FeedbackLabel::NonRelevant)
203            .map(|i| &i.embedding)
204            .collect();
205
206        // --- Compute centroids ---
207        let relevant_centroid = compute_centroid(&relevant, dim);
208        let non_relevant_centroid = compute_centroid(&non_relevant, dim);
209
210        // --- Rocchio update ---
211        let alpha = self.config.alpha;
212        let beta = self.config.beta;
213        let gamma = self.config.gamma;
214        let normalize = self.config.normalize_result;
215
216        let mut new_query: Vec<f32> = session.current_query.iter().map(|&q| alpha * q).collect();
217
218        // β term — skipped when there are no relevant docs (centroid is all zeros)
219        if !relevant.is_empty() {
220            for (nq, rc) in new_query.iter_mut().zip(relevant_centroid.iter()) {
221                *nq += beta * rc;
222            }
223        }
224
225        // γ term — skipped when there are no non-relevant docs
226        if !non_relevant.is_empty() {
227            for (nq, nc) in new_query.iter_mut().zip(non_relevant_centroid.iter()) {
228                *nq -= gamma * nc;
229            }
230        }
231
232        // --- Optional L2 normalisation ---
233        if normalize {
234            l2_normalize(&mut new_query);
235        }
236
237        // --- Persist updates ---
238        session.current_query = new_query.clone();
239        session.rounds += 1;
240        self.total_rounds += 1;
241
242        Some(new_query)
243    }
244
245    /// Retrieve an immutable reference to a session.
246    pub fn session(&self, session_id: u64) -> Option<&FeedbackSession> {
247        self.sessions.get(&session_id)
248    }
249
250    /// Compute aggregate statistics across all sessions.
251    pub fn stats(&self) -> FeedbackStats {
252        let total_sessions = self.sessions.len();
253        let total_rounds = self.total_rounds;
254        let avg_rounds_per_session = if total_sessions == 0 {
255            f64::NAN
256        } else {
257            total_rounds as f64 / total_sessions as f64
258        };
259        FeedbackStats {
260            total_sessions,
261            total_rounds,
262            avg_rounds_per_session,
263        }
264    }
265}
266
267// ---------------------------------------------------------------------------
268// Mathematical helpers
269// ---------------------------------------------------------------------------
270
271/// Compute the element-wise mean of `vecs`.  Returns a zero vector of length
272/// `dim` when `vecs` is empty.
273fn compute_centroid(vecs: &[&Vec<f32>], dim: usize) -> Vec<f32> {
274    if vecs.is_empty() {
275        return vec![0.0f32; dim];
276    }
277    let n = vecs.len() as f32;
278    let mut centroid = vec![0.0f32; dim];
279    for v in vecs {
280        for (c, &x) in centroid.iter_mut().zip(v.iter()) {
281            *c += x;
282        }
283    }
284    for c in centroid.iter_mut() {
285        *c /= n;
286    }
287    centroid
288}
289
290/// L2-normalise `v` in-place.  A zero vector is left unchanged.
291fn l2_normalize(v: &mut [f32]) {
292    let norm: f32 = v.iter().map(|&x| x * x).sum::<f32>().sqrt();
293    if norm > f32::EPSILON {
294        for x in v.iter_mut() {
295            *x /= norm;
296        }
297    }
298}
299
300/// Cosine similarity between two vectors.  Returns `1.0` when either vector
301/// has zero magnitude (treats them as identical to avoid division by zero).
302pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
303    if a.len() != b.len() || a.is_empty() {
304        return 1.0;
305    }
306    let dot: f32 = a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum();
307    let mag_a: f32 = a.iter().map(|&x| x * x).sum::<f32>().sqrt();
308    let mag_b: f32 = b.iter().map(|&x| x * x).sum::<f32>().sqrt();
309    if mag_a < f32::EPSILON || mag_b < f32::EPSILON {
310        return 1.0;
311    }
312    (dot / (mag_a * mag_b)).clamp(-1.0, 1.0)
313}
314
315// ---------------------------------------------------------------------------
316// Tests
317// ---------------------------------------------------------------------------
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    fn default_engine() -> SemanticRelevanceFeedback {
324        SemanticRelevanceFeedback::new(RocchioConfig::default())
325    }
326
327    fn unit_vec(dim: usize, idx: usize) -> Vec<f32> {
328        let mut v = vec![0.0f32; dim];
329        v[idx] = 1.0;
330        v
331    }
332
333    // 1. new() starts empty
334    #[test]
335    fn test_new_starts_empty() {
336        let engine = default_engine();
337        assert!(engine.sessions.is_empty());
338        assert_eq!(engine.total_rounds, 0);
339        assert_eq!(engine.next_session_id, 1);
340    }
341
342    // 2. create_session returns incrementing ids
343    #[test]
344    fn test_create_session_incrementing_ids() {
345        let mut engine = default_engine();
346        let id1 = engine.create_session(vec![1.0, 0.0]);
347        let id2 = engine.create_session(vec![0.0, 1.0]);
348        let id3 = engine.create_session(vec![0.5, 0.5]);
349        assert_eq!(id1, 1);
350        assert_eq!(id2, 2);
351        assert_eq!(id3, 3);
352    }
353
354    // 3. create_session stores original and current query
355    #[test]
356    fn test_create_session_stores_queries() {
357        let mut engine = default_engine();
358        let q = vec![0.3f32, 0.7, 0.0];
359        let id = engine.create_session(q.clone());
360        let session = engine.session(id).expect("session must exist");
361        assert_eq!(session.original_query, q);
362        assert_eq!(session.current_query, q);
363    }
364
365    // 4. add_feedback appends item
366    #[test]
367    fn test_add_feedback_appends() {
368        let mut engine = default_engine();
369        let id = engine.create_session(vec![1.0, 0.0]);
370        let item = FeedbackItem {
371            doc_id: 42,
372            embedding: vec![0.8, 0.2],
373            label: FeedbackLabel::Relevant,
374        };
375        assert!(engine.add_feedback(id, item));
376        let session = engine.session(id).expect("test: session must exist");
377        assert_eq!(session.feedback_items.len(), 1);
378        assert_eq!(session.feedback_items[0].doc_id, 42);
379    }
380
381    // 5. add_feedback returns false for unknown session
382    #[test]
383    fn test_add_feedback_unknown_session() {
384        let mut engine = default_engine();
385        let item = FeedbackItem {
386            doc_id: 1,
387            embedding: vec![1.0],
388            label: FeedbackLabel::Relevant,
389        };
390        assert!(!engine.add_feedback(999, item));
391    }
392
393    // 6. apply_rocchio updates current_query
394    #[test]
395    fn test_apply_rocchio_updates_current_query() {
396        let mut engine = SemanticRelevanceFeedback::new(RocchioConfig {
397            normalize_result: false,
398            ..RocchioConfig::default()
399        });
400        let id = engine.create_session(vec![1.0, 0.0]);
401        engine.add_feedback(
402            id,
403            FeedbackItem {
404                doc_id: 1,
405                embedding: vec![0.0, 1.0],
406                label: FeedbackLabel::Relevant,
407            },
408        );
409        let new_q = engine.apply_rocchio(id).expect("must return Some");
410        let session = engine
411            .session(id)
412            .expect("test: session must exist after rocchio");
413        assert_eq!(session.current_query, new_q);
414        // alpha*[1,0] + beta*[0,1] = [1.0, 0.75]
415        assert!((new_q[0] - 1.0).abs() < 1e-5);
416        assert!((new_q[1] - 0.75).abs() < 1e-5);
417    }
418
419    // 7. apply_rocchio with no relevant docs (beta skipped)
420    #[test]
421    fn test_apply_rocchio_no_relevant_docs() {
422        let mut engine = SemanticRelevanceFeedback::new(RocchioConfig {
423            normalize_result: false,
424            ..RocchioConfig::default()
425        });
426        let id = engine.create_session(vec![1.0, 0.0]);
427        engine.add_feedback(
428            id,
429            FeedbackItem {
430                doc_id: 1,
431                embedding: vec![0.0, 1.0],
432                label: FeedbackLabel::NonRelevant,
433            },
434        );
435        let new_q = engine.apply_rocchio(id).expect("Some");
436        // alpha*[1,0] - gamma*[0,1] = [1.0, -0.25]
437        assert!((new_q[0] - 1.0).abs() < 1e-5);
438        assert!((new_q[1] - (-0.25)).abs() < 1e-5);
439    }
440
441    // 8. apply_rocchio with no non-relevant docs (gamma skipped)
442    #[test]
443    fn test_apply_rocchio_no_non_relevant_docs() {
444        let mut engine = SemanticRelevanceFeedback::new(RocchioConfig {
445            normalize_result: false,
446            ..RocchioConfig::default()
447        });
448        let id = engine.create_session(vec![1.0, 0.0]);
449        engine.add_feedback(
450            id,
451            FeedbackItem {
452                doc_id: 1,
453                embedding: vec![0.0, 1.0],
454                label: FeedbackLabel::Relevant,
455            },
456        );
457        let new_q = engine.apply_rocchio(id).expect("Some");
458        // alpha*[1,0] + beta*[0,1] = [1.0, 0.75]
459        assert!((new_q[0] - 1.0).abs() < 1e-5);
460        assert!((new_q[1] - 0.75).abs() < 1e-5);
461    }
462
463    // 9. apply_rocchio increments rounds
464    #[test]
465    fn test_apply_rocchio_increments_rounds() {
466        let mut engine = default_engine();
467        let id = engine.create_session(vec![1.0, 0.0]);
468        assert_eq!(
469            engine
470                .session(id)
471                .expect("test: session must exist before round")
472                .rounds,
473            0
474        );
475        engine.apply_rocchio(id);
476        assert_eq!(
477            engine
478                .session(id)
479                .expect("test: session must exist after round 1")
480                .rounds,
481            1
482        );
483        engine.apply_rocchio(id);
484        assert_eq!(
485            engine
486                .session(id)
487                .expect("test: session must exist after round 2")
488                .rounds,
489            2
490        );
491    }
492
493    // 10. apply_rocchio returns None for unknown session
494    #[test]
495    fn test_apply_rocchio_none_for_unknown() {
496        let mut engine = default_engine();
497        assert!(engine.apply_rocchio(42).is_none());
498    }
499
500    // 11. relevant_count correct
501    #[test]
502    fn test_relevant_count() {
503        let mut engine = default_engine();
504        let id = engine.create_session(vec![1.0]);
505        for _ in 0..3 {
506            engine.add_feedback(
507                id,
508                FeedbackItem {
509                    doc_id: 0,
510                    embedding: vec![1.0],
511                    label: FeedbackLabel::Relevant,
512                },
513            );
514        }
515        engine.add_feedback(
516            id,
517            FeedbackItem {
518                doc_id: 1,
519                embedding: vec![1.0],
520                label: FeedbackLabel::NonRelevant,
521            },
522        );
523        assert_eq!(
524            engine
525                .session(id)
526                .expect("test: session must exist")
527                .relevant_count(),
528            3
529        );
530    }
531
532    // 12. non_relevant_count correct
533    #[test]
534    fn test_non_relevant_count() {
535        let mut engine = default_engine();
536        let id = engine.create_session(vec![1.0]);
537        for _ in 0..2 {
538            engine.add_feedback(
539                id,
540                FeedbackItem {
541                    doc_id: 0,
542                    embedding: vec![1.0],
543                    label: FeedbackLabel::NonRelevant,
544                },
545            );
546        }
547        assert_eq!(
548            engine
549                .session(id)
550                .expect("test: session must exist")
551                .non_relevant_count(),
552            2
553        );
554    }
555
556    // 13. Neutral items not counted in relevant/non_relevant
557    #[test]
558    fn test_neutral_not_counted() {
559        let mut engine = default_engine();
560        let id = engine.create_session(vec![1.0, 0.0]);
561        engine.add_feedback(
562            id,
563            FeedbackItem {
564                doc_id: 7,
565                embedding: vec![0.5, 0.5],
566                label: FeedbackLabel::Neutral,
567            },
568        );
569        let session = engine.session(id).expect("test: session must exist");
570        assert_eq!(session.relevant_count(), 0);
571        assert_eq!(session.non_relevant_count(), 0);
572    }
573
574    // 14. Neutral items not used in Rocchio computation
575    #[test]
576    fn test_neutral_not_used_in_rocchio() {
577        let mut engine = SemanticRelevanceFeedback::new(RocchioConfig {
578            normalize_result: false,
579            ..RocchioConfig::default()
580        });
581        let id = engine.create_session(vec![1.0, 0.0]);
582        // Add only Neutral — result should be alpha * original_query
583        engine.add_feedback(
584            id,
585            FeedbackItem {
586                doc_id: 99,
587                embedding: vec![0.0, 1.0],
588                label: FeedbackLabel::Neutral,
589            },
590        );
591        let new_q = engine.apply_rocchio(id).expect("Some");
592        // alpha * [1.0, 0.0] = [1.0, 0.0]
593        assert!((new_q[0] - 1.0).abs() < 1e-5);
594        assert!((new_q[1] - 0.0).abs() < 1e-5);
595    }
596
597    // 15. normalize_result=true produces unit vector
598    #[test]
599    fn test_normalize_true_produces_unit_vector() {
600        let mut engine = SemanticRelevanceFeedback::new(RocchioConfig {
601            normalize_result: true,
602            ..RocchioConfig::default()
603        });
604        let id = engine.create_session(vec![1.0, 0.0]);
605        engine.add_feedback(
606            id,
607            FeedbackItem {
608                doc_id: 1,
609                embedding: vec![0.0, 1.0],
610                label: FeedbackLabel::Relevant,
611            },
612        );
613        let new_q = engine.apply_rocchio(id).expect("Some");
614        let norm: f32 = new_q.iter().map(|&x| x * x).sum::<f32>().sqrt();
615        assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
616    }
617
618    // 16. normalize_result=false keeps raw result
619    #[test]
620    fn test_normalize_false_keeps_raw() {
621        let mut engine = SemanticRelevanceFeedback::new(RocchioConfig {
622            normalize_result: false,
623            ..RocchioConfig::default()
624        });
625        let id = engine.create_session(vec![1.0, 0.0]);
626        engine.add_feedback(
627            id,
628            FeedbackItem {
629                doc_id: 1,
630                embedding: vec![0.0, 1.0],
631                label: FeedbackLabel::Relevant,
632            },
633        );
634        let new_q = engine.apply_rocchio(id).expect("Some");
635        // [1.0, 0.75] — norm ≠ 1
636        let norm: f32 = new_q.iter().map(|&x| x * x).sum::<f32>().sqrt();
637        assert!(
638            (norm - 1.0).abs() > 1e-3,
639            "norm should not be 1.0, got {norm}"
640        );
641    }
642
643    // 17. query_shift is 0.0 when no rounds applied
644    #[test]
645    fn test_query_shift_zero_no_rounds() {
646        let mut engine = default_engine();
647        let id = engine.create_session(vec![1.0, 0.0]);
648        let shift = engine
649            .session(id)
650            .expect("test: session should exist for query_shift check")
651            .query_shift();
652        assert!(shift.abs() < 1e-5, "shift={shift}");
653    }
654
655    // 18. query_shift > 0 after Rocchio round
656    #[test]
657    fn test_query_shift_nonzero_after_rounds() {
658        let mut engine = default_engine();
659        let id = engine.create_session(unit_vec(4, 0));
660        engine.add_feedback(
661            id,
662            FeedbackItem {
663                doc_id: 1,
664                embedding: unit_vec(4, 1),
665                label: FeedbackLabel::Relevant,
666            },
667        );
668        engine.apply_rocchio(id);
669        let shift = engine
670            .session(id)
671            .expect("test: session should exist after apply_rocchio")
672            .query_shift();
673        assert!(
674            shift > 0.0,
675            "shift should be positive after round, got {shift}"
676        );
677    }
678
679    // 19. multiple rounds accumulate changes
680    #[test]
681    fn test_multiple_rounds_accumulate() {
682        let mut engine = default_engine();
683        let id = engine.create_session(unit_vec(3, 0));
684        engine.add_feedback(
685            id,
686            FeedbackItem {
687                doc_id: 1,
688                embedding: unit_vec(3, 2),
689                label: FeedbackLabel::Relevant,
690            },
691        );
692        engine.apply_rocchio(id);
693        let shift_after_1 = engine
694            .session(id)
695            .expect("test: session should exist after first apply_rocchio")
696            .query_shift();
697        engine.apply_rocchio(id);
698        let shift_after_2 = engine
699            .session(id)
700            .expect("test: session should exist after second apply_rocchio")
701            .query_shift();
702        // Each round pulls the query further toward the relevant centroid
703        assert!(
704            shift_after_2 > shift_after_1 || (shift_after_2 - shift_after_1).abs() < 1e-3,
705            "shift should not decrease: after_1={shift_after_1}, after_2={shift_after_2}"
706        );
707        assert_eq!(
708            engine
709                .session(id)
710                .expect("test: session should exist for rounds count check")
711                .rounds,
712            2
713        );
714    }
715
716    // 20. stats total_sessions correct
717    #[test]
718    fn test_stats_total_sessions() {
719        let mut engine = default_engine();
720        engine.create_session(vec![1.0]);
721        engine.create_session(vec![0.5]);
722        engine.create_session(vec![0.0]);
723        assert_eq!(engine.stats().total_sessions, 3);
724    }
725
726    // 21. stats total_rounds correct
727    #[test]
728    fn test_stats_total_rounds() {
729        let mut engine = default_engine();
730        let id1 = engine.create_session(vec![1.0, 0.0]);
731        let id2 = engine.create_session(vec![0.0, 1.0]);
732        engine.apply_rocchio(id1);
733        engine.apply_rocchio(id1);
734        engine.apply_rocchio(id2);
735        assert_eq!(engine.stats().total_rounds, 3);
736    }
737
738    // 22. stats avg_rounds_per_session correct
739    #[test]
740    fn test_stats_avg_rounds_per_session() {
741        let mut engine = default_engine();
742        let id1 = engine.create_session(vec![1.0, 0.0]);
743        let id2 = engine.create_session(vec![0.0, 1.0]);
744        engine.apply_rocchio(id1);
745        engine.apply_rocchio(id1);
746        engine.apply_rocchio(id2);
747        // 3 rounds / 2 sessions = 1.5
748        let avg = engine.stats().avg_rounds_per_session;
749        assert!((avg - 1.5).abs() < 1e-9, "avg={avg}");
750    }
751}