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