uqa_operators/
progressive_fusion.rs1use std::collections::{BTreeMap, BTreeSet};
16use std::sync::Arc;
17
18use uqa_core::{IndexStats, Payload, PostingEntry, PostingList};
19use uqa_fusion::LogitGating;
20use uqa_scoring::{logit, sigmoid};
21use uqa_storage::StorageBackendError;
22
23use crate::base::{require_probability, ExecutionContext, Operator, OperatorResult};
24use crate::hybrid::coverage_based_default;
25
26pub struct ProgressiveFusionOperator {
27 pub stages: Vec<(Vec<Arc<dyn Operator>>, usize)>,
28 pub alpha: f64,
29 pub gating: Option<String>,
30}
31
32impl ProgressiveFusionOperator {
33 pub fn new(stages: Vec<(Vec<Arc<dyn Operator>>, usize)>, alpha: f64) -> Self {
34 Self::with_gating(stages, alpha, None)
35 }
36
37 pub fn with_gating(
38 stages: Vec<(Vec<Arc<dyn Operator>>, usize)>,
39 alpha: f64,
40 gating: Option<String>,
41 ) -> Self {
42 Self {
43 stages,
44 alpha,
45 gating,
46 }
47 }
48}
49
50impl Operator for ProgressiveFusionOperator {
51 fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
52 if self.stages.is_empty() {
53 return Err(StorageBackendError::Other(
54 "ProgressiveFusionOperator requires at least one stage".to_string(),
55 ));
56 }
57 if !self.alpha.is_finite() || !(0.0..=1.0).contains(&self.alpha) {
58 return Err(StorageBackendError::Other(format!(
59 "ProgressiveFusionOperator alpha must be finite and in [0, 1], got {}",
60 self.alpha
61 )));
62 }
63 let gating = match self.gating.as_deref() {
64 Some(name) => LogitGating::parse(name).ok_or_else(|| {
65 StorageBackendError::Other(format!(
66 "ProgressiveFusionOperator has unknown gating function {name:?}"
67 ))
68 })?,
69 None => LogitGating::Pass,
70 };
71 let mut signal_lists: Vec<PostingList> = Vec::new();
72 let mut candidate_ids: Option<BTreeSet<u64>> = None;
73 let mut last_result: PostingList = PostingList::new();
74
75 for (signals, k) in &self.stages {
76 if signals.is_empty() {
77 return Err(StorageBackendError::Other(
78 "ProgressiveFusionOperator stages require at least one signal".to_string(),
79 ));
80 }
81 for signal in signals {
82 let mut pl = signal.execute(ctx)?;
83 for entry in pl.entries() {
84 require_probability(entry.payload.score, "progressive fusion")?;
85 }
86 if let Some(cands) = &candidate_ids {
87 let kept: Vec<PostingEntry> = pl
88 .entries()
89 .iter()
90 .filter(|e| cands.contains(&e.doc_id))
91 .cloned()
92 .collect();
93 pl = PostingList::from_sorted_unchecked(kept);
94 }
95 signal_lists.push(pl);
96 }
97 let mut score_maps = Vec::with_capacity(signal_lists.len());
98 let mut all_doc_ids = BTreeSet::new();
99 for posting in &signal_lists {
100 let map: BTreeMap<u64, f64> = posting
101 .entries()
102 .iter()
103 .map(|entry| {
104 all_doc_ids.insert(entry.doc_id);
105 (entry.doc_id, entry.payload.score)
106 })
107 .collect();
108 score_maps.push(map);
109 }
110 let total = all_doc_ids.len();
111 let defaults: Vec<f64> = score_maps
112 .iter()
113 .map(|scores| coverage_based_default(scores.len(), total, 0.01))
114 .collect();
115 let n = signal_lists.len();
116 let confidence = (n as f64).powf(self.alpha);
117 let mut scored: Vec<PostingEntry> = all_doc_ids
118 .into_iter()
119 .map(|doc_id| {
120 let mean_gated_logit = score_maps
121 .iter()
122 .zip(&defaults)
123 .map(|(scores, default)| {
124 gating.apply(logit(scores.get(&doc_id).copied().unwrap_or(*default)))
125 })
126 .sum::<f64>()
127 / n as f64;
128 let fused = sigmoid(confidence * mean_gated_logit);
129 PostingEntry::new(doc_id, Payload::with_score(fused))
130 })
131 .collect();
132 scored.sort_by_key(|e| e.doc_id);
133 let scored_pl = PostingList::from_sorted_unchecked(scored);
134 let topk = scored_pl.ranked().select_top_k(*k);
135 candidate_ids = Some(topk.doc_ids().collect());
136 last_result = topk;
137 }
138 Ok(last_result)
139 }
140
141 fn cost_estimate(&self, stats: &IndexStats) -> f64 {
142 let total_n = stats.total_docs as f64;
143 let mut total = 0.0;
144 let mut card = total_n;
145 for (signals, k) in &self.stages {
146 let ratio = if total_n > 0.0 { card / total_n } else { 1.0 };
147 for sig in signals {
148 total += sig.cost_estimate(stats) * ratio;
149 }
150 card = card.min(*k as f64);
151 }
152 total
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 struct ConstOperator(Vec<PostingEntry>);
161
162 impl Operator for ConstOperator {
163 fn execute(&self, _ctx: &ExecutionContext) -> OperatorResult {
164 Ok(PostingList::from_sorted_unchecked(self.0.clone()))
165 }
166 }
167
168 fn entry(id: u64, score: f64) -> PostingEntry {
169 PostingEntry::new(id, Payload::with_score(score))
170 }
171
172 #[test]
173 fn single_stage_keeps_top_k() {
174 let signal = Arc::new(ConstOperator(vec![
175 entry(1, 0.9),
176 entry(2, 0.4),
177 entry(3, 0.7),
178 ])) as Arc<dyn Operator>;
179 let op = ProgressiveFusionOperator::new(vec![(vec![signal], 2)], 0.0);
180 let result = op.execute(&ExecutionContext::new()).unwrap();
181 let ids: Vec<u64> = result.doc_ids().collect();
182 assert_eq!(ids.len(), 2);
183 assert!(ids.contains(&1));
184 assert!(ids.contains(&3));
185 }
186
187 #[test]
188 fn second_stage_intersects_with_prior_candidates() {
189 let stage_0 = Arc::new(ConstOperator(vec![
190 entry(1, 0.9),
191 entry(2, 0.8),
192 entry(3, 0.7),
193 entry(4, 0.6),
194 ])) as Arc<dyn Operator>;
195 let stage_1 = Arc::new(ConstOperator(vec![
196 entry(1, 0.95),
197 entry(4, 0.95),
198 entry(5, 0.95),
199 ])) as Arc<dyn Operator>;
200 let op = ProgressiveFusionOperator::new(vec![(vec![stage_0], 3), (vec![stage_1], 2)], 0.0);
201 let result = op.execute(&ExecutionContext::new()).unwrap();
202 let ids: Vec<u64> = result.doc_ids().collect();
203 assert_eq!(ids.len(), 2);
209 assert!(ids.contains(&1));
210 }
211}