1use std::collections::{BTreeMap, BinaryHeap, HashMap, HashSet};
2
3use crate::hash::seeded_hash;
4use crate::heap::{HeapEntry, push_bounded};
5use crate::{BucketKey, GroupKey};
6
7type RankHashKey = (u64, u64);
9
10pub 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
33pub 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 pub max_group_share_in_any_bucket: Option<f64>,
46}
47
48pub 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("a) = 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 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 "as,
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 "as,
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 "as,
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 "as,
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}