1use 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
29pub 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
55const ADAPTIVE_SPREAD_SHARE: f64 = 0.5;
59
60fn 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
105pub 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
141pub 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
168pub 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
262pub 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 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 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 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 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#[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
518pub 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
565pub 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
608pub 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 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
728pub 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
842pub 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;