use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct ScoreContribution {
pub factor: String,
pub weight: f64,
pub raw_score: f64,
pub weighted_score: f64,
}
#[derive(Debug, Clone)]
pub struct ExplanationNode {
pub doc_id: String,
pub final_score: f64,
pub contributions: Vec<ScoreContribution>,
pub rank: usize,
pub explanation_text: String,
}
#[derive(Debug, Clone)]
pub struct ExplainerConfig {
pub max_contributions: usize,
pub min_contribution_weight: f64,
pub include_negative: bool,
pub verbose: bool,
}
impl Default for ExplainerConfig {
fn default() -> Self {
Self {
max_contributions: 10,
min_contribution_weight: 0.001,
include_negative: false,
verbose: false,
}
}
}
#[derive(Debug, Clone)]
pub struct QueryContext {
pub query: String,
pub query_terms: Vec<String>,
pub embedding_dim: usize,
}
#[derive(Debug, Clone, Default)]
pub struct ExplainerStats {
pub explanations_generated: u64,
pub avg_contributions_per_result: f64,
pub total_results_explained: u64,
}
pub struct SearchExplainer {
config: ExplainerConfig,
stats: ExplainerStats,
}
impl SearchExplainer {
pub fn new(config: ExplainerConfig) -> Self {
Self {
config,
stats: ExplainerStats::default(),
}
}
pub fn explain_result(
&mut self,
doc_id: &str,
score: f64,
contributions: Vec<ScoreContribution>,
rank: usize,
ctx: &QueryContext,
) -> ExplanationNode {
let filtered = self.filter_contributions(contributions);
let mut sorted = filtered;
sorted.sort_by(|a, b| {
b.weighted_score
.abs()
.partial_cmp(&a.weighted_score.abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
let truncated: Vec<ScoreContribution> = sorted
.into_iter()
.take(self.config.max_contributions)
.collect();
let explanation_text =
Self::build_explanation_text(doc_id, score, rank, &truncated, ctx, self.config.verbose);
let contrib_count = truncated.len() as f64;
self.stats.explanations_generated += 1;
self.stats.total_results_explained += 1;
let n = self.stats.total_results_explained as f64;
self.stats.avg_contributions_per_result =
self.stats.avg_contributions_per_result * (n - 1.0) / n + contrib_count / n;
ExplanationNode {
doc_id: doc_id.to_string(),
final_score: score,
contributions: truncated,
rank,
explanation_text,
}
}
pub fn explain_batch(
&mut self,
results: Vec<(String, f64, Vec<ScoreContribution>)>,
ctx: &QueryContext,
) -> Vec<ExplanationNode> {
results
.into_iter()
.enumerate()
.map(|(idx, (doc_id, score, contribs))| {
self.explain_result(&doc_id, score, contribs, idx + 1, ctx)
})
.collect()
}
pub fn format_explanation(node: &ExplanationNode) -> String {
let mut out = String::with_capacity(256);
out.push_str(&format!(
"Result #{}: doc=\"{}\" score={:.4}\n",
node.rank, node.doc_id, node.final_score
));
out.push_str("Score contributions:\n");
for (i, c) in node.contributions.iter().enumerate() {
out.push_str(&format!(
" {}. [{}] raw={:.4} weight={:.4} weighted={:.4}\n",
i + 1,
c.factor,
c.raw_score,
c.weight,
c.weighted_score
));
}
if node.contributions.is_empty() {
out.push_str(" (no contributions to display)\n");
}
out.push_str(&format!("Explanation: {}\n", node.explanation_text));
out
}
pub fn top_contributions<'a>(
node: &'a ExplanationNode,
n: usize,
) -> Vec<&'a ScoreContribution> {
let mut refs: Vec<&'a ScoreContribution> = node.contributions.iter().collect();
refs.sort_by(|a, b| {
b.weighted_score
.abs()
.partial_cmp(&a.weighted_score.abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
refs.into_iter().take(n).collect()
}
pub fn score_breakdown(contributions: &[ScoreContribution]) -> HashMap<String, f64> {
let mut map: HashMap<String, f64> = HashMap::new();
for c in contributions {
*map.entry(c.factor.clone()).or_insert(0.0) += c.weighted_score;
}
map
}
pub fn filter_contributions(
&self,
contributions: Vec<ScoreContribution>,
) -> Vec<ScoreContribution> {
contributions
.into_iter()
.filter(|c| {
if !self.config.include_negative && c.weighted_score < 0.0 {
return false;
}
c.weighted_score.abs() >= self.config.min_contribution_weight
})
.collect()
}
pub fn cosine_contribution(
query_vec: &[f64],
doc_vec: &[f64],
weight: f64,
) -> ScoreContribution {
let raw = cosine_similarity(query_vec, doc_vec);
ScoreContribution {
factor: "cosine_similarity".to_string(),
weight,
raw_score: raw,
weighted_score: raw * weight,
}
}
pub fn term_frequency_contribution(
term: &str,
tf: f64,
idf: f64,
weight: f64,
) -> ScoreContribution {
let raw = tf * idf;
ScoreContribution {
factor: format!("tf_idf:{}", term),
weight,
raw_score: raw,
weighted_score: raw * weight,
}
}
pub fn compare_explanations(a: &ExplanationNode, b: &ExplanationNode) -> String {
let (winner, loser) = if a.final_score >= b.final_score {
(a, b)
} else {
(b, a)
};
let score_diff = winner.final_score - loser.final_score;
let mut lines: Vec<String> = Vec::new();
lines.push(format!(
"\"{}\" (rank #{}, score={:.4}) outranks \"{}\" (rank #{}, score={:.4}) by {:.4}.",
winner.doc_id,
winner.rank,
winner.final_score,
loser.doc_id,
loser.rank,
loser.final_score,
score_diff
));
let winner_bd = Self::score_breakdown(&winner.contributions);
let loser_bd = Self::score_breakdown(&loser.contributions);
let mut factors: Vec<String> = winner_bd
.keys()
.chain(loser_bd.keys())
.cloned()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
factors.sort();
let mut factor_diffs: Vec<(String, f64)> = factors
.iter()
.map(|f| {
let w = winner_bd.get(f).copied().unwrap_or(0.0);
let l = loser_bd.get(f).copied().unwrap_or(0.0);
(f.clone(), w - l)
})
.filter(|(_, diff)| diff.abs() > 1e-9)
.collect();
factor_diffs.sort_by(|a, b| {
b.1.abs()
.partial_cmp(&a.1.abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
if factor_diffs.is_empty() {
lines.push("No significant per-factor differences detected.".to_string());
} else {
lines.push("Key factor differences (winner − loser):".to_string());
for (factor, diff) in &factor_diffs {
let direction = if *diff > 0.0 { "higher" } else { "lower" };
lines.push(format!(
" [{factor}]: {direction} by {:.4} in winner",
diff.abs()
));
}
}
lines.join("\n")
}
pub fn stats(&self) -> &ExplainerStats {
&self.stats
}
fn build_explanation_text(
doc_id: &str,
score: f64,
rank: usize,
contributions: &[ScoreContribution],
ctx: &QueryContext,
verbose: bool,
) -> String {
let dominant = contributions
.first()
.map(|c| c.factor.as_str())
.unwrap_or("unknown");
let mut text = format!(
"Document \"{doc_id}\" ranked #{rank} with score {score:.4}. \
Primary relevance driver: [{dominant}]. \
Query had {} term(s) and {}-dim embedding.",
ctx.query_terms.len(),
ctx.embedding_dim,
);
if verbose {
let breakdown = Self::score_breakdown(contributions);
let mut pairs: Vec<(&String, &f64)> = breakdown.iter().collect();
pairs.sort_by(|a, b| {
b.1.abs()
.partial_cmp(&a.1.abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
text.push_str(" Verbose breakdown:");
for (factor, total) in pairs {
text.push_str(&format!(" [{factor}={total:.4}]"));
}
}
text
}
}
fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let len = a.len().min(b.len());
let dot: f64 = a[..len]
.iter()
.zip(b[..len].iter())
.map(|(x, y)| x * y)
.sum();
let norm_a: f64 = a[..len].iter().map(|x| x * x).sum::<f64>().sqrt();
let norm_b: f64 = b[..len].iter().map(|x| x * x).sum::<f64>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
(dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn default_config() -> ExplainerConfig {
ExplainerConfig {
max_contributions: 10,
min_contribution_weight: 0.001,
include_negative: false,
verbose: false,
}
}
fn sample_ctx() -> QueryContext {
QueryContext {
query: "semantic search".to_string(),
query_terms: vec!["semantic".to_string(), "search".to_string()],
embedding_dim: 128,
}
}
fn make_contribution(factor: &str, weight: f64, raw: f64) -> ScoreContribution {
ScoreContribution {
factor: factor.to_string(),
weight,
raw_score: raw,
weighted_score: raw * weight,
}
}
#[test]
fn test_single_result_explanation_fields() {
let mut explainer = SearchExplainer::new(default_config());
let ctx = sample_ctx();
let contribs = vec![make_contribution("cosine_similarity", 0.8, 0.9)];
let node = explainer.explain_result("doc-001", 0.72, contribs, 1, &ctx);
assert_eq!(node.doc_id, "doc-001");
assert_eq!(node.rank, 1);
assert!((node.final_score - 0.72).abs() < 1e-9);
assert!(!node.explanation_text.is_empty());
}
#[test]
fn test_single_result_contributions_stored() {
let mut explainer = SearchExplainer::new(default_config());
let ctx = sample_ctx();
let contribs = vec![
make_contribution("cosine_similarity", 0.7, 0.85),
make_contribution("tf_idf:rust", 0.3, 0.4),
];
let node = explainer.explain_result("doc-abc", 0.73, contribs, 2, &ctx);
assert_eq!(node.contributions.len(), 2);
}
#[test]
fn test_batch_explanation_ranks() {
let mut explainer = SearchExplainer::new(default_config());
let ctx = sample_ctx();
let results = vec![
(
"a".to_string(),
0.9,
vec![make_contribution("cos", 1.0, 0.9)],
),
(
"b".to_string(),
0.7,
vec![make_contribution("cos", 1.0, 0.7)],
),
(
"c".to_string(),
0.5,
vec![make_contribution("cos", 1.0, 0.5)],
),
];
let nodes = explainer.explain_batch(results, &ctx);
assert_eq!(nodes.len(), 3);
assert_eq!(nodes[0].rank, 1);
assert_eq!(nodes[1].rank, 2);
assert_eq!(nodes[2].rank, 3);
assert_eq!(nodes[0].doc_id, "a");
}
#[test]
fn test_format_explanation_contains_doc_id() {
let node = ExplanationNode {
doc_id: "my-doc".to_string(),
final_score: 0.88,
contributions: vec![make_contribution("cosine_similarity", 0.9, 0.88)],
rank: 1,
explanation_text: "Test explanation.".to_string(),
};
let formatted = SearchExplainer::format_explanation(&node);
assert!(formatted.contains("my-doc"));
assert!(formatted.contains("0.88") || formatted.contains("0.7920"));
}
#[test]
fn test_format_explanation_includes_factor() {
let node = ExplanationNode {
doc_id: "doc".to_string(),
final_score: 0.5,
contributions: vec![make_contribution("tf_idf:cat", 0.5, 0.4)],
rank: 3,
explanation_text: "Explanation.".to_string(),
};
let formatted = SearchExplainer::format_explanation(&node);
assert!(formatted.contains("tf_idf:cat"));
}
#[test]
fn test_format_explanation_empty_contributions() {
let node = ExplanationNode {
doc_id: "empty".to_string(),
final_score: 0.0,
contributions: vec![],
rank: 99,
explanation_text: "No factors.".to_string(),
};
let formatted = SearchExplainer::format_explanation(&node);
assert!(formatted.contains("no contributions"));
}
#[test]
fn test_top_contributions_ordering() {
let node = ExplanationNode {
doc_id: "doc".to_string(),
final_score: 1.0,
contributions: vec![
make_contribution("low", 0.1, 0.2), make_contribution("high", 0.9, 0.95), make_contribution("mid", 0.5, 0.6), ],
rank: 1,
explanation_text: String::new(),
};
let top = SearchExplainer::top_contributions(&node, 2);
assert_eq!(top.len(), 2);
assert_eq!(top[0].factor, "high");
assert_eq!(top[1].factor, "mid");
}
#[test]
fn test_top_contributions_n_greater_than_len() {
let node = ExplanationNode {
doc_id: "doc".to_string(),
final_score: 0.5,
contributions: vec![make_contribution("only", 0.5, 0.5)],
rank: 1,
explanation_text: String::new(),
};
let top = SearchExplainer::top_contributions(&node, 100);
assert_eq!(top.len(), 1);
}
#[test]
fn test_score_breakdown_aggregation() {
let contribs = vec![
make_contribution("cos", 0.5, 0.8), make_contribution("cos", 0.5, 0.6), make_contribution("tfidf", 0.3, 0.5), ];
let bd = SearchExplainer::score_breakdown(&contribs);
assert!((bd["cos"] - 0.7).abs() < 1e-9);
assert!((bd["tfidf"] - 0.15).abs() < 1e-9);
}
#[test]
fn test_score_breakdown_empty() {
let bd = SearchExplainer::score_breakdown(&[]);
assert!(bd.is_empty());
}
#[test]
fn test_filter_contributions_min_weight() {
let config = ExplainerConfig {
min_contribution_weight: 0.1,
include_negative: false,
..default_config()
};
let explainer = SearchExplainer::new(config);
let contribs = vec![
make_contribution("big", 0.9, 0.9), make_contribution("tiny", 0.01, 0.01), ];
let filtered = explainer.filter_contributions(contribs);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].factor, "big");
}
#[test]
fn test_filter_contributions_negative_excluded() {
let config = ExplainerConfig {
include_negative: false,
min_contribution_weight: 0.0,
..default_config()
};
let explainer = SearchExplainer::new(config);
let contribs = vec![
make_contribution("pos", 0.5, 0.8),
ScoreContribution {
factor: "neg".to_string(),
weight: 0.5,
raw_score: -0.6,
weighted_score: -0.3,
},
];
let filtered = explainer.filter_contributions(contribs);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].factor, "pos");
}
#[test]
fn test_filter_contributions_negative_included() {
let config = ExplainerConfig {
include_negative: true,
min_contribution_weight: 0.0,
..default_config()
};
let explainer = SearchExplainer::new(config);
let contribs = vec![
make_contribution("pos", 0.5, 0.8),
ScoreContribution {
factor: "neg".to_string(),
weight: 0.5,
raw_score: -0.6,
weighted_score: -0.3,
},
];
let filtered = explainer.filter_contributions(contribs);
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_cosine_contribution_identical_vectors() {
let v = vec![0.1, 0.2, 0.3, 0.4];
let c = SearchExplainer::cosine_contribution(&v, &v, 1.0);
assert_eq!(c.factor, "cosine_similarity");
assert!((c.raw_score - 1.0).abs() < 1e-9);
assert!((c.weighted_score - 1.0).abs() < 1e-9);
}
#[test]
fn test_cosine_contribution_orthogonal_vectors() {
let a = vec![1.0, 0.0, 0.0];
let b = vec![0.0, 1.0, 0.0];
let c = SearchExplainer::cosine_contribution(&a, &b, 0.8);
assert!((c.raw_score).abs() < 1e-9);
assert!((c.weighted_score).abs() < 1e-9);
}
#[test]
fn test_cosine_contribution_weight_applied() {
let v = vec![1.0, 0.0];
let w = 0.6;
let c = SearchExplainer::cosine_contribution(&v, &v, w);
assert!((c.weighted_score - w).abs() < 1e-9);
}
#[test]
fn test_cosine_contribution_zero_vector() {
let a = vec![0.0, 0.0, 0.0];
let b = vec![0.5, 0.5, 0.5];
let c = SearchExplainer::cosine_contribution(&a, &b, 1.0);
assert_eq!(c.raw_score, 0.0);
}
#[test]
fn test_cosine_contribution_empty_vectors() {
let c = SearchExplainer::cosine_contribution(&[], &[], 1.0);
assert_eq!(c.raw_score, 0.0);
}
#[test]
fn test_term_frequency_contribution_factor_name() {
let c = SearchExplainer::term_frequency_contribution("rust", 0.5, 3.2, 0.4);
assert_eq!(c.factor, "tf_idf:rust");
assert!((c.raw_score - 0.5 * 3.2).abs() < 1e-9);
assert!((c.weighted_score - 0.5 * 3.2 * 0.4).abs() < 1e-9);
}
#[test]
fn test_term_frequency_contribution_zero_tf() {
let c = SearchExplainer::term_frequency_contribution("absent", 0.0, 5.0, 1.0);
assert_eq!(c.raw_score, 0.0);
assert_eq!(c.weighted_score, 0.0);
}
#[test]
fn test_compare_explanations_winner_identified() {
let a = ExplanationNode {
doc_id: "alpha".to_string(),
final_score: 0.9,
contributions: vec![make_contribution("cos", 0.9, 0.95)],
rank: 1,
explanation_text: String::new(),
};
let b = ExplanationNode {
doc_id: "beta".to_string(),
final_score: 0.5,
contributions: vec![make_contribution("cos", 0.9, 0.55)],
rank: 2,
explanation_text: String::new(),
};
let comparison = SearchExplainer::compare_explanations(&a, &b);
assert!(comparison.contains("alpha"));
assert!(comparison.contains("outranks"));
}
#[test]
fn test_compare_explanations_no_factor_differences() {
let node = |id: &str, score: f64, rank: usize| ExplanationNode {
doc_id: id.to_string(),
final_score: score,
contributions: vec![],
rank,
explanation_text: String::new(),
};
let a = node("x", 0.8, 1);
let b = node("y", 0.3, 2);
let text = SearchExplainer::compare_explanations(&a, &b);
assert!(text.contains("No significant per-factor differences"));
}
#[test]
fn test_explain_batch_empty_contributions_per_item() {
let mut explainer = SearchExplainer::new(default_config());
let ctx = sample_ctx();
let results = vec![
("doc1".to_string(), 0.5, vec![]),
("doc2".to_string(), 0.3, vec![]),
];
let nodes = explainer.explain_batch(results, &ctx);
assert_eq!(nodes.len(), 2);
assert!(nodes[0].contributions.is_empty());
}
#[test]
fn test_verbose_mode_explanation_text() {
let config = ExplainerConfig {
verbose: true,
..default_config()
};
let mut explainer = SearchExplainer::new(config);
let ctx = sample_ctx();
let contribs = vec![make_contribution("cosine_similarity", 0.8, 0.9)];
let node = explainer.explain_result("verbose-doc", 0.72, contribs, 1, &ctx);
assert!(node.explanation_text.contains("Verbose breakdown"));
}
#[test]
fn test_non_verbose_mode_no_verbose_detail() {
let mut explainer = SearchExplainer::new(default_config());
let ctx = sample_ctx();
let contribs = vec![make_contribution("cosine_similarity", 0.8, 0.9)];
let node = explainer.explain_result("quiet-doc", 0.72, contribs, 1, &ctx);
assert!(!node.explanation_text.contains("Verbose breakdown"));
}
#[test]
fn test_stats_single_explanation() {
let mut explainer = SearchExplainer::new(default_config());
let ctx = sample_ctx();
let _ = explainer.explain_result("d", 0.5, vec![make_contribution("c", 0.5, 0.5)], 1, &ctx);
let stats = explainer.stats();
assert_eq!(stats.explanations_generated, 1);
assert_eq!(stats.total_results_explained, 1);
assert!((stats.avg_contributions_per_result - 1.0).abs() < 1e-9);
}
#[test]
fn test_stats_batch_tracking() {
let mut explainer = SearchExplainer::new(default_config());
let ctx = sample_ctx();
let results = vec![
(
"d1".to_string(),
0.9,
vec![
make_contribution("c", 0.5, 0.9),
make_contribution("t", 0.3, 0.4),
],
),
(
"d2".to_string(),
0.7,
vec![make_contribution("c", 0.5, 0.7)],
),
];
let _ = explainer.explain_batch(results, &ctx);
let stats = explainer.stats();
assert_eq!(stats.total_results_explained, 2);
assert!((stats.avg_contributions_per_result - 1.5).abs() < 1e-9);
}
#[test]
fn test_large_batch_no_panic() {
let mut explainer = SearchExplainer::new(default_config());
let ctx = sample_ctx();
let results: Vec<(String, f64, Vec<ScoreContribution>)> = (0..500)
.map(|i| {
let doc_id = format!("doc-{i}");
let score = 1.0 - i as f64 / 500.0;
let contribs = vec![make_contribution("cos", 0.8, score)];
(doc_id, score, contribs)
})
.collect();
let nodes = explainer.explain_batch(results, &ctx);
assert_eq!(nodes.len(), 500);
assert_eq!(explainer.stats().total_results_explained, 500);
}
#[test]
fn test_max_contributions_truncation() {
let config = ExplainerConfig {
max_contributions: 2,
..default_config()
};
let mut explainer = SearchExplainer::new(config);
let ctx = sample_ctx();
let contribs = vec![
make_contribution("a", 0.9, 0.9),
make_contribution("b", 0.8, 0.8),
make_contribution("c", 0.7, 0.7),
make_contribution("d", 0.6, 0.6),
];
let node = explainer.explain_result("doc", 0.85, contribs, 1, &ctx);
assert_eq!(node.contributions.len(), 2);
}
#[test]
fn test_cosine_similarity_symmetry() {
let a = vec![0.3, 0.4, 0.5, 0.6];
let b = vec![0.1, 0.9, 0.2, 0.7];
let ab = SearchExplainer::cosine_contribution(&a, &b, 1.0).raw_score;
let ba = SearchExplainer::cosine_contribution(&b, &a, 1.0).raw_score;
assert!((ab - ba).abs() < 1e-9);
}
}