1#![allow(
19 clippy::needless_pass_by_value,
20 clippy::similar_names,
21 clippy::too_many_lines,
22 clippy::explicit_iter_loop
23)]
24
25use std::collections::{BTreeMap, BTreeSet};
26use std::sync::Arc;
27
28use uqa_core::{Payload, PostingEntry, PostingList};
29use uqa_fusion::{AttentionFusion, LearnedFusion, MultiHeadAttentionFusion};
30use uqa_scoring::VectorProbabilityTransform;
31use uqa_storage::{StorageBackendError, StorageBackendResult};
32
33use crate::base::{
34 missing_backend, require_finite_score, require_probability, ExecutionContext, Operator,
35 OperatorResult,
36};
37use crate::hybrid::coverage_based_default;
38use crate::primitive::{ScoreOperator, TermOperator};
39
40type ScoreMap = BTreeMap<u64, f64>;
41type CollectedScores = (Vec<ScoreMap>, BTreeSet<u64>);
42
43fn collect_score_maps(
44 signals: &[Arc<dyn Operator>],
45 ctx: &ExecutionContext,
46) -> StorageBackendResult<CollectedScores> {
47 let mut maps: Vec<ScoreMap> = Vec::with_capacity(signals.len());
48 let mut all_ids: BTreeSet<u64> = BTreeSet::new();
49 for sig in signals {
50 let pl = sig.execute(ctx)?;
51 let mut m: BTreeMap<u64, f64> = BTreeMap::new();
52 for entry in pl.iter() {
53 require_probability(entry.payload.score, "learned/attention fusion")?;
54 m.insert(entry.doc_id, entry.payload.score);
55 all_ids.insert(entry.doc_id);
56 }
57 maps.push(m);
58 }
59 Ok((maps, all_ids))
60}
61
62fn require_single_active_evidence(probabilities: &[Option<f64>]) -> StorageBackendResult<f64> {
63 probabilities
64 .iter()
65 .flatten()
66 .next()
67 .copied()
68 .ok_or_else(|| {
69 StorageBackendError::Other(
70 "multi-field fusion invariant violated: the single active signal has no evidence"
71 .to_string(),
72 )
73 })
74}
75
76pub enum AttentionFuser {
80 Single(AttentionFusion),
81 MultiHead(MultiHeadAttentionFusion),
82}
83
84impl AttentionFuser {
85 fn validate_inputs(
86 &self,
87 signal_count: usize,
88 query_feature_count: usize,
89 ) -> Result<(), &'static str> {
90 match self {
91 AttentionFuser::Single(attention) => {
92 attention.validate_inputs(signal_count, query_feature_count)
93 }
94 AttentionFuser::MultiHead(attention) => {
95 attention.validate_inputs(signal_count, query_feature_count)
96 }
97 }
98 }
99
100 fn fuse_batch(
101 &self,
102 probabilities: &[Vec<f64>],
103 query_features: &[f64],
104 ) -> Result<Vec<f64>, &'static str> {
105 match self {
106 AttentionFuser::Single(attention) => {
107 attention.fuse_batch(probabilities, query_features)
108 }
109 AttentionFuser::MultiHead(attention) => {
110 attention.fuse_batch(probabilities, query_features)
111 }
112 }
113 }
114}
115
116pub struct AttentionFusionOperator {
117 pub signals: Vec<Arc<dyn Operator>>,
118 pub attention: AttentionFuser,
119 pub query_features: Vec<f64>,
120}
121
122impl AttentionFusionOperator {
123 pub fn new(
124 signals: Vec<Arc<dyn Operator>>,
125 attention: AttentionFuser,
126 query_features: Vec<f64>,
127 ) -> Self {
128 Self {
129 signals,
130 attention,
131 query_features,
132 }
133 }
134}
135
136impl Operator for AttentionFusionOperator {
137 fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
138 self.attention
139 .validate_inputs(self.signals.len(), self.query_features.len())
140 .map_err(|error| StorageBackendError::Other(error.to_string()))?;
141 let (score_maps, all_ids) = collect_score_maps(&self.signals, ctx)?;
142 let total = all_ids.len();
143 if total == 0 {
144 return Ok(PostingList::default());
145 }
146 let defaults: Vec<f64> = score_maps
147 .iter()
148 .map(|m| coverage_based_default(m.len(), total, 0.01))
149 .collect();
150 let mut candidate_ids = Vec::with_capacity(total);
151 let mut probabilities = Vec::with_capacity(total);
152 for doc_id in all_ids {
153 let probs: Vec<f64> = score_maps
154 .iter()
155 .enumerate()
156 .map(|(j, m)| *m.get(&doc_id).unwrap_or(&defaults[j]))
157 .collect();
158 candidate_ids.push(doc_id);
159 probabilities.push(probs);
160 }
161 let fused = self
162 .attention
163 .fuse_batch(&probabilities, &self.query_features)
164 .map_err(|error| StorageBackendError::Other(error.to_string()))?;
165 if fused.len() != candidate_ids.len() {
166 return Err(StorageBackendError::Other(format!(
167 "attention fusion returned {} scores for {} candidates",
168 fused.len(),
169 candidate_ids.len()
170 )));
171 }
172 let entries = candidate_ids
173 .into_iter()
174 .zip(fused)
175 .map(|(doc_id, score)| {
176 PostingEntry::new(
177 doc_id,
178 Payload {
179 score,
180 ..Default::default()
181 },
182 )
183 })
184 .collect();
185 Ok(PostingList::from_sorted_unchecked(entries))
186 }
187}
188
189pub struct LearnedFusionOperator {
191 pub signals: Vec<Arc<dyn Operator>>,
192 pub learned: LearnedFusion,
193}
194
195impl LearnedFusionOperator {
196 pub fn new(signals: Vec<Arc<dyn Operator>>, learned: LearnedFusion) -> Self {
197 Self { signals, learned }
198 }
199}
200
201impl Operator for LearnedFusionOperator {
202 fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
203 self.learned
204 .validate_inputs(self.signals.len())
205 .map_err(|error| StorageBackendError::Other(error.to_string()))?;
206 let (score_maps, all_ids) = collect_score_maps(&self.signals, ctx)?;
207 let total = all_ids.len();
208 if total == 0 {
209 return Ok(PostingList::default());
210 }
211 let defaults: Vec<f64> = score_maps
212 .iter()
213 .map(|m| coverage_based_default(m.len(), total, 0.01))
214 .collect();
215 let mut entries: Vec<PostingEntry> = Vec::with_capacity(total);
216 for doc_id in all_ids {
217 let probs: Vec<f64> = score_maps
218 .iter()
219 .enumerate()
220 .map(|(j, m)| *m.get(&doc_id).unwrap_or(&defaults[j]))
221 .collect();
222 let fused = self
223 .learned
224 .fuse(&probs)
225 .map_err(|error| StorageBackendError::Other(error.to_string()))?;
226 entries.push(PostingEntry::new(
227 doc_id,
228 Payload {
229 score: fused,
230 ..Default::default()
231 },
232 ));
233 }
234 Ok(PostingList::from_sorted_unchecked(entries))
235 }
236}
237
238pub struct MultiFieldSearchOperator {
245 pub fields: Vec<String>,
246 pub queries: Vec<String>,
247 pub weights: Vec<f64>,
248 pub bayesian_params: uqa_scoring::BayesianBM25Params,
249 pub fusion_alpha: f64,
250}
251
252impl MultiFieldSearchOperator {
253 pub fn new(fields: Vec<String>, query: impl Into<String>, weights: Option<Vec<f64>>) -> Self {
254 let n = fields.len();
255 let query = query.into();
256 Self {
257 fields,
258 queries: vec![query; n],
259 weights: weights.unwrap_or_else(|| vec![1.0; n]),
260 bayesian_params: uqa_scoring::BayesianBM25Params::default(),
261 fusion_alpha: 0.5,
262 }
263 }
264
265 pub fn with_queries(
266 fields: Vec<String>,
267 queries: Vec<String>,
268 weights: Option<Vec<f64>>,
269 ) -> Self {
270 let n = fields.len();
271 Self {
272 fields,
273 queries,
274 weights: weights.unwrap_or_else(|| vec![1.0; n]),
275 bayesian_params: uqa_scoring::BayesianBM25Params::default(),
276 fusion_alpha: 0.5,
277 }
278 }
279}
280
281impl Operator for MultiFieldSearchOperator {
282 fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
283 use std::sync::Arc as StdArc;
284 use uqa_scoring::{BayesianBM25Scorer, Scorer};
285
286 let Some(idx) = ctx.inverted_index.as_ref() else {
287 return Err(missing_backend("inverted-index", "multi-field search"));
288 };
289 if self.fields.is_empty() {
290 return Err(StorageBackendError::Other(
291 "multi-field search requires at least one field".to_string(),
292 ));
293 }
294 if self.weights.len() != self.fields.len() {
295 return Err(StorageBackendError::Other(format!(
296 "multi-field search has {} fields but {} weights",
297 self.fields.len(),
298 self.weights.len()
299 )));
300 }
301 if self.queries.len() != self.fields.len() {
302 return Err(StorageBackendError::Other(format!(
303 "multi-field search has {} fields but {} queries",
304 self.fields.len(),
305 self.queries.len()
306 )));
307 }
308 let mut per_field: Vec<BTreeMap<u64, f64>> = Vec::with_capacity(self.fields.len());
314 let mut all_ids: BTreeSet<u64> = BTreeSet::new();
315 for (field, query) in self.fields.iter().zip(&self.queries) {
316 let analyzer = idx.get_search_analyzer(field);
317 let terms = analyzer.analyze(query)?;
318 let term_op: Arc<dyn Operator> = Arc::new(TermOperator::new(query, field));
319 let scorer: Arc<dyn Scorer> = Arc::new(
320 BayesianBM25Scorer::new(
321 self.bayesian_params
322 .scaled_for_query_terms(terms.len())
323 .evidence_params(),
324 StdArc::new(idx.field_stats(field)?),
325 )
326 .map_err(|error| StorageBackendError::Other(error.to_string()))?,
327 );
328 let score_op = ScoreOperator::new(scorer, term_op, terms, field);
329 let pl = score_op.execute(ctx)?;
330 let mut m: BTreeMap<u64, f64> = BTreeMap::new();
331 for entry in pl.iter() {
332 require_probability(entry.payload.score, "multi-field search")?;
333 m.insert(entry.doc_id, entry.payload.score);
334 all_ids.insert(entry.doc_id);
335 }
336 per_field.push(m);
337 }
338
339 let total = all_ids.len();
340 if total == 0 {
341 return Ok(PostingList::default());
342 }
343
344 let weight_sum: f64 = self.weights.iter().sum();
345 let normalized: Vec<f64> = if weight_sum > 0.0
346 && self
347 .weights
348 .iter()
349 .all(|weight| weight.is_finite() && *weight >= 0.0)
350 {
351 self.weights.iter().map(|w| w / weight_sum).collect()
352 } else {
353 return Err(StorageBackendError::Other(
354 "multi-field weights must be non-negative and have a positive finite sum"
355 .to_string(),
356 ));
357 };
358
359 let active_field_count = per_field.iter().filter(|scores| !scores.is_empty()).count();
360 let mut fusion = uqa_fusion::RobustPositiveEvidencePool::new(self.fusion_alpha)
361 .map_err(|error| StorageBackendError::Other(error.to_string()))?;
362 if self.bayesian_params.base_rate > 0.0 {
363 fusion = fusion
364 .with_base_rate(self.bayesian_params.base_rate)
365 .map_err(|error| StorageBackendError::Other(error.to_string()))?;
366 }
367 let mut entries: Vec<PostingEntry> = Vec::with_capacity(total);
368 for doc_id in all_ids {
369 let probabilities: Vec<Option<f64>> = per_field
370 .iter()
371 .map(|scores| scores.get(&doc_id).copied())
372 .collect();
373 let fused = if active_field_count == 1 {
374 let evidence = require_single_active_evidence(&probabilities)?;
377 fusion.fuse(&[evidence])
378 } else {
379 fusion
380 .fuse_weighted_sparse(&probabilities, &normalized)
381 .map_err(|error| StorageBackendError::Other(error.to_string()))?
382 };
383 entries.push(PostingEntry::new(
384 doc_id,
385 Payload {
386 score: fused,
387 ..Default::default()
388 },
389 ));
390 }
391 Ok(PostingList::from_sorted_unchecked(entries))
392 }
393
394 fn cost_estimate(&self, stats: &uqa_core::IndexStats) -> f64 {
395 stats.total_docs as f64 * self.fields.len() as f64
396 }
397}
398
399#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
406pub enum RelevantSampleSplit {
407 #[default]
409 TopQuartile,
410 DistanceGap,
414}
415
416pub struct QueryPoolVectorScoreOperator {
430 pub query_vector: Vec<f32>,
431 pub k: usize,
432 pub field: String,
433 pub base_rate: f64,
437 pub split: RelevantSampleSplit,
438}
439
440#[deprecated(
443 since = "0.1.0",
444 note = "use QueryPoolVectorScoreOperator; use VectorCalibrationModel for reusable calibrated probabilities"
445)]
446pub type CalibratedVectorOperator = QueryPoolVectorScoreOperator;
447
448impl QueryPoolVectorScoreOperator {
449 pub fn new(query_vector: Vec<f32>, k: usize, field: impl Into<String>) -> Self {
450 Self {
451 query_vector,
452 k,
453 field: field.into(),
454 base_rate: 0.5,
455 split: RelevantSampleSplit::default(),
456 }
457 }
458
459 pub fn with_split(mut self, split: RelevantSampleSplit) -> Self {
460 self.split = split;
461 self
462 }
463
464 pub fn with_base_rate(mut self, base_rate: f64) -> Self {
465 self.base_rate = base_rate;
466 self
467 }
468}
469
470pub fn calibrate_query_pool_postings(
472 raw: &PostingList,
473 split: RelevantSampleSplit,
474 base_rate: f64,
475) -> StorageBackendResult<PostingList> {
476 if !base_rate.is_finite() || base_rate <= 0.0 || base_rate >= 1.0 {
477 return Err(StorageBackendError::Other(format!(
478 "calibrated vector base_rate must be finite and in (0, 1), got {base_rate}"
479 )));
480 }
481 if raw.is_empty() {
482 return Ok(PostingList::default());
483 }
484
485 let mut distances = Vec::with_capacity(raw.len());
486 for entry in raw.entries() {
487 require_finite_score(entry.payload.score, "calibrated vector search")?;
488 if !(-1.0..=1.0).contains(&entry.payload.score) {
489 return Err(StorageBackendError::Other(format!(
490 "calibrated vector search requires cosine scores in [-1, 1], got {}",
491 entry.payload.score
492 )));
493 }
494 distances.push(1.0 - entry.payload.score);
495 }
496 let calibrator = fit_pool_calibration(&distances, split, base_rate)?;
497
498 let mut out_entries: Vec<PostingEntry> = Vec::with_capacity(raw.len());
499 for (entry, distance) in raw.iter().zip(&distances) {
500 let posterior = match calibrator.as_ref() {
501 Some(transform) => transform
502 .calibrate_one(*distance)
503 .map_err(|error| StorageBackendError::Other(error.to_string()))?,
504 None => base_rate,
505 };
506 out_entries.push(PostingEntry::new(
507 entry.doc_id,
508 Payload {
509 score: posterior.clamp(1e-6, 1.0 - 1e-6),
510 ..Default::default()
511 },
512 ));
513 }
514 out_entries.sort_by_key(|entry| entry.doc_id);
515 Ok(PostingList::from_sorted_unchecked(out_entries))
516}
517
518impl Operator for QueryPoolVectorScoreOperator {
519 fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
520 if !self.base_rate.is_finite() || self.base_rate <= 0.0 || self.base_rate >= 1.0 {
521 return Err(StorageBackendError::Other(format!(
522 "calibrated vector base_rate must be finite and in (0, 1), got {}",
523 self.base_rate
524 )));
525 }
526 if self.query_vector.is_empty()
527 || self
528 .query_vector
529 .iter()
530 .any(|component| !component.is_finite())
531 {
532 return Err(StorageBackendError::Other(
533 "calibrated vector search requires a non-empty finite query vector".to_string(),
534 ));
535 }
536 let Some(idx) = ctx.vector_indexes.get(&self.field) else {
537 return Err(missing_backend("vector-index", "calibrated vector search"));
538 };
539 let raw = idx.search_knn(&self.query_vector, self.k)?;
540 calibrate_query_pool_postings(&raw, self.split, self.base_rate)
541 }
542}
543
544pub fn fit_pool_calibration(
549 distances: &[f64],
550 split: RelevantSampleSplit,
551 base_rate: f64,
552) -> StorageBackendResult<Option<VectorProbabilityTransform>> {
553 if !base_rate.is_finite() || base_rate <= 0.0 || base_rate >= 1.0 {
554 return Err(StorageBackendError::Other(format!(
555 "pool calibration base_rate must be finite and in (0, 1), got {base_rate}"
556 )));
557 }
558 if distances.iter().any(|distance| !distance.is_finite()) {
559 return Err(StorageBackendError::Other(
560 "pool calibration distances must be finite".to_string(),
561 ));
562 }
563 if distances.len() < 2 {
564 return Ok(None);
565 }
566 let mut sorted = distances.to_vec();
567 sorted.sort_by(f64::total_cmp);
568
569 let head_len = match split {
570 RelevantSampleSplit::TopQuartile => quartile_head(sorted.len()),
571 RelevantSampleSplit::DistanceGap => {
572 distance_gap_split(&sorted).unwrap_or_else(|| quartile_head(sorted.len()))
573 }
574 }
575 .clamp(1, sorted.len() - 1);
576
577 let mu_match = mean(&sorted[..head_len]);
578 let mu_random = mean(&sorted[head_len..]);
579 let sigma = standard_deviation(&sorted);
580 if sigma <= f64::EPSILON || mu_random - mu_match <= f64::EPSILON {
581 return Ok(None);
582 }
583 Ok(Some(
584 VectorProbabilityTransform::new(mu_match, mu_random, sigma, base_rate)
585 .map_err(|error| StorageBackendError::Other(error.to_string()))?,
586 ))
587}
588
589fn quartile_head(pool_size: usize) -> usize {
590 pool_size.div_ceil(4)
591}
592
593fn distance_gap_split(sorted: &[f64]) -> Option<usize> {
596 let mut max_gap = 0.0f64;
597 let mut split_index = None;
598 for (index, window) in sorted.windows(2).enumerate() {
599 let gap = window[1] - window[0];
600 if gap > max_gap {
601 max_gap = gap;
602 split_index = Some(index + 1);
603 }
604 }
605 split_index
606}
607
608fn mean(values: &[f64]) -> f64 {
609 values.iter().sum::<f64>() / values.len() as f64
610}
611
612fn standard_deviation(values: &[f64]) -> f64 {
613 let mu = mean(values);
614 let variance = values
615 .iter()
616 .map(|value| {
617 let difference = value - mu;
618 difference * difference
619 })
620 .sum::<f64>()
621 / values.len() as f64;
622 variance.sqrt()
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use uqa_core::{Payload, PostingEntry, PostingList};
629
630 struct LiteralOperator(Vec<(u64, f64)>);
631 impl Operator for LiteralOperator {
632 fn execute(&self, _ctx: &ExecutionContext) -> OperatorResult {
633 Ok(PostingList::from_sorted_unchecked(
634 self.0
635 .iter()
636 .map(|(d, s)| {
637 PostingEntry::new(
638 *d,
639 Payload {
640 score: *s,
641 ..Default::default()
642 },
643 )
644 })
645 .collect(),
646 ))
647 }
648 }
649
650 #[test]
651 fn learned_fusion_combines_two_signals() {
652 let signals: Vec<Arc<dyn Operator>> = vec![
653 Arc::new(LiteralOperator(vec![(1, 0.8), (2, 0.6)])),
654 Arc::new(LiteralOperator(vec![(1, 0.7), (3, 0.4)])),
655 ];
656 let learned = LearnedFusion::new(2, 0.0);
657 let op = LearnedFusionOperator::new(signals, learned);
658 let pl = op.execute(&ExecutionContext::new()).unwrap();
659 let ids: Vec<u64> = pl.iter().map(|e| e.doc_id).collect();
660 assert_eq!(ids, vec![1, 2, 3]);
661 }
662
663 #[test]
664 fn missing_single_active_evidence_is_an_invariant_error() {
665 let error = require_single_active_evidence(&[None, None]).unwrap_err();
666 assert!(error.to_string().contains("single active signal"));
667 }
668
669 #[test]
670 fn query_pool_vector_missing_index_is_an_execution_error() {
671 let op = QueryPoolVectorScoreOperator::new(vec![0.0; 3], 0, "missing").with_base_rate(0.5);
672 let error = op.execute(&ExecutionContext::new()).unwrap_err();
673 assert!(error.to_string().contains("vector-index"));
674 }
675
676 #[test]
677 fn pool_calibration_discriminates_head_from_tail() {
678 let distances = [0.02, 0.05, 0.30, 0.35, 0.40, 0.45, 0.50, 0.55];
679 let transform = fit_pool_calibration(&distances, RelevantSampleSplit::TopQuartile, 0.5)
680 .expect("valid fit request")
681 .expect("separated pool fits");
682 let head = transform.calibrate_one(0.02).unwrap();
683 let mid = transform.calibrate_one(0.30).unwrap();
684 let tail = transform.calibrate_one(0.55).unwrap();
685 assert!(head > mid && mid > tail, "{head} > {mid} > {tail}");
686 assert!(head > 0.5, "head evidence must be positive, got {head}");
687 assert!(tail < 0.5, "tail evidence must be negative, got {tail}");
688 }
689
690 #[test]
691 fn pool_calibration_rejects_uninformative_pools() {
692 assert!(
693 fit_pool_calibration(&[0.3], RelevantSampleSplit::TopQuartile, 0.5)
694 .unwrap()
695 .is_none()
696 );
697 assert!(
698 fit_pool_calibration(&[0.3, 0.3, 0.3, 0.3], RelevantSampleSplit::TopQuartile, 0.5)
699 .unwrap()
700 .is_none()
701 );
702 }
703
704 #[test]
705 fn pool_calibration_rejects_invalid_numeric_inputs() {
706 assert!(
707 fit_pool_calibration(&[f64::NAN, 0.2], RelevantSampleSplit::TopQuartile, 0.5).is_err()
708 );
709 assert!(fit_pool_calibration(&[0.1, 0.2], RelevantSampleSplit::TopQuartile, 1.0).is_err());
710 }
711
712 #[test]
713 fn distance_gap_split_finds_the_semantic_cliff() {
714 let sorted = [0.05, 0.06, 0.07, 0.40, 0.42, 0.44];
715 assert_eq!(distance_gap_split(&sorted), Some(3));
716 assert_eq!(distance_gap_split(&[0.3, 0.3, 0.3]), None);
717 }
718}