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}