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, StorePolicyScorer};
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}