use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::hash::Hash;
use khive_fusion::{fuse, FusionStrategy};
use khive_score::{weighted_sum, DeterministicScore};
use serde_json::Value;
use crate::{Result, RetrievalError};
pub async fn fuse_two_stage<Id, Resolve, Execution>(
engine_arms: Vec<Vec<(Id, DeterministicScore)>>,
engine_strategy: Option<&FusionStrategy>,
text: Vec<(Id, DeterministicScore)>,
hybrid_strategy: &FusionStrategy,
resolve_custom: Resolve,
) -> Result<Vec<(Id, DeterministicScore)>>
where
Id: Eq + Hash + Clone + Ord,
Resolve: FnOnce(String, Value, [Vec<(Id, DeterministicScore)>; 2], usize) -> Option<Execution>,
Execution: Future<Output = Result<Vec<(Id, DeterministicScore)>>>,
{
let keyword_only = matches!(hybrid_strategy, FusionStrategy::KeywordOnly);
let checked_engine = match (keyword_only, engine_strategy) {
(true, None) => None,
(true, Some(_)) => return Err(invalid("engine", "must be absent for KeywordOnly")),
(false, None) => return Err(invalid("engine", "is required for this hybrid strategy")),
(false, Some(strategy)) => {
if !matches!(
strategy,
FusionStrategy::Rrf { .. } | FusionStrategy::WeightedRrf { .. }
) {
return Err(invalid("engine", "accepts only Rrf or WeightedRrf"));
}
Some(checked_strategy(strategy, engine_arms.len(), "engine")?)
}
};
let checked_hybrid = checked_strategy(hybrid_strategy, 2, "hybrid")?;
let vector = match checked_engine {
Some(strategy) => checked_fuse(engine_arms, &strategy, "engine")?,
None => Vec::new(),
};
let sources = [vector, text];
match checked_hybrid {
FusionStrategy::Custom { name, params } => {
let limit = union_size(&sources);
let execution = resolve_custom(name.clone(), params, sources, limit)
.ok_or_else(|| invalid("hybrid", &format!("unknown custom strategy '{name}'")))?;
execution.await
}
FusionStrategy::Weighted { weights } => checked_linear(sources, &weights),
FusionStrategy::KeywordOnly => {
let [_, text] = sources;
let mut seen = HashSet::new();
Ok(text
.into_iter()
.filter(|(id, _)| seen.insert(id.clone()))
.collect())
}
strategy => checked_fuse(Vec::from(sources), &strategy, "hybrid"),
}
}
fn invalid(stage: &str, message: &str) -> RetrievalError {
RetrievalError::Fusion(format!("{stage} fusion: {message}"))
}
fn checked_strategy(
strategy: &FusionStrategy,
arm_count: usize,
stage: &str,
) -> Result<FusionStrategy> {
match strategy {
FusionStrategy::Rrf { k } | FusionStrategy::WeightedRrf { k, .. } if *k == 0 => {
return Err(invalid(stage, "RRF k must be at least 1"));
}
FusionStrategy::Weighted { weights } | FusionStrategy::WeightedRrf { weights, .. } => {
if weights.len() != arm_count {
return Err(invalid(
stage,
"requires exactly one weight per ordered source slot",
));
}
if weights
.iter()
.any(|weight| !weight.is_finite() || *weight <= 0.0)
{
return Err(invalid(
stage,
"weights must be finite and strictly positive",
));
}
}
FusionStrategy::Custom { name, .. } if name.is_empty() => {
return Err(invalid(stage, "custom strategy name must not be empty"));
}
_ => {}
}
Ok(match strategy {
FusionStrategy::Rrf { k } => FusionStrategy::WeightedRrf {
k: *k,
weights: vec![1.0; arm_count],
},
_ => strategy.clone(),
})
}
fn union_size<Id: Eq + Hash>(sources: &[Vec<(Id, DeterministicScore)>]) -> usize {
sources
.iter()
.flatten()
.map(|(id, _)| id)
.collect::<HashSet<_>>()
.len()
}
fn checked_fuse<Id: Eq + Hash + Clone + Ord>(
sources: Vec<Vec<(Id, DeterministicScore)>>,
strategy: &FusionStrategy,
stage: &str,
) -> Result<Vec<(Id, DeterministicScore)>> {
if let FusionStrategy::Rrf { k } | FusionStrategy::WeightedRrf { k, .. } = strategy {
if sources
.iter()
.any(|source| k.checked_add(source.len()).is_none())
{
return Err(invalid(stage, "RRF k plus source rank exceeds usize"));
}
}
let limit = union_size(&sources);
fuse(sources, strategy, limit).map_err(|error| invalid(stage, &error.to_string()))
}
fn checked_linear<Id: Eq + Hash + Clone + Ord>(
sources: [Vec<(Id, DeterministicScore)>; 2],
weights: &[f64],
) -> Result<Vec<(Id, DeterministicScore)>> {
let max_weight = weights[0].max(weights[1]);
let scaled = [weights[0] / max_weight, weights[1] / max_weight];
let sum = scaled[0] + scaled[1];
let normalized_weights = [scaled[0] / sum, scaled[1] / sum];
let scale = i128::from(DeterministicScore::from_f64(1.0).to_raw());
let mut combined: HashMap<Id, i64> = HashMap::new();
for (source, weight) in sources.into_iter().zip(normalized_weights) {
let Some(min) = source.iter().map(|(_, score)| score.to_raw()).min() else {
continue;
};
let max = source
.iter()
.map(|(_, score)| score.to_raw())
.max()
.expect("nonempty source");
let span = i128::from(max) - i128::from(min);
let mut best: HashMap<Id, DeterministicScore> = HashMap::new();
for (id, score) in source {
let raw = score.to_raw();
if raw <= DeterministicScore::NEG_INF.to_raw()
|| raw == DeterministicScore::MAX.to_raw()
{
return Err(invalid("hybrid", "linear input scores must be finite"));
}
let normalized = if span == 0 {
scale
} else {
(i128::from(raw) - i128::from(min)) * scale / span
};
let score = DeterministicScore::from_raw(normalized as i64);
best.entry(id)
.and_modify(|held| *held = (*held).max(score))
.or_insert(score);
}
for (id, score) in best {
let contribution = weighted_sum(&[score], &[weight])
.map_err(|error| invalid("hybrid", &error.to_string()))?
.to_raw();
let held = combined.entry(id).or_default();
*held = held
.checked_add(contribution)
.filter(|raw| *raw < i64::MAX && *raw > DeterministicScore::NEG_INF.to_raw())
.ok_or_else(|| invalid("hybrid", "linear score exceeds the finite score range"))?;
}
}
let mut results: Vec<_> = combined
.into_iter()
.map(|(id, raw)| (id, DeterministicScore::from_raw(raw)))
.collect();
results.sort_by(|(a_id, a_score), (b_id, b_score)| {
b_score.cmp(a_score).then_with(|| a_id.cmp(b_id))
});
Ok(results)
}