Skip to main content

kevy_store/
zset_algebra.rs

1//! zset algebra (Redis 6.2 `ZINTERSTORE` / `ZUNIONSTORE` /
2//! `ZDIFFSTORE` / `ZINTERCARD` semantics): pure combination helpers
3//! over gathered `(member, score)` lists + the store-side
4//! materialization write.
5//!
6//! The pure functions take inputs ALREADY extracted from their keys
7//! (each input = one source key's scored members, sets contributing
8//! score 1.0 per Redis) so both consumers share one semantics:
9//! the embedded facade (reads under shard locks) and the server's
10//! cross-shard gather reducer.
11
12#[cfg(not(feature = "std"))]
13use crate::nostd_prelude::*;
14use crate::{Store, StoreError};
15
16/// Scratch tables for the set-algebra passes: std's hash tables on
17/// `std`, alloc's B-trees without it (`no_std` correctness form —
18/// these are per-call temporaries, not hot state).
19#[cfg(feature = "std")]
20type ScratchMap<K, V> = std::collections::HashMap<K, V>;
21#[cfg(feature = "std")]
22type ScratchSet<T> = std::collections::HashSet<T>;
23#[cfg(not(feature = "std"))]
24type ScratchMap<K, V> = alloc::collections::BTreeMap<K, V>;
25#[cfg(not(feature = "std"))]
26type ScratchSet<T> = alloc::collections::BTreeSet<T>;
27
28/// `AGGREGATE` mode for inter/union (Redis 6.2; default `Sum`).
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ZAggregate {
31    /// Weighted sum of scores (the default).
32    Sum,
33    /// Minimum weighted score.
34    Min,
35    /// Maximum weighted score.
36    Max,
37}
38
39fn agg(a: f64, b: f64, mode: ZAggregate) -> f64 {
40    match mode {
41        ZAggregate::Sum => a + b,
42        ZAggregate::Min => a.min(b),
43        ZAggregate::Max => a.max(b),
44    }
45}
46
47fn weight_of(weights: Option<&[f64]>, i: usize) -> f64 {
48    weights.map_or(1.0, |w| w.get(i).copied().unwrap_or(1.0))
49}
50
51/// `ZUNIONSTORE` combination: every member of any input, scores
52/// aggregated across the inputs it appears in (weighted).
53pub fn zunion(
54    inputs: &[Vec<(Vec<u8>, f64)>],
55    weights: Option<&[f64]>,
56    mode: ZAggregate,
57) -> Vec<(Vec<u8>, f64)> {
58    let mut acc: Vec<(Vec<u8>, f64)> = Vec::new();
59    let mut idx: ScratchMap<Vec<u8>, usize> = ScratchMap::new();
60    for (i, input) in inputs.iter().enumerate() {
61        let w = weight_of(weights, i);
62        for (m, s) in input {
63            let ws = s * w;
64            match idx.get(m) {
65                Some(&slot) => acc[slot].1 = agg(acc[slot].1, ws, mode),
66                None => {
67                    idx.insert(m.clone(), acc.len());
68                    acc.push((m.clone(), ws));
69                }
70            }
71        }
72    }
73    acc
74}
75
76/// `ZINTERSTORE` combination: members present in EVERY input.
77pub fn zinter(
78    inputs: &[Vec<(Vec<u8>, f64)>],
79    weights: Option<&[f64]>,
80    mode: ZAggregate,
81) -> Vec<(Vec<u8>, f64)> {
82    let Some((first, rest)) = inputs.split_first() else {
83        return Vec::new();
84    };
85    // Membership maps for the non-first inputs.
86    let maps: Vec<ScratchMap<&[u8], f64>> =
87        rest.iter().map(|inp| inp.iter().map(|(m, s)| (m.as_slice(), *s)).collect()).collect();
88    let w0 = weight_of(weights, 0);
89    let mut out = Vec::new();
90    'member: for (m, s) in first {
91        let mut score = s * w0;
92        for (j, map) in maps.iter().enumerate() {
93            match map.get(m.as_slice()) {
94                Some(&sj) => score = agg(score, sj * weight_of(weights, j + 1), mode),
95                None => continue 'member,
96            }
97        }
98        out.push((m.clone(), score));
99    }
100    out
101}
102
103/// `ZDIFFSTORE` combination: members of the first input absent from
104/// every other input; scores from the first input (no weights /
105/// aggregate — Redis 6.2 defines none for ZDIFF).
106pub fn zdiff(inputs: &[Vec<(Vec<u8>, f64)>]) -> Vec<(Vec<u8>, f64)> {
107    let Some((first, rest)) = inputs.split_first() else {
108        return Vec::new();
109    };
110    let excluded: ScratchSet<&[u8]> =
111        rest.iter().flat_map(|inp| inp.iter().map(|(m, _)| m.as_slice())).collect();
112    first.iter().filter(|(m, _)| !excluded.contains(m.as_slice())).cloned().collect()
113}
114
115/// `ZINTERCARD` (with optional `LIMIT`, 0 = unlimited): cardinality of
116/// the intersection, short-circuiting at the limit.
117pub fn zintercard(inputs: &[Vec<(Vec<u8>, f64)>], limit: usize) -> usize {
118    let Some((first, rest)) = inputs.split_first() else {
119        return 0;
120    };
121    let maps: Vec<ScratchSet<&[u8]>> =
122        rest.iter().map(|inp| inp.iter().map(|(m, _)| m.as_slice()).collect()).collect();
123    let mut n = 0;
124    'member: for (m, _) in first {
125        for map in &maps {
126            if !map.contains(m.as_slice()) {
127                continue 'member;
128            }
129        }
130        n += 1;
131        if limit != 0 && n >= limit {
132            return n;
133        }
134    }
135    n
136}
137
138impl Store {
139    /// Extract one source key's scored members for the algebra ops:
140    /// zsets as-is, sets with score 1.0 (Redis semantics), absent key
141    /// = empty, any other type = `WrongType`.
142    pub fn zset_or_set_members(&mut self, key: &[u8]) -> Result<Vec<(Vec<u8>, f64)>, StoreError> {
143        use crate::Value;
144        match self.live_entry(key) {
145            None => Ok(Vec::new()),
146            Some(e) => match &e.value {
147                Value::ZSet(z) => Ok(z.by_member.iter().map(|(m, s)| (m.to_vec(), *s)).collect()),
148                Value::SegZSet(z) => Ok(z.ordered().map(|(m, s)| (m.to_vec(), s)).collect()),
149                Value::SmallZSetInline(z) => Ok(z.iter().map(|(m, s)| (m.to_vec(), s)).collect()),
150                Value::Set(s) => Ok(s.iter().map(|m| (m.to_vec(), 1.0)).collect()),
151                Value::SegSet(s) => Ok(s.keys().map(|m| (m.to_vec(), 1.0)).collect()),
152                Value::SmallSetInline(s) => Ok(s.iter().map(|m| (m.to_vec(), 1.0)).collect()),
153                _ => Err(StoreError::WrongType),
154            },
155        }
156    }
157
158    /// Materialize an algebra result at `dst`: existing value (any
159    /// type) is dropped, result written as a zset — Redis `*STORE`
160    /// overwrite semantics. Empty result deletes `dst` (Redis drops
161    /// the destination rather than storing an empty zset). Returns
162    /// the stored cardinality.
163    pub fn zstore_result(&mut self, dst: &[u8], pairs: &[(Vec<u8>, f64)]) -> usize {
164        self.del(&[dst]);
165        if pairs.is_empty() {
166            return 0;
167        }
168        let scored: Vec<(f64, &[u8])> = pairs.iter().map(|(m, s)| (*s, m.as_slice())).collect();
169        let _ = self.zadd(dst, &scored);
170        pairs.len()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn zs(pairs: &[(&str, f64)]) -> Vec<(Vec<u8>, f64)> {
179        pairs.iter().map(|(m, s)| (m.as_bytes().to_vec(), *s)).collect()
180    }
181
182    #[test]
183    fn union_weights_and_aggregates() {
184        let a = zs(&[("x", 1.0), ("y", 2.0)]);
185        let b = zs(&[("y", 3.0), ("z", 4.0)]);
186        let mut u = zunion(&[a.clone(), b.clone()], None, ZAggregate::Sum);
187        u.sort_by(|x, y| x.0.cmp(&y.0));
188        assert_eq!(u, zs(&[("x", 1.0), ("y", 5.0), ("z", 4.0)]));
189        // WEIGHTS 2 3, MIN
190        let mut u = zunion(&[a, b], Some(&[2.0, 3.0]), ZAggregate::Min);
191        u.sort_by(|x, y| x.0.cmp(&y.0));
192        assert_eq!(u, zs(&[("x", 2.0), ("y", 4.0), ("z", 12.0)]));
193    }
194
195    #[test]
196    fn inter_semantics() {
197        let a = zs(&[("x", 1.0), ("y", 2.0)]);
198        let b = zs(&[("y", 3.0), ("z", 4.0)]);
199        assert_eq!(zinter(&[a.clone(), b.clone()], None, ZAggregate::Sum), zs(&[("y", 5.0)]));
200        assert_eq!(zinter(&[a, b], Some(&[10.0, 1.0]), ZAggregate::Max), zs(&[("y", 20.0)]));
201        assert!(zinter(&[], None, ZAggregate::Sum).is_empty());
202    }
203
204    #[test]
205    fn diff_and_intercard() {
206        let a = zs(&[("x", 1.0), ("y", 2.0), ("z", 3.0)]);
207        let b = zs(&[("y", 9.0)]);
208        let mut d = zdiff(&[a.clone(), b.clone()]);
209        d.sort_by(|x, y| x.0.cmp(&y.0));
210        assert_eq!(d, zs(&[("x", 1.0), ("z", 3.0)]));
211        assert_eq!(zintercard(&[a.clone(), b.clone()], 0), 1);
212        let c = zs(&[("x", 0.0), ("y", 0.0), ("z", 0.0)]);
213        assert_eq!(zintercard(&[a.clone(), c.clone()], 0), 3);
214        assert_eq!(zintercard(&[a, c], 2), 2); // LIMIT short-circuit
215    }
216
217    #[test]
218    fn store_materialization_and_source_extraction() {
219        let mut s = Store::new();
220        s.zadd(b"z", &[(1.0, b"a".as_slice()), (2.0, b"b".as_slice())]).unwrap();
221        s.sadd(b"s", &[b"a".as_slice(), b"c".as_slice()]).unwrap();
222        let mut zm = s.zset_or_set_members(b"z").unwrap();
223        zm.sort_by(|x, y| x.0.cmp(&y.0));
224        assert_eq!(zm, zs(&[("a", 1.0), ("b", 2.0)]));
225        let mut sm = s.zset_or_set_members(b"s").unwrap();
226        sm.sort_by(|x, y| x.0.cmp(&y.0));
227        assert_eq!(sm, zs(&[("a", 1.0), ("c", 1.0)]));
228        assert!(s.zset_or_set_members(b"missing").unwrap().is_empty());
229        s.set(b"str", b"v".to_vec(), None, false, false);
230        assert!(s.zset_or_set_members(b"str").is_err());
231
232        // *STORE overwrite + empty-result-deletes semantics.
233        s.set(b"dst", b"old".to_vec(), None, false, false);
234        assert_eq!(s.zstore_result(b"dst", &zs(&[("m", 7.0)])), 1);
235        assert_eq!(s.zscore(b"dst", b"m").unwrap(), Some(7.0));
236        assert_eq!(s.zstore_result(b"dst", &[]), 0);
237        assert_eq!(s.zcard(b"dst").unwrap(), 0);
238        assert_eq!(s.exists(&[b"dst".as_slice()]), 0);
239    }
240}