use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoreExplanation {
pub final_score: f32,
pub dense_score: f32,
pub sparse_score: f32,
pub fusion_strategy: FusionStrategy,
pub alpha: f32,
pub calculation: String,
pub contributions: ScoreContributions,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoreContributions {
pub dense_contribution: f32,
pub sparse_contribution: f32,
pub explanation: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub enum FusionStrategy {
WeightedSum,
ReciprocalRankFusion,
DistributionBased,
RelativeScore,
Max,
Min,
HarmonicMean,
GeometricMean,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HybridSearchConfig {
pub fusion_strategy: FusionStrategy,
pub alpha: f32,
pub rrf_k: f32,
pub normalize_scores: bool,
pub autocut: Option<usize>,
}
impl Default for HybridSearchConfig {
fn default() -> Self {
Self {
fusion_strategy: FusionStrategy::WeightedSum,
alpha: 0.7,
rrf_k: 60.0,
normalize_scores: true,
autocut: None,
}
}
}
#[derive(Debug, Clone)]
pub struct HybridQuery {
pub dense: Option<Vec<f32>>,
pub sparse: Option<(Vec<usize>, Vec<f32>)>,
pub k: usize,
pub config: HybridSearchConfig,
}
impl HybridQuery {
pub fn new(dense: Vec<f32>, sparse_indices: Vec<usize>, sparse_values: Vec<f32>) -> Self {
Self {
dense: Some(dense),
sparse: Some((sparse_indices, sparse_values)),
k: 10,
config: HybridSearchConfig::default(),
}
}
pub fn dense_only(dense: Vec<f32>) -> Self {
Self {
dense: Some(dense),
sparse: None,
k: 10,
config: HybridSearchConfig::default(),
}
}
pub fn sparse_only(indices: Vec<usize>, values: Vec<f32>) -> Self {
Self {
dense: None,
sparse: Some((indices, values)),
k: 10,
config: HybridSearchConfig::default(),
}
}
pub fn with_k(mut self, k: usize) -> Self {
self.k = k;
self
}
pub fn with_alpha(mut self, alpha: f32) -> Self {
self.config.alpha = alpha.clamp(0.0, 1.0);
self
}
pub fn with_fusion_strategy(mut self, strategy: FusionStrategy) -> Self {
self.config.fusion_strategy = strategy;
self
}
pub fn with_normalization(mut self, normalize: bool) -> Self {
self.config.normalize_scores = normalize;
self
}
}
pub fn hybrid_search_score(
dense_score: f32,
sparse_score: f32,
config: &HybridSearchConfig,
) -> f32 {
match config.fusion_strategy {
FusionStrategy::WeightedSum => {
config.alpha * dense_score + (1.0 - config.alpha) * sparse_score
}
FusionStrategy::ReciprocalRankFusion => {
let dense_rank_score = 1.0 / (config.rrf_k + (1.0 - dense_score));
let sparse_rank_score = 1.0 / (config.rrf_k + (1.0 - sparse_score));
dense_rank_score + sparse_rank_score
}
FusionStrategy::DistributionBased => {
config.alpha * dense_score + (1.0 - config.alpha) * sparse_score
}
FusionStrategy::RelativeScore => {
config.alpha * dense_score + (1.0 - config.alpha) * sparse_score
}
FusionStrategy::Max => dense_score.max(sparse_score),
FusionStrategy::Min => dense_score.min(sparse_score),
FusionStrategy::HarmonicMean => {
if dense_score + sparse_score == 0.0 {
0.0
} else {
2.0 * dense_score * sparse_score / (dense_score + sparse_score)
}
}
FusionStrategy::GeometricMean => {
if dense_score < 0.0 || sparse_score < 0.0 {
0.0
} else {
(dense_score * sparse_score).sqrt()
}
}
}
}
pub fn explain_hybrid_score(
dense_score: f32,
sparse_score: f32,
config: &HybridSearchConfig,
) -> ScoreExplanation {
let final_score = hybrid_search_score(dense_score, sparse_score, config);
let (calculation, contributions) = match config.fusion_strategy {
FusionStrategy::WeightedSum => {
let dense_weight = config.alpha;
let sparse_weight = 1.0 - config.alpha;
let dense_contrib = dense_score * dense_weight;
let sparse_contrib = sparse_score * sparse_weight;
let calc = format!(
"WeightedSum: {:.4} * {:.4} + {:.4} * {:.4} = {:.4}",
dense_weight, dense_score, sparse_weight, sparse_score, final_score
);
let total = dense_contrib + sparse_contrib;
let contributions = if total > 0.0 {
ScoreContributions {
dense_contribution: dense_contrib / total,
sparse_contribution: sparse_contrib / total,
explanation: format!(
"Dense: {:.1}%, Sparse: {:.1}%",
(dense_contrib / total) * 100.0,
(sparse_contrib / total) * 100.0
),
}
} else {
ScoreContributions {
dense_contribution: 0.5,
sparse_contribution: 0.5,
explanation: "Both scores are zero".to_string(),
}
};
(calc, contributions)
}
FusionStrategy::ReciprocalRankFusion => {
let dense_rank_score = 1.0 / (config.rrf_k + (1.0 - dense_score));
let sparse_rank_score = 1.0 / (config.rrf_k + (1.0 - sparse_score));
let calc = format!(
"RRF: 1/({:.1} + {:.4}) + 1/({:.1} + {:.4}) = {:.4}",
config.rrf_k,
1.0 - dense_score,
config.rrf_k,
1.0 - sparse_score,
final_score
);
let total = dense_rank_score + sparse_rank_score;
let contributions = ScoreContributions {
dense_contribution: dense_rank_score / total,
sparse_contribution: sparse_rank_score / total,
explanation: format!(
"Dense rank: {:.1}%, Sparse rank: {:.1}%",
(dense_rank_score / total) * 100.0,
(sparse_rank_score / total) * 100.0
),
};
(calc, contributions)
}
FusionStrategy::DistributionBased | FusionStrategy::RelativeScore => {
let dense_weight = config.alpha;
let sparse_weight = 1.0 - config.alpha;
let dense_contrib = dense_score * dense_weight;
let sparse_contrib = sparse_score * sparse_weight;
let strategy_name = match config.fusion_strategy {
FusionStrategy::DistributionBased => "DBSF",
_ => "RelativeScore",
};
let calc = format!(
"{}: {:.4} * {:.4} + {:.4} * {:.4} = {:.4}",
strategy_name, dense_weight, dense_score, sparse_weight, sparse_score, final_score
);
let total = dense_contrib + sparse_contrib;
let contributions = if total > 0.0 {
ScoreContributions {
dense_contribution: dense_contrib / total,
sparse_contribution: sparse_contrib / total,
explanation: format!(
"Dense: {:.1}%, Sparse: {:.1}%",
(dense_contrib / total) * 100.0,
(sparse_contrib / total) * 100.0
),
}
} else {
ScoreContributions {
dense_contribution: 0.5,
sparse_contribution: 0.5,
explanation: "Both scores are zero".to_string(),
}
};
(calc, contributions)
}
FusionStrategy::Max => {
let calc = format!(
"Max: max({:.4}, {:.4}) = {:.4}",
dense_score, sparse_score, final_score
);
let contributions = if dense_score > sparse_score {
ScoreContributions {
dense_contribution: 1.0,
sparse_contribution: 0.0,
explanation: "Dense score was higher (100% contribution)".to_string(),
}
} else if sparse_score > dense_score {
ScoreContributions {
dense_contribution: 0.0,
sparse_contribution: 1.0,
explanation: "Sparse score was higher (100% contribution)".to_string(),
}
} else {
ScoreContributions {
dense_contribution: 0.5,
sparse_contribution: 0.5,
explanation: "Scores were equal".to_string(),
}
};
(calc, contributions)
}
FusionStrategy::Min => {
let calc = format!(
"Min: min({:.4}, {:.4}) = {:.4}",
dense_score, sparse_score, final_score
);
let contributions = if dense_score < sparse_score {
ScoreContributions {
dense_contribution: 1.0,
sparse_contribution: 0.0,
explanation: "Dense score was lower (limited by it)".to_string(),
}
} else if sparse_score < dense_score {
ScoreContributions {
dense_contribution: 0.0,
sparse_contribution: 1.0,
explanation: "Sparse score was lower (limited by it)".to_string(),
}
} else {
ScoreContributions {
dense_contribution: 0.5,
sparse_contribution: 0.5,
explanation: "Scores were equal".to_string(),
}
};
(calc, contributions)
}
FusionStrategy::HarmonicMean => {
let calc = if dense_score + sparse_score == 0.0 {
"HarmonicMean: 0 (both scores zero)".to_string()
} else {
format!(
"HarmonicMean: 2 * {:.4} * {:.4} / ({:.4} + {:.4}) = {:.4}",
dense_score, sparse_score, dense_score, sparse_score, final_score
)
};
let contributions = ScoreContributions {
dense_contribution: 0.5,
sparse_contribution: 0.5,
explanation: "Both scores contribute (harmonic mean penalizes low scores)"
.to_string(),
};
(calc, contributions)
}
FusionStrategy::GeometricMean => {
let calc = if dense_score < 0.0 || sparse_score < 0.0 {
"GeometricMean: 0 (negative score)".to_string()
} else {
format!(
"GeometricMean: sqrt({:.4} * {:.4}) = {:.4}",
dense_score, sparse_score, final_score
)
};
let contributions = ScoreContributions {
dense_contribution: 0.5,
sparse_contribution: 0.5,
explanation: "Both scores contribute equally (geometric mean)".to_string(),
};
(calc, contributions)
}
};
ScoreExplanation {
final_score,
dense_score,
sparse_score,
fusion_strategy: config.fusion_strategy,
alpha: config.alpha,
calculation,
contributions,
}
}
pub fn normalize_scores(scores: &[f32]) -> Vec<f32> {
if scores.is_empty() {
return vec![];
}
if scores.len() == 1 {
return vec![1.0];
}
let min_score = scores.iter().copied().fold(f32::INFINITY, f32::min);
let max_score = scores.iter().copied().fold(f32::NEG_INFINITY, f32::max);
if (max_score - min_score).abs() < 1e-10 {
return vec![0.5; scores.len()];
}
scores
.iter()
.map(|&score| (score - min_score) / (max_score - min_score))
.collect()
}
pub fn normalize_scores_zscore(scores: &[f32]) -> Vec<f32> {
if scores.is_empty() {
return vec![];
}
if scores.len() == 1 {
return vec![0.5];
}
let mean = scores.iter().sum::<f32>() / scores.len() as f32;
let variance = scores.iter().map(|&x| (x - mean).powi(2)).sum::<f32>() / scores.len() as f32;
let std_dev = variance.sqrt();
if std_dev < 1e-10 {
return vec![0.5; scores.len()];
}
scores
.iter()
.map(|&score| {
let z = (score - mean) / std_dev;
1.0 / (1.0 + (-z).exp())
})
.collect()
}
pub fn normalize_scores_dbsf(scores: &[f32]) -> Vec<f32> {
if scores.is_empty() {
return vec![];
}
if scores.len() == 1 {
return vec![0.5];
}
let mean = scores.iter().sum::<f32>() / scores.len() as f32;
let variance = scores.iter().map(|&x| (x - mean).powi(2)).sum::<f32>() / scores.len() as f32;
let std_dev = variance.sqrt();
if std_dev < 1e-10 {
return vec![0.5; scores.len()];
}
let lower_bound = mean - 3.0 * std_dev;
let upper_bound = mean + 3.0 * std_dev;
let range = upper_bound - lower_bound;
if range < f32::EPSILON {
return vec![0.5; scores.len()];
}
scores
.iter()
.map(|&score| {
let clamped = score.clamp(lower_bound, upper_bound);
(clamped - lower_bound) / range
})
.collect()
}
pub fn apply_autocut<T: Clone>(results: Vec<(T, f32)>, autocut: usize) -> Vec<(T, f32)> {
if autocut == 0 || results.len() <= 1 {
return results;
}
let mut drops: Vec<f32> = Vec::with_capacity(results.len() - 1);
for i in 0..results.len() - 1 {
let drop = results[i].1 - results[i + 1].1;
drops.push(drop);
}
if drops.is_empty() {
return results;
}
let mut sorted_drops = drops.clone();
sorted_drops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median_drop = if sorted_drops.len() % 2 == 0 {
let mid = sorted_drops.len() / 2;
(sorted_drops[mid - 1] + sorted_drops[mid]) / 2.0
} else {
sorted_drops[sorted_drops.len() / 2]
};
let jump_threshold = median_drop * 2.0;
let mut jump_positions: Vec<usize> = Vec::new();
for (i, &drop) in drops.iter().enumerate() {
if drop > jump_threshold && drop > 0.01 {
jump_positions.push(i + 1); }
}
if jump_positions.is_empty() {
return results;
}
let cut_position = jump_positions.get(autocut - 1).copied().unwrap_or_else(|| {
*jump_positions.last().unwrap()
});
results.into_iter().take(cut_position).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fusion_weighted_sum() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::WeightedSum,
alpha: 0.7,
..Default::default()
};
let score = hybrid_search_score(0.8, 0.6, &config);
let expected = 0.7 * 0.8 + 0.3 * 0.6;
assert!((score - expected).abs() < 1e-6);
}
#[test]
fn test_fusion_weighted_sum_pure_dense() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::WeightedSum,
alpha: 1.0, ..Default::default()
};
let score = hybrid_search_score(0.8, 0.6, &config);
assert!((score - 0.8).abs() < 1e-6);
}
#[test]
fn test_fusion_weighted_sum_pure_sparse() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::WeightedSum,
alpha: 0.0, ..Default::default()
};
let score = hybrid_search_score(0.8, 0.6, &config);
assert!((score - 0.6).abs() < 1e-6);
}
#[test]
fn test_fusion_max() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::Max,
..Default::default()
};
let score = hybrid_search_score(0.8, 0.6, &config);
assert!((score - 0.8).abs() < 1e-6);
let score2 = hybrid_search_score(0.5, 0.9, &config);
assert!((score2 - 0.9).abs() < 1e-6);
}
#[test]
fn test_fusion_min() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::Min,
..Default::default()
};
let score = hybrid_search_score(0.8, 0.6, &config);
assert!((score - 0.6).abs() < 1e-6);
let score2 = hybrid_search_score(0.5, 0.9, &config);
assert!((score2 - 0.5).abs() < 1e-6);
}
#[test]
fn test_fusion_harmonic_mean() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::HarmonicMean,
..Default::default()
};
let score = hybrid_search_score(0.8, 0.6, &config);
let expected = 2.0 * 0.8 * 0.6 / (0.8 + 0.6);
assert!((score - expected).abs() < 1e-6);
}
#[test]
fn test_fusion_harmonic_mean_zero() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::HarmonicMean,
..Default::default()
};
let score = hybrid_search_score(0.0, 0.0, &config);
assert_eq!(score, 0.0);
}
#[test]
fn test_normalize_scores_minmax() {
let scores = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let normalized = normalize_scores(&scores);
assert_eq!(normalized.len(), 5);
assert!((normalized[0] - 0.0).abs() < 1e-6); assert!((normalized[4] - 1.0).abs() < 1e-6); assert!((normalized[2] - 0.5).abs() < 1e-6); }
#[test]
fn test_normalize_scores_all_same() {
let scores = vec![5.0, 5.0, 5.0];
let normalized = normalize_scores(&scores);
assert_eq!(normalized.len(), 3);
assert!(normalized.iter().all(|&x| (x - 0.5).abs() < 1e-6));
}
#[test]
fn test_normalize_scores_single() {
let scores = vec![5.0];
let normalized = normalize_scores(&scores);
assert_eq!(normalized.len(), 1);
assert!((normalized[0] - 1.0).abs() < 1e-6);
}
#[test]
fn test_normalize_scores_empty() {
let scores = vec![];
let normalized = normalize_scores(&scores);
assert_eq!(normalized.len(), 0);
}
#[test]
fn test_normalize_scores_zscore() {
let scores = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let normalized = normalize_scores_zscore(&scores);
assert_eq!(normalized.len(), 5);
assert!(normalized.iter().all(|&x| x >= 0.0 && x <= 1.0));
let mean = normalized.iter().sum::<f32>() / normalized.len() as f32;
assert!((mean - 0.5).abs() < 0.1);
}
#[test]
fn test_hybrid_query_builder() {
let query = HybridQuery::new(vec![0.1, 0.2, 0.3], vec![10, 25], vec![1.0, 2.0])
.with_k(20)
.with_alpha(0.8)
.with_fusion_strategy(FusionStrategy::Max);
assert_eq!(query.k, 20);
assert!((query.config.alpha - 0.8).abs() < 1e-6);
assert_eq!(query.config.fusion_strategy, FusionStrategy::Max);
}
#[test]
fn test_hybrid_query_dense_only() {
let query = HybridQuery::dense_only(vec![0.1, 0.2, 0.3]);
assert!(query.dense.is_some());
assert!(query.sparse.is_none());
}
#[test]
fn test_hybrid_query_sparse_only() {
let query = HybridQuery::sparse_only(vec![1, 2, 3], vec![0.5, 0.6, 0.7]);
assert!(query.dense.is_none());
assert!(query.sparse.is_some());
}
#[test]
fn test_alpha_clamping() {
let query = HybridQuery::dense_only(vec![0.1]).with_alpha(1.5);
assert!((query.config.alpha - 1.0).abs() < 1e-6);
let query2 = HybridQuery::dense_only(vec![0.1]).with_alpha(-0.5);
assert!((query2.config.alpha - 0.0).abs() < 1e-6); }
#[test]
fn test_fusion_rrf() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::ReciprocalRankFusion,
rrf_k: 60.0,
..Default::default()
};
let score = hybrid_search_score(0.9, 0.8, &config);
assert!(score > 0.0);
let score2 = hybrid_search_score(0.5, 0.5, &config);
assert!(score > score2);
}
#[test]
fn test_fusion_geometric_mean() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::GeometricMean,
..Default::default()
};
let score = hybrid_search_score(0.64, 0.36, &config);
assert!((score - 0.48).abs() < 1e-6);
}
#[test]
fn test_fusion_geometric_mean_negative() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::GeometricMean,
..Default::default()
};
let score = hybrid_search_score(-0.5, 0.5, &config);
assert_eq!(score, 0.0);
}
#[test]
fn test_normalize_dbsf_basic() {
let scores = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let normalized = normalize_scores_dbsf(&scores);
assert_eq!(normalized.len(), 5);
assert!(normalized.iter().all(|&x| x >= 0.0 && x <= 1.0));
assert!(normalized[0] < normalized[2]); assert!(normalized[2] < normalized[4]); }
#[test]
fn test_normalize_dbsf_with_outliers() {
let scores = vec![1.0, 2.0, 3.0, 4.0, 100.0];
let normalized = normalize_scores_dbsf(&scores);
assert_eq!(normalized.len(), 5);
assert!(normalized.iter().all(|&x| x >= 0.0 && x <= 1.0));
let outlier_score = normalized[4];
assert!(
outlier_score >= 0.8,
"Outlier should be near upper bound after clamping"
);
}
#[test]
fn test_normalize_dbsf_all_same() {
let scores = vec![5.0, 5.0, 5.0, 5.0];
let normalized = normalize_scores_dbsf(&scores);
assert_eq!(normalized.len(), 4);
assert!(normalized.iter().all(|&x| (x - 0.5).abs() < 1e-6));
}
#[test]
fn test_normalize_dbsf_empty() {
let scores: Vec<f32> = vec![];
let normalized = normalize_scores_dbsf(&scores);
assert_eq!(normalized.len(), 0);
}
#[test]
fn test_normalize_dbsf_single() {
let scores = vec![42.0];
let normalized = normalize_scores_dbsf(&scores);
assert_eq!(normalized.len(), 1);
assert!((normalized[0] - 0.5).abs() < 1e-6);
}
#[test]
fn test_normalize_dbsf_vs_minmax() {
let scores = vec![
10.0, 12.0, 11.0, 13.0, 100.0, ];
let dbsf = normalize_scores_dbsf(&scores);
let minmax = normalize_scores(&scores);
assert!(dbsf.iter().all(|&x| x >= 0.0 && x <= 1.0));
assert!(minmax.iter().all(|&x| x >= 0.0 && x <= 1.0));
let minmax_val_at_13 = minmax[3]; assert!(
minmax_val_at_13 < 0.1,
"Min-max compresses normal values: {}",
minmax_val_at_13
);
let _dbsf_outlier = dbsf[4];
let minmax_outlier = minmax[4];
assert_eq!(minmax_outlier, 1.0, "Min-max puts outlier at 1.0");
}
#[test]
fn test_normalize_dbsf_normal_distribution() {
let scores = vec![
2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0, 10.0, ];
let normalized = normalize_scores_dbsf(&scores);
assert!(normalized.iter().all(|&x| x >= 0.0 && x <= 1.0));
let outlier_score = normalized[scores.len() - 1];
assert!(outlier_score > 0.9);
}
#[test]
fn test_fusion_strategies_comparison() {
let dense = 0.8;
let sparse = 0.6;
let ws_config = HybridSearchConfig {
fusion_strategy: FusionStrategy::WeightedSum,
alpha: 0.7,
..Default::default()
};
let ws_score = hybrid_search_score(dense, sparse, &ws_config);
assert!((ws_score - (0.7 * 0.8 + 0.3 * 0.6)).abs() < 1e-6);
let hm_config = HybridSearchConfig {
fusion_strategy: FusionStrategy::HarmonicMean,
..Default::default()
};
let hm_score = hybrid_search_score(dense, sparse, &hm_config);
let expected_hm = 2.0 * 0.8 * 0.6 / (0.8 + 0.6);
assert!((hm_score - expected_hm).abs() < 1e-6);
let gm_config = HybridSearchConfig {
fusion_strategy: FusionStrategy::GeometricMean,
..Default::default()
};
let gm_score = hybrid_search_score(dense, sparse, &gm_config);
let expected_gm = (0.8 * 0.6_f32).sqrt();
assert!((gm_score - expected_gm).abs() < 1e-6);
assert!(hm_score <= gm_score);
assert!(gm_score <= ws_score); }
#[test]
fn test_all_fusion_strategies_produce_valid_scores() {
let test_cases = vec![(0.9, 0.8), (0.5, 0.5), (0.1, 0.9), (0.0, 1.0), (1.0, 0.0)];
let strategies = vec![
FusionStrategy::WeightedSum,
FusionStrategy::ReciprocalRankFusion,
FusionStrategy::DistributionBased,
FusionStrategy::RelativeScore,
FusionStrategy::Max,
FusionStrategy::Min,
FusionStrategy::HarmonicMean,
FusionStrategy::GeometricMean,
];
for (dense, sparse) in test_cases {
for strategy in &strategies {
let config = HybridSearchConfig {
fusion_strategy: *strategy,
alpha: 0.7,
rrf_k: 60.0,
normalize_scores: true,
autocut: None,
};
let score = hybrid_search_score(dense, sparse, &config);
assert!(
score.is_finite(),
"Strategy {:?} produced non-finite score for ({}, {})",
strategy,
dense,
sparse
);
if !matches!(strategy, FusionStrategy::ReciprocalRankFusion) {
assert!(
score >= 0.0 && score <= 1.5,
"Strategy {:?} produced out-of-range score {} for ({}, {})",
strategy,
score,
dense,
sparse
);
}
}
}
}
#[test]
fn test_autocut_disabled() {
let results = vec![
("doc1".to_string(), 0.9),
("doc2".to_string(), 0.5),
("doc3".to_string(), 0.1),
];
let no_cut = apply_autocut(results.clone(), 0);
assert_eq!(no_cut.len(), 3);
}
#[test]
fn test_autocut_single_result() {
let results = vec![("doc1".to_string(), 0.9)];
let cut = apply_autocut(results.clone(), 1);
assert_eq!(cut.len(), 1); }
#[test]
fn test_autocut_clear_jump() {
let results = vec![
("doc1".to_string(), 0.95),
("doc2".to_string(), 0.92),
("doc3".to_string(), 0.90), ("doc4".to_string(), 0.45), ("doc5".to_string(), 0.42),
("doc6".to_string(), 0.40), ];
let cut = apply_autocut(results, 1);
assert_eq!(cut.len(), 3); assert_eq!(cut[0].0, "doc1");
assert_eq!(cut[1].0, "doc2");
assert_eq!(cut[2].0, "doc3");
}
#[test]
fn test_autocut_multiple_jumps() {
let results = vec![
("doc1".to_string(), 0.95),
("doc2".to_string(), 0.90), ("doc3".to_string(), 0.60), ("doc4".to_string(), 0.55), ("doc5".to_string(), 0.25), ("doc6".to_string(), 0.20), ];
let cut1 = apply_autocut(results.clone(), 1);
assert_eq!(cut1.len(), 2);
let cut2 = apply_autocut(results, 2);
assert_eq!(cut2.len(), 4); }
#[test]
fn test_autocut_no_jumps() {
let results = vec![
("doc1".to_string(), 0.90),
("doc2".to_string(), 0.85),
("doc3".to_string(), 0.80),
("doc4".to_string(), 0.75),
("doc5".to_string(), 0.70),
];
let cut = apply_autocut(results.clone(), 1);
assert_eq!(cut.len(), 5);
}
#[test]
fn test_autocut_equal_scores() {
let results = vec![
("doc1".to_string(), 0.8),
("doc2".to_string(), 0.8),
("doc3".to_string(), 0.8),
("doc4".to_string(), 0.8),
];
let cut = apply_autocut(results.clone(), 1);
assert_eq!(cut.len(), 4); }
#[test]
fn test_autocut_jump_at_end() {
let results = vec![
("doc1".to_string(), 0.9),
("doc2".to_string(), 0.85),
("doc3".to_string(), 0.82),
("doc4".to_string(), 0.80),
("doc5".to_string(), 0.1), ];
let cut = apply_autocut(results, 1);
assert_eq!(cut.len(), 4); }
#[test]
fn test_autocut_request_more_jumps_than_exist() {
let results = vec![
("doc1".to_string(), 0.9),
("doc2".to_string(), 0.85),
("doc3".to_string(), 0.2), ("doc4".to_string(), 0.15),
];
let cut = apply_autocut(results, 5); assert_eq!(cut.len(), 2); }
#[test]
fn test_autocut_with_integers() {
let results = vec![
(1, 0.95),
(2, 0.92),
(3, 0.90),
(4, 0.45), (5, 0.42),
];
let cut = apply_autocut(results, 1);
assert_eq!(cut.len(), 3);
assert_eq!(cut[0].0, 1);
assert_eq!(cut[1].0, 2);
assert_eq!(cut[2].0, 3);
}
#[test]
fn test_autocut_realistic_rag_scenario() {
let results = vec![
("chunk1".to_string(), 0.89), ("chunk2".to_string(), 0.87),
("chunk3".to_string(), 0.85),
("chunk4".to_string(), 0.52), ("chunk5".to_string(), 0.50), ("chunk6".to_string(), 0.48),
("chunk7".to_string(), 0.46),
("chunk8".to_string(), 0.45),
("chunk9".to_string(), 0.43),
("chunk10".to_string(), 0.41),
];
let cut = apply_autocut(results, 1);
assert!(cut.len() <= 4); assert!(cut.len() >= 3);
assert!(cut.iter().all(|(_, score)| *score > 0.8));
}
#[test]
fn test_autocut_preserves_order() {
let results = vec![
("doc1".to_string(), 0.9),
("doc2".to_string(), 0.85),
("doc3".to_string(), 0.5), ("doc4".to_string(), 0.45),
];
let cut = apply_autocut(results, 1);
assert_eq!(cut[0].0, "doc1");
assert_eq!(cut[1].0, "doc2");
assert!(cut[0].1 > cut[1].1); }
#[test]
fn test_autocut_empty_results() {
let results: Vec<(String, f32)> = vec![];
let cut = apply_autocut(results, 1);
assert_eq!(cut.len(), 0);
}
#[test]
fn test_explain_weighted_sum() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::WeightedSum,
alpha: 0.7,
..Default::default()
};
let explanation = explain_hybrid_score(0.8, 0.6, &config);
assert_eq!(explanation.dense_score, 0.8);
assert_eq!(explanation.sparse_score, 0.6);
assert_eq!(explanation.fusion_strategy, FusionStrategy::WeightedSum);
assert_eq!(explanation.alpha, 0.7);
assert!((explanation.final_score - 0.74).abs() < 1e-6);
assert!(explanation.calculation.contains("WeightedSum"));
assert!(explanation.calculation.contains("0.7"));
assert!(explanation.calculation.contains("0.8"));
assert!(explanation.contributions.dense_contribution > 0.7);
assert!(explanation.contributions.sparse_contribution < 0.3);
}
#[test]
fn test_explain_rrf() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::ReciprocalRankFusion,
rrf_k: 60.0,
..Default::default()
};
let explanation = explain_hybrid_score(0.9, 0.5, &config);
assert_eq!(
explanation.fusion_strategy,
FusionStrategy::ReciprocalRankFusion
);
assert!(explanation.final_score > 0.0);
assert!(explanation.calculation.contains("RRF"));
assert!(explanation.calculation.contains("60"));
let total = explanation.contributions.dense_contribution
+ explanation.contributions.sparse_contribution;
assert!((total - 1.0).abs() < 0.01);
}
#[test]
fn test_explain_max() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::Max,
..Default::default()
};
let explanation = explain_hybrid_score(0.8, 0.6, &config);
assert_eq!(explanation.final_score, 0.8); assert!(explanation.calculation.contains("Max"));
assert_eq!(explanation.contributions.dense_contribution, 1.0);
assert_eq!(explanation.contributions.sparse_contribution, 0.0);
assert!(explanation
.contributions
.explanation
.contains("Dense score was higher"));
}
#[test]
fn test_explain_min() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::Min,
..Default::default()
};
let explanation = explain_hybrid_score(0.8, 0.6, &config);
assert_eq!(explanation.final_score, 0.6); assert!(explanation.calculation.contains("Min"));
assert_eq!(explanation.contributions.dense_contribution, 0.0);
assert_eq!(explanation.contributions.sparse_contribution, 1.0);
assert!(explanation
.contributions
.explanation
.contains("Sparse score was lower"));
}
#[test]
fn test_explain_harmonic_mean() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::HarmonicMean,
..Default::default()
};
let explanation = explain_hybrid_score(0.8, 0.6, &config);
assert!((explanation.final_score - 0.6857).abs() < 0.01);
assert!(explanation.calculation.contains("HarmonicMean"));
assert_eq!(explanation.contributions.dense_contribution, 0.5);
assert_eq!(explanation.contributions.sparse_contribution, 0.5);
}
#[test]
fn test_explain_geometric_mean() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::GeometricMean,
..Default::default()
};
let explanation = explain_hybrid_score(0.64, 0.36, &config);
assert!((explanation.final_score - 0.48).abs() < 0.01);
assert!(explanation.calculation.contains("GeometricMean"));
assert_eq!(explanation.contributions.dense_contribution, 0.5);
assert_eq!(explanation.contributions.sparse_contribution, 0.5);
}
#[test]
fn test_explain_dbsf() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::DistributionBased,
alpha: 0.6,
..Default::default()
};
let explanation = explain_hybrid_score(0.7, 0.5, &config);
assert_eq!(
explanation.fusion_strategy,
FusionStrategy::DistributionBased
);
assert!(explanation.calculation.contains("DBSF"));
assert!(explanation.final_score > 0.0);
}
#[test]
fn test_explain_relative_score() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::RelativeScore,
alpha: 0.8,
..Default::default()
};
let explanation = explain_hybrid_score(0.9, 0.3, &config);
assert_eq!(explanation.fusion_strategy, FusionStrategy::RelativeScore);
assert!(explanation.calculation.contains("RelativeScore"));
assert!(explanation.final_score > 0.0);
}
#[test]
fn test_explain_zero_scores() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::WeightedSum,
alpha: 0.7,
..Default::default()
};
let explanation = explain_hybrid_score(0.0, 0.0, &config);
assert_eq!(explanation.final_score, 0.0);
assert_eq!(explanation.dense_score, 0.0);
assert_eq!(explanation.sparse_score, 0.0);
assert!(explanation
.contributions
.explanation
.contains("Both scores are zero"));
}
#[test]
fn test_explain_equal_scores_max() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::Max,
..Default::default()
};
let explanation = explain_hybrid_score(0.7, 0.7, &config);
assert_eq!(explanation.final_score, 0.7);
assert_eq!(explanation.contributions.dense_contribution, 0.5);
assert_eq!(explanation.contributions.sparse_contribution, 0.5);
assert!(explanation.contributions.explanation.contains("equal"));
}
#[test]
fn test_explain_serialization() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::WeightedSum,
alpha: 0.7,
..Default::default()
};
let explanation = explain_hybrid_score(0.8, 0.6, &config);
let json = serde_json::to_string(&explanation).unwrap();
assert!(json.contains("final_score"));
assert!(json.contains("dense_score"));
assert!(json.contains("calculation"));
let deserialized: ScoreExplanation = serde_json::from_str(&json).unwrap();
assert!((deserialized.final_score - explanation.final_score).abs() < 1e-6);
assert_eq!(deserialized.fusion_strategy, explanation.fusion_strategy);
}
#[test]
fn test_explain_pure_dense() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::WeightedSum,
alpha: 1.0, ..Default::default()
};
let explanation = explain_hybrid_score(0.8, 0.6, &config);
assert!((explanation.final_score - 0.8).abs() < 1e-6);
assert!(explanation.contributions.dense_contribution > 0.99); }
#[test]
fn test_explain_pure_sparse() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::WeightedSum,
alpha: 0.0, ..Default::default()
};
let explanation = explain_hybrid_score(0.8, 0.6, &config);
assert!((explanation.final_score - 0.6).abs() < 1e-6);
assert!(explanation.contributions.sparse_contribution > 0.99); }
#[test]
fn test_explain_realistic_rag_scenario() {
let config = HybridSearchConfig {
fusion_strategy: FusionStrategy::WeightedSum,
alpha: 0.7, ..Default::default()
};
let explanation = explain_hybrid_score(0.92, 0.15, &config);
assert!(explanation.final_score > 0.6);
assert!(explanation.contributions.dense_contribution > 0.8);
assert!(explanation.calculation.len() > 0);
assert!(explanation.contributions.explanation.len() > 0);
}
}