Skip to main content

uqa_operators/
progressive_fusion.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Progressive multi-signal fusion (Section 7, Paper 4).
8//!
9//! Each stage introduces a new batch of signals, intersects them with
10//! the surviving candidate set from the prior stage, fuses every
11//! accumulated signal via weighted log-odds conjunction, and keeps the
12//! top-`k` highest-scoring candidates. Stages run in order; the
13//! operator returns the result of the final stage.
14
15use 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        // Stage 0 keeps {1,2,3}. Stage 1 contributes only doc 1 (4, 5
204        // are filtered to the candidate set from stage 0). Remaining
205        // docs accumulate stage 0 score plus 0.5 prior for stage 1
206        // signal. Top-2 should be doc 1 (combo of high signal + signal)
207        // and one of {2, 3}.
208        assert_eq!(ids.len(), 2);
209        assert!(ids.contains(&1));
210    }
211}