Skip to main content

zeppelin_embed/fusion/
mod.rs

1//! Deterministic vector/lexical fusion for library callers and
2//! [`Store::search_hybrid`](crate::lifecycle::Store::search_hybrid).
3//!
4//! Store-owned hybrid pins both legs to one generation and delegates score
5//! validation, normalization, alpha policy, RRF fallback, termination, and
6//! [`FusionReport`](crate::fusion::FusionReport) construction here. A hybrid
7//! query with no explicit store
8//! tier uses exact vector scores; explicit tiers retain their selected score
9//! provenance, including typed refusal of estimated scores.
10//!
11//! See the local [architecture-decision ledger](ARCHITECTURE.md).
12
13mod cc;
14pub(crate) use cc::StoreFusionScratch;
15mod normalize;
16mod rrf;
17mod rules;
18
19use std::collections::BTreeSet;
20
21pub use rules::{
22    ALPHA_POLICY_VERSION, FusionRule, LEXICAL_RULE_ALPHA, RARE_DOCUMENT_FREQUENCY_THRESHOLD,
23    RuleSignals,
24};
25
26/// MEASURED (`tasks/evidence/17-fusion.md`, 2026-08-26, BEIR SciFact with
27/// Cohere embed-english-v3 vectors): the optimum of an eleven-point grid,
28/// nDCG@10 0.7642 against 0.7181 dense-only and 0.6917 lexical-only; the
29/// band within one point is 0.6..0.8. It coincides with the Bruch, TOIS
30/// 2024 literature prior the placeholder started from.
31pub const DEFAULT_ALPHA: f64 = 0.7;
32
33/// Store score policy v1: fixed zero lower anchors, the validated vector norm
34/// enclosure and exact maximum live combined BM25 as upper anchors. Missing
35/// lexical membership is an explicit zero. An empty lexical leg gives vectors
36/// full weight; a zero vector enclosure scores every present vector as one.
37/// Store fusion uses these values even for equal or singleton lists. The pure
38/// complete-list and bounded fusion APIs retain their min-max/RRF semantics.
39pub const HYBRID_NORMALIZATION_POLICY_VERSION: u16 = 1;
40
41/// PLACEHOLDER -- NOT YET MEASURED.
42///
43/// Conventional reciprocal-rank-fusion offset. No Zeppelin corpus
44/// measurement has established this value.
45pub const RRF_K: u32 = 60;
46
47/// PLACEHOLDER -- NOT YET MEASURED.
48///
49/// Geometric fusion rounds attempted before exact full-list materialization.
50/// The fallback preserves correctness; this value affects work and reporting.
51pub const DEFAULT_MAX_ROUNDS: usize = 8;
52
53/// MEASURED (`tasks/hybrid-search-optimization/PLAN.md` §A.2, BEIR SciFact
54/// with Cohere embed-english-v3 vectors): with exact anchors and cross-filled
55/// windows, a 50-wide window reproduces the full-list fused top-10 in 300/300
56/// judged queries and the stability bound proves it in 299/300. Twenty and
57/// thirty reproduce only 283 and 293. The engine's own window measurement is
58/// recorded in `tasks/evidence/R04-hybrid-window.md`.
59pub const HYBRID_WINDOW_FLOOR: usize = 50;
60
61/// PLACEHOLDER -- NOT YET MEASURED.
62///
63/// Per-result window growth, so a large `k` keeps a window wider than the
64/// requested result count. Correctness never depends on it: an unproven
65/// window widens and a window covering the corpus is the full-list fusion.
66pub const HYBRID_WINDOW_PER_K: usize = 5;
67
68/// Whether a vector score is safe to blend.
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub enum ScorePrecision {
71    /// Recomputed from the full-precision vector.
72    Exact,
73    /// A quantized or otherwise approximate ordering score.
74    Estimated,
75}
76
77/// What a producer can prove about candidates outside its supplied window.
78/// This is independent of whether the supplied scores are exact.
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub enum CandidateCoverage {
81    /// Every eligible candidate is represented by the complete producer list.
82    Exhaustive,
83    /// The producer supplies the true ordered boundary of a bounded window.
84    CertifiedBounded,
85    /// Candidate membership or its unseen boundary is not certified.
86    Approximate,
87}
88
89/// Store-owned facts required before a bounded fusion stop is a certificate.
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub struct HybridProvenance {
92    /// Precision of the vector producer's retained scores.
93    pub vector_precision: ScorePrecision,
94    /// Coverage supplied by the vector producer, independent of precision.
95    pub vector_coverage: CandidateCoverage,
96    /// Coverage supplied by the lexical producer.
97    pub lexical_coverage: CandidateCoverage,
98    /// Every union candidate has its other score or proven nonmembership.
99    pub cross_scores_complete: bool,
100}
101
102/// One ranked vector candidate. Squared-L2 is lower-is-better.
103#[derive(Clone, Debug, PartialEq)]
104pub struct VectorCandidate<I> {
105    id: I,
106    squared_l2: f64,
107    precision: ScorePrecision,
108}
109
110impl<I> VectorCandidate<I> {
111    /// Constructs a candidate whose squared-L2 was computed exactly.
112    #[must_use]
113    pub const fn exact(id: I, squared_l2: f64) -> Self {
114        Self {
115            id,
116            squared_l2,
117            precision: ScorePrecision::Exact,
118        }
119    }
120
121    /// Constructs an estimated candidate so the fusion guard can reject it.
122    #[must_use]
123    pub const fn estimated(id: I, score: f64) -> Self {
124        Self {
125            id,
126            squared_l2: score,
127            precision: ScorePrecision::Estimated,
128        }
129    }
130
131    /// Returns the leg-native identity.
132    #[must_use]
133    pub const fn id(&self) -> &I {
134        &self.id
135    }
136
137    /// Returns exact squared-L2 when [`Self::precision`] is exact.
138    #[must_use]
139    pub const fn squared_l2(&self) -> f64 {
140        self.squared_l2
141    }
142
143    /// Returns the score provenance checked by fusion.
144    #[must_use]
145    pub const fn precision(&self) -> ScorePrecision {
146        self.precision
147    }
148}
149
150/// One ranked lexical candidate. BM25 is higher-is-better.
151#[derive(Clone, Debug, PartialEq)]
152pub struct LexicalCandidate<I> {
153    id: I,
154    bm25: f64,
155}
156
157impl<I> LexicalCandidate<I> {
158    /// Constructs one exact BM25 candidate.
159    #[must_use]
160    pub const fn new(id: I, bm25: f64) -> Self {
161        Self { id, bm25 }
162    }
163
164    /// Returns the leg-native identity.
165    #[must_use]
166    pub const fn id(&self) -> &I {
167        &self.id
168    }
169
170    /// Returns the exact BM25 score.
171    #[must_use]
172    pub const fn bm25(&self) -> f64 {
173        self.bm25
174    }
175}
176
177/// Pure fusion parameters over already-executed legs.
178#[derive(Clone, Debug, PartialEq)]
179pub struct HybridQuery {
180    /// Requested fused result count.
181    pub k: usize,
182    /// Explicit alpha, or `None` for the versioned policy.
183    pub alpha: Option<f64>,
184    /// Query-shape inputs to the versioned rule policy.
185    pub rule_signals: RuleSignals,
186    /// Whether rule-based alpha shifts are enabled.
187    pub rules_enabled: bool,
188    /// Maximum geometric stability-bound rounds.
189    pub max_rounds: usize,
190    /// Epoch that interpreted both legs, copied into the report.
191    pub epoch: Option<crate::epoch::EpochIdentity>,
192}
193
194impl HybridQuery {
195    /// Constructs a query using the measured policy defaults: alpha
196    /// `DEFAULT_ALPHA` and query-shape rules disabled (policy version 2).
197    #[must_use]
198    pub const fn new(k: usize) -> Self {
199        Self {
200            k,
201            alpha: None,
202            rule_signals: RuleSignals::none(),
203            rules_enabled: false,
204            max_rounds: DEFAULT_MAX_ROUNDS,
205            epoch: None,
206        }
207    }
208
209    /// Selects an explicit alpha. Explicit values bypass rule shifts.
210    #[must_use]
211    pub const fn with_alpha(mut self, alpha: f64) -> Self {
212        self.alpha = Some(alpha);
213        self
214    }
215
216    /// Supplies deterministic rule inputs.
217    #[must_use]
218    pub const fn with_rule_signals(mut self, signals: RuleSignals) -> Self {
219        self.rule_signals = signals;
220        self
221    }
222
223    /// Enables the query-shape alpha rules (`fusion::rules`). Off by default
224    /// since policy version 2: every measured rule cell lost against the
225    /// no-rule baseline on SciFact (`tasks/evidence/17-fusion.md`).
226    #[must_use]
227    pub const fn with_rules(mut self) -> Self {
228        self.rules_enabled = true;
229        self
230    }
231
232    /// Disables every query-shape alpha rule (the default).
233    #[must_use]
234    pub const fn without_rules(mut self) -> Self {
235        self.rules_enabled = false;
236        self
237    }
238
239    /// Selects a geometric-round cap. Zero requests immediate full-list materialization.
240    #[must_use]
241    pub const fn with_max_rounds(mut self, max_rounds: usize) -> Self {
242        self.max_rounds = max_rounds;
243        self
244    }
245
246    /// Attaches the shared interpretation epoch to the report.
247    #[must_use]
248    pub const fn with_epoch(mut self, epoch: crate::epoch::EpochIdentity) -> Self {
249        self.epoch = Some(epoch);
250        self
251    }
252}
253
254/// Fusion formula actually used.
255#[derive(Clone, Copy, Debug, Eq, PartialEq)]
256pub enum FusionMethod {
257    /// Per-leg min-max normalization followed by an alpha-weighted sum.
258    ConvexCombination,
259    /// Rank-only fallback because at least one leg could not be normalized.
260    ReciprocalRankFusion,
261}
262
263/// Leg named by validation and fallback reports.
264#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
265pub enum FusionLeg {
266    /// Squared-L2 vector leg.
267    Vector,
268    /// BM25 lexical leg.
269    Lexical,
270}
271
272/// Why min-max normalization was not defined for a leg.
273#[derive(Clone, Copy, Debug, Eq, PartialEq)]
274pub enum DegenerateKind {
275    /// The leg returned no candidates.
276    Empty,
277    /// The leg returned one candidate.
278    SingleHit,
279    /// Every raw score in the leg was equal.
280    AllScoresEqual,
281}
282
283/// One reported fallback cause.
284#[derive(Clone, Copy, Debug, Eq, PartialEq)]
285pub struct DegenerateLeg {
286    /// Affected leg.
287    pub leg: FusionLeg,
288    /// Exact degeneracy.
289    pub kind: DegenerateKind,
290}
291
292/// How the widening loop completed.
293#[derive(Clone, Copy, Debug, Eq, PartialEq)]
294pub enum FusionTermination {
295    /// Bounds proved that no unseen candidate could change the ordered top-k.
296    StableBound,
297    /// Both complete lists fit in the current round.
298    ListsExhausted,
299    /// The round cap fired, then full lists were materialized for correctness.
300    BudgetFullMaterialization,
301    /// A bounded window could not be proved stable; the caller must widen it.
302    WindowUnproven,
303    /// Returned an approximate candidate union without certifying unseen rows.
304    ApproximateCandidates,
305}
306
307/// Exact vector-leg extremes for one bounded window.
308///
309/// The extremes are corpus-wide, not window-wide: windowed min-max
310/// normalization measurably loses quality (PLAN.md §A.2), so a bounded
311/// producer supplies a deterministic ceiling that no alive row exceeds.
312#[derive(Clone, Copy, Debug, PartialEq)]
313pub struct VectorBounds {
314    /// Lower normalization anchor. Complete-list callers use the nearest
315    /// alive row; Store normalization policy v1 uses zero.
316    pub min_squared_l2: f64,
317    /// Producer-supplied squared-L2 ceiling over every alive row.
318    pub max_squared_l2: f64,
319    /// Exact squared-L2 of the (W+1)-th row, absent when the leg is complete.
320    pub next_unseen_squared_l2: Option<f64>,
321}
322
323/// Exact lexical-leg extremes for one bounded window.
324#[derive(Clone, Copy, Debug, PartialEq)]
325pub struct LexicalBounds {
326    /// Exact BM25 of the best matching document.
327    pub max_bm25: f64,
328    /// Lower normalization anchor. Complete-list callers use the worst
329    /// matching score; Store normalization policy v1 uses zero.
330    pub min_bm25: f64,
331    /// Exact BM25 of the (W+1)-th hit, absent when the leg is complete.
332    pub next_unseen_bm25: Option<f64>,
333}
334
335/// Per-leg exact ranges supplied by bounded producers, validated by fusion.
336#[derive(Clone, Copy, Debug, Default, PartialEq)]
337pub struct LegBounds {
338    /// Vector-leg extremes, absent when the leg produced nothing.
339    pub vector: Option<VectorBounds>,
340    /// Lexical-leg extremes, absent when the leg produced nothing.
341    pub lexical: Option<LexicalBounds>,
342}
343
344/// Truthful deterministic report for one fusion.
345#[derive(Clone, Debug, PartialEq)]
346pub struct FusionReport {
347    /// Formula actually used.
348    pub method: FusionMethod,
349    /// Effective alpha, including a reported rule shift when applicable.
350    pub effective_alpha: f64,
351    /// Rules applied in stable policy order.
352    pub applied_rules: Vec<FusionRule>,
353    /// Degenerate leg conditions. Pure APIs select RRF for these conditions;
354    /// Store policy v1 keeps its defined fixed-anchor value scores.
355    pub degenerate_legs: Vec<DegenerateLeg>,
356    /// Number of geometric rounds attempted.
357    pub rounds: usize,
358    /// True only when the round cap forced exact full-list materialization.
359    pub budget_exhausted: bool,
360    /// Termination reason.
361    pub termination: FusionTermination,
362    /// Policy data version used for rule selection.
363    pub alpha_policy_version: u16,
364    /// Shared interpretation epoch supplied by the caller.
365    pub epoch: Option<crate::epoch::EpochIdentity>,
366}
367
368/// One fused hit with both transparent raw leg scores.
369#[derive(Clone, Debug, PartialEq)]
370pub struct FusedHit<K> {
371    /// Caller-defined common join key.
372    pub key: K,
373    /// Exact squared-L2, absent when the document appeared only lexically.
374    pub vector_squared_l2: Option<f64>,
375    /// Exact BM25, absent when the document appeared only in the vector leg.
376    pub lexical_bm25: Option<f64>,
377    /// Convex-combination or reciprocal-rank score, always higher-is-better.
378    pub fused_score: f64,
379}
380
381/// Fused top-k and its report.
382#[derive(Clone, Debug, PartialEq)]
383pub struct FusionOutcome<K> {
384    /// Deterministically ordered top-k.
385    pub hits: Vec<FusedHit<K>>,
386    /// Formula, rules, fallback, and termination facts.
387    pub report: FusionReport,
388}
389
390/// Typed rejection from the pure fusion seam.
391#[derive(Clone, Debug, PartialEq)]
392pub enum FusionError {
393    /// Alpha was NaN, infinite, or outside the closed unit interval.
394    InvalidAlpha(f64),
395    /// A raw score was NaN or infinite.
396    NonFiniteScore {
397        /// Affected leg.
398        leg: FusionLeg,
399        /// Zero-based rank.
400        rank: usize,
401    },
402    /// Squared-L2 or BM25 was negative.
403    NegativeScore {
404        /// Affected leg.
405        leg: FusionLeg,
406        /// Zero-based rank.
407        rank: usize,
408    },
409    /// The vector window still carried an estimated score.
410    EstimatedVectorScore {
411        /// Zero-based rank.
412        rank: usize,
413    },
414    /// A bounded producer supplied a range its own window contradicts.
415    InvalidBounds {
416        /// Affected leg.
417        leg: FusionLeg,
418        /// Stable description of the violated bound.
419        detail: &'static str,
420    },
421    /// A supposedly ranked input list changed score direction.
422    UnrankedInput {
423        /// Affected leg.
424        leg: FusionLeg,
425        /// Rank whose score violates the prior rank.
426        rank: usize,
427    },
428    /// The caller could not map a leg-native id to the common identity.
429    MissingDocumentIdentity {
430        /// Affected leg.
431        leg: FusionLeg,
432        /// Zero-based rank.
433        rank: usize,
434    },
435    /// Two candidates in one leg mapped to the same common identity.
436    DuplicateDocumentIdentity {
437        /// Affected leg.
438        leg: FusionLeg,
439        /// Zero-based rank of the duplicate.
440        rank: usize,
441    },
442    /// A leg timed out; partial hits are never returned.
443    Timeout {
444        /// Permanently false.
445        partial: bool,
446    },
447    /// A leg was cancelled; partial hits are never returned.
448    Cancelled {
449        /// Permanently false.
450        partial: bool,
451    },
452    /// Store close cancelled a leg; partial hits are never returned.
453    ReadCancelled {
454        /// Permanently false.
455        partial: bool,
456    },
457    /// A Store-owned leg thread could not be created.
458    LegThreadStart {
459        /// Leg whose required execution thread could not start.
460        leg: FusionLeg,
461        /// Stable operating-system error detail.
462        detail: String,
463    },
464    /// A Store-owned leg panicked and was contained at the join seam.
465    LegPanic {
466        /// Leg that panicked.
467        leg: FusionLeg,
468        /// Stable detail that never exposes an unwind payload.
469        detail: &'static str,
470    },
471    /// A caller-owned leg failed before fusion.
472    Leg {
473        /// Affected leg.
474        leg: FusionLeg,
475        /// Typed classification of the failure, so hosts do not parse `detail`.
476        kind: LegFailureKind,
477        /// Stable caller-provided failure description.
478        detail: String,
479    },
480}
481
482/// Typed classification of a leg failure carried by [`FusionError::Leg`].
483#[derive(Clone, Copy, Debug, Eq, PartialEq)]
484pub enum LegFailureKind {
485    /// Store admission, lifecycle, or persistence failed; see the kind.
486    Store(crate::lifecycle::StoreErrorKind),
487    /// Scan-tier execution failed.
488    Scan,
489    /// Graph-tier execution failed.
490    Graph,
491    /// An immutable segment read failed.
492    Segment,
493    /// Lexical planning, indexing, or scoring failed.
494    Lexical,
495    /// A leg-internal invariant failed.
496    Invariant,
497    /// A caller-owned leg reported an opaque failure.
498    Caller,
499}
500
501impl std::fmt::Display for FusionError {
502    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
503        match self {
504            Self::InvalidAlpha(alpha) => write!(formatter, "fusion alpha {alpha} is outside 0..=1"),
505            Self::NonFiniteScore { leg, rank } => {
506                write!(formatter, "{leg:?} score at rank {rank} is non-finite")
507            }
508            Self::NegativeScore { leg, rank } => {
509                write!(formatter, "{leg:?} score at rank {rank} is negative")
510            }
511            Self::EstimatedVectorScore { rank } => {
512                write!(
513                    formatter,
514                    "vector score at rank {rank} was not exactly rescored"
515                )
516            }
517            Self::InvalidBounds { leg, detail } => {
518                write!(formatter, "{leg:?} leg bounds are invalid: {detail}")
519            }
520            Self::UnrankedInput { leg, rank } => {
521                write!(
522                    formatter,
523                    "{leg:?} input changes score direction at rank {rank}"
524                )
525            }
526            Self::MissingDocumentIdentity { leg, rank } => write!(
527                formatter,
528                "{leg:?} candidate at rank {rank} lacks a common document identity"
529            ),
530            Self::DuplicateDocumentIdentity { leg, rank } => write!(
531                formatter,
532                "{leg:?} candidate at rank {rank} repeats a common document identity"
533            ),
534            Self::Timeout { partial } => {
535                write!(
536                    formatter,
537                    "hybrid query deadline expired (partial={partial})"
538                )
539            }
540            Self::Cancelled { partial } => {
541                write!(formatter, "hybrid query was cancelled (partial={partial})")
542            }
543            Self::ReadCancelled { partial } => write!(
544                formatter,
545                "store close cancelled hybrid query (partial={partial})"
546            ),
547            Self::LegThreadStart { leg, detail } => {
548                write!(formatter, "{leg:?} leg thread did not start: {detail}")
549            }
550            Self::LegPanic { leg, detail } => {
551                write!(formatter, "{leg:?} leg panic was contained: {detail}")
552            }
553            Self::Leg { leg, kind, detail } => {
554                write!(formatter, "{leg:?} leg failed ({kind:?}): {detail}")
555            }
556        }
557    }
558}
559
560impl std::error::Error for FusionError {}
561
562impl From<crate::lifecycle::QueryError> for FusionError {
563    fn from(error: crate::lifecycle::QueryError) -> Self {
564        match error {
565            crate::lifecycle::QueryError::Timeout { .. } => Self::Timeout { partial: false },
566            crate::lifecycle::QueryError::Cancelled { .. } => Self::Cancelled { partial: false },
567            crate::lifecycle::QueryError::ReadCancelled { .. } => {
568                Self::ReadCancelled { partial: false }
569            }
570            other => {
571                let kind = match &other {
572                    crate::lifecycle::QueryError::Store(error) => {
573                        LegFailureKind::Store(error.kind())
574                    }
575                    crate::lifecycle::QueryError::Scan(_) => LegFailureKind::Scan,
576                    crate::lifecycle::QueryError::Graph(_) => LegFailureKind::Graph,
577                    crate::lifecycle::QueryError::Timeout { .. }
578                    | crate::lifecycle::QueryError::Cancelled { .. }
579                    | crate::lifecycle::QueryError::ReadCancelled { .. } => LegFailureKind::Caller,
580                };
581                Self::Leg {
582                    leg: FusionLeg::Vector,
583                    kind,
584                    detail: other.to_string(),
585                }
586            }
587        }
588    }
589}
590
591#[derive(Clone, Debug)]
592pub(crate) struct JoinedVector<K> {
593    pub(crate) key: K,
594    pub(crate) squared_l2: f64,
595}
596
597#[derive(Clone, Debug)]
598pub(crate) struct JoinedLexical<K> {
599    pub(crate) key: K,
600    pub(crate) bm25: f64,
601}
602
603/// Executes caller-owned legs and fuses only when both complete successfully.
604///
605/// The closures are the library seam for a caller holding a `Store` and a
606/// `LexicalIndex`. A typed timeout or cancellation escapes without partial
607/// fused hits.
608pub fn execute_hybrid<K, V, L, VectorRun, LexicalRun, VectorJoin, LexicalJoin>(
609    query: &HybridQuery,
610    vector_run: VectorRun,
611    lexical_run: LexicalRun,
612    vector_join: VectorJoin,
613    lexical_join: LexicalJoin,
614) -> Result<FusionOutcome<K>, FusionError>
615where
616    K: Clone + Ord,
617    VectorRun: FnOnce() -> Result<Vec<VectorCandidate<V>>, FusionError>,
618    LexicalRun: FnOnce() -> Result<Vec<LexicalCandidate<L>>, FusionError>,
619    VectorJoin: FnMut(&V) -> Option<K>,
620    LexicalJoin: FnMut(&L) -> Option<K>,
621{
622    let vector = vector_run()?;
623    let lexical = lexical_run()?;
624    fuse(query, &vector, &lexical, vector_join, lexical_join)
625}
626
627/// Fuses two ranked, exactly-scored candidate lists on a caller-defined key.
628///
629/// Vector scores must be ascending exact squared-L2 and lexical scores must be
630/// descending exact BM25. Tie order in either input is irrelevant: fused ties
631/// use the ordered common key.
632pub fn fuse<K, V, L, VectorJoin, LexicalJoin>(
633    query: &HybridQuery,
634    vector: &[VectorCandidate<V>],
635    lexical: &[LexicalCandidate<L>],
636    mut vector_join: VectorJoin,
637    mut lexical_join: LexicalJoin,
638) -> Result<FusionOutcome<K>, FusionError>
639where
640    K: Clone + Ord,
641    VectorJoin: FnMut(&V) -> Option<K>,
642    LexicalJoin: FnMut(&L) -> Option<K>,
643{
644    let (effective_alpha, applied_rules) = rules::effective_alpha(query)?;
645    let joined_vector = join_vector(vector, &mut vector_join)?;
646    let joined_lexical = join_lexical(lexical, &mut lexical_join)?;
647    cc::fuse_joined(
648        query,
649        &joined_vector,
650        &joined_lexical,
651        effective_alpha,
652        applied_rules,
653    )
654}
655
656/// Fuses two bounded windows plus their exact per-leg ranges.
657///
658/// This is the seam for a bounded producer. It differs from [`fuse`] in
659/// exactly two ways: the normalization ranges come from `bounds` rather than
660/// from the ends of the supplied lists, and stability is decided against the
661/// supplied (W+1)-th values rather than against the next element of a
662/// complete list. It scores each window once; there is no geometric round
663/// loop, because widening means re-running the producers.
664///
665/// The caller must cross-fill: every document appearing in one window must
666/// also appear in the other with its exact score, or be absent from the other
667/// leg entirely. Under that contract every candidate this function can see
668/// carries complete information, so only a document outside both windows can
669/// still change the answer, and `bounds` decides whether one could.
670///
671/// Returns [`FusionTermination::WindowUnproven`] when it could not, which the
672/// caller answers by widening the window and producing again.
673///
674/// # Errors
675///
676/// Returns [`FusionError`] for the same leg violations as [`fuse`], plus
677/// [`FusionError::InvalidBounds`] when a supplied range is non-finite,
678/// inverted, or contradicted by its own window.
679pub fn fuse_bounded<K, V, L, VectorJoin, LexicalJoin>(
680    query: &HybridQuery,
681    vector_window: &[VectorCandidate<V>],
682    lexical_window: &[LexicalCandidate<L>],
683    bounds: LegBounds,
684    mut vector_join: VectorJoin,
685    mut lexical_join: LexicalJoin,
686) -> Result<FusionOutcome<K>, FusionError>
687where
688    K: Clone + Ord,
689    VectorJoin: FnMut(&V) -> Option<K>,
690    LexicalJoin: FnMut(&L) -> Option<K>,
691{
692    let (effective_alpha, applied_rules) = rules::effective_alpha(query)?;
693    let joined_vector = join_vector(vector_window, &mut vector_join)?;
694    let joined_lexical = join_lexical(lexical_window, &mut lexical_join)?;
695    cc::fuse_bounded_joined(
696        query,
697        &joined_vector,
698        &joined_lexical,
699        bounds,
700        effective_alpha,
701        applied_rules,
702    )
703}
704
705/// Store-only entry: callers supply complete cross-scores and fixed anchors.
706pub(crate) fn fuse_store_bounded<K, V, L, VectorJoin, LexicalJoin>(
707    query: &HybridQuery,
708    vector_window: &[VectorCandidate<V>],
709    lexical_window: &[LexicalCandidate<L>],
710    bounds: LegBounds,
711    mut vector_join: VectorJoin,
712    mut lexical_join: LexicalJoin,
713    scratch: &mut StoreFusionScratch<K>,
714) -> Result<FusionOutcome<K>, FusionError>
715where
716    K: Clone + Ord,
717    VectorJoin: FnMut(&V) -> Option<K>,
718    LexicalJoin: FnMut(&L) -> Option<K>,
719{
720    let (alpha, rules) = rules::effective_alpha(query)?;
721    let vector = join_vector(vector_window, &mut vector_join)?;
722    let lexical = join_lexical(lexical_window, &mut lexical_join)?;
723    cc::fuse_store_bounded_joined(query, &vector, &lexical, bounds, alpha, rules, scratch)
724}
725
726fn join_vector<K, V, Join>(
727    candidates: &[VectorCandidate<V>],
728    join: &mut Join,
729) -> Result<Vec<JoinedVector<K>>, FusionError>
730where
731    K: Clone + Ord,
732    Join: FnMut(&V) -> Option<K>,
733{
734    let mut joined = Vec::with_capacity(candidates.len());
735    let mut identities = BTreeSet::new();
736    let mut previous = None;
737    for (rank, candidate) in candidates.iter().enumerate() {
738        if candidate.precision != ScorePrecision::Exact {
739            return Err(FusionError::EstimatedVectorScore { rank });
740        }
741        if !candidate.squared_l2.is_finite() {
742            return Err(FusionError::NonFiniteScore {
743                leg: FusionLeg::Vector,
744                rank,
745            });
746        }
747        if candidate.squared_l2 < 0.0 {
748            return Err(FusionError::NegativeScore {
749                leg: FusionLeg::Vector,
750                rank,
751            });
752        }
753        if previous.is_some_and(|score| candidate.squared_l2 < score) {
754            return Err(FusionError::UnrankedInput {
755                leg: FusionLeg::Vector,
756                rank,
757            });
758        }
759        previous = Some(candidate.squared_l2);
760        let key = join(&candidate.id).ok_or(FusionError::MissingDocumentIdentity {
761            leg: FusionLeg::Vector,
762            rank,
763        })?;
764        if !identities.insert(key.clone()) {
765            return Err(FusionError::DuplicateDocumentIdentity {
766                leg: FusionLeg::Vector,
767                rank,
768            });
769        }
770        joined.push(JoinedVector {
771            key,
772            squared_l2: candidate.squared_l2,
773        });
774    }
775    joined.sort_by(|left, right| {
776        left.squared_l2
777            .total_cmp(&right.squared_l2)
778            .then_with(|| left.key.cmp(&right.key))
779    });
780    Ok(joined)
781}
782
783fn join_lexical<K, L, Join>(
784    candidates: &[LexicalCandidate<L>],
785    join: &mut Join,
786) -> Result<Vec<JoinedLexical<K>>, FusionError>
787where
788    K: Clone + Ord,
789    Join: FnMut(&L) -> Option<K>,
790{
791    let mut joined = Vec::with_capacity(candidates.len());
792    let mut identities = BTreeSet::new();
793    let mut previous = None;
794    for (rank, candidate) in candidates.iter().enumerate() {
795        if !candidate.bm25.is_finite() {
796            return Err(FusionError::NonFiniteScore {
797                leg: FusionLeg::Lexical,
798                rank,
799            });
800        }
801        if candidate.bm25 < 0.0 {
802            return Err(FusionError::NegativeScore {
803                leg: FusionLeg::Lexical,
804                rank,
805            });
806        }
807        if previous.is_some_and(|score| candidate.bm25 > score) {
808            return Err(FusionError::UnrankedInput {
809                leg: FusionLeg::Lexical,
810                rank,
811            });
812        }
813        previous = Some(candidate.bm25);
814        let key = join(&candidate.id).ok_or(FusionError::MissingDocumentIdentity {
815            leg: FusionLeg::Lexical,
816            rank,
817        })?;
818        if !identities.insert(key.clone()) {
819            return Err(FusionError::DuplicateDocumentIdentity {
820                leg: FusionLeg::Lexical,
821                rank,
822            });
823        }
824        joined.push(JoinedLexical {
825            key,
826            bm25: candidate.bm25,
827        });
828    }
829    joined.sort_by(|left, right| {
830        right
831            .bm25
832            .total_cmp(&left.bm25)
833            .then_with(|| left.key.cmp(&right.key))
834    });
835    Ok(joined)
836}