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