Skip to main content

ipfrs_semantic/
search_explainer.rs

1//! Search Result Explainer
2//!
3//! Explains semantic search result relevance with detailed score breakdowns,
4//! human-readable explanations, and comparative analysis between results.
5
6use std::collections::HashMap;
7
8/// A single contribution to a document's final relevance score.
9#[derive(Debug, Clone)]
10pub struct ScoreContribution {
11    /// Name of the scoring factor (e.g., "cosine_similarity", "tf_idf").
12    pub factor: String,
13    /// Relative weight of this factor in the overall scoring mix.
14    pub weight: f64,
15    /// Unweighted raw score for this factor.
16    pub raw_score: f64,
17    /// Effective contribution: `raw_score * weight`.
18    pub weighted_score: f64,
19}
20
21/// Explanation for a single search result, including per-factor breakdowns.
22#[derive(Debug, Clone)]
23pub struct ExplanationNode {
24    /// Document identifier.
25    pub doc_id: String,
26    /// Aggregated final relevance score.
27    pub final_score: f64,
28    /// Ordered list of contributing scoring factors.
29    pub contributions: Vec<ScoreContribution>,
30    /// 1-based rank position among all results.
31    pub rank: usize,
32    /// Human-readable explanation text generated by the explainer.
33    pub explanation_text: String,
34}
35
36/// Configuration controlling explainer behaviour and verbosity.
37#[derive(Debug, Clone)]
38pub struct ExplainerConfig {
39    /// Maximum number of contributions to surface in the explanation.
40    pub max_contributions: usize,
41    /// Minimum absolute `weighted_score` required to include a contribution.
42    pub min_contribution_weight: f64,
43    /// Whether to include factors whose weighted score is negative.
44    pub include_negative: bool,
45    /// When `true`, emit extra diagnostic detail in the explanation text.
46    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/// Contextual information about the originating query.
61#[derive(Debug, Clone)]
62pub struct QueryContext {
63    /// Raw query string.
64    pub query: String,
65    /// Individual terms extracted from the query string.
66    pub query_terms: Vec<String>,
67    /// Dimensionality of the query embedding vector.
68    pub embedding_dim: usize,
69}
70
71/// Running statistics collected across all explanation calls.
72#[derive(Debug, Clone, Default)]
73pub struct ExplainerStats {
74    /// Total number of individual explanations generated.
75    pub explanations_generated: u64,
76    /// Rolling average of contributions per explained result.
77    pub avg_contributions_per_result: f64,
78    /// Total number of results that have been explained.
79    pub total_results_explained: u64,
80}
81
82/// Generates human-readable relevance explanations for semantic search results.
83pub struct SearchExplainer {
84    config: ExplainerConfig,
85    stats: ExplainerStats,
86}
87
88impl SearchExplainer {
89    /// Creates a new `SearchExplainer` with the provided configuration.
90    pub fn new(config: ExplainerConfig) -> Self {
91        Self {
92            config,
93            stats: ExplainerStats::default(),
94        }
95    }
96
97    /// Explains a single search result, returning a richly annotated [`ExplanationNode`].
98    ///
99    /// The method filters and sorts contributions according to the current
100    /// [`ExplainerConfig`], then generates explanation text.
101    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        // Update rolling average of contributions per result.
130        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    /// Explains a batch of results, assigning ranks by order in `results`.
144    ///
145    /// Each entry in `results` is `(doc_id, score, contributions)`.
146    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    /// Formats an [`ExplanationNode`] into a human-readable multi-line string.
161    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    /// Returns up to `n` contributions sorted by descending absolute weighted score.
186    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    /// Aggregates contributions by factor name, returning a map of factor → total weighted score.
201    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    /// Filters contributions according to `min_contribution_weight` and `include_negative`.
210    pub fn filter_contributions(
211        &self,
212        contributions: Vec<ScoreContribution>,
213    ) -> Vec<ScoreContribution> {
214        contributions
215            .into_iter()
216            .filter(|c| {
217                // Respect include_negative flag.
218                if !self.config.include_negative && c.weighted_score < 0.0 {
219                    return false;
220                }
221                // Keep only contributions above the minimum weight threshold.
222                c.weighted_score.abs() >= self.config.min_contribution_weight
223            })
224            .collect()
225    }
226
227    /// Builds a [`ScoreContribution`] from the cosine similarity between two vectors.
228    ///
229    /// Returns a zero-score contribution when either vector is empty or has zero norm.
230    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    /// Builds a [`ScoreContribution`] from a TF-IDF component.
245    ///
246    /// `tf` is term frequency, `idf` is inverse document frequency.
247    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    /// Compares two [`ExplanationNode`]s and returns a textual diff explaining
263    /// why the higher-ranked result outscored the lower-ranked one.
264    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        // Collect all factor names from both nodes.
288        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        // Sort largest absolute difference first.
308        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    /// Returns a reference to the current running statistics.
331    pub fn stats(&self) -> &ExplainerStats {
332        &self.stats
333    }
334
335    // ── Internal helpers ────────────────────────────────────────────────────
336
337    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
376// ── Pure-function utilities ─────────────────────────────────────────────────
377
378/// Computes cosine similarity between two f64 slices.
379/// Returns `0.0` when either vector is all-zero or the slices are empty.
380fn 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// ── Tests ───────────────────────────────────────────────────────────────────
399
400#[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    // 1. Single result explanation produces correct doc_id and rank.
431    #[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    // 2. Single result: contributions are properly stored.
444    #[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    // 3. Batch explanation assigns ranks in order (1-based).
457    #[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    // 4. format_explanation returns non-empty string containing doc_id.
487    #[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    // 5. format_explanation includes factor name in output.
502    #[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    // 6. format_explanation with empty contributions shows placeholder.
516    #[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    // 7. top_contributions returns contributions sorted by descending |weighted_score|.
530    #[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),   // weighted = 0.02
537                make_contribution("high", 0.9, 0.95), // weighted = 0.855
538                make_contribution("mid", 0.5, 0.6),   // weighted = 0.30
539            ],
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    // 8. top_contributions with n > len returns all.
550    #[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    // 9. score_breakdown aggregates same factor correctly.
564    #[test]
565    fn test_score_breakdown_aggregation() {
566        let contribs = vec![
567            make_contribution("cos", 0.5, 0.8),   // 0.4
568            make_contribution("cos", 0.5, 0.6),   // 0.3
569            make_contribution("tfidf", 0.3, 0.5), // 0.15
570        ];
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    // 10. score_breakdown on empty slice returns empty map.
577    #[test]
578    fn test_score_breakdown_empty() {
579        let bd = SearchExplainer::score_breakdown(&[]);
580        assert!(bd.is_empty());
581    }
582
583    // 11. filter_contributions removes entries below min_weight.
584    #[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),    // 0.81 → keep
594            make_contribution("tiny", 0.01, 0.01), // 0.0001 → filter
595        ];
596        let filtered = explainer.filter_contributions(contribs);
597        assert_eq!(filtered.len(), 1);
598        assert_eq!(filtered[0].factor, "big");
599    }
600
601    // 12. filter_contributions removes negative when include_negative = false.
602    #[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    // 13. filter_contributions keeps negative when include_negative = true.
625    #[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    // 14. cosine_contribution – parallel identical vectors → similarity 1.0.
647    #[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    // 15. cosine_contribution – orthogonal vectors → similarity 0.0.
657    #[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    // 16. cosine_contribution – weight is applied correctly.
667    #[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    // 17. cosine_contribution – zero vectors return 0.
676    #[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    // 18. cosine_contribution – empty vectors return 0.
685    #[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    // 19. term_frequency_contribution encodes term name in factor.
692    #[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    // 20. term_frequency_contribution with zero tf gives zero score.
701    #[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    // 21. compare_explanations correctly identifies the higher-scored result as winner.
709    #[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    // 22. compare_explanations – factors with no difference note no significant diff.
731    #[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    // 23. Empty contributions batch still returns nodes of correct length.
747    #[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    // 24. Verbose mode includes extra detail in explanation_text.
761    #[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    // 25. Non-verbose mode does NOT include verbose detail.
775    #[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    // 26. Stats are updated correctly after single explanation.
785    #[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    // 27. Stats rolling average is tracked across a batch.
797    #[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        // Avg = (2 + 1) / 2 = 1.5
820        assert!((stats.avg_contributions_per_result - 1.5).abs() < 1e-9);
821    }
822
823    // 28. Large batch does not panic and returns all nodes.
824    #[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    // 29. max_contributions truncates correctly.
842    #[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    // 30. cosine_similarity is symmetric.
861    #[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}