1use std::collections::HashMap;
7
8#[derive(Debug, Clone)]
10pub struct ScoreContribution {
11 pub factor: String,
13 pub weight: f64,
15 pub raw_score: f64,
17 pub weighted_score: f64,
19}
20
21#[derive(Debug, Clone)]
23pub struct ExplanationNode {
24 pub doc_id: String,
26 pub final_score: f64,
28 pub contributions: Vec<ScoreContribution>,
30 pub rank: usize,
32 pub explanation_text: String,
34}
35
36#[derive(Debug, Clone)]
38pub struct ExplainerConfig {
39 pub max_contributions: usize,
41 pub min_contribution_weight: f64,
43 pub include_negative: bool,
45 pub verbose: bool,
47}
48
49impl Default for ExplainerConfig {
50 fn default() -> Self {
51 Self {
52 max_contributions: 10,
53 min_contribution_weight: 0.001,
54 include_negative: false,
55 verbose: false,
56 }
57 }
58}
59
60#[derive(Debug, Clone)]
62pub struct QueryContext {
63 pub query: String,
65 pub query_terms: Vec<String>,
67 pub embedding_dim: usize,
69}
70
71#[derive(Debug, Clone, Default)]
73pub struct ExplainerStats {
74 pub explanations_generated: u64,
76 pub avg_contributions_per_result: f64,
78 pub total_results_explained: u64,
80}
81
82pub struct SearchExplainer {
84 config: ExplainerConfig,
85 stats: ExplainerStats,
86}
87
88impl SearchExplainer {
89 pub fn new(config: ExplainerConfig) -> Self {
91 Self {
92 config,
93 stats: ExplainerStats::default(),
94 }
95 }
96
97 pub fn explain_result(
102 &mut self,
103 doc_id: &str,
104 score: f64,
105 contributions: Vec<ScoreContribution>,
106 rank: usize,
107 ctx: &QueryContext,
108 ) -> ExplanationNode {
109 let filtered = self.filter_contributions(contributions);
110 let mut sorted = filtered;
111 sorted.sort_by(|a, b| {
112 b.weighted_score
113 .abs()
114 .partial_cmp(&a.weighted_score.abs())
115 .unwrap_or(std::cmp::Ordering::Equal)
116 });
117 let truncated: Vec<ScoreContribution> = sorted
118 .into_iter()
119 .take(self.config.max_contributions)
120 .collect();
121
122 let explanation_text =
123 Self::build_explanation_text(doc_id, score, rank, &truncated, ctx, self.config.verbose);
124
125 let contrib_count = truncated.len() as f64;
126
127 self.stats.explanations_generated += 1;
128 self.stats.total_results_explained += 1;
129 let n = self.stats.total_results_explained as f64;
131 self.stats.avg_contributions_per_result =
132 self.stats.avg_contributions_per_result * (n - 1.0) / n + contrib_count / n;
133
134 ExplanationNode {
135 doc_id: doc_id.to_string(),
136 final_score: score,
137 contributions: truncated,
138 rank,
139 explanation_text,
140 }
141 }
142
143 pub fn explain_batch(
147 &mut self,
148 results: Vec<(String, f64, Vec<ScoreContribution>)>,
149 ctx: &QueryContext,
150 ) -> Vec<ExplanationNode> {
151 results
152 .into_iter()
153 .enumerate()
154 .map(|(idx, (doc_id, score, contribs))| {
155 self.explain_result(&doc_id, score, contribs, idx + 1, ctx)
156 })
157 .collect()
158 }
159
160 pub fn format_explanation(node: &ExplanationNode) -> String {
162 let mut out = String::with_capacity(256);
163 out.push_str(&format!(
164 "Result #{}: doc=\"{}\" score={:.4}\n",
165 node.rank, node.doc_id, node.final_score
166 ));
167 out.push_str("Score contributions:\n");
168 for (i, c) in node.contributions.iter().enumerate() {
169 out.push_str(&format!(
170 " {}. [{}] raw={:.4} weight={:.4} weighted={:.4}\n",
171 i + 1,
172 c.factor,
173 c.raw_score,
174 c.weight,
175 c.weighted_score
176 ));
177 }
178 if node.contributions.is_empty() {
179 out.push_str(" (no contributions to display)\n");
180 }
181 out.push_str(&format!("Explanation: {}\n", node.explanation_text));
182 out
183 }
184
185 pub fn top_contributions<'a>(
187 node: &'a ExplanationNode,
188 n: usize,
189 ) -> Vec<&'a ScoreContribution> {
190 let mut refs: Vec<&'a ScoreContribution> = node.contributions.iter().collect();
191 refs.sort_by(|a, b| {
192 b.weighted_score
193 .abs()
194 .partial_cmp(&a.weighted_score.abs())
195 .unwrap_or(std::cmp::Ordering::Equal)
196 });
197 refs.into_iter().take(n).collect()
198 }
199
200 pub fn score_breakdown(contributions: &[ScoreContribution]) -> HashMap<String, f64> {
202 let mut map: HashMap<String, f64> = HashMap::new();
203 for c in contributions {
204 *map.entry(c.factor.clone()).or_insert(0.0) += c.weighted_score;
205 }
206 map
207 }
208
209 pub fn filter_contributions(
211 &self,
212 contributions: Vec<ScoreContribution>,
213 ) -> Vec<ScoreContribution> {
214 contributions
215 .into_iter()
216 .filter(|c| {
217 if !self.config.include_negative && c.weighted_score < 0.0 {
219 return false;
220 }
221 c.weighted_score.abs() >= self.config.min_contribution_weight
223 })
224 .collect()
225 }
226
227 pub fn cosine_contribution(
231 query_vec: &[f64],
232 doc_vec: &[f64],
233 weight: f64,
234 ) -> ScoreContribution {
235 let raw = cosine_similarity(query_vec, doc_vec);
236 ScoreContribution {
237 factor: "cosine_similarity".to_string(),
238 weight,
239 raw_score: raw,
240 weighted_score: raw * weight,
241 }
242 }
243
244 pub fn term_frequency_contribution(
248 term: &str,
249 tf: f64,
250 idf: f64,
251 weight: f64,
252 ) -> ScoreContribution {
253 let raw = tf * idf;
254 ScoreContribution {
255 factor: format!("tf_idf:{}", term),
256 weight,
257 raw_score: raw,
258 weighted_score: raw * weight,
259 }
260 }
261
262 pub fn compare_explanations(a: &ExplanationNode, b: &ExplanationNode) -> String {
265 let (winner, loser) = if a.final_score >= b.final_score {
266 (a, b)
267 } else {
268 (b, a)
269 };
270
271 let score_diff = winner.final_score - loser.final_score;
272 let mut lines: Vec<String> = Vec::new();
273 lines.push(format!(
274 "\"{}\" (rank #{}, score={:.4}) outranks \"{}\" (rank #{}, score={:.4}) by {:.4}.",
275 winner.doc_id,
276 winner.rank,
277 winner.final_score,
278 loser.doc_id,
279 loser.rank,
280 loser.final_score,
281 score_diff
282 ));
283
284 let winner_bd = Self::score_breakdown(&winner.contributions);
285 let loser_bd = Self::score_breakdown(&loser.contributions);
286
287 let mut factors: Vec<String> = winner_bd
289 .keys()
290 .chain(loser_bd.keys())
291 .cloned()
292 .collect::<std::collections::HashSet<_>>()
293 .into_iter()
294 .collect();
295 factors.sort();
296
297 let mut factor_diffs: Vec<(String, f64)> = factors
298 .iter()
299 .map(|f| {
300 let w = winner_bd.get(f).copied().unwrap_or(0.0);
301 let l = loser_bd.get(f).copied().unwrap_or(0.0);
302 (f.clone(), w - l)
303 })
304 .filter(|(_, diff)| diff.abs() > 1e-9)
305 .collect();
306
307 factor_diffs.sort_by(|a, b| {
309 b.1.abs()
310 .partial_cmp(&a.1.abs())
311 .unwrap_or(std::cmp::Ordering::Equal)
312 });
313
314 if factor_diffs.is_empty() {
315 lines.push("No significant per-factor differences detected.".to_string());
316 } else {
317 lines.push("Key factor differences (winner − loser):".to_string());
318 for (factor, diff) in &factor_diffs {
319 let direction = if *diff > 0.0 { "higher" } else { "lower" };
320 lines.push(format!(
321 " [{factor}]: {direction} by {:.4} in winner",
322 diff.abs()
323 ));
324 }
325 }
326
327 lines.join("\n")
328 }
329
330 pub fn stats(&self) -> &ExplainerStats {
332 &self.stats
333 }
334
335 fn build_explanation_text(
338 doc_id: &str,
339 score: f64,
340 rank: usize,
341 contributions: &[ScoreContribution],
342 ctx: &QueryContext,
343 verbose: bool,
344 ) -> String {
345 let dominant = contributions
346 .first()
347 .map(|c| c.factor.as_str())
348 .unwrap_or("unknown");
349
350 let mut text = format!(
351 "Document \"{doc_id}\" ranked #{rank} with score {score:.4}. \
352 Primary relevance driver: [{dominant}]. \
353 Query had {} term(s) and {}-dim embedding.",
354 ctx.query_terms.len(),
355 ctx.embedding_dim,
356 );
357
358 if verbose {
359 let breakdown = Self::score_breakdown(contributions);
360 let mut pairs: Vec<(&String, &f64)> = breakdown.iter().collect();
361 pairs.sort_by(|a, b| {
362 b.1.abs()
363 .partial_cmp(&a.1.abs())
364 .unwrap_or(std::cmp::Ordering::Equal)
365 });
366 text.push_str(" Verbose breakdown:");
367 for (factor, total) in pairs {
368 text.push_str(&format!(" [{factor}={total:.4}]"));
369 }
370 }
371
372 text
373 }
374}
375
376fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
381 if a.is_empty() || b.is_empty() {
382 return 0.0;
383 }
384 let len = a.len().min(b.len());
385 let dot: f64 = a[..len]
386 .iter()
387 .zip(b[..len].iter())
388 .map(|(x, y)| x * y)
389 .sum();
390 let norm_a: f64 = a[..len].iter().map(|x| x * x).sum::<f64>().sqrt();
391 let norm_b: f64 = b[..len].iter().map(|x| x * x).sum::<f64>().sqrt();
392 if norm_a == 0.0 || norm_b == 0.0 {
393 return 0.0;
394 }
395 (dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
396}
397
398#[cfg(test)]
401mod tests {
402 use super::*;
403
404 fn default_config() -> ExplainerConfig {
405 ExplainerConfig {
406 max_contributions: 10,
407 min_contribution_weight: 0.001,
408 include_negative: false,
409 verbose: false,
410 }
411 }
412
413 fn sample_ctx() -> QueryContext {
414 QueryContext {
415 query: "semantic search".to_string(),
416 query_terms: vec!["semantic".to_string(), "search".to_string()],
417 embedding_dim: 128,
418 }
419 }
420
421 fn make_contribution(factor: &str, weight: f64, raw: f64) -> ScoreContribution {
422 ScoreContribution {
423 factor: factor.to_string(),
424 weight,
425 raw_score: raw,
426 weighted_score: raw * weight,
427 }
428 }
429
430 #[test]
432 fn test_single_result_explanation_fields() {
433 let mut explainer = SearchExplainer::new(default_config());
434 let ctx = sample_ctx();
435 let contribs = vec![make_contribution("cosine_similarity", 0.8, 0.9)];
436 let node = explainer.explain_result("doc-001", 0.72, contribs, 1, &ctx);
437 assert_eq!(node.doc_id, "doc-001");
438 assert_eq!(node.rank, 1);
439 assert!((node.final_score - 0.72).abs() < 1e-9);
440 assert!(!node.explanation_text.is_empty());
441 }
442
443 #[test]
445 fn test_single_result_contributions_stored() {
446 let mut explainer = SearchExplainer::new(default_config());
447 let ctx = sample_ctx();
448 let contribs = vec![
449 make_contribution("cosine_similarity", 0.7, 0.85),
450 make_contribution("tf_idf:rust", 0.3, 0.4),
451 ];
452 let node = explainer.explain_result("doc-abc", 0.73, contribs, 2, &ctx);
453 assert_eq!(node.contributions.len(), 2);
454 }
455
456 #[test]
458 fn test_batch_explanation_ranks() {
459 let mut explainer = SearchExplainer::new(default_config());
460 let ctx = sample_ctx();
461 let results = vec![
462 (
463 "a".to_string(),
464 0.9,
465 vec![make_contribution("cos", 1.0, 0.9)],
466 ),
467 (
468 "b".to_string(),
469 0.7,
470 vec![make_contribution("cos", 1.0, 0.7)],
471 ),
472 (
473 "c".to_string(),
474 0.5,
475 vec![make_contribution("cos", 1.0, 0.5)],
476 ),
477 ];
478 let nodes = explainer.explain_batch(results, &ctx);
479 assert_eq!(nodes.len(), 3);
480 assert_eq!(nodes[0].rank, 1);
481 assert_eq!(nodes[1].rank, 2);
482 assert_eq!(nodes[2].rank, 3);
483 assert_eq!(nodes[0].doc_id, "a");
484 }
485
486 #[test]
488 fn test_format_explanation_contains_doc_id() {
489 let node = ExplanationNode {
490 doc_id: "my-doc".to_string(),
491 final_score: 0.88,
492 contributions: vec![make_contribution("cosine_similarity", 0.9, 0.88)],
493 rank: 1,
494 explanation_text: "Test explanation.".to_string(),
495 };
496 let formatted = SearchExplainer::format_explanation(&node);
497 assert!(formatted.contains("my-doc"));
498 assert!(formatted.contains("0.88") || formatted.contains("0.7920"));
499 }
500
501 #[test]
503 fn test_format_explanation_includes_factor() {
504 let node = ExplanationNode {
505 doc_id: "doc".to_string(),
506 final_score: 0.5,
507 contributions: vec![make_contribution("tf_idf:cat", 0.5, 0.4)],
508 rank: 3,
509 explanation_text: "Explanation.".to_string(),
510 };
511 let formatted = SearchExplainer::format_explanation(&node);
512 assert!(formatted.contains("tf_idf:cat"));
513 }
514
515 #[test]
517 fn test_format_explanation_empty_contributions() {
518 let node = ExplanationNode {
519 doc_id: "empty".to_string(),
520 final_score: 0.0,
521 contributions: vec![],
522 rank: 99,
523 explanation_text: "No factors.".to_string(),
524 };
525 let formatted = SearchExplainer::format_explanation(&node);
526 assert!(formatted.contains("no contributions"));
527 }
528
529 #[test]
531 fn test_top_contributions_ordering() {
532 let node = ExplanationNode {
533 doc_id: "doc".to_string(),
534 final_score: 1.0,
535 contributions: vec![
536 make_contribution("low", 0.1, 0.2), make_contribution("high", 0.9, 0.95), make_contribution("mid", 0.5, 0.6), ],
540 rank: 1,
541 explanation_text: String::new(),
542 };
543 let top = SearchExplainer::top_contributions(&node, 2);
544 assert_eq!(top.len(), 2);
545 assert_eq!(top[0].factor, "high");
546 assert_eq!(top[1].factor, "mid");
547 }
548
549 #[test]
551 fn test_top_contributions_n_greater_than_len() {
552 let node = ExplanationNode {
553 doc_id: "doc".to_string(),
554 final_score: 0.5,
555 contributions: vec![make_contribution("only", 0.5, 0.5)],
556 rank: 1,
557 explanation_text: String::new(),
558 };
559 let top = SearchExplainer::top_contributions(&node, 100);
560 assert_eq!(top.len(), 1);
561 }
562
563 #[test]
565 fn test_score_breakdown_aggregation() {
566 let contribs = vec![
567 make_contribution("cos", 0.5, 0.8), make_contribution("cos", 0.5, 0.6), make_contribution("tfidf", 0.3, 0.5), ];
571 let bd = SearchExplainer::score_breakdown(&contribs);
572 assert!((bd["cos"] - 0.7).abs() < 1e-9);
573 assert!((bd["tfidf"] - 0.15).abs() < 1e-9);
574 }
575
576 #[test]
578 fn test_score_breakdown_empty() {
579 let bd = SearchExplainer::score_breakdown(&[]);
580 assert!(bd.is_empty());
581 }
582
583 #[test]
585 fn test_filter_contributions_min_weight() {
586 let config = ExplainerConfig {
587 min_contribution_weight: 0.1,
588 include_negative: false,
589 ..default_config()
590 };
591 let explainer = SearchExplainer::new(config);
592 let contribs = vec![
593 make_contribution("big", 0.9, 0.9), make_contribution("tiny", 0.01, 0.01), ];
596 let filtered = explainer.filter_contributions(contribs);
597 assert_eq!(filtered.len(), 1);
598 assert_eq!(filtered[0].factor, "big");
599 }
600
601 #[test]
603 fn test_filter_contributions_negative_excluded() {
604 let config = ExplainerConfig {
605 include_negative: false,
606 min_contribution_weight: 0.0,
607 ..default_config()
608 };
609 let explainer = SearchExplainer::new(config);
610 let contribs = vec![
611 make_contribution("pos", 0.5, 0.8),
612 ScoreContribution {
613 factor: "neg".to_string(),
614 weight: 0.5,
615 raw_score: -0.6,
616 weighted_score: -0.3,
617 },
618 ];
619 let filtered = explainer.filter_contributions(contribs);
620 assert_eq!(filtered.len(), 1);
621 assert_eq!(filtered[0].factor, "pos");
622 }
623
624 #[test]
626 fn test_filter_contributions_negative_included() {
627 let config = ExplainerConfig {
628 include_negative: true,
629 min_contribution_weight: 0.0,
630 ..default_config()
631 };
632 let explainer = SearchExplainer::new(config);
633 let contribs = vec![
634 make_contribution("pos", 0.5, 0.8),
635 ScoreContribution {
636 factor: "neg".to_string(),
637 weight: 0.5,
638 raw_score: -0.6,
639 weighted_score: -0.3,
640 },
641 ];
642 let filtered = explainer.filter_contributions(contribs);
643 assert_eq!(filtered.len(), 2);
644 }
645
646 #[test]
648 fn test_cosine_contribution_identical_vectors() {
649 let v = vec![0.1, 0.2, 0.3, 0.4];
650 let c = SearchExplainer::cosine_contribution(&v, &v, 1.0);
651 assert_eq!(c.factor, "cosine_similarity");
652 assert!((c.raw_score - 1.0).abs() < 1e-9);
653 assert!((c.weighted_score - 1.0).abs() < 1e-9);
654 }
655
656 #[test]
658 fn test_cosine_contribution_orthogonal_vectors() {
659 let a = vec![1.0, 0.0, 0.0];
660 let b = vec![0.0, 1.0, 0.0];
661 let c = SearchExplainer::cosine_contribution(&a, &b, 0.8);
662 assert!((c.raw_score).abs() < 1e-9);
663 assert!((c.weighted_score).abs() < 1e-9);
664 }
665
666 #[test]
668 fn test_cosine_contribution_weight_applied() {
669 let v = vec![1.0, 0.0];
670 let w = 0.6;
671 let c = SearchExplainer::cosine_contribution(&v, &v, w);
672 assert!((c.weighted_score - w).abs() < 1e-9);
673 }
674
675 #[test]
677 fn test_cosine_contribution_zero_vector() {
678 let a = vec![0.0, 0.0, 0.0];
679 let b = vec![0.5, 0.5, 0.5];
680 let c = SearchExplainer::cosine_contribution(&a, &b, 1.0);
681 assert_eq!(c.raw_score, 0.0);
682 }
683
684 #[test]
686 fn test_cosine_contribution_empty_vectors() {
687 let c = SearchExplainer::cosine_contribution(&[], &[], 1.0);
688 assert_eq!(c.raw_score, 0.0);
689 }
690
691 #[test]
693 fn test_term_frequency_contribution_factor_name() {
694 let c = SearchExplainer::term_frequency_contribution("rust", 0.5, 3.2, 0.4);
695 assert_eq!(c.factor, "tf_idf:rust");
696 assert!((c.raw_score - 0.5 * 3.2).abs() < 1e-9);
697 assert!((c.weighted_score - 0.5 * 3.2 * 0.4).abs() < 1e-9);
698 }
699
700 #[test]
702 fn test_term_frequency_contribution_zero_tf() {
703 let c = SearchExplainer::term_frequency_contribution("absent", 0.0, 5.0, 1.0);
704 assert_eq!(c.raw_score, 0.0);
705 assert_eq!(c.weighted_score, 0.0);
706 }
707
708 #[test]
710 fn test_compare_explanations_winner_identified() {
711 let a = ExplanationNode {
712 doc_id: "alpha".to_string(),
713 final_score: 0.9,
714 contributions: vec![make_contribution("cos", 0.9, 0.95)],
715 rank: 1,
716 explanation_text: String::new(),
717 };
718 let b = ExplanationNode {
719 doc_id: "beta".to_string(),
720 final_score: 0.5,
721 contributions: vec![make_contribution("cos", 0.9, 0.55)],
722 rank: 2,
723 explanation_text: String::new(),
724 };
725 let comparison = SearchExplainer::compare_explanations(&a, &b);
726 assert!(comparison.contains("alpha"));
727 assert!(comparison.contains("outranks"));
728 }
729
730 #[test]
732 fn test_compare_explanations_no_factor_differences() {
733 let node = |id: &str, score: f64, rank: usize| ExplanationNode {
734 doc_id: id.to_string(),
735 final_score: score,
736 contributions: vec![],
737 rank,
738 explanation_text: String::new(),
739 };
740 let a = node("x", 0.8, 1);
741 let b = node("y", 0.3, 2);
742 let text = SearchExplainer::compare_explanations(&a, &b);
743 assert!(text.contains("No significant per-factor differences"));
744 }
745
746 #[test]
748 fn test_explain_batch_empty_contributions_per_item() {
749 let mut explainer = SearchExplainer::new(default_config());
750 let ctx = sample_ctx();
751 let results = vec![
752 ("doc1".to_string(), 0.5, vec![]),
753 ("doc2".to_string(), 0.3, vec![]),
754 ];
755 let nodes = explainer.explain_batch(results, &ctx);
756 assert_eq!(nodes.len(), 2);
757 assert!(nodes[0].contributions.is_empty());
758 }
759
760 #[test]
762 fn test_verbose_mode_explanation_text() {
763 let config = ExplainerConfig {
764 verbose: true,
765 ..default_config()
766 };
767 let mut explainer = SearchExplainer::new(config);
768 let ctx = sample_ctx();
769 let contribs = vec![make_contribution("cosine_similarity", 0.8, 0.9)];
770 let node = explainer.explain_result("verbose-doc", 0.72, contribs, 1, &ctx);
771 assert!(node.explanation_text.contains("Verbose breakdown"));
772 }
773
774 #[test]
776 fn test_non_verbose_mode_no_verbose_detail() {
777 let mut explainer = SearchExplainer::new(default_config());
778 let ctx = sample_ctx();
779 let contribs = vec![make_contribution("cosine_similarity", 0.8, 0.9)];
780 let node = explainer.explain_result("quiet-doc", 0.72, contribs, 1, &ctx);
781 assert!(!node.explanation_text.contains("Verbose breakdown"));
782 }
783
784 #[test]
786 fn test_stats_single_explanation() {
787 let mut explainer = SearchExplainer::new(default_config());
788 let ctx = sample_ctx();
789 let _ = explainer.explain_result("d", 0.5, vec![make_contribution("c", 0.5, 0.5)], 1, &ctx);
790 let stats = explainer.stats();
791 assert_eq!(stats.explanations_generated, 1);
792 assert_eq!(stats.total_results_explained, 1);
793 assert!((stats.avg_contributions_per_result - 1.0).abs() < 1e-9);
794 }
795
796 #[test]
798 fn test_stats_batch_tracking() {
799 let mut explainer = SearchExplainer::new(default_config());
800 let ctx = sample_ctx();
801 let results = vec![
802 (
803 "d1".to_string(),
804 0.9,
805 vec![
806 make_contribution("c", 0.5, 0.9),
807 make_contribution("t", 0.3, 0.4),
808 ],
809 ),
810 (
811 "d2".to_string(),
812 0.7,
813 vec![make_contribution("c", 0.5, 0.7)],
814 ),
815 ];
816 let _ = explainer.explain_batch(results, &ctx);
817 let stats = explainer.stats();
818 assert_eq!(stats.total_results_explained, 2);
819 assert!((stats.avg_contributions_per_result - 1.5).abs() < 1e-9);
821 }
822
823 #[test]
825 fn test_large_batch_no_panic() {
826 let mut explainer = SearchExplainer::new(default_config());
827 let ctx = sample_ctx();
828 let results: Vec<(String, f64, Vec<ScoreContribution>)> = (0..500)
829 .map(|i| {
830 let doc_id = format!("doc-{i}");
831 let score = 1.0 - i as f64 / 500.0;
832 let contribs = vec![make_contribution("cos", 0.8, score)];
833 (doc_id, score, contribs)
834 })
835 .collect();
836 let nodes = explainer.explain_batch(results, &ctx);
837 assert_eq!(nodes.len(), 500);
838 assert_eq!(explainer.stats().total_results_explained, 500);
839 }
840
841 #[test]
843 fn test_max_contributions_truncation() {
844 let config = ExplainerConfig {
845 max_contributions: 2,
846 ..default_config()
847 };
848 let mut explainer = SearchExplainer::new(config);
849 let ctx = sample_ctx();
850 let contribs = vec![
851 make_contribution("a", 0.9, 0.9),
852 make_contribution("b", 0.8, 0.8),
853 make_contribution("c", 0.7, 0.7),
854 make_contribution("d", 0.6, 0.6),
855 ];
856 let node = explainer.explain_result("doc", 0.85, contribs, 1, &ctx);
857 assert_eq!(node.contributions.len(), 2);
858 }
859
860 #[test]
862 fn test_cosine_similarity_symmetry() {
863 let a = vec![0.3, 0.4, 0.5, 0.6];
864 let b = vec![0.1, 0.9, 0.2, 0.7];
865 let ab = SearchExplainer::cosine_contribution(&a, &b, 1.0).raw_score;
866 let ba = SearchExplainer::cosine_contribution(&b, &a, 1.0).raw_score;
867 assert!((ab - ba).abs() < 1e-9);
868 }
869}