Skip to main content

uqa_operators/
hybrid.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Hybrid text + vector operators, exact evidence fusion, and robust retrieval
8//! pooling.
9
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13use uqa_core::{
14    DocId, FieldName, IndexStats, Payload, PostingEntry, PostingList, Predicate, Value,
15};
16use uqa_fusion::{
17    AdaptivePositiveEvidencePool as AdaptivePositiveEvidenceFuser, BayesianEvidenceFusion,
18    LogitGating, ProbabilisticBoolean, RobustPositiveEvidencePool, SignalQuality,
19};
20use uqa_scoring::EvidenceLogit;
21use uqa_storage::{StorageBackendError, StorageBackendResult};
22
23use crate::base::{
24    missing_backend, require_probability, ExecutionContext, Operator, OperatorResult,
25};
26use crate::primitive::TermOperator;
27use crate::vector::VectorSimilarityOperator;
28
29/// Default probability for documents missing from a signal, interpolated
30/// by the signal's coverage (Section 5, Paper 3 / Section 4, Paper 4):
31///
32/// `default = 0.5 * (1 - r) + floor * r`
33///
34/// where `r = n_hits / n_total`. A signal that returns nothing reports
35/// neutral evidence (0.5, logit 0); a signal that covers everything
36/// flags absence as strong negative evidence (= floor, default 0.01).
37pub fn coverage_based_default(n_hits: usize, n_total: usize, floor: f64) -> f64 {
38    if n_total == 0 {
39        return 0.5;
40    }
41    let r = n_hits as f64 / n_total as f64;
42    f64::midpoint(1.0 - r, 0.0) + floor * r
43}
44
45fn validate_probability_postings(
46    postings: &PostingList,
47    operation: &str,
48) -> StorageBackendResult<()> {
49    for entry in postings.entries() {
50        require_probability(entry.payload.score, operation)?;
51    }
52    Ok(())
53}
54
55/// Share of the adaptive weight mass distributed by gated-evidence
56/// spread; the remainder stays uniform so no matching signal is ever
57/// silenced entirely.
58const ADAPTIVE_SPREAD_SHARE: f64 = 0.5;
59
60/// Discrimination-based per-signal weights: each signal's weight blends
61/// a uniform share with its share of the total gated-evidence spread
62/// across its own matches. A signal that assigns every candidate the
63/// same evidence carries no ranking information and sinks toward the
64/// uniform floor. Returns `None` when no signal has measurable spread,
65/// falling back to the unweighted mean.
66fn adaptive_signal_weights(
67    fuser: &RobustPositiveEvidencePool,
68    score_maps: &[BTreeMap<DocId, f64>],
69) -> Option<Vec<f64>> {
70    let spreads: Vec<f64> = score_maps
71        .iter()
72        .map(|scores| {
73            if scores.len() < 2 {
74                return 0.0;
75            }
76            let logits: Vec<f64> = scores
77                .values()
78                .map(|probability| fuser.gated_logit(*probability))
79                .collect();
80            let mean = logits.iter().sum::<f64>() / logits.len() as f64;
81            let variance = logits
82                .iter()
83                .map(|logit| {
84                    let difference = logit - mean;
85                    difference * difference
86                })
87                .sum::<f64>()
88                / logits.len() as f64;
89            variance.sqrt()
90        })
91        .collect();
92    let total: f64 = spreads.iter().sum();
93    if total <= f64::EPSILON {
94        return None;
95    }
96    let uniform = (1.0 - ADAPTIVE_SPREAD_SHARE) / score_maps.len() as f64;
97    Some(
98        spreads
99            .iter()
100            .map(|spread| uniform + ADAPTIVE_SPREAD_SHARE * spread / total)
101            .collect(),
102    )
103}
104
105/// `Hybrid_{t, q, theta} = T(t) AND V_theta(q)` (Definition 3.3.1).
106pub struct HybridTextVectorOperator {
107    term_op: TermOperator,
108    vector_op: VectorSimilarityOperator,
109}
110
111impl HybridTextVectorOperator {
112    pub fn new(
113        term: impl Into<String>,
114        text_field: impl Into<FieldName>,
115        query_vector: Vec<f32>,
116        threshold: f32,
117        vector_field: impl Into<FieldName>,
118    ) -> Self {
119        Self {
120            term_op: TermOperator::new(term, text_field),
121            vector_op: VectorSimilarityOperator::new(query_vector, threshold, vector_field),
122        }
123    }
124}
125
126impl Operator for HybridTextVectorOperator {
127    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
128        Ok(self
129            .term_op
130            .execute(ctx)?
131            .merge_intersection_owned(&self.vector_op.execute(ctx)?))
132    }
133
134    fn cost_estimate(&self, stats: &IndexStats) -> f64 {
135        self.term_op
136            .cost_estimate(stats)
137            .min(self.vector_op.cost_estimate(stats))
138    }
139}
140
141/// `SemanticFilter_{q, theta, L} = L AND V_theta(q)` (Definition 3.3.4).
142pub struct SemanticFilterOperator {
143    pub source: Arc<dyn Operator>,
144    pub vector_op: VectorSimilarityOperator,
145}
146
147impl SemanticFilterOperator {
148    pub fn new(source: Arc<dyn Operator>, vector_op: VectorSimilarityOperator) -> Self {
149        Self { source, vector_op }
150    }
151}
152
153impl Operator for SemanticFilterOperator {
154    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
155        Ok(self
156            .source
157            .execute(ctx)?
158            .merge_intersection_owned(&self.vector_op.execute(ctx)?))
159    }
160
161    fn cost_estimate(&self, stats: &IndexStats) -> f64 {
162        self.source
163            .cost_estimate(stats)
164            .min(self.vector_op.cost_estimate(stats))
165    }
166}
167
168/// Exact Bayesian fusion of signed prior-free evidence.
169///
170/// Each present score is interpreted as a prior-free probability-like evidence
171/// value and converted to a signed [`EvidenceLogit`]. Missing signals contribute
172/// the additive identity zero. The configured corpus prior enters exactly once,
173/// after which the operator computes
174/// `sigmoid(logit(base_rate) + sum(evidence_i))` without gating, confidence
175/// scaling, normalized weights, or adaptive query-pool statistics.
176pub struct BayesianEvidenceFusionOperator {
177    pub signals: Vec<Arc<dyn Operator>>,
178    pub base_rate: f64,
179    pub top_k: Option<usize>,
180}
181
182impl BayesianEvidenceFusionOperator {
183    pub fn new(signals: Vec<Arc<dyn Operator>>, base_rate: f64) -> Self {
184        Self {
185            signals,
186            base_rate,
187            top_k: None,
188        }
189    }
190
191    pub fn with_top_k(mut self, top_k: usize) -> Self {
192        self.top_k = Some(top_k);
193        self
194    }
195}
196
197impl Operator for BayesianEvidenceFusionOperator {
198    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
199        if self.signals.is_empty() {
200            return Err(StorageBackendError::Other(
201                "Bayesian evidence fusion requires at least one signal".to_string(),
202            ));
203        }
204        let fusion = BayesianEvidenceFusion::new(self.base_rate)
205            .map_err(|error| StorageBackendError::Other(error.to_string()))?;
206        let posting_lists: Vec<PostingList> = self
207            .signals
208            .iter()
209            .map(|signal| signal.execute(ctx))
210            .collect::<StorageBackendResult<_>>()?;
211        for posting_list in &posting_lists {
212            validate_probability_postings(posting_list, "Bayesian evidence fusion")?;
213        }
214
215        let mut all_doc_ids = std::collections::BTreeSet::new();
216        let score_maps: Vec<BTreeMap<DocId, f64>> = posting_lists
217            .iter()
218            .map(|posting_list| {
219                let mut scores = BTreeMap::new();
220                for entry in posting_list {
221                    scores.insert(entry.doc_id, entry.payload.score);
222                    all_doc_ids.insert(entry.doc_id);
223                }
224                scores
225            })
226            .collect();
227        if all_doc_ids.is_empty() {
228            return Ok(PostingList::new());
229        }
230
231        let mut entries = Vec::with_capacity(all_doc_ids.len());
232        for doc_id in all_doc_ids {
233            let evidence: Vec<EvidenceLogit> = score_maps
234                .iter()
235                .filter_map(|scores| scores.get(&doc_id).copied())
236                .map(EvidenceLogit::from_prior_free_probability)
237                .collect::<Result<_, _>>()
238                .map_err(|error| StorageBackendError::Other(error.to_string()))?;
239            let posterior = fusion
240                .fuse(&evidence)
241                .map_err(|error| StorageBackendError::Other(error.to_string()))?;
242            entries.push(PostingEntry::new(
243                doc_id,
244                Payload::with_score(posterior.value()),
245            ));
246        }
247        let result = PostingList::from_sorted_unchecked(entries);
248        Ok(match self.top_k {
249            Some(k) => result.ranked().select_top_k(k),
250            None => result,
251        })
252    }
253
254    fn cost_estimate(&self, stats: &IndexStats) -> f64 {
255        self.signals
256            .iter()
257            .map(|signal| signal.cost_estimate(stats))
258            .sum()
259    }
260}
261
262/// Robust positive-evidence pooling for retrieval ranking.
263///
264/// Each signal must produce prior-free evidence probabilities in `[0, 1]`; a
265/// configured `base_rate` enters the pool exactly once. This is a ranking
266/// heuristic, not the conditional-independence Bayesian sum implemented by
267/// [`BayesianEvidenceFusionOperator`].
268/// Missing documents contribute zero gated logit, and a signal with no
269/// matches at all stays in the declared signal set as neutral evidence
270/// (Lucene PR 16410 semantics: the clause count that governs `n^alpha`
271/// and the uniform denominator never shrinks at execution time). The
272/// default softplus gating floors match evidence at the prior;
273/// `LogitGating::Pass` matches Lucene's signed default.
274pub struct RobustPositiveEvidencePoolOperator {
275    pub signals: Vec<Arc<dyn Operator>>,
276    pub alpha: f64,
277    pub gating: LogitGating,
278    pub base_rate: Option<f64>,
279    pub weights: Option<Vec<f64>>,
280    /// Derive per-signal weights from each signal's gated-evidence
281    /// spread over its matches (Theorem 8.3 reliability weighting,
282    /// estimated unsupervised). Ignored when explicit `weights` are
283    /// set.
284    pub adaptive_weights: bool,
285    pub logit_min: Option<Vec<f64>>,
286    pub logit_max: Option<Vec<f64>>,
287    pub top_k: Option<usize>,
288}
289
290impl RobustPositiveEvidencePoolOperator {
291    pub fn new(signals: Vec<Arc<dyn Operator>>, alpha: f64) -> Self {
292        Self {
293            signals,
294            alpha,
295            gating: LogitGating::Softplus,
296            base_rate: None,
297            weights: None,
298            adaptive_weights: false,
299            logit_min: None,
300            logit_max: None,
301            top_k: None,
302        }
303    }
304
305    pub fn with_adaptive_weights(mut self) -> Self {
306        self.adaptive_weights = true;
307        self
308    }
309
310    pub fn with_gating(mut self, gating: LogitGating) -> Self {
311        self.gating = gating;
312        self
313    }
314
315    /// Fusion-level relevance prior, applied exactly once.
316    pub fn with_base_rate(mut self, base_rate: f64) -> Self {
317        self.base_rate = Some(base_rate);
318        self
319    }
320
321    pub fn with_weights(mut self, weights: Vec<f64>) -> Self {
322        self.weights = Some(weights);
323        self
324    }
325
326    pub fn with_logit_normalization(mut self, logit_min: Vec<f64>, logit_max: Vec<f64>) -> Self {
327        self.logit_min = Some(logit_min);
328        self.logit_max = Some(logit_max);
329        self
330    }
331
332    pub fn with_top_k(mut self, top_k: usize) -> Self {
333        self.top_k = Some(top_k);
334        self
335    }
336}
337
338impl Operator for RobustPositiveEvidencePoolOperator {
339    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
340        if self.signals.is_empty() {
341            return Err(StorageBackendError::Other(
342                "positive-evidence pool requires at least one signal".to_string(),
343            ));
344        }
345        if !self.alpha.is_finite() || !(0.0..=1.0).contains(&self.alpha) {
346            return Err(StorageBackendError::Other(format!(
347                "positive-evidence pool alpha must be finite and in [0, 1], got {}",
348                self.alpha
349            )));
350        }
351        if let Some(base_rate) = self.base_rate {
352            if !base_rate.is_finite() || base_rate <= 0.0 || base_rate >= 1.0 {
353                return Err(StorageBackendError::Other(format!(
354                    "positive-evidence pool base_rate must be finite and in (0, 1), got {base_rate}"
355                )));
356            }
357        }
358        let mut fuser = RobustPositiveEvidencePool::new(self.alpha)
359            .map_err(|error| StorageBackendError::Other(error.to_string()))?
360            .with_logit_gating(self.gating);
361        if let Some(base_rate) = self.base_rate {
362            fuser = fuser
363                .with_base_rate(base_rate)
364                .map_err(|error| StorageBackendError::Other(error.to_string()))?;
365        }
366        fuser
367            .validate_configuration(
368                self.signals.len(),
369                self.weights.as_deref(),
370                self.logit_min.as_deref(),
371                self.logit_max.as_deref(),
372            )
373            .map_err(|error| StorageBackendError::Other(error.to_string()))?;
374        let posting_lists: Vec<PostingList> = self
375            .signals
376            .iter()
377            .map(|sig| sig.execute(ctx))
378            .collect::<StorageBackendResult<_>>()?;
379        for posting_list in &posting_lists {
380            validate_probability_postings(posting_list, "positive-evidence pool")?;
381        }
382
383        // Build per-signal score maps and the universal doc id set.
384        let mut all_doc_ids: std::collections::BTreeSet<DocId> = std::collections::BTreeSet::new();
385        let score_maps: Vec<BTreeMap<DocId, f64>> = posting_lists
386            .iter()
387            .map(|pl| {
388                let mut smap = BTreeMap::new();
389                for entry in pl {
390                    smap.insert(entry.doc_id, entry.payload.score);
391                    all_doc_ids.insert(entry.doc_id);
392                }
393                smap
394            })
395            .collect();
396
397        if all_doc_ids.is_empty() {
398            return Ok(PostingList::new());
399        }
400
401        // A signal with no matches still contributes neutral evidence to
402        // every document: the declared signal count governs `n^alpha`
403        // and the uniform denominator, so a document's fused score
404        // cannot depend on whether another signal happened to match
405        // elsewhere (Lucene PR 16410 semantics).
406        let weights = self.weights.clone().or_else(|| {
407            if self.adaptive_weights {
408                adaptive_signal_weights(&fuser, &score_maps)
409            } else {
410                None
411            }
412        });
413        let mut entries = Vec::with_capacity(all_doc_ids.len());
414        for doc_id in &all_doc_ids {
415            let probabilities: Vec<Option<f64>> = score_maps
416                .iter()
417                .map(|scores| scores.get(doc_id).copied())
418                .collect();
419            let fused_score = fuser
420                .fuse_configured(
421                    &probabilities,
422                    weights.as_deref(),
423                    self.logit_min.as_deref(),
424                    self.logit_max.as_deref(),
425                )
426                .map_err(|error| StorageBackendError::Other(error.to_string()))?;
427            entries.push(PostingEntry::new(*doc_id, Payload::with_score(fused_score)));
428        }
429        let result = PostingList::from_sorted_unchecked(entries);
430        Ok(match self.top_k {
431            Some(k) => result.ranked().select_top_k(k),
432            None => result,
433        })
434    }
435
436    fn cost_estimate(&self, stats: &IndexStats) -> f64 {
437        self.signals.iter().map(|s| s.cost_estimate(stats)).sum()
438    }
439}
440
441/// Probabilistic Boolean fusion. Each signal must produce calibrated
442/// probabilities in `(0, 1)`; missing documents fall back to a
443/// coverage-based default. `mode = And` multiplies probabilities,
444/// `mode = Or` uses inclusion-exclusion via [`ProbabilisticBoolean`].
445#[derive(Clone, Copy, Debug, PartialEq, Eq)]
446pub enum ProbBoolMode {
447    And,
448    Or,
449}
450
451pub struct ProbBoolFusionOperator {
452    pub signals: Vec<Arc<dyn Operator>>,
453    pub mode: ProbBoolMode,
454}
455
456impl ProbBoolFusionOperator {
457    pub fn new(signals: Vec<Arc<dyn Operator>>, mode: ProbBoolMode) -> Self {
458        Self { signals, mode }
459    }
460}
461
462impl Operator for ProbBoolFusionOperator {
463    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
464        if self.signals.is_empty() {
465            return Err(StorageBackendError::Other(
466                "probabilistic boolean fusion requires at least one signal".to_string(),
467            ));
468        }
469        let posting_lists: Vec<PostingList> = self
470            .signals
471            .iter()
472            .map(|sig| sig.execute(ctx))
473            .collect::<StorageBackendResult<_>>()?;
474        for posting_list in &posting_lists {
475            validate_probability_postings(posting_list, "probabilistic boolean fusion")?;
476        }
477        let mut all_doc_ids: std::collections::BTreeSet<DocId> = std::collections::BTreeSet::new();
478        let score_maps: Vec<BTreeMap<DocId, f64>> = posting_lists
479            .iter()
480            .map(|pl| {
481                let mut smap = BTreeMap::new();
482                for entry in pl {
483                    smap.insert(entry.doc_id, entry.payload.score);
484                    all_doc_ids.insert(entry.doc_id);
485                }
486                smap
487            })
488            .collect();
489        if all_doc_ids.is_empty() {
490            return Ok(PostingList::new());
491        }
492        let num_docs = all_doc_ids.len();
493        let defaults: Vec<f64> = score_maps
494            .iter()
495            .map(|m| coverage_based_default(m.len(), num_docs, 0.01))
496            .collect();
497        let mut entries: Vec<PostingEntry> = Vec::with_capacity(num_docs);
498        for doc_id in &all_doc_ids {
499            let probs: Vec<f64> = score_maps
500                .iter()
501                .zip(&defaults)
502                .map(|(m, def)| m.get(doc_id).copied().unwrap_or(*def))
503                .collect();
504            let fused = match self.mode {
505                ProbBoolMode::And => ProbabilisticBoolean::and(&probs),
506                ProbBoolMode::Or => ProbabilisticBoolean::or(&probs),
507            };
508            entries.push(PostingEntry::new(*doc_id, Payload::with_score(fused)));
509        }
510        Ok(PostingList::from_sorted_unchecked(entries))
511    }
512
513    fn cost_estimate(&self, stats: &IndexStats) -> f64 {
514        self.signals.iter().map(|s| s.cost_estimate(stats)).sum()
515    }
516}
517
518/// Probabilistic NOT (`P(¬signal) = 1 - P(signal)`). Documents present in
519/// `signal` get
520/// `1 - score`; documents missing from the signal but present in
521/// the document store get `1 - default_prob`.
522pub struct ProbNotOperator {
523    pub signal: Arc<dyn Operator>,
524    pub default_prob: f64,
525}
526
527impl ProbNotOperator {
528    pub fn new(signal: Arc<dyn Operator>, default_prob: f64) -> Self {
529        Self {
530            signal,
531            default_prob,
532        }
533    }
534}
535
536impl Operator for ProbNotOperator {
537    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
538        require_probability(self.default_prob, "probabilistic NOT default")?;
539        let pl = self.signal.execute(ctx)?;
540        validate_probability_postings(&pl, "probabilistic NOT")?;
541        let mut score_map: BTreeMap<DocId, f64> = BTreeMap::new();
542        let mut all_ids: std::collections::BTreeSet<DocId> = std::collections::BTreeSet::new();
543        for entry in &pl {
544            score_map.insert(entry.doc_id, entry.payload.score);
545            all_ids.insert(entry.doc_id);
546        }
547        if let Some(store) = ctx.document_store.as_ref() {
548            for id in store.doc_ids()? {
549                all_ids.insert(id);
550            }
551        }
552        let mut entries: Vec<PostingEntry> = Vec::with_capacity(all_ids.len());
553        for doc_id in &all_ids {
554            let p = score_map.get(doc_id).copied().unwrap_or(self.default_prob);
555            entries.push(PostingEntry::new(*doc_id, Payload::with_score(1.0 - p)));
556        }
557        Ok(PostingList::from_sorted_unchecked(entries))
558    }
559
560    fn cost_estimate(&self, stats: &IndexStats) -> f64 {
561        self.signal.cost_estimate(stats)
562    }
563}
564
565/// `VE(V1, V2) = V1 AND NOT V2` — keeps documents from `positive` that are
566/// dissimilar to `negative_op`'s query. The negative side is wired through a
567/// [`VectorSimilarityOperator`] threshold so the caller decides what
568/// counts as "too similar".
569pub struct VectorExclusionOperator {
570    pub positive: Arc<dyn Operator>,
571    pub negative_op: VectorSimilarityOperator,
572}
573
574impl VectorExclusionOperator {
575    pub fn new(
576        positive: Arc<dyn Operator>,
577        negative_vector: Vec<f32>,
578        negative_threshold: f32,
579        field: impl Into<FieldName>,
580    ) -> Self {
581        Self {
582            positive,
583            negative_op: VectorSimilarityOperator::new(negative_vector, negative_threshold, field),
584        }
585    }
586}
587
588impl Operator for VectorExclusionOperator {
589    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
590        let positive_pl = self.positive.execute(ctx)?;
591        let negative_pl = self.negative_op.execute(ctx)?;
592        let negative_ids: std::collections::BTreeSet<DocId> =
593            negative_pl.entries().iter().map(|e| e.doc_id).collect();
594        let mut entries: Vec<PostingEntry> = Vec::new();
595        for entry in positive_pl.entries() {
596            if !negative_ids.contains(&entry.doc_id) {
597                entries.push(entry.clone());
598            }
599        }
600        Ok(PostingList::from_sorted_unchecked(entries))
601    }
602
603    fn cost_estimate(&self, stats: &IndexStats) -> f64 {
604        self.positive.cost_estimate(stats) + self.negative_op.cost_estimate(stats)
605    }
606}
607
608/// Facet counts conditioned on vector similarity. Output rows are synthetic
609/// posting
610/// entries — `doc_id` is a positional placeholder, `payload.score`
611/// is the bucket count, and `payload.fields` carries the
612/// `_facet_field` / `_facet_value` / `_facet_count` triple.
613pub struct FacetVectorOperator {
614    pub facet_field: String,
615    pub vector_op: VectorSimilarityOperator,
616    pub source: Option<Arc<dyn Operator>>,
617}
618
619impl FacetVectorOperator {
620    pub fn new(
621        facet_field: impl Into<String>,
622        query_vector: Vec<f32>,
623        threshold: f32,
624        source: Option<Arc<dyn Operator>>,
625    ) -> Self {
626        Self {
627            facet_field: facet_field.into(),
628            // The UQA SQL contract defaults the field to "embedding".
629            vector_op: VectorSimilarityOperator::new(query_vector, threshold, "embedding"),
630            source,
631        }
632    }
633}
634
635impl Operator for FacetVectorOperator {
636    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
637        let vector_pl = self.vector_op.execute(ctx)?;
638        let vector_ids: std::collections::BTreeSet<DocId> =
639            vector_pl.entries().iter().map(|e| e.doc_id).collect();
640        let candidate_ids: Vec<DocId> = if let Some(src) = &self.source {
641            src.execute(ctx)?
642                .entries()
643                .iter()
644                .filter(|e| vector_ids.contains(&e.doc_id))
645                .map(|e| e.doc_id)
646                .collect()
647        } else {
648            let mut v: Vec<DocId> = vector_ids.iter().copied().collect();
649            v.sort_unstable();
650            v
651        };
652        let Some(doc_store) = ctx.document_store.as_ref() else {
653            return Err(missing_backend("document-store", "vector facet"));
654        };
655        let mut value_counts: BTreeMap<String, u64> = BTreeMap::new();
656        for doc_id in candidate_ids {
657            if doc_store.get(doc_id)?.is_none() {
658                return Err(StorageBackendError::Other(format!(
659                    "vector facet candidate {doc_id} is missing from the document store"
660                )));
661            }
662            if let Some(value) = doc_store.get_field(doc_id, &self.facet_field)? {
663                if !matches!(value, Value::Null) {
664                    let key = value_to_facet_string(&value);
665                    let count = value_counts.entry(key).or_insert(0);
666                    *count = count.checked_add(1).ok_or_else(|| {
667                        StorageBackendError::Other("vector facet count overflowed u64".to_string())
668                    })?;
669                }
670            }
671        }
672        let mut entries: Vec<PostingEntry> = Vec::with_capacity(value_counts.len());
673        for (i, (value, count)) in value_counts.into_iter().enumerate() {
674            if count > 9_007_199_254_740_992 {
675                return Err(StorageBackendError::Other(format!(
676                    "vector facet count {count} cannot be represented exactly as an f64 score"
677                )));
678            }
679            let mut fields = BTreeMap::new();
680            fields.insert(
681                "_facet_field".to_string(),
682                Value::Str(self.facet_field.clone()),
683            );
684            fields.insert("_facet_value".to_string(), Value::Str(value));
685            fields.insert(
686                "_facet_count".to_string(),
687                Value::Int(i64::try_from(count).map_err(|_| {
688                    StorageBackendError::Other(format!(
689                        "vector facet count {count} exceeds the Value::Int range"
690                    ))
691                })?),
692            );
693            entries.push(PostingEntry::new(
694                DocId::try_from(i).map_err(|_| {
695                    StorageBackendError::Other(format!(
696                        "vector facet bucket index {i} exceeds the document-id range"
697                    ))
698                })?,
699                Payload {
700                    positions: Vec::new(),
701                    score: count as f64,
702                    fields,
703                },
704            ));
705        }
706        Ok(PostingList::from_sorted_unchecked(entries))
707    }
708
709    fn cost_estimate(&self, stats: &IndexStats) -> f64 {
710        let mut base = self.vector_op.cost_estimate(stats);
711        if let Some(src) = &self.source {
712            base += src.cost_estimate(stats);
713        }
714        base
715    }
716}
717
718fn value_to_facet_string(v: &Value) -> String {
719    match v {
720        Value::Str(s) => s.clone(),
721        Value::Int(n) => n.to_string(),
722        Value::Float(f) => format!("{f}"),
723        Value::Bool(b) => b.to_string(),
724        other => format!("{other:?}"),
725    }
726}
727
728/// Adaptive positive-evidence pooling: runs each signal, computes a
729/// per-signal `SignalQuality` (coverage / variance / calibration
730/// error), and combines through [`AdaptivePositiveEvidenceFuser::fuse`].
731pub struct AdaptivePositiveEvidencePoolOperator {
732    pub signals: Vec<Arc<dyn Operator>>,
733    pub base_alpha: f64,
734    pub gating: Option<String>,
735}
736
737impl AdaptivePositiveEvidencePoolOperator {
738    pub fn new(signals: Vec<Arc<dyn Operator>>, base_alpha: f64, gating: Option<String>) -> Self {
739        Self {
740            signals,
741            base_alpha,
742            gating,
743        }
744    }
745}
746
747impl Operator for AdaptivePositiveEvidencePoolOperator {
748    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
749        if self.signals.is_empty() {
750            return Err(StorageBackendError::Other(
751                "adaptive positive-evidence pool requires at least one signal".to_string(),
752            ));
753        }
754        if !self.base_alpha.is_finite() || !(0.0..=1.0).contains(&self.base_alpha) {
755            return Err(StorageBackendError::Other(format!(
756                "adaptive positive-evidence pool alpha must be finite and in [0, 1], got {}",
757                self.base_alpha
758            )));
759        }
760        let posting_lists: Vec<PostingList> = self
761            .signals
762            .iter()
763            .map(|sig| sig.execute(ctx))
764            .collect::<StorageBackendResult<_>>()?;
765        for posting_list in &posting_lists {
766            validate_probability_postings(posting_list, "adaptive positive-evidence pool")?;
767        }
768        let mut all_doc_ids: std::collections::BTreeSet<DocId> = std::collections::BTreeSet::new();
769        let score_maps: Vec<BTreeMap<DocId, f64>> = posting_lists
770            .iter()
771            .map(|pl| {
772                let mut smap = BTreeMap::new();
773                for entry in pl {
774                    smap.insert(entry.doc_id, entry.payload.score);
775                    all_doc_ids.insert(entry.doc_id);
776                }
777                smap
778            })
779            .collect();
780        if all_doc_ids.is_empty() {
781            return Ok(PostingList::new());
782        }
783        let num_docs = all_doc_ids.len();
784        let qualities: Vec<SignalQuality> = score_maps
785            .iter()
786            .map(|smap| {
787                let coverage = if num_docs > 0 {
788                    smap.len() as f64 / num_docs as f64
789                } else {
790                    0.0
791                };
792                let scores: Vec<f64> = smap.values().copied().collect();
793                let variance = if scores.len() > 1 {
794                    let mean = scores.iter().sum::<f64>() / scores.len() as f64;
795                    scores.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / scores.len() as f64
796                } else {
797                    0.0
798                };
799                let mean_score = if scores.is_empty() {
800                    0.5
801                } else {
802                    scores.iter().sum::<f64>() / scores.len() as f64
803                };
804                SignalQuality {
805                    coverage_ratio: coverage,
806                    score_variance: variance,
807                    calibration_error: (mean_score - 0.5).abs(),
808                }
809            })
810            .collect();
811        let defaults: Vec<f64> = score_maps
812            .iter()
813            .map(|m| coverage_based_default(m.len(), num_docs, 0.01))
814            .collect();
815        let mut fusion = AdaptivePositiveEvidenceFuser::new(self.base_alpha);
816        if let Some(name) = &self.gating {
817            let gating = LogitGating::parse(name).ok_or_else(|| {
818                StorageBackendError::Other(format!("unknown positive-evidence gate: {name}"))
819            })?;
820            fusion = fusion.with_gating(gating);
821        }
822        let mut entries: Vec<PostingEntry> = Vec::with_capacity(num_docs);
823        for doc_id in &all_doc_ids {
824            let probs: Vec<f64> = score_maps
825                .iter()
826                .zip(&defaults)
827                .map(|(m, def)| m.get(doc_id).copied().unwrap_or(*def))
828                .collect();
829            let fused = fusion
830                .fuse(&probs, &qualities)
831                .map_err(|error| StorageBackendError::Other(error.to_string()))?;
832            entries.push(PostingEntry::new(*doc_id, Payload::with_score(fused)));
833        }
834        Ok(PostingList::from_sorted_unchecked(entries))
835    }
836
837    fn cost_estimate(&self, stats: &IndexStats) -> f64 {
838        self.signals.iter().map(|s| s.cost_estimate(stats)).sum()
839    }
840}
841
842/// Index-driven scan. Wraps a boxed [`uqa_storage::Index`] and runs
843/// `scan(predicate)` against
844/// it. The optimiser's `apply_index_scan` rewrites a `Filter` into
845/// this when an index covers the predicate.
846pub struct IndexScanOperator {
847    pub index: Arc<dyn uqa_storage::Index>,
848    pub field: String,
849    pub predicate: Predicate,
850}
851
852impl IndexScanOperator {
853    pub fn new(
854        index: Arc<dyn uqa_storage::Index>,
855        field: impl Into<String>,
856        predicate: Predicate,
857    ) -> Self {
858        Self {
859            index,
860            field: field.into(),
861            predicate,
862        }
863    }
864}
865
866impl Operator for IndexScanOperator {
867    fn execute(&self, _ctx: &ExecutionContext) -> OperatorResult {
868        Ok(self.index.scan(&self.predicate))
869    }
870
871    fn cost_estimate(&self, _stats: &IndexStats) -> f64 {
872        self.index.scan_cost(&self.predicate)
873    }
874}
875
876#[cfg(test)]
877mod tests;