Skip to main content

yo_kv/
zsetops.rs

1//! Sorted set algebra, and how a result gets into order without a descent per
2//! member.
3//!
4//! `ZUNION`, `ZINTER`, `ZDIFF`, `ZINTERCARD` and the three `*STORE` forms. The
5//! set algebra next door is about which members come out. This one is about that
6//! and about what score each of them comes out with, which is where the work is:
7//! a member in three of the inputs has three scores, and `WEIGHTS` and
8//! `AGGREGATE` say how those become one.
9//!
10//! # A set is a sorted set where every score is one
11//!
12//! `ZUNIONSTORE d 2 zs plain` is legal and every one of these commands takes
13//! either type. That is not a special case bolted on, it is Redis's rule and it
14//! falls out of an [`Operand`] which answers the two questions the algebra asks,
15//! what members are in you and what score do you give this one, whichever it is
16//! holding.
17//!
18//! # Order comes last, once, and costs nothing extra
19//!
20//! The obvious way to build a union is to add each member to a result sorted set
21//! as it is found, which is a tree descent per member and a second one every time
22//! a later input raises a score that is already in there. For a union of four
23//! sets of a hundred thousand that is somewhere over half a million descents to
24//! produce four hundred thousand members.
25//!
26//! So nothing is ordered while it is being worked out. Every operation
27//! accumulates into an [`Elements<f64>`], which is the same member to score table
28//! a sorted set is half made of and which knows nothing about order, so a member
29//! appearing again is a hash probe and a float operation and no more than that.
30//! Ordering happens once at the end, in [`Zset::from_elements`], which sorts row
31//! numbers and then fills the tree by appending, and the append case is the one
32//! the tree is fastest at.
33//!
34//! The part worth noticing is that the accumulator is not copied into the result.
35//! It becomes the result. The member bytes are written once, when the first input
36//! that has that member is walked, and they are never moved again.
37//!
38//! # Probe or accumulate
39//!
40//! `SINTER` measured probing as faster at every number of inputs and the same
41//! argument holds here, so [`gather`] walks the smallest input and asks the
42//! others. `ZDIFF` walks the first and asks the others, because the first is the
43//! only one whose members can be in the answer. `ZUNION` has no choice: every
44//! member of every input is in the answer, so all of them are walked.
45//!
46//! # NaN
47//!
48//! Redis turns one into a zero rather than refusing the command, which is worth
49//! knowing because there are two ways to get one and both are reachable from
50//! ordinary arguments. A weight of zero against a score of infinity is a NaN, and
51//! so is a sum of two infinities of opposite sign. Neither can be stored, since a
52//! NaN compares equal to nothing including itself, so both become zero here.
53
54use yo_common::num::DIGITS_MAX;
55
56use crate::elem::Elements;
57use crate::listpack::Entry;
58use crate::set::Set;
59use crate::zset::Zset;
60
61/// How the scores of a member that is in more than one input become one score.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum Aggregate {
64    /// Add them up. `AGGREGATE SUM`, and the default.
65    #[default]
66    Sum,
67    /// Keep the lowest. `AGGREGATE MIN`.
68    Min,
69    /// Keep the highest. `AGGREGATE MAX`.
70    Max,
71}
72
73/// Which algebra is being done.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum Op {
76    /// Every member of every input. `ZUNION`.
77    Union,
78    /// Only members in all of them. `ZINTER`.
79    Inter,
80    /// Members in the first and in none of the rest. `ZDIFF`.
81    Diff,
82}
83
84/// One input to a sorted set operation.
85///
86/// A plain set is an operand because Redis says it is, and it behaves as a
87/// sorted set in which every member scores one.
88#[derive(Debug, Clone, Copy)]
89pub enum Operand<'a> {
90    /// A sorted set, with the scores it holds.
91    Zset(&'a Zset),
92    /// A plain set, where every member scores one.
93    Set(&'a Set),
94    /// A key that is not there, which is an empty input and not an error.
95    Missing,
96}
97
98impl Operand<'_> {
99    /// How many members are in this input.
100    #[must_use]
101    pub fn len(&self) -> usize {
102        match self {
103            Operand::Zset(z) => z.len(),
104            Operand::Set(s) => s.len(),
105            Operand::Missing => 0,
106        }
107    }
108
109    /// Whether this input has no members.
110    #[must_use]
111    pub fn is_empty(&self) -> bool {
112        self.len() == 0
113    }
114
115    /// The score this input gives a member, or `None` if it does not have it.
116    fn score(&self, member: &[u8]) -> Option<f64> {
117        match self {
118            Operand::Zset(z) => z.score(member),
119            Operand::Set(s) => s.contains(member).then_some(1.0),
120            Operand::Missing => None,
121        }
122    }
123
124    /// Hand every member and its score over, in whatever order is cheapest.
125    fn walk<F: FnMut(&[u8], f64)>(&self, mut f: F) {
126        let mut digits = [0u8; DIGITS_MAX];
127        match self {
128            Operand::Zset(z) => z.walk(0, z.len(), false, |m, s| f(bytes(m, &mut digits), s)),
129            Operand::Set(s) => {
130                for m in s.iter() {
131                    f(bytes(m, &mut digits), 1.0);
132                }
133            }
134            Operand::Missing => {}
135        }
136    }
137}
138
139/// The bytes of a member, which for one stored as an integer are the buffer.
140#[inline]
141fn bytes<'a>(m: Entry<'a>, digits: &'a mut [u8; DIGITS_MAX]) -> &'a [u8] {
142    match m {
143        Entry::Str(s) => s,
144        Entry::Int(n) => yo_common::num::i64_digits(digits, n),
145    }
146}
147
148/// A score with its weight applied, with Redis's rule for a NaN.
149///
150/// A weight of zero against an infinite score is the reachable case, and it is
151/// reachable from `ZUNIONSTORE d 2 a b WEIGHTS 0 1` against a set holding an
152/// infinite score, which is not an exotic thing to write.
153#[inline]
154fn weighted(score: f64, weight: f64) -> f64 {
155    let v = score * weight;
156    if v.is_nan() { 0.0 } else { v }
157}
158
159/// Fold a second score into one already held.
160#[inline]
161fn fold(now: f64, next: f64, agg: Aggregate) -> f64 {
162    match agg {
163        Aggregate::Sum => {
164            let v = now + next;
165            if v.is_nan() { 0.0 } else { v }
166        }
167        Aggregate::Min => {
168            if next < now {
169                next
170            } else {
171                now
172            }
173        }
174        Aggregate::Max => {
175            if next > now {
176                next
177            } else {
178                now
179            }
180        }
181    }
182}
183
184/// Work out an operation and answer the member to score table it produced.
185///
186/// The table is unordered, because ordering it costs a sort and no operation
187/// needs one until it is about to be read. [`Zset::from_elements`] is what turns
188/// it into something with ranks.
189///
190/// `weights` is either empty, meaning every input counts once, or one weight per
191/// input. A shorter list than that is the caller's bug and the missing ones count
192/// as one.
193#[must_use]
194pub fn gather(op: Op, inputs: &[Operand<'_>], weights: &[f64], agg: Aggregate) -> Elements<f64> {
195    let weight = |i: usize| weights.get(i).copied().unwrap_or(1.0);
196    match op {
197        Op::Union => {
198            let mut out = Elements::with_capacity(hint(inputs, op));
199            for (i, input) in inputs.iter().enumerate() {
200                let w = weight(i);
201                input.walk(|member, score| {
202                    let v = weighted(score, w);
203                    // The first input to hold a member writes its bytes. Every
204                    // later one that holds it touches the score and nothing else.
205                    match out.get_mut(member) {
206                        Some(now) => *now = fold(*now, v, agg),
207                        None => {
208                            let _ = out.insert(member, v);
209                        }
210                    }
211                });
212            }
213            out
214        }
215        Op::Inter => {
216            // The smallest input, because a member that is not in it cannot be
217            // in the answer and walking any larger one asks more questions for
218            // the same result.
219            let Some(small) = (0..inputs.len()).min_by_key(|&i| inputs[i].len()) else {
220                return Elements::with_capacity(0);
221            };
222            let mut out = Elements::with_capacity(inputs[small].len().clamp(16, 1 << 16));
223            if inputs.iter().any(Operand::is_empty) {
224                return out;
225            }
226            inputs[small].walk(|member, score| {
227                let mut total = weighted(score, weight(small));
228                for (i, other) in inputs.iter().enumerate() {
229                    if i == small {
230                        continue;
231                    }
232                    // A member missing from any input ends the questions for
233                    // that member rather than the ones for the rest of them.
234                    let Some(s) = other.score(member) else { return };
235                    total = fold(total, weighted(s, weight(i)), agg);
236                }
237                let _ = out.insert(member, total);
238            });
239            out
240        }
241        Op::Diff => {
242            let Some((first, rest)) = inputs.split_first() else {
243                return Elements::with_capacity(0);
244            };
245            let mut out = Elements::with_capacity(first.len().clamp(16, 1 << 16));
246            first.walk(|member, score| {
247                if rest.iter().any(|o| o.score(member).is_some()) {
248                    return;
249                }
250                // No weight and no aggregate. `ZDIFF` takes neither, because
251                // every member in its answer came from exactly one input.
252                let _ = out.insert(member, score);
253            });
254            out
255        }
256    }
257}
258
259/// `ZINTERCARD numkeys key [key ...] [LIMIT limit]`.
260///
261/// Counting only, so nothing is stored and no score is worked out. A limit stops
262/// the walk as soon as it is reached, which is the only reason the command exists
263/// separately from `ZINTER` with the members thrown away.
264#[must_use]
265pub fn intercard(inputs: &[Operand<'_>], limit: usize) -> usize {
266    if inputs.is_empty() || inputs.iter().any(Operand::is_empty) {
267        return 0;
268    }
269    let small = (0..inputs.len())
270        .min_by_key(|&i| inputs[i].len())
271        .expect("not empty");
272    let stop = if limit == 0 { usize::MAX } else { limit };
273    let mut found = 0;
274    inputs[small].walk(|member, _| {
275        if found >= stop {
276            return;
277        }
278        if inputs
279            .iter()
280            .enumerate()
281            .all(|(i, o)| i == small || o.score(member).is_some())
282        {
283            found += 1;
284        }
285    });
286    found
287}
288
289/// How much room a result is likely to want.
290///
291/// A union is at most every member of every input and usually far fewer, so this
292/// is an over estimate that costs one allocation to be wrong about, against a
293/// growth every time it doubles if it is under.
294fn hint(inputs: &[Operand<'_>], op: Op) -> usize {
295    let total: usize = match op {
296        Op::Union => inputs.iter().map(Operand::len).sum(),
297        _ => inputs.first().map_or(0, Operand::len),
298    };
299    total.clamp(16, 1 << 20)
300}
301
302#[cfg(test)]
303mod tests {
304    use core::cmp::Ordering;
305
306    use super::*;
307    use crate::set::Limits as SetLimits;
308    use crate::zset::Limits;
309
310    /// The order a sorted set is in: score first, then the member bytes.
311    ///
312    /// The tests work the answer out the slow way and then put it in this order
313    /// to compare, which is the only place in this file that needs it. The
314    /// module itself never orders anything, because that is
315    /// [`Zset::from_elements`]'s job and doing it here as well would be two
316    /// implementations of one rule.
317    fn cmp_key(a: (f64, &[u8]), b: (f64, &[u8])) -> Ordering {
318        match a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal) {
319            Ordering::Equal => a.1.cmp(b.1),
320            other => other,
321        }
322    }
323
324    fn zs(pairs: &[(&str, f64)]) -> Zset {
325        let mut z = Zset::new();
326        for (m, s) in pairs {
327            z.add(m.as_bytes(), *s, &Limits::DEFAULT);
328        }
329        z
330    }
331
332    fn plain(members: &[&str]) -> Set {
333        let mut s = Set::new();
334        for m in members {
335            s.add(m.as_bytes(), &SetLimits::DEFAULT);
336        }
337        s
338    }
339
340    /// The result in rank order, which is what every caller of this actually
341    /// wants and is the only way to compare two of them.
342    fn ordered(got: Elements<f64>) -> Vec<(String, f64)> {
343        let mut out: Vec<(String, f64)> = (0..got.len())
344            .map(|i| got.at(i).expect("in range"))
345            .map(|(n, s)| (String::from_utf8(n.to_vec()).unwrap(), *s))
346            .collect();
347        out.sort_by(|a, b| cmp_key((a.1, a.0.as_bytes()), (b.1, b.0.as_bytes())));
348        out
349    }
350
351    fn named(got: Vec<(String, f64)>) -> Vec<String> {
352        got.into_iter().map(|(m, _)| m).collect()
353    }
354
355    #[test]
356    fn a_union_adds_the_scores_of_a_member_in_both() {
357        let a = zs(&[("x", 1.0), ("y", 2.0)]);
358        let b = zs(&[("y", 3.0), ("z", 4.0)]);
359        let got = ordered(gather(
360            Op::Union,
361            &[Operand::Zset(&a), Operand::Zset(&b)],
362            &[],
363            Aggregate::Sum,
364        ));
365        assert_eq!(
366            got,
367            [("x".into(), 1.0), ("z".into(), 4.0), ("y".into(), 5.0)]
368        );
369    }
370
371    #[test]
372    fn min_and_max_keep_one_score_rather_than_adding_them() {
373        let a = zs(&[("y", 2.0)]);
374        let b = zs(&[("y", 7.0)]);
375        let ops = [Operand::Zset(&a), Operand::Zset(&b)];
376        assert_eq!(
377            ordered(gather(Op::Union, &ops, &[], Aggregate::Min)),
378            [("y".to_string(), 2.0)]
379        );
380        assert_eq!(
381            ordered(gather(Op::Union, &ops, &[], Aggregate::Max)),
382            [("y".to_string(), 7.0)]
383        );
384    }
385
386    #[test]
387    fn weights_multiply_before_anything_is_aggregated() {
388        let a = zs(&[("x", 1.0), ("y", 2.0)]);
389        let b = zs(&[("y", 3.0)]);
390        let ops = [Operand::Zset(&a), Operand::Zset(&b)];
391        let got = ordered(gather(Op::Union, &ops, &[2.0, 10.0], Aggregate::Sum));
392        assert_eq!(got, [("x".into(), 2.0), ("y".into(), 34.0)]);
393        // MIN sees the weighted scores and not the raw ones, so the input with
394        // the larger raw score can still be the one that wins.
395        let got = ordered(gather(Op::Union, &ops, &[2.0, 0.5], Aggregate::Min));
396        assert_eq!(got, [("y".into(), 1.5), ("x".into(), 2.0)]);
397    }
398
399    #[test]
400    fn an_intersection_only_keeps_what_every_input_has() {
401        let a = zs(&[("x", 1.0), ("y", 2.0), ("z", 3.0)]);
402        let b = zs(&[("y", 10.0), ("z", 20.0)]);
403        let c = zs(&[("z", 100.0)]);
404        let ops = [Operand::Zset(&a), Operand::Zset(&b), Operand::Zset(&c)];
405        assert_eq!(
406            ordered(gather(Op::Inter, &ops, &[], Aggregate::Sum)),
407            [("z".to_string(), 123.0)]
408        );
409        assert_eq!(intercard(&ops, 0), 1);
410        // An empty input anywhere is an empty intersection.
411        let ops = [Operand::Zset(&a), Operand::Missing];
412        assert!(ordered(gather(Op::Inter, &ops, &[], Aggregate::Sum)).is_empty());
413        assert_eq!(intercard(&ops, 0), 0);
414    }
415
416    #[test]
417    fn a_difference_keeps_the_first_input_scores() {
418        let a = zs(&[("x", 1.0), ("y", 2.0), ("z", 3.0)]);
419        let b = zs(&[("y", 99.0)]);
420        let ops = [Operand::Zset(&a), Operand::Zset(&b)];
421        assert_eq!(
422            ordered(gather(Op::Diff, &ops, &[], Aggregate::Sum)),
423            [("x".into(), 1.0), ("z".into(), 3.0)]
424        );
425        // A first input that is not there is an empty answer whatever the rest
426        // hold, and a later one that is not there takes nothing away.
427        assert!(
428            ordered(gather(
429                Op::Diff,
430                &[Operand::Missing, Operand::Zset(&a)],
431                &[],
432                Aggregate::Sum
433            ))
434            .is_empty()
435        );
436        let ops = [Operand::Zset(&a), Operand::Missing];
437        assert_eq!(
438            named(ordered(gather(Op::Diff, &ops, &[], Aggregate::Sum))),
439            ["x", "y", "z"]
440        );
441    }
442
443    #[test]
444    fn a_plain_set_counts_as_a_sorted_set_where_every_score_is_one() {
445        let a = zs(&[("x", 5.0), ("y", 6.0)]);
446        let b = plain(&["y", "z"]);
447        let ops = [Operand::Zset(&a), Operand::Set(&b)];
448        let got = ordered(gather(Op::Union, &ops, &[], Aggregate::Sum));
449        assert_eq!(
450            got,
451            [("z".into(), 1.0), ("x".into(), 5.0), ("y".into(), 7.0)]
452        );
453        assert_eq!(
454            ordered(gather(Op::Inter, &ops, &[], Aggregate::Sum)),
455            [("y".to_string(), 7.0)]
456        );
457        assert_eq!(
458            named(ordered(gather(Op::Diff, &ops, &[], Aggregate::Sum))),
459            ["x"]
460        );
461    }
462
463    /// An intset holds numbers and a table holds their digits, so a member only
464    /// crosses between the two if the walk hands over bytes either way.
465    #[test]
466    fn an_integer_member_crosses_between_a_set_and_a_sorted_set() {
467        let a = zs(&[("17", 5.0), ("42", 6.0)]);
468        let b = plain(&["42", "99"]);
469        assert_eq!(b.encoding().name(), "intset");
470        let ops = [Operand::Zset(&a), Operand::Set(&b)];
471        assert_eq!(
472            ordered(gather(Op::Inter, &ops, &[], Aggregate::Sum)),
473            [("42".to_string(), 7.0)]
474        );
475        assert_eq!(intercard(&ops, 0), 1);
476        assert_eq!(
477            named(ordered(gather(Op::Union, &ops, &[], Aggregate::Sum))),
478            ["99", "17", "42"]
479        );
480    }
481
482    #[test]
483    fn a_score_that_would_be_a_nan_becomes_a_zero() {
484        // A weight of zero against an infinity.
485        let a = zs(&[("x", f64::INFINITY)]);
486        let ops = [Operand::Zset(&a)];
487        assert_eq!(
488            ordered(gather(Op::Union, &ops, &[0.0], Aggregate::Sum)),
489            [("x".to_string(), 0.0)]
490        );
491        // Two infinities of opposite sign, added.
492        let b = zs(&[("x", f64::NEG_INFINITY)]);
493        let ops = [Operand::Zset(&a), Operand::Zset(&b)];
494        assert_eq!(
495            ordered(gather(Op::Union, &ops, &[], Aggregate::Sum)),
496            [("x".to_string(), 0.0)]
497        );
498    }
499
500    #[test]
501    fn a_limit_stops_a_cardinality_count_where_it_was_told_to() {
502        let a = zs(&[("a", 1.0), ("b", 1.0), ("c", 1.0), ("d", 1.0)]);
503        let b = zs(&[("a", 1.0), ("b", 1.0), ("c", 1.0), ("d", 1.0)]);
504        let ops = [Operand::Zset(&a), Operand::Zset(&b)];
505        assert_eq!(intercard(&ops, 0), 4);
506        assert_eq!(intercard(&ops, 2), 2);
507        assert_eq!(intercard(&ops, 99), 4);
508        assert_eq!(intercard(&[], 0), 0);
509    }
510
511    #[test]
512    fn a_union_of_thousands_agrees_with_the_slow_way_of_working_it_out() {
513        let one: Vec<(String, f64)> = (0..3_000)
514            .map(|i| (format!("m{i:05}"), f64::from(i)))
515            .collect();
516        let two: Vec<(String, f64)> = (1_500..4_500)
517            .map(|i| (format!("m{i:05}"), f64::from(i) * 2.0))
518            .collect();
519        let mut a = Zset::new();
520        for (m, s) in &one {
521            a.add(m.as_bytes(), *s, &Limits::DEFAULT);
522        }
523        let mut b = Zset::new();
524        for (m, s) in &two {
525            b.add(m.as_bytes(), *s, &Limits::DEFAULT);
526        }
527        let ops = [Operand::Zset(&a), Operand::Zset(&b)];
528
529        let mut want: std::collections::BTreeMap<String, f64> = std::collections::BTreeMap::new();
530        for (m, s) in one.iter().chain(two.iter()) {
531            *want.entry(m.clone()).or_insert(0.0) += s;
532        }
533        let mut want: Vec<(String, f64)> = want.into_iter().collect();
534        want.sort_by(|x, y| cmp_key((x.1, x.0.as_bytes()), (y.1, y.0.as_bytes())));
535        assert_eq!(ordered(gather(Op::Union, &ops, &[], Aggregate::Sum)), want);
536        assert_eq!(intercard(&ops, 0), 1_500);
537    }
538}