Skip to main content

linkrs_query/executor/streaming/operators/
intersect.rs

1//! WCO intersect probe execution over sorted adjacency lists.
2//!
3//! For each probe row, every build side contributes the adjacency list of
4//! its bound value; the probe emits one output row per intersect value
5//! present in ALL lists, crossed with the matching payload rows. Sorted
6//! adjacency plus linear merge intersection keeps the per-probe cost
7//! proportional to the adjacency sizes rather than their product.
8//!
9//! Execution core: this executor and [`IntersectBuild`](super::intersect_build::IntersectBuild)
10//! back the streaming [`WcoIntersectOperator`](super::wco_operator::WcoIntersectOperator),
11//! which drains chunk inputs into the build tables and probes them per
12//! probe row. Keep the row layout (`probe ++ [intersect] ++ builds`) in
13//! sync with the operator's output assembly.
14
15use std::collections::HashMap;
16
17use linkrs_core::value::NullType;
18use linkrs_core::Value;
19
20use super::intersect_build::{IntersectBuild, IntersectKey};
21
22/// Linear merge intersection of two sorted key slices.
23///
24/// Both inputs must be sorted; duplicates collapse to a single output key.
25/// Runs in `O(left.len() + right.len())`.
26pub fn two_way_sorted_intersect(
27    left: &[IntersectKey],
28    right: &[IntersectKey],
29) -> Vec<IntersectKey> {
30    let mut out = Vec::new();
31    let (mut i, mut j) = (0, 0);
32    while i < left.len() && j < right.len() {
33        match left[i].cmp(&right[j]) {
34            std::cmp::Ordering::Less => i += 1,
35            std::cmp::Ordering::Greater => j += 1,
36            std::cmp::Ordering::Equal => {
37                if out.last() != Some(&left[i]) {
38                    out.push(left[i].clone());
39                }
40                i += 1;
41                j += 1;
42            }
43        }
44    }
45    out
46}
47
48/// Incrementally intersect N sorted key lists, smallest first.
49///
50/// Empty input or any empty list yields an empty result.
51pub fn multiway_sorted_intersect(lists: &[Vec<IntersectKey>]) -> Vec<IntersectKey> {
52    if lists.is_empty() || lists.iter().any(Vec::is_empty) {
53        return Vec::new();
54    }
55    let mut order: Vec<usize> = (0..lists.len()).collect();
56    order.sort_by_key(|i| lists[*i].len());
57    let mut acc = lists[order[0]].clone();
58    for idx in order.into_iter().skip(1) {
59        acc = two_way_sorted_intersect(&acc, &lists[idx]);
60        if acc.is_empty() {
61            break;
62        }
63    }
64    acc
65}
66
67/// Per-build-side probe wiring: which probe column holds the bound value.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct IntersectDataInfo {
70    pub bound_col: usize,
71}
72
73/// N-way intersect executor: one probe side plus N sorted build tables.
74///
75/// Output row layout: `probe_row ++ [intersect_value] ++ build_row_1 ++ ...
76/// ++ build_row_N`, where each `build_row_i` is a full matching build row.
77/// Callers resolve column positions through the slot layout before
78/// constructing the executor.
79#[derive(Debug, Default)]
80pub struct WcoIntersectExecutor {
81    builds: Vec<IntersectBuild>,
82    bound_cols: Vec<usize>,
83}
84
85impl WcoIntersectExecutor {
86    /// Create an executor over finished build tables. `bound_cols[i]` is
87    /// the probe-side column holding the bound value for `builds[i]`.
88    pub fn new(builds: Vec<IntersectBuild>, bound_cols: Vec<usize>) -> Self {
89        assert_eq!(
90            builds.len(),
91            bound_cols.len(),
92            "one probe bound column per build side"
93        );
94        assert!(
95            !builds.is_empty(),
96            "WCO intersect needs at least one build side"
97        );
98        Self { builds, bound_cols }
99    }
100
101    pub fn num_builds(&self) -> usize {
102        self.builds.len()
103    }
104
105    /// Release the build tables. The streaming operator moves tables out
106    /// per probe chunk and restores them afterwards so consecutive chunks
107    /// reuse the same sealed state without cloning.
108    pub fn into_builds(self) -> Vec<IntersectBuild> {
109        self.builds
110    }
111
112    /// Probe with one row; returns zero or more output rows.
113    pub fn probe_row(&self, probe_row: &[Value]) -> Vec<Vec<Value>> {
114        // 1. Look up every adjacency list; any miss kills the row.
115        let mut adjacencies: Vec<&[Vec<Value>]> = Vec::with_capacity(self.builds.len());
116        for (build, bound_col) in self.builds.iter().zip(self.bound_cols.iter()) {
117            let Some(bound) = probe_row.get(*bound_col) else {
118                return Vec::new();
119            };
120            let rows = build.lookup(bound);
121            if rows.is_empty() {
122                return Vec::new();
123            }
124            adjacencies.push(rows);
125        }
126
127        // 2. Group each adjacency by intersect key (lists are pre-sorted,
128        // so keys come out sorted) and remember a representative value.
129        let mut key_lists: Vec<Vec<IntersectKey>> = Vec::with_capacity(adjacencies.len());
130        let mut groups: Vec<HashMap<IntersectKey, Vec<usize>>> =
131            Vec::with_capacity(adjacencies.len());
132        let mut representatives: HashMap<IntersectKey, Value> = HashMap::new();
133        for (build_idx, rows) in adjacencies.iter().enumerate() {
134            let intersect_col = self.builds[build_idx].intersect_col();
135            let mut group: HashMap<IntersectKey, Vec<usize>> = HashMap::new();
136            let mut keys: Vec<IntersectKey> = Vec::new();
137            for (row_idx, row) in rows.iter().enumerate() {
138                let Some(value) = row.get(intersect_col) else {
139                    continue;
140                };
141                let key = IntersectKey::from(value);
142                if !group.contains_key(&key) {
143                    keys.push(key.clone());
144                    if build_idx == 0 {
145                        representatives
146                            .entry(key.clone())
147                            .or_insert_with(|| value.clone());
148                    }
149                }
150                group.entry(key).or_default().push(row_idx);
151            }
152            if keys.is_empty() {
153                return Vec::new();
154            }
155            key_lists.push(keys);
156            groups.push(group);
157        }
158
159        // 3. Merge-intersect the key lists.
160        let common = multiway_sorted_intersect(&key_lists);
161        if common.is_empty() {
162            return Vec::new();
163        }
164
165        // 4. Emit the cartesian product of matching payloads per key.
166        let mut out = Vec::new();
167        for key in &common {
168            let rep = representatives
169                .get(key)
170                .cloned()
171                .unwrap_or(Value::Null(NullType::Null));
172            let base: Vec<Value> = probe_row
173                .iter()
174                .cloned()
175                .chain(std::iter::once(rep))
176                .collect();
177            let mut partials = vec![base];
178            for (build_idx, group) in groups.iter().enumerate() {
179                let Some(matched) = group.get(key) else {
180                    partials.clear();
181                    break;
182                };
183                let rows = adjacencies[build_idx];
184                let mut next = Vec::with_capacity(partials.len() * matched.len());
185                for partial in &partials {
186                    for row_idx in matched {
187                        let mut row = partial.clone();
188                        row.extend(rows[*row_idx].iter().cloned());
189                        next.push(row);
190                    }
191                }
192                partials = next;
193            }
194            out.extend(partials);
195        }
196        out
197    }
198
199    /// Probe with many rows, concatenating the per-row outputs.
200    pub fn probe_rows(&self, probe_rows: &[Vec<Value>]) -> Vec<Vec<Value>> {
201        let mut out = Vec::new();
202        for row in probe_rows {
203            out.extend(self.probe_row(row));
204        }
205        out
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn k(v: i32) -> IntersectKey {
214        IntersectKey::Int(v as i64)
215    }
216
217    #[test]
218    fn two_way_merge_basics() {
219        assert_eq!(
220            two_way_sorted_intersect(&[k(1), k(2), k(3)], &[k(2), k(3), k(4)]),
221            vec![k(2), k(3)]
222        );
223        assert!(two_way_sorted_intersect(&[k(1)], &[k(2)]).is_empty());
224        assert!(two_way_sorted_intersect(&[], &[k(1)]).is_empty());
225    }
226
227    #[test]
228    fn two_way_merge_collapses_duplicates() {
229        assert_eq!(
230            two_way_sorted_intersect(&[k(1), k(1), k(2)], &[k(1), k(2), k(2)]),
231            vec![k(1), k(2)]
232        );
233    }
234
235    #[test]
236    fn multiway_smallest_first() {
237        let lists = vec![
238            vec![k(1), k(2), k(3), k(4)],
239            vec![k(2), k(4)],
240            vec![k(1), k(2), k(4)],
241        ];
242        assert_eq!(multiway_sorted_intersect(&lists), vec![k(2), k(4)]);
243        assert!(multiway_sorted_intersect(&[vec![k(1)], vec![]]).is_empty());
244        assert!(multiway_sorted_intersect(&[]).is_empty());
245    }
246
247    fn triangle_executor() -> WcoIntersectExecutor {
248        // Probe (a, b); build1 keyed by a carries c; build2 keyed by b carries c.
249        let mut b1 = IntersectBuild::new(0, 1);
250        b1.append_rows(&[
251            vec![Value::Int(1), Value::Int(10)],
252            vec![Value::Int(1), Value::Int(20)],
253            vec![Value::Int(2), Value::Int(30)],
254        ]);
255        b1.finish();
256        let mut b2 = IntersectBuild::new(0, 1);
257        b2.append_rows(&[
258            vec![Value::Int(2), Value::Int(20)],
259            vec![Value::Int(2), Value::Int(40)],
260        ]);
261        b2.finish();
262        WcoIntersectExecutor::new(vec![b1, b2], vec![0, 1])
263    }
264
265    #[test]
266    fn triangle_probe_emits_common_adjacency() {
267        let exec = triangle_executor();
268        let out = exec.probe_row(&[Value::Int(1), Value::Int(2)]);
269        // Common c = {20}: probe ++ [c] ++ build1 row ++ build2 row.
270        assert_eq!(out.len(), 1);
271        assert_eq!(
272            out[0],
273            vec![
274                Value::Int(1),
275                Value::Int(2),
276                Value::Int(20),
277                Value::Int(1),
278                Value::Int(20),
279                Value::Int(2),
280                Value::Int(20),
281            ]
282        );
283    }
284
285    #[test]
286    fn probe_without_common_adjacency_emits_nothing() {
287        let exec = triangle_executor();
288        // a=2 -> {30}; b=2 -> {20, 40}: disjoint.
289        assert!(exec.probe_row(&[Value::Int(2), Value::Int(2)]).is_empty());
290        // Unknown bound value.
291        assert!(exec.probe_row(&[Value::Int(9), Value::Int(2)]).is_empty());
292    }
293
294    #[test]
295    fn duplicate_adjacency_fans_out() {
296        let mut b1 = IntersectBuild::new(0, 1);
297        b1.append_rows(&[
298            vec![Value::Int(1), Value::Int(7), Value::string("x")],
299            vec![Value::Int(1), Value::Int(7), Value::string("y")],
300        ]);
301        b1.finish();
302        let mut b2 = IntersectBuild::new(0, 1);
303        b2.append_rows(&[vec![Value::Int(1), Value::Int(7), Value::string("z")]]);
304        b2.finish();
305        let exec = WcoIntersectExecutor::new(vec![b1, b2], vec![0, 0]);
306        let out = exec.probe_row(&[Value::Int(1)]);
307        assert_eq!(out.len(), 2);
308    }
309}