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            // A leg stopped by its deadline, by a cancel token, or by store
571            // close is a control outcome, not a malformed request. The
572            // top-level arms above see the lifecycle-typed form, but every
573            // mid-flight check inside a leg reports the scan-typed form, so
574            // both must classify the same way (ZE-181). Every other
575            // `ScanError` stays a `Leg` failure below.
576            crate::lifecycle::QueryError::Scan(crate::scan::ScanError::Timeout { .. }) => {
577                Self::Timeout { partial: false }
578            }
579            crate::lifecycle::QueryError::Scan(crate::scan::ScanError::Cancelled { .. }) => {
580                Self::Cancelled { partial: false }
581            }
582            crate::lifecycle::QueryError::Scan(crate::scan::ScanError::ReadCancelled {
583                ..
584            }) => Self::ReadCancelled { partial: false },
585            other => {
586                let kind = match &other {
587                    crate::lifecycle::QueryError::Store(error) => {
588                        LegFailureKind::Store(error.kind())
589                    }
590                    crate::lifecycle::QueryError::Scan(_) => LegFailureKind::Scan,
591                    crate::lifecycle::QueryError::Graph(_) => LegFailureKind::Graph,
592                    crate::lifecycle::QueryError::Timeout { .. }
593                    | crate::lifecycle::QueryError::Cancelled { .. }
594                    | crate::lifecycle::QueryError::ReadCancelled { .. } => LegFailureKind::Caller,
595                };
596                Self::Leg {
597                    leg: FusionLeg::Vector,
598                    kind,
599                    detail: other.to_string(),
600                }
601            }
602        }
603    }
604}
605
606#[derive(Clone, Debug)]
607pub(crate) struct JoinedVector<K> {
608    pub(crate) key: K,
609    pub(crate) squared_l2: f64,
610}
611
612#[derive(Clone, Debug)]
613pub(crate) struct JoinedLexical<K> {
614    pub(crate) key: K,
615    pub(crate) bm25: f64,
616}
617
618/// Executes caller-owned legs and fuses only when both complete successfully.
619///
620/// The closures are the library seam for a caller holding a `Store` and a
621/// `LexicalIndex`. A typed timeout or cancellation escapes without partial
622/// fused hits.
623pub fn execute_hybrid<K, V, L, VectorRun, LexicalRun, VectorJoin, LexicalJoin>(
624    query: &HybridQuery,
625    vector_run: VectorRun,
626    lexical_run: LexicalRun,
627    vector_join: VectorJoin,
628    lexical_join: LexicalJoin,
629) -> Result<FusionOutcome<K>, FusionError>
630where
631    K: Clone + Ord,
632    VectorRun: FnOnce() -> Result<Vec<VectorCandidate<V>>, FusionError>,
633    LexicalRun: FnOnce() -> Result<Vec<LexicalCandidate<L>>, FusionError>,
634    VectorJoin: FnMut(&V) -> Option<K>,
635    LexicalJoin: FnMut(&L) -> Option<K>,
636{
637    let vector = vector_run()?;
638    let lexical = lexical_run()?;
639    fuse(query, &vector, &lexical, vector_join, lexical_join)
640}
641
642/// Fuses two ranked, exactly-scored candidate lists on a caller-defined key.
643///
644/// Vector scores must be ascending exact squared-L2 and lexical scores must be
645/// descending exact BM25. Tie order in either input is irrelevant: fused ties
646/// use the ordered common key.
647pub fn fuse<K, V, L, VectorJoin, LexicalJoin>(
648    query: &HybridQuery,
649    vector: &[VectorCandidate<V>],
650    lexical: &[LexicalCandidate<L>],
651    mut vector_join: VectorJoin,
652    mut lexical_join: LexicalJoin,
653) -> Result<FusionOutcome<K>, FusionError>
654where
655    K: Clone + Ord,
656    VectorJoin: FnMut(&V) -> Option<K>,
657    LexicalJoin: FnMut(&L) -> Option<K>,
658{
659    let (effective_alpha, applied_rules) = rules::effective_alpha(query)?;
660    let joined_vector = join_vector(vector, &mut vector_join)?;
661    let joined_lexical = join_lexical(lexical, &mut lexical_join)?;
662    cc::fuse_joined(
663        query,
664        &joined_vector,
665        &joined_lexical,
666        effective_alpha,
667        applied_rules,
668    )
669}
670
671/// Fuses two bounded windows plus their exact per-leg ranges.
672///
673/// This is the seam for a bounded producer. It differs from [`fuse`] in
674/// exactly two ways: the normalization ranges come from `bounds` rather than
675/// from the ends of the supplied lists, and stability is decided against the
676/// supplied (W+1)-th values rather than against the next element of a
677/// complete list. It scores each window once; there is no geometric round
678/// loop, because widening means re-running the producers.
679///
680/// The caller must cross-fill: every document appearing in one window must
681/// also appear in the other with its exact score, or be absent from the other
682/// leg entirely. Under that contract every candidate this function can see
683/// carries complete information, so only a document outside both windows can
684/// still change the answer, and `bounds` decides whether one could.
685///
686/// Returns [`FusionTermination::WindowUnproven`] when it could not, which the
687/// caller answers by widening the window and producing again.
688///
689/// # Errors
690///
691/// Returns [`FusionError`] for the same leg violations as [`fuse`], plus
692/// [`FusionError::InvalidBounds`] when a supplied range is non-finite,
693/// inverted, or contradicted by its own window.
694pub fn fuse_bounded<K, V, L, VectorJoin, LexicalJoin>(
695    query: &HybridQuery,
696    vector_window: &[VectorCandidate<V>],
697    lexical_window: &[LexicalCandidate<L>],
698    bounds: LegBounds,
699    mut vector_join: VectorJoin,
700    mut lexical_join: LexicalJoin,
701) -> Result<FusionOutcome<K>, FusionError>
702where
703    K: Clone + Ord,
704    VectorJoin: FnMut(&V) -> Option<K>,
705    LexicalJoin: FnMut(&L) -> Option<K>,
706{
707    let (effective_alpha, applied_rules) = rules::effective_alpha(query)?;
708    let joined_vector = join_vector(vector_window, &mut vector_join)?;
709    let joined_lexical = join_lexical(lexical_window, &mut lexical_join)?;
710    cc::fuse_bounded_joined(
711        query,
712        &joined_vector,
713        &joined_lexical,
714        bounds,
715        effective_alpha,
716        applied_rules,
717    )
718}
719
720/// Store-only entry: callers supply complete cross-scores and fixed anchors.
721pub(crate) fn fuse_store_bounded<K, V, L, VectorJoin, LexicalJoin>(
722    query: &HybridQuery,
723    vector_window: &[VectorCandidate<V>],
724    lexical_window: &[LexicalCandidate<L>],
725    bounds: LegBounds,
726    mut vector_join: VectorJoin,
727    mut lexical_join: LexicalJoin,
728    scratch: &mut StoreFusionScratch<K>,
729) -> Result<FusionOutcome<K>, FusionError>
730where
731    K: Clone + Ord,
732    VectorJoin: FnMut(&V) -> Option<K>,
733    LexicalJoin: FnMut(&L) -> Option<K>,
734{
735    let (alpha, rules) = rules::effective_alpha(query)?;
736    let vector = join_vector(vector_window, &mut vector_join)?;
737    let lexical = join_lexical(lexical_window, &mut lexical_join)?;
738    cc::fuse_store_bounded_joined(query, &vector, &lexical, bounds, alpha, rules, scratch)
739}
740
741fn join_vector<K, V, Join>(
742    candidates: &[VectorCandidate<V>],
743    join: &mut Join,
744) -> Result<Vec<JoinedVector<K>>, FusionError>
745where
746    K: Clone + Ord,
747    Join: FnMut(&V) -> Option<K>,
748{
749    let mut joined = Vec::with_capacity(candidates.len());
750    let mut identities = BTreeSet::new();
751    let mut previous = None;
752    for (rank, candidate) in candidates.iter().enumerate() {
753        if candidate.precision != ScorePrecision::Exact {
754            return Err(FusionError::EstimatedVectorScore { rank });
755        }
756        if !candidate.squared_l2.is_finite() {
757            return Err(FusionError::NonFiniteScore {
758                leg: FusionLeg::Vector,
759                rank,
760            });
761        }
762        if candidate.squared_l2 < 0.0 {
763            return Err(FusionError::NegativeScore {
764                leg: FusionLeg::Vector,
765                rank,
766            });
767        }
768        if previous.is_some_and(|score| candidate.squared_l2 < score) {
769            return Err(FusionError::UnrankedInput {
770                leg: FusionLeg::Vector,
771                rank,
772            });
773        }
774        previous = Some(candidate.squared_l2);
775        let key = join(&candidate.id).ok_or(FusionError::MissingDocumentIdentity {
776            leg: FusionLeg::Vector,
777            rank,
778        })?;
779        if !identities.insert(key.clone()) {
780            return Err(FusionError::DuplicateDocumentIdentity {
781                leg: FusionLeg::Vector,
782                rank,
783            });
784        }
785        joined.push(JoinedVector {
786            key,
787            squared_l2: candidate.squared_l2,
788        });
789    }
790    joined.sort_by(|left, right| {
791        left.squared_l2
792            .total_cmp(&right.squared_l2)
793            .then_with(|| left.key.cmp(&right.key))
794    });
795    Ok(joined)
796}
797
798fn join_lexical<K, L, Join>(
799    candidates: &[LexicalCandidate<L>],
800    join: &mut Join,
801) -> Result<Vec<JoinedLexical<K>>, FusionError>
802where
803    K: Clone + Ord,
804    Join: FnMut(&L) -> Option<K>,
805{
806    let mut joined = Vec::with_capacity(candidates.len());
807    let mut identities = BTreeSet::new();
808    let mut previous = None;
809    for (rank, candidate) in candidates.iter().enumerate() {
810        if !candidate.bm25.is_finite() {
811            return Err(FusionError::NonFiniteScore {
812                leg: FusionLeg::Lexical,
813                rank,
814            });
815        }
816        if candidate.bm25 < 0.0 {
817            return Err(FusionError::NegativeScore {
818                leg: FusionLeg::Lexical,
819                rank,
820            });
821        }
822        if previous.is_some_and(|score| candidate.bm25 > score) {
823            return Err(FusionError::UnrankedInput {
824                leg: FusionLeg::Lexical,
825                rank,
826            });
827        }
828        previous = Some(candidate.bm25);
829        let key = join(&candidate.id).ok_or(FusionError::MissingDocumentIdentity {
830            leg: FusionLeg::Lexical,
831            rank,
832        })?;
833        if !identities.insert(key.clone()) {
834            return Err(FusionError::DuplicateDocumentIdentity {
835                leg: FusionLeg::Lexical,
836                rank,
837            });
838        }
839        joined.push(JoinedLexical {
840            key,
841            bm25: candidate.bm25,
842        });
843    }
844    joined.sort_by(|left, right| {
845        right
846            .bm25
847            .total_cmp(&left.bm25)
848            .then_with(|| left.key.cmp(&right.key))
849    });
850    Ok(joined)
851}