Skip to main content

kevy_index/
agg.rs

1//! [`AggSegment`] — one shard's slice of one aggregate index (KIND
2//! agg): per-group count / sum / min / max maintained
3//! synchronously with writes. min/max stay EXACT under deletion via a
4//! per-group value multiset (BTreeMap value → multiplicity); a
5//! row → (group, value) reverse map supports O(log) updates — the
6//! same derived-by-construction discipline as [`crate::Segment`].
7
8use std::collections::{BTreeMap, HashMap};
9
10use crate::IndexValue;
11
12/// One group's live statistics.
13#[derive(Debug, Clone, PartialEq)]
14pub struct GroupStats {
15    /// Rows in the group.
16    pub count: u64,
17    /// Sum of the aggregated field (f64 accumulation — the i64
18    /// overflow guard; precision bounds documented).
19    pub sum: f64,
20    /// Exact minimum (None only when count == 0).
21    pub min: Option<IndexValue>,
22    /// Exact maximum.
23    pub max: Option<IndexValue>,
24}
25
26impl GroupStats {
27    /// Derived average.
28    pub fn avg(&self) -> Option<f64> {
29        (self.count > 0).then(|| self.sum / self.count as f64)
30    }
31}
32
33struct Group {
34    count: u64,
35    sum: f64,
36    /// value → multiplicity; min/max = first/last key.
37    values: BTreeMap<IndexValue, u32>,
38}
39
40/// Ranking metric for [`AggSegment::top_groups`].
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum AggBy {
43    /// By row count (default).
44    #[default]
45    Count,
46    /// By sum.
47    Sum,
48    /// By minimum (ascending — smallest mins first).
49    Min,
50    /// By maximum (descending — largest maxes first).
51    Max,
52}
53
54impl AggBy {
55    /// Wire tag.
56    pub fn parse(raw: &[u8]) -> Option<AggBy> {
57        if raw.eq_ignore_ascii_case(b"count") {
58            Some(AggBy::Count)
59        } else if raw.eq_ignore_ascii_case(b"sum") {
60            Some(AggBy::Sum)
61        } else if raw.eq_ignore_ascii_case(b"min") {
62            Some(AggBy::Min)
63        } else if raw.eq_ignore_ascii_case(b"max") {
64            Some(AggBy::Max)
65        } else {
66            None
67        }
68    }
69}
70
71/// Sizing counters (memory formula / IDX.LIST).
72#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
73pub struct AggStats {
74    /// Live groups.
75    pub groups: u64,
76    /// Rows participating.
77    pub rows: u64,
78    /// Rows excluded (coerce failure / missing group field).
79    pub excluded: u64,
80    /// Approximate heap bytes (the measured side of the documented
81    /// memory formula).
82    pub approx_bytes: u64,
83}
84
85/// One shard's aggregate segment.
86#[derive(Default)]
87pub struct AggSegment {
88    groups: HashMap<Vec<u8>, Group>,
89    /// row key → (group, value) for O(log) update/remove.
90    rows: HashMap<Vec<u8>, (Vec<u8>, IndexValue)>,
91    excluded: u64,
92    /// Running counters, so `stats()` never walks the maps
93    /// (the walk ran on every tiering tick). Each mirrors one walking
94    /// term of the byte formula; `recompute_stats` is the reference
95    /// the tests hold them to.
96    distinct_total: u64,
97    gkey_bytes: u64,
98    row_key_bytes: u64,
99}
100
101impl AggSegment {
102    /// Empty segment.
103    pub fn new() -> Self {
104        Self::default()
105    }
106
107    /// (Re-)register one row: `Some((group, value))` = row
108    /// participates; `None` = removed or excluded. `excluded_row`
109    /// marks the None case as a coercion/missing-field exclusion
110    /// (counted) rather than a plain delete.
111    // missing_panics_doc: the only panic is the "group of live row" expect —
112    // an internal rows↔groups invariant, never reachable from caller input.
113    #[allow(clippy::missing_panics_doc)]
114    pub fn apply(&mut self, key: &[u8], entry: Option<(Vec<u8>, IndexValue)>, excluded_row: bool) {
115        if let Some((group, val)) = &entry
116            && self.fast_path_same_group(key, group, val)
117        {
118            return;
119        }
120        self.retract_row(key);
121        match entry {
122            Some((group, val)) => {
123                let g = self.groups.entry(group.clone()).or_insert(Group {
124                    count: 0,
125                    sum: 0.0,
126                    values: BTreeMap::new(),
127                });
128                if g.count == 0 {
129                    self.gkey_bytes += group.len() as u64;
130                }
131                g.count += 1;
132                g.sum += val.as_f64();
133                let slot = g.values.entry(val.clone()).or_insert(0);
134                *slot += 1;
135                if *slot == 1 {
136                    self.distinct_total += 1;
137                }
138                self.row_key_bytes += key.len() as u64 + 10;
139                self.rows.insert(key.to_vec(), (group, val));
140            }
141            None if excluded_row => self.excluded += 1,
142            None => {}
143        }
144    }
145
146    /// Fast path: same-group value update (the dominant serving write
147    /// shape — measured 16.8% write tax on a Zipf corpus with the
148    /// retract+register path; hot groups make every full retract pay
149    /// two map round-trips and two clones). `true` = handled.
150    fn fast_path_same_group(&mut self, key: &[u8], group: &[u8], val: &IndexValue) -> bool {
151        let Some((old_group, old_val)) = self.rows.get_mut(key) else { return false };
152        if old_group != group {
153            return false;
154        }
155        if old_val == val {
156            return true; // nothing changed
157        }
158        let g = self.groups.get_mut(group).expect("group of live row");
159        g.sum += val.as_f64() - old_val.as_f64();
160        match g.values.get_mut(old_val) {
161            Some(m) if *m > 1 => *m -= 1,
162            _ => {
163                g.values.remove(old_val);
164                self.distinct_total -= 1;
165            }
166        }
167        let slot = g.values.entry(val.clone()).or_insert(0);
168        *slot += 1;
169        if *slot == 1 {
170            self.distinct_total += 1;
171        }
172        *old_val = val.clone();
173        true
174    }
175
176    /// Retract one row's current contribution (no-op for an unknown
177    /// key); drops the group when its last row leaves.
178    fn retract_row(&mut self, key: &[u8]) {
179        if let Some((old_group, old_val)) = self.rows.remove(key) {
180            self.row_key_bytes -= key.len() as u64 + 10;
181            let empty = {
182                let g = self.groups.get_mut(&old_group).expect("group of live row");
183                g.count -= 1;
184                g.sum -= old_val.as_f64();
185                match g.values.get_mut(&old_val) {
186                    Some(m) if *m > 1 => *m -= 1,
187                    _ => {
188                        g.values.remove(&old_val);
189                        self.distinct_total -= 1;
190                    }
191                }
192                g.count == 0
193            };
194            if empty {
195                self.groups.remove(&old_group);
196                self.gkey_bytes -= old_group.len() as u64;
197            }
198        }
199    }
200
201    /// One group's stats (`count == 0` shape for an unknown group).
202    pub fn group(&self, group: &[u8]) -> GroupStats {
203        match self.groups.get(group) {
204            Some(g) => GroupStats {
205                count: g.count,
206                sum: g.sum,
207                min: g.values.keys().next().cloned(),
208                max: g.values.keys().next_back().cloned(),
209            },
210            None => GroupStats { count: 0, sum: 0.0, min: None, max: None },
211        }
212    }
213
214    /// Top `limit` groups ranked by `by` (count/sum/max descending,
215    /// min ascending), ties broken by group key ascending.
216    ///
217    /// Bounded selection over BORROWED keys — the first cut cloned
218    /// and sorted every group per query (measured as the dominant
219    /// per-shard cost at 10k groups); only the winners materialize.
220    pub fn top_groups(&self, by: AggBy, limit: usize) -> Vec<(Vec<u8>, GroupStats)> {
221        let score_of = |g: &Group| -> f64 {
222            match by {
223                AggBy::Count => g.count as f64,
224                AggBy::Sum => g.sum,
225                AggBy::Max => {
226                    g.values.keys().next_back().map_or(f64::NEG_INFINITY, IndexValue::as_f64)
227                }
228                AggBy::Min => g.values.keys().next().map_or(f64::NEG_INFINITY, |v| -v.as_f64()),
229            }
230        };
231        // float_cmp: exact equality is the tiebreak trigger — an epsilon would
232        // make the top-K selection non-deterministic for equal scores.
233        #[allow(clippy::float_cmp)]
234        let better = |a: (f64, &[u8]), b: (f64, &[u8])| a.0 > b.0 || (a.0 == b.0 && a.1 < b.1);
235        let mut top: Vec<(f64, &Vec<u8>)> = Vec::with_capacity(limit.min(1024) + 1);
236        for (k, g) in &self.groups {
237            let cand = (score_of(g), k);
238            if top.len() < limit {
239                top.push(cand);
240                if top.len() == limit {
241                    top.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(b.1)));
242                }
243            } else if let Some(last) = top.last()
244                && better((cand.0, cand.1), (last.0, last.1))
245            {
246                let pos = top.partition_point(|e| better((e.0, e.1), (cand.0, cand.1)));
247                top.insert(pos, cand);
248                top.pop();
249            }
250        }
251        if top.len() < limit {
252            top.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(b.1)));
253        }
254        top.into_iter().map(|(_, k)| (k.clone(), self.group(k))).collect()
255    }
256
257    /// Every group, UNRANKED — the fan-out chunk shape (ranking
258    /// happens once, at the reduce, after cross-shard merge; sorting
259    /// per shard would be wasted work).
260    pub fn all_groups(&self) -> Vec<(Vec<u8>, GroupStats)> {
261        self.groups.keys().map(|k| (k.clone(), self.group(k))).collect()
262    }
263
264    /// Membership probe (verify hook).
265    pub fn contains(&self, key: &[u8]) -> bool {
266        self.rows.contains_key(key)
267    }
268
269    /// This segment's live row count — the one field of [`Self::stats`]
270    /// that is a `len()`. Its own accessor because `stats` also sums every
271    /// group's values, every group key and every row key to estimate bytes,
272    /// and a caller asking "how many rows" should not pay for that.
273    pub fn rows(&self) -> u64 {
274        self.rows.len() as u64
275    }
276
277    /// Live counters — O(1): every term is a running
278    /// counter maintained at the mutation sites. Byte constants
279    /// calibrated against measured RSS growth at 1M rows / 10k Zipf
280    /// groups (the first-cut 40/24 constants overestimated 2× —
281    /// BTreeMap packs ~11 entries per node and the reverse map's vecs
282    /// are small-alloc pooled).
283    pub fn stats(&self) -> AggStats {
284        AggStats {
285            groups: self.groups.len() as u64,
286            rows: self.rows.len() as u64,
287            excluded: self.excluded,
288            approx_bytes: self.gkey_bytes
289                + self.groups.len() as u64 * 64
290                + self.distinct_total * 18
291                + self.row_key_bytes,
292        }
293    }
294
295    /// The walking reference — recomputes every byte term from the
296    /// live maps. Test-only: production reads the running counters.
297    #[cfg(test)]
298    pub(crate) fn recompute_stats(&self) -> AggStats {
299        let distinct: u64 = self.groups.values().map(|g| g.values.len() as u64).sum();
300        let gkey: u64 = self.groups.keys().map(|k| k.len() as u64).sum();
301        let rowbytes: u64 = self.rows.keys().map(|k| (k.len() + 10) as u64).sum();
302        AggStats {
303            groups: self.groups.len() as u64,
304            rows: self.rows.len() as u64,
305            excluded: self.excluded,
306            approx_bytes: gkey + self.groups.len() as u64 * 64 + distinct * 18 + rowbytes,
307        }
308    }
309}
310
311/// Shared ranking order (per-shard AND at the reduce after merging
312/// shard partials — one definition, no drift).
313pub fn sort_groups(all: &mut [(Vec<u8>, GroupStats)], by: AggBy) {
314    match by {
315        AggBy::Count => all.sort_by(|a, b| b.1.count.cmp(&a.1.count).then_with(|| a.0.cmp(&b.0))),
316        AggBy::Sum => all.sort_by(|a, b| b.1.sum.total_cmp(&a.1.sum).then_with(|| a.0.cmp(&b.0))),
317        AggBy::Min => all.sort_by(|a, b| {
318            match (&a.1.min, &b.1.min) {
319                (Some(x), Some(y)) => x.cmp(y),
320                (Some(_), None) => std::cmp::Ordering::Less,
321                (None, Some(_)) => std::cmp::Ordering::Greater,
322                (None, None) => std::cmp::Ordering::Equal,
323            }
324            .then_with(|| a.0.cmp(&b.0))
325        }),
326        AggBy::Max => all.sort_by(|a, b| {
327            match (&b.1.max, &a.1.max) {
328                (Some(x), Some(y)) => x.cmp(y),
329                (Some(_), None) => std::cmp::Ordering::Less,
330                (None, Some(_)) => std::cmp::Ordering::Greater,
331                (None, None) => std::cmp::Ordering::Equal,
332            }
333            .then_with(|| a.0.cmp(&b.0))
334        }),
335    }
336}
337
338/// Merge shard partials for one group (reduce side): counts/sums add,
339/// min/max take extremes.
340pub fn merge_group(into: &mut GroupStats, part: &GroupStats) {
341    into.count += part.count;
342    into.sum += part.sum;
343    into.min = match (into.min.take(), part.min.clone()) {
344        (Some(a), Some(b)) => Some(if b < a { b } else { a }),
345        (a, b) => a.or(b),
346    };
347    into.max = match (into.max.take(), part.max.clone()) {
348        (Some(a), Some(b)) => Some(if b > a { b } else { a }),
349        (a, b) => a.or(b),
350    };
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    fn seg() -> AggSegment {
358        let mut s = AggSegment::new();
359        // orders: group = status, value = amount
360        for (k, g, v) in [
361            ("o1", "paid", 100),
362            ("o2", "paid", 250),
363            ("o3", "open", 40),
364            ("o4", "paid", 100),
365            ("o5", "open", 999),
366        ] {
367            s.apply(k.as_bytes(), Some((g.as_bytes().to_vec(), IndexValue::I64(v))), false);
368        }
369        s
370    }
371
372    #[test]
373    fn group_stats_exact() {
374        let s = seg();
375        let g = s.group(b"paid");
376        assert_eq!((g.count, g.sum), (3, 450.0));
377        assert_eq!(g.min, Some(IndexValue::I64(100)));
378        assert_eq!(g.max, Some(IndexValue::I64(250)));
379        assert_eq!(g.avg(), Some(150.0));
380        let none = s.group(b"nope");
381        assert_eq!(none.count, 0);
382        assert!(none.min.is_none() && none.avg().is_none());
383    }
384
385    #[test]
386    fn min_max_exact_under_delete_and_update() {
387        let mut s = seg();
388        // delete the paid max (o2=250): max must fall back to 100
389        s.apply(b"o2", None, false);
390        let g = s.group(b"paid");
391        assert_eq!((g.count, g.max.clone()), (2, Some(IndexValue::I64(100))));
392        // duplicate values: removing ONE 100 keeps the other
393        s.apply(b"o1", None, false);
394        let g = s.group(b"paid");
395        assert_eq!((g.count, g.min.clone()), (1, Some(IndexValue::I64(100))));
396        // update moves a row across groups
397        s.apply(b"o3", Some((b"paid".to_vec(), IndexValue::I64(40))), false);
398        assert_eq!(s.group(b"paid").count, 2);
399        assert_eq!(s.group(b"open").count, 1);
400        assert_eq!(s.group(b"paid").min, Some(IndexValue::I64(40)));
401        // last row leaving a group drops the group
402        s.apply(b"o5", None, false);
403        assert_eq!(s.group(b"open").count, 0);
404        assert_eq!(s.stats().groups, 1);
405    }
406
407    #[test]
408    fn top_groups_all_metrics() {
409        let s = seg();
410        let top = s.top_groups(AggBy::Count, 10);
411        assert_eq!(top[0].0, b"paid".to_vec());
412        let top = s.top_groups(AggBy::Sum, 10);
413        assert_eq!(top[0].0, b"open".to_vec(), "open sum 1039 > paid 450");
414        let top = s.top_groups(AggBy::Min, 10);
415        assert_eq!(top[0].0, b"open".to_vec(), "min ascending: 40 first");
416        let top = s.top_groups(AggBy::Max, 1);
417        assert_eq!(top.len(), 1);
418        assert_eq!(top[0].0, b"open".to_vec(), "max 999");
419    }
420
421    #[test]
422    fn excluded_counted_and_merge() {
423        let mut s = seg();
424        s.apply(b"bad1", None, true);
425        s.apply(b"bad2", None, true);
426        assert_eq!(s.stats().excluded, 2);
427        assert!(s.contains(b"o1") && !s.contains(b"bad1"));
428        // cross-shard merge semantics
429        let mut a = s.group(b"paid");
430        let b = seg().group(b"paid");
431        merge_group(&mut a, &b);
432        assert_eq!((a.count, a.sum), (6, 900.0));
433        assert_eq!(a.min, Some(IndexValue::I64(100)));
434        assert_eq!(a.max, Some(IndexValue::I64(250)));
435        // merge with an empty partial keeps extremes
436        let mut e = GroupStats { count: 0, sum: 0.0, min: None, max: None };
437        merge_group(&mut e, &a);
438        assert_eq!(e.max, Some(IndexValue::I64(250)));
439    }
440
441    #[test]
442    fn stats_bytes_nonzero() {
443        let s = seg();
444        let st = s.stats();
445        assert_eq!((st.groups, st.rows), (2, 5));
446        assert!(st.approx_bytes > 0);
447    }
448
449    /// `stats()` reads running counters instead of walking the
450    /// maps. A mixed workload — inserts, the same-group fast path,
451    /// group moves, removals down to empty — holds them to the walking
452    /// reference after every step.
453    #[test]
454    fn running_stats_never_drift_from_the_walking_reference() {
455        let mut s = AggSegment::new();
456        let check = |s: &AggSegment, at: &str| {
457            assert_eq!(s.stats(), s.recompute_stats(), "counter drift after {at}");
458        };
459        let mut x = 0x2545F491u64;
460        let mut next = move || {
461            x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
462            (x >> 33) as u32
463        };
464        let groups = [b"eng".as_slice(), b"sales", b"ops"];
465        for round in 0..300u32 {
466            let key = format!("r:{}", next() % 30);
467            match next() % 6 {
468                0 => s.apply(key.as_bytes(), None, false),
469                1 => s.apply(key.as_bytes(), None, true), // excluded
470                _ => {
471                    let g = groups[(next() % 3) as usize].to_vec();
472                    // Small value domain forces shared distinct entries.
473                    let v = IndexValue::I64(i64::from(next() % 7));
474                    s.apply(key.as_bytes(), Some((g, v)), false);
475                }
476            }
477            check(&s, &format!("round {round}"));
478        }
479        for i in 0..30u32 {
480            s.apply(format!("r:{i}").as_bytes(), None, false);
481        }
482        check(&s, "full drain");
483        let end = s.stats();
484        assert_eq!((end.groups, end.rows), (0, 0));
485    }
486}