Skip to main content

hyphae_retrieval/
hybrid.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Deterministic reciprocal-rank fusion under hybrid semantics v1.
4
5use std::collections::BTreeMap;
6
7use thiserror::Error;
8
9use crate::{
10    ExactAbstentionReason, ExactRetrievalOutcome, LexicalAbstentionReason, LexicalOutcome,
11};
12
13/// Fixed RRF rank constant.
14pub const HYBRID_RRF_CONSTANT: u64 = 60;
15const CONTRIBUTION_SCALE: u64 = 1_000_000_000;
16const MAX_WEIGHT: u32 = 1_000_000;
17
18/// Complete fusion request.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct HybridRequest {
21    /// Positive lexical branch weight.
22    pub lexical_weight: u32,
23    /// Positive exact-vector branch weight.
24    pub vector_weight: u32,
25    /// Maximum returned fused matches.
26    pub limit: usize,
27}
28
29/// Preserved reason for an absent branch.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum HybridBranchAbsence {
32    /// Lexical branch had no candidates.
33    LexicalNoCandidates,
34    /// Exact branch had no candidates.
35    VectorNoCandidates,
36    /// Exact branch was below its threshold.
37    VectorBelowThreshold,
38    /// Exact branch was ambiguous under margin policy.
39    VectorAmbiguous,
40}
41
42/// Full per-result fusion explanation.
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct HybridExplanation {
45    /// One-based lexical rank.
46    pub lexical_rank: Option<u64>,
47    /// Canonical BM25F score.
48    pub lexical_score_nanos: Option<i64>,
49    /// One-based exact-vector rank.
50    pub vector_rank: Option<u64>,
51    /// Canonical integer cosine score.
52    pub vector_score_nanos: Option<i64>,
53    /// Integer lexical contribution.
54    pub lexical_contribution: u64,
55    /// Integer vector contribution.
56    pub vector_contribution: u64,
57    /// Checked contribution sum.
58    pub fusion_score: u64,
59    /// One-based final rank.
60    pub final_rank: u64,
61}
62
63/// One canonical fused match.
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct HybridMatch {
66    /// Binary object key.
67    pub key: Vec<u8>,
68    /// Explainable fusion components.
69    pub explanation: HybridExplanation,
70}
71
72/// Both branches abstained.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct HybridAbstention {
75    /// Lexical reason.
76    pub lexical: HybridBranchAbsence,
77    /// Exact-vector reason.
78    pub vector: HybridBranchAbsence,
79}
80
81/// Complete hybrid outcome.
82#[derive(Clone, Debug, Eq, PartialEq)]
83pub enum HybridOutcome {
84    /// Fused or explicit single-modality matches.
85    Matches {
86        /// Final matches.
87        matches: Vec<HybridMatch>,
88        /// Preserved lexical absence.
89        lexical_absence: Option<HybridBranchAbsence>,
90        /// Preserved vector absence.
91        vector_absence: Option<HybridBranchAbsence>,
92    },
93    /// Both branches abstained.
94    Abstained(HybridAbstention),
95}
96
97/// Fusion failure.
98#[derive(Clone, Debug, Error, Eq, PartialEq)]
99pub enum HybridError {
100    /// Weights must be positive and bounded.
101    #[error("hybrid branch weights must be in 1..=1000000")]
102    InvalidWeight,
103    /// At least one result must be requested.
104    #[error("hybrid result limit must be nonzero")]
105    ZeroLimit,
106    /// A branch unexpectedly repeats one key.
107    #[error("hybrid branch contains a duplicate key")]
108    DuplicateBranchKey,
109    /// Checked integer arithmetic failed.
110    #[error("hybrid contribution arithmetic overflow")]
111    ArithmeticOverflow,
112}
113
114#[derive(Clone, Copy, Default)]
115struct Accumulator {
116    lexical_rank: Option<u64>,
117    lexical_score_nanos: Option<i64>,
118    vector_rank: Option<u64>,
119    vector_score_nanos: Option<i64>,
120}
121
122/// Fuses complete branch outcomes with deterministic RRF.
123///
124/// # Errors
125///
126/// Returns invalid-input, duplicate-key, or arithmetic failure with no
127/// partial result.
128pub fn fuse_hybrid(
129    lexical: &LexicalOutcome,
130    vector: &ExactRetrievalOutcome,
131    request: &HybridRequest,
132) -> Result<HybridOutcome, HybridError> {
133    validate_request(request)?;
134    let (lexical_matches, lexical_absence) = lexical_branch(lexical);
135    let (vector_matches, vector_absence) = vector_branch(vector);
136    if let (Some(lexical), Some(vector)) = (lexical_absence, vector_absence) {
137        return Ok(HybridOutcome::Abstained(HybridAbstention {
138            lexical,
139            vector,
140        }));
141    }
142    let mut combined = BTreeMap::<Vec<u8>, Accumulator>::new();
143    if let Some(matches) = lexical_matches {
144        for (index, matched) in matches.iter().enumerate() {
145            let rank = one_based(index)?;
146            let entry = combined.entry(matched.key.clone()).or_default();
147            if entry.lexical_rank.replace(rank).is_some() {
148                return Err(HybridError::DuplicateBranchKey);
149            }
150            entry.lexical_score_nanos = Some(matched.score_nanos);
151        }
152    }
153    if let Some(matches) = vector_matches {
154        for (index, matched) in matches.iter().enumerate() {
155            let rank = one_based(index)?;
156            let entry = combined.entry(matched.key.clone()).or_default();
157            if entry.vector_rank.replace(rank).is_some() {
158                return Err(HybridError::DuplicateBranchKey);
159            }
160            entry.vector_score_nanos = Some(matched.score_nanos);
161        }
162    }
163    let mut matches = combined
164        .into_iter()
165        .map(|(key, entry)| build_match(key, entry, request))
166        .collect::<Result<Vec<_>, HybridError>>()?;
167    matches.sort_by(|left, right| {
168        right
169            .explanation
170            .fusion_score
171            .cmp(&left.explanation.fusion_score)
172            .then_with(|| left.key.cmp(&right.key))
173    });
174    matches.truncate(request.limit);
175    for (index, matched) in matches.iter_mut().enumerate() {
176        matched.explanation.final_rank = one_based(index)?;
177    }
178    Ok(HybridOutcome::Matches {
179        matches,
180        lexical_absence,
181        vector_absence,
182    })
183}
184
185fn validate_request(request: &HybridRequest) -> Result<(), HybridError> {
186    if !(1..=MAX_WEIGHT).contains(&request.lexical_weight)
187        || !(1..=MAX_WEIGHT).contains(&request.vector_weight)
188    {
189        return Err(HybridError::InvalidWeight);
190    }
191    if request.limit == 0 {
192        return Err(HybridError::ZeroLimit);
193    }
194    Ok(())
195}
196
197fn lexical_branch(
198    lexical: &LexicalOutcome,
199) -> (Option<&[crate::LexicalMatch]>, Option<HybridBranchAbsence>) {
200    match lexical {
201        LexicalOutcome::Matches { matches, .. } => (Some(matches.as_slice()), None),
202        LexicalOutcome::Abstained(abstention) => (
203            None,
204            Some(match abstention.reason {
205                LexicalAbstentionReason::NoCandidates => HybridBranchAbsence::LexicalNoCandidates,
206            }),
207        ),
208    }
209}
210
211fn vector_branch(
212    vector: &ExactRetrievalOutcome,
213) -> (
214    Option<&[crate::ExactRetrievalMatch]>,
215    Option<HybridBranchAbsence>,
216) {
217    match vector {
218        ExactRetrievalOutcome::Matches { matches, .. } => (Some(matches.as_slice()), None),
219        ExactRetrievalOutcome::Abstained(abstention) => (
220            None,
221            Some(match abstention.reason {
222                ExactAbstentionReason::NoCandidates => HybridBranchAbsence::VectorNoCandidates,
223                ExactAbstentionReason::BelowThreshold => HybridBranchAbsence::VectorBelowThreshold,
224                ExactAbstentionReason::Ambiguous => HybridBranchAbsence::VectorAmbiguous,
225            }),
226        ),
227    }
228}
229
230fn build_match(
231    key: Vec<u8>,
232    entry: Accumulator,
233    request: &HybridRequest,
234) -> Result<HybridMatch, HybridError> {
235    let lexical_contribution = contribution(request.lexical_weight, entry.lexical_rank)?;
236    let vector_contribution = contribution(request.vector_weight, entry.vector_rank)?;
237    let fusion_score = lexical_contribution
238        .checked_add(vector_contribution)
239        .ok_or(HybridError::ArithmeticOverflow)?;
240    Ok(HybridMatch {
241        key,
242        explanation: HybridExplanation {
243            lexical_rank: entry.lexical_rank,
244            lexical_score_nanos: entry.lexical_score_nanos,
245            vector_rank: entry.vector_rank,
246            vector_score_nanos: entry.vector_score_nanos,
247            lexical_contribution,
248            vector_contribution,
249            fusion_score,
250            final_rank: 0,
251        },
252    })
253}
254
255fn one_based(index: usize) -> Result<u64, HybridError> {
256    u64::try_from(index)
257        .ok()
258        .and_then(|rank| rank.checked_add(1))
259        .ok_or(HybridError::ArithmeticOverflow)
260}
261
262fn contribution(weight: u32, rank: Option<u64>) -> Result<u64, HybridError> {
263    let Some(rank) = rank else {
264        return Ok(0);
265    };
266    let denominator = HYBRID_RRF_CONSTANT
267        .checked_add(rank)
268        .ok_or(HybridError::ArithmeticOverflow)?;
269    u64::from(weight)
270        .checked_mul(CONTRIBUTION_SCALE)
271        .and_then(|numerator| numerator.checked_div(denominator))
272        .ok_or(HybridError::ArithmeticOverflow)
273}
274
275#[cfg(test)]
276mod tests {
277    use crate::{ExactAbstention, ExactRetrievalMatch, LexicalMatch};
278
279    use super::*;
280
281    fn lexical(keys: &[&[u8]]) -> LexicalOutcome {
282        LexicalOutcome::Matches {
283            matches: keys
284                .iter()
285                .enumerate()
286                .map(|(index, key)| LexicalMatch {
287                    key: key.to_vec(),
288                    score_nanos: 100 - i64::try_from(index).unwrap_or(0),
289                    terms: Vec::new(),
290                })
291                .collect(),
292            scanned_documents: keys.len() as u64,
293            matched_documents: keys.len() as u64,
294            query_tokens: vec!["x".into()],
295        }
296    }
297
298    fn vector(keys: &[&[u8]]) -> ExactRetrievalOutcome {
299        ExactRetrievalOutcome::Matches {
300            matches: keys
301                .iter()
302                .enumerate()
303                .map(|(index, key)| ExactRetrievalMatch {
304                    key: key.to_vec(),
305                    score_nanos: 100 - i64::try_from(index).unwrap_or(0),
306                })
307                .collect(),
308            scanned_candidates: keys.len() as u64,
309        }
310    }
311
312    #[test]
313    fn rrf_deduplicates_and_explains_rank() -> Result<(), HybridError> {
314        let outcome = fuse_hybrid(
315            &lexical(&[b"a", b"b"]),
316            &vector(&[b"b", b"c"]),
317            &HybridRequest {
318                lexical_weight: 1,
319                vector_weight: 1,
320                limit: 3,
321            },
322        )?;
323        let HybridOutcome::Matches { matches, .. } = outcome else {
324            return Err(HybridError::ArithmeticOverflow);
325        };
326        assert_eq!(matches[0].key, b"b");
327        assert_eq!(matches[0].explanation.lexical_rank, Some(2));
328        assert_eq!(matches[0].explanation.vector_rank, Some(1));
329        assert_eq!(matches[0].explanation.final_rank, 1);
330        Ok(())
331    }
332
333    #[test]
334    fn one_abstaining_branch_is_explicit() -> Result<(), HybridError> {
335        let lexical = LexicalOutcome::Abstained(crate::LexicalAbstention {
336            reason: LexicalAbstentionReason::NoCandidates,
337            scanned_documents: 1,
338            query_tokens: vec!["x".into()],
339        });
340        let outcome = fuse_hybrid(
341            &lexical,
342            &vector(&[b"a"]),
343            &HybridRequest {
344                lexical_weight: 1,
345                vector_weight: 1,
346                limit: 1,
347            },
348        )?;
349        assert!(matches!(
350            outcome,
351            HybridOutcome::Matches {
352                lexical_absence: Some(HybridBranchAbsence::LexicalNoCandidates),
353                ..
354            }
355        ));
356        let both = fuse_hybrid(
357            &lexical,
358            &ExactRetrievalOutcome::Abstained(ExactAbstention {
359                reason: ExactAbstentionReason::NoCandidates,
360                best_score_nanos: None,
361                runner_up_score_nanos: None,
362                scanned_candidates: 0,
363            }),
364            &HybridRequest {
365                lexical_weight: 1,
366                vector_weight: 1,
367                limit: 1,
368            },
369        )?;
370        assert!(matches!(both, HybridOutcome::Abstained(_)));
371        Ok(())
372    }
373}