1use std::collections::HashMap;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum FeedbackLabel {
16 Relevant,
18 NonRelevant,
20 Neutral,
22}
23
24#[derive(Clone, Debug)]
26pub struct FeedbackItem {
27 pub doc_id: u64,
29 pub embedding: Vec<f32>,
31 pub label: FeedbackLabel,
33}
34
35#[derive(Clone, Debug)]
37pub struct RocchioConfig {
38 pub alpha: f32,
40 pub beta: f32,
42 pub gamma: f32,
44 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#[derive(Clone, Debug)]
61pub struct FeedbackSession {
62 pub session_id: u64,
64 pub original_query: Vec<f32>,
66 pub current_query: Vec<f32>,
69 pub rounds: u32,
72 pub feedback_items: Vec<FeedbackItem>,
74}
75
76impl FeedbackSession {
77 pub fn relevant_count(&self) -> usize {
79 self.feedback_items
80 .iter()
81 .filter(|i| i.label == FeedbackLabel::Relevant)
82 .count()
83 }
84
85 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 pub fn query_shift(&self) -> f32 {
96 1.0 - cosine_similarity(&self.original_query, &self.current_query)
97 }
98}
99
100#[derive(Clone, Debug)]
103pub struct FeedbackStats {
104 pub total_sessions: usize,
106 pub total_rounds: u64,
108 pub avg_rounds_per_session: f64,
110}
111
112pub struct SemanticRelevanceFeedback {
119 pub sessions: HashMap<u64, FeedbackSession>,
121 pub next_session_id: u64,
123 pub config: RocchioConfig,
125 pub total_rounds: u64,
127}
128
129impl SemanticRelevanceFeedback {
130 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 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 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 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 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 let relevant_centroid = compute_centroid(&relevant, dim);
208 let non_relevant_centroid = compute_centroid(&non_relevant, dim);
209
210 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 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 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 if normalize {
234 l2_normalize(&mut new_query);
235 }
236
237 session.current_query = new_query.clone();
239 session.rounds += 1;
240 self.total_rounds += 1;
241
242 Some(new_query)
243 }
244
245 pub fn session(&self, session_id: u64) -> Option<&FeedbackSession> {
247 self.sessions.get(&session_id)
248 }
249
250 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
267fn 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
290fn 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
300pub 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 assert!((new_q[0] - 1.0).abs() < 1e-5);
416 assert!((new_q[1] - 0.75).abs() < 1e-5);
417 }
418
419 #[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 assert!((new_q[0] - 1.0).abs() < 1e-5);
438 assert!((new_q[1] - (-0.25)).abs() < 1e-5);
439 }
440
441 #[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 assert!((new_q[0] - 1.0).abs() < 1e-5);
460 assert!((new_q[1] - 0.75).abs() < 1e-5);
461 }
462
463 #[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 #[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 #[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 #[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 #[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 #[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 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 assert!((new_q[0] - 1.0).abs() < 1e-5);
594 assert!((new_q[1] - 0.0).abs() < 1e-5);
595 }
596
597 #[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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 let avg = engine.stats().avg_rounds_per_session;
749 assert!((avg - 1.5).abs() < 1e-9, "avg={avg}");
750 }
751}