Skip to main content

stratifykit_core/
sampling.rs

1use std::collections::{BTreeMap, BinaryHeap, HashMap, HashSet};
2
3use crate::hash::seeded_hash;
4use crate::heap::{HeapEntry, push_bounded};
5use crate::{BucketKey, GroupKey};
6
7/// Rank + hash tie-break key for one bucket's bounded heap in [`group_aware_fill`].
8type RankHashKey = (u64, u64);
9
10/// Bounded top-K reservoir sample: keeps `count` items keyed by `seeded_hash(seed, key_fn(item))`,
11/// restoring original input order in the result (an A-Res-style weighted reservoir sample,
12/// streamed at O(count) memory instead of materializing the whole input).
13pub fn reservoir_sample<R>(
14    items: impl Iterator<Item = R>,
15    count: usize,
16    seed: u64,
17    key_fn: impl Fn(&R) -> &str,
18) -> (Vec<R>, usize) {
19    let mut heap: BinaryHeap<HeapEntry<u64, R>> =
20        BinaryHeap::with_capacity(count.saturating_add(1));
21    let mut total = 0usize;
22    for record in items {
23        let key = seeded_hash(seed, key_fn(&record));
24        let index = total;
25        total += 1;
26        push_bounded(&mut heap, count, HeapEntry { key, index, record });
27    }
28    let mut kept: Vec<HeapEntry<u64, R>> = heap.into_vec();
29    kept.sort_by_key(|e| e.index);
30    (kept.into_iter().map(|e| e.record).collect(), total)
31}
32
33/// Outcome of [`group_aware_fill`]: kept items (original input order) plus the diversity/quota
34/// stats a caller typically wants to report (e.g. in a run manifest).
35pub struct GroupAwareFillResult<R> {
36    pub kept: Vec<R>,
37    pub total: usize,
38    pub quota_candidates: usize,
39    pub bucket_not_in_quota: usize,
40    pub distinct_groups_kept: usize,
41    /// Largest fraction any single group contributed to any one output bucket, considering only
42    /// buckets with >=2 kept items (a singleton bucket is trivially "100% one group" and would
43    /// otherwise always pin this at 1.0, telling a reader nothing about whether the fill actually
44    /// diversified any bucket that had a real choice to make). `None` if no bucket had >=2 kept.
45    pub max_group_share_in_any_bucket: Option<f64>,
46}
47
48/// Group-aware bounded-heap quota fill: streams `items` once, assigns each a bucket via
49/// `bucket_key_fn`, and -- among items whose bucket has a quota in `quotas` -- fills that
50/// bucket's quota preferring group diversity (`group_key_fn`) over first-come-first-served.
51///
52/// Algorithm: a pre-tally of `(bucket, group) -> how many already seen` gives each item a rank
53/// (its group's Nth occurrence in that bucket, 0-indexed); the item is then pushed onto that
54/// bucket's bounded top-`quota` heap keyed on `(rank, seeded_hash(seed, group))`. Lower rank
55/// always wins, so every group's *first* occurrence in a bucket outranks every group's *second*
56/// occurrence, across all groups -- one group can never consume a whole bucket's quota while a
57/// second group present in it is starved out. The hash only breaks ties within one rank value.
58pub fn group_aware_fill<R>(
59    items: impl Iterator<Item = R>,
60    quotas: &BTreeMap<BucketKey, usize>,
61    seed: u64,
62    bucket_key_fn: impl Fn(&R) -> BucketKey,
63    group_key_fn: impl Fn(&R) -> GroupKey,
64) -> GroupAwareFillResult<R> {
65    let mut total = 0usize;
66    let mut bucket_not_in_quota = 0usize;
67    let mut quota_candidates = 0usize;
68    let mut group_rank: HashMap<(BucketKey, GroupKey), u64> = HashMap::new();
69    let mut heaps: HashMap<BucketKey, BinaryHeap<HeapEntry<RankHashKey, R>>> = HashMap::new();
70
71    for record in items {
72        let index = total;
73        total += 1;
74        let bucket = bucket_key_fn(&record);
75        let Some(&quota) = quotas.get(&bucket) else {
76            bucket_not_in_quota += 1;
77            continue;
78        };
79        quota_candidates += 1;
80
81        let group = group_key_fn(&record);
82        let counter = group_rank
83            .entry((bucket.clone(), group.clone()))
84            .or_insert(0);
85        let rank = *counter;
86        *counter += 1;
87
88        let key = (rank, seeded_hash(seed, &group));
89        push_bounded(
90            heaps.entry(bucket).or_default(),
91            quota,
92            HeapEntry { key, index, record },
93        );
94    }
95
96    let mut kept: Vec<HeapEntry<RankHashKey, R>> =
97        heaps.into_values().flat_map(|h| h.into_vec()).collect();
98    kept.sort_by_key(|e| e.index);
99
100    let mut per_bucket_groups: HashMap<BucketKey, HashMap<GroupKey, usize>> = HashMap::new();
101    for entry in &kept {
102        let bucket = bucket_key_fn(&entry.record);
103        let group = group_key_fn(&entry.record);
104        *per_bucket_groups
105            .entry(bucket)
106            .or_default()
107            .entry(group)
108            .or_default() += 1;
109    }
110    let max_group_share_in_any_bucket = per_bucket_groups
111        .values()
112        .filter_map(|groups| {
113            let bucket_total: usize = groups.values().sum();
114            (bucket_total >= 2)
115                .then(|| groups.values().copied().max().unwrap_or(0) as f64 / bucket_total as f64)
116        })
117        .fold(None::<f64>, |acc, share| {
118            Some(acc.map_or(share, |a| a.max(share)))
119        });
120    let distinct_groups_kept = kept
121        .iter()
122        .map(|e| group_key_fn(&e.record))
123        .collect::<HashSet<_>>()
124        .len();
125
126    GroupAwareFillResult {
127        kept: kept.into_iter().map(|e| e.record).collect(),
128        total,
129        quota_candidates,
130        bucket_not_in_quota,
131        distinct_groups_kept,
132        max_group_share_in_any_bucket,
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn reservoir_sample_restores_input_order_and_is_deterministic() {
142        let items: Vec<String> = (0..20).map(|n: u32| n.to_string()).collect();
143        let (kept_a, total_a) = reservoir_sample(items.clone().into_iter(), 5, 7, |s| s.as_str());
144        let (kept_b, total_b) = reservoir_sample(items.into_iter(), 5, 7, |s| s.as_str());
145        assert_eq!(total_a, 20);
146        assert_eq!(total_b, 20);
147        assert_eq!(kept_a, kept_b);
148        assert_eq!(kept_a.len(), 5);
149        let indices: Vec<u32> = kept_a.iter().map(|s| s.parse().unwrap()).collect();
150        let mut sorted = indices.clone();
151        sorted.sort();
152        assert_eq!(indices, sorted, "result should be in original input order");
153    }
154
155    #[test]
156    fn group_aware_fill_does_not_let_one_group_starve_out_another() {
157        // 10 items from "root-a", 1 item from "root-b", all in one bucket with quota 2.
158        let mut items: Vec<(&str, &str)> = (0..10).map(|_| ("bucket", "root-a")).collect();
159        items.push(("bucket", "root-b"));
160        let mut quotas = BTreeMap::new();
161        quotas.insert("bucket".to_string(), 2);
162
163        let result = group_aware_fill(
164            items.into_iter(),
165            &quotas,
166            1,
167            |(bucket, _)| bucket.to_string(),
168            |(_, group)| group.to_string(),
169        );
170
171        assert_eq!(result.kept.len(), 2);
172        let groups: HashSet<&str> = result.kept.iter().map(|(_, g)| *g).collect();
173        assert!(
174            groups.contains("root-b"),
175            "the sole root-b item must survive, not be starved out"
176        );
177    }
178
179    #[test]
180    fn group_aware_fill_reports_bucket_not_in_quota() {
181        let items = vec![("known", 1), ("unknown", 2)];
182        let mut quotas = BTreeMap::new();
183        quotas.insert("known".to_string(), 10);
184
185        let result = group_aware_fill(
186            items.into_iter(),
187            &quotas,
188            0,
189            |(bucket, _)| bucket.to_string(),
190            |(_, group)| group.to_string(),
191        );
192
193        assert_eq!(result.total, 2);
194        assert_eq!(result.quota_candidates, 1);
195        assert_eq!(result.bucket_not_in_quota, 1);
196        assert_eq!(result.kept.len(), 1);
197    }
198
199    #[test]
200    fn group_aware_fill_with_no_quotas_drops_every_item() {
201        let items = vec![("a", 1), ("b", 2), ("c", 3)];
202        let quotas: BTreeMap<String, usize> = BTreeMap::new();
203
204        let result = group_aware_fill(
205            items.into_iter(),
206            &quotas,
207            0,
208            |(bucket, _)| bucket.to_string(),
209            |(_, group)| group.to_string(),
210        );
211
212        assert_eq!(result.total, 3);
213        assert_eq!(result.quota_candidates, 0);
214        assert_eq!(result.bucket_not_in_quota, 3);
215        assert!(result.kept.is_empty());
216        assert_eq!(result.distinct_groups_kept, 0);
217        assert_eq!(result.max_group_share_in_any_bucket, None);
218    }
219
220    #[test]
221    fn group_aware_fill_on_empty_input_is_a_safe_no_op() {
222        let items: Vec<(&str, &str)> = Vec::new();
223        let mut quotas = BTreeMap::new();
224        quotas.insert("bucket".to_string(), 5);
225
226        let result = group_aware_fill(
227            items.into_iter(),
228            &quotas,
229            0,
230            |(bucket, _)| bucket.to_string(),
231            |(_, group)| group.to_string(),
232        );
233
234        assert_eq!(result.total, 0);
235        assert!(result.kept.is_empty());
236    }
237
238    #[test]
239    fn reservoir_sample_on_empty_input_returns_nothing() {
240        let items: Vec<String> = Vec::new();
241        let (kept, total) = reservoir_sample(items.into_iter(), 5, 1, |s| s.as_str());
242        assert_eq!(total, 0);
243        assert!(kept.is_empty());
244    }
245}