1use crate::GoldenCase;
16
17#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum Sample {
20 Count(usize),
22 Ratio(f64),
24 PerTag(usize),
28}
29
30impl Sample {
31 pub fn parse(s: &str) -> Result<Self, String> {
33 let s = s.trim();
34 if let Some(rest) = s.strip_prefix("per-tag") {
35 let k = match rest.strip_prefix(':') {
36 Some(n) => n.trim().parse::<usize>().map_err(|_| {
37 format!("invalid --sample count in '{s}' (expected per-tag:<N>)")
38 })?,
39 None if rest.is_empty() => 1,
40 None => {
41 return Err(format!(
42 "invalid --sample '{s}' (expected `per-tag` or `per-tag:<N>`)"
43 ))
44 }
45 };
46 if k == 0 {
47 return Err("--sample per-tag:0 selects nothing".to_string());
48 }
49 return Ok(Sample::PerTag(k));
50 }
51 if let Some(pct) = s.strip_suffix('%') {
52 let p: f64 = pct
53 .trim()
54 .parse()
55 .map_err(|_| format!("invalid --sample percent '{s}'"))?;
56 if !(p > 0.0 && p <= 100.0) {
57 return Err(format!("--sample percent must be in (0, 100], got '{s}'"));
58 }
59 return Ok(Sample::Ratio(p / 100.0));
60 }
61 if let Ok(n) = s.parse::<usize>() {
63 if n == 0 {
64 return Err("--sample 0 selects nothing".to_string());
65 }
66 return Ok(Sample::Count(n));
67 }
68 if let Ok(f) = s.parse::<f64>() {
69 if f > 0.0 && f < 1.0 {
70 return Ok(Sample::Ratio(f));
71 }
72 return Err(format!(
73 "--sample fraction must be in (0, 1), got '{s}' (use an integer for a count)"
74 ));
75 }
76 Err(format!(
77 "invalid --sample '{s}' (expected a count `8`, a fraction `0.2` / `20%`, or `per-tag[:K]`)"
78 ))
79 }
80}
81
82#[derive(Debug, Clone, Default)]
84pub struct CaseFilter {
85 pub tags: Vec<String>,
87 pub sample: Option<Sample>,
89}
90
91impl CaseFilter {
92 pub fn from_flags(tags: &str, sample: Option<&str>) -> Result<Self, String> {
94 let tags = tags
95 .split(',')
96 .map(str::trim)
97 .filter(|s| !s.is_empty())
98 .map(str::to_string)
99 .collect();
100 let sample = match sample {
101 Some(s) => Some(Sample::parse(s)?),
102 None => None,
103 };
104 Ok(CaseFilter { tags, sample })
105 }
106
107 pub fn is_active(&self) -> bool {
109 !self.tags.is_empty() || self.sample.is_some()
110 }
111}
112
113#[derive(Debug, Clone)]
116pub struct Selection {
117 pub indices: Vec<usize>,
119 pub total: usize,
121 pub note: String,
123}
124
125impl Selection {
126 pub fn selected(&self) -> usize {
128 self.indices.len()
129 }
130}
131
132pub fn select_cases(cases: &[GoldenCase], filter: &CaseFilter) -> Selection {
135 let total = cases.len();
136
137 let pool: Vec<usize> = if filter.tags.is_empty() {
139 (0..total).collect()
140 } else {
141 (0..total)
142 .filter(|&i| {
143 cases[i]
144 .tags
145 .iter()
146 .any(|t| filter.tags.iter().any(|w| w == t))
147 })
148 .collect()
149 };
150
151 let indices = match filter.sample {
153 None => pool.clone(),
154 Some(Sample::Count(n)) => even_pick(&pool, n),
155 Some(Sample::Ratio(f)) => {
156 let k = ((pool.len() as f64 * f).ceil() as usize).clamp(1.min(pool.len()), pool.len());
157 even_pick(&pool, k)
158 }
159 Some(Sample::PerTag(k)) => per_tag_pick(cases, &pool, k),
160 };
161
162 let note = describe(filter, indices.len(), total);
163 Selection {
164 indices,
165 total,
166 note,
167 }
168}
169
170fn even_pick(pool: &[usize], k: usize) -> Vec<usize> {
172 let n = pool.len();
173 if k >= n {
174 return pool.to_vec();
175 }
176 if k == 0 {
177 return Vec::new();
178 }
179 let mut picked = Vec::with_capacity(k);
182 let mut last: Option<usize> = None;
183 for i in 0..k {
184 let pos = i * n / k;
185 if last != Some(pos) {
186 picked.push(pool[pos]);
187 last = Some(pos);
188 }
189 }
190 picked
191}
192
193fn per_tag_pick(cases: &[GoldenCase], pool: &[usize], k: usize) -> Vec<usize> {
197 use std::collections::HashMap;
198 let mut count: HashMap<&str, usize> = HashMap::new();
199 let mut picked = Vec::new();
200 for &i in pool {
201 let tags = &cases[i].tags;
202 let buckets: Vec<&str> = if tags.is_empty() {
203 vec![""]
204 } else {
205 tags.iter().map(String::as_str).collect()
206 };
207 let covers_new = buckets.iter().any(|b| *count.get(b).unwrap_or(&0) < k);
208 if covers_new {
209 for b in buckets {
210 *count.entry(b).or_insert(0) += 1;
211 }
212 picked.push(i);
213 }
214 }
215 picked
216}
217
218fn describe(filter: &CaseFilter, selected: usize, total: usize) -> String {
220 let mut parts = Vec::new();
221 if !filter.tags.is_empty() {
222 parts.push(format!("tags∈{{{}}}", filter.tags.join(",")));
223 }
224 match filter.sample {
225 Some(Sample::Count(n)) => parts.push(format!("sample≤{n} (even stride)")),
226 Some(Sample::Ratio(f)) => parts.push(format!("sample {:.0}% (even stride)", f * 100.0)),
227 Some(Sample::PerTag(k)) => parts.push(format!("sample per-tag:{k}")),
228 None => {}
229 }
230 let filt = if parts.is_empty() {
231 "no filter".to_string()
232 } else {
233 parts.join("; ")
234 };
235 format!("golden selection: {selected}/{total} cases [{filt}]")
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use warble_eval_compare::{MatchMode, Table, Tolerance};
242
243 fn case(id: &str, tags: &[&str]) -> GoldenCase {
244 GoldenCase {
245 id: id.to_string(),
246 question: format!("q {id}"),
247 tags: tags.iter().map(|s| s.to_string()).collect(),
248 match_mode: MatchMode::Scalar,
249 tolerance: Tolerance::default(),
250 expected: Table {
251 columns: vec!["n".into()],
252 rows: vec![vec![serde_json::json!(1)]],
253 },
254 result_kind: crate::ResultKind::Table,
255 verdict_field: None,
256 }
257 }
258
259 fn ids(cases: &[GoldenCase], sel: &Selection) -> Vec<String> {
260 sel.indices.iter().map(|&i| cases[i].id.clone()).collect()
261 }
262
263 #[test]
266 fn parse_count_ratio_pertag_and_percent() {
267 assert_eq!(Sample::parse("8").unwrap(), Sample::Count(8));
268 assert_eq!(Sample::parse("per-tag").unwrap(), Sample::PerTag(1));
269 assert_eq!(Sample::parse("per-tag:2").unwrap(), Sample::PerTag(2));
270 match Sample::parse("0.25").unwrap() {
271 Sample::Ratio(f) => assert!((f - 0.25).abs() < 1e-9),
272 other => panic!("expected ratio, got {other:?}"),
273 }
274 match Sample::parse("20%").unwrap() {
275 Sample::Ratio(f) => assert!((f - 0.20).abs() < 1e-9),
276 other => panic!("expected ratio, got {other:?}"),
277 }
278 }
279
280 #[test]
281 fn parse_rejects_nonsense_and_empty_selections() {
282 assert!(Sample::parse("0").is_err());
283 assert!(Sample::parse("per-tag:0").is_err());
284 assert!(Sample::parse("1.0").is_err()); assert!(Sample::parse("banana").is_err());
286 assert!(Sample::parse("150%").is_err());
287 }
288
289 #[test]
292 fn tags_union_keeps_cases_carrying_any_requested_tag() {
293 let cases = vec![
294 case("a", &["easy"]),
295 case("b", &["hard", "join"]),
296 case("c", &["join"]),
297 case("d", &["timezone"]),
298 ];
299 let f = CaseFilter::from_flags("easy,join", None).unwrap();
300 let sel = select_cases(&cases, &f);
301 assert_eq!(ids(&cases, &sel), vec!["a", "b", "c"]);
302 assert_eq!(sel.total, 4);
303 assert!(sel.note.contains("3/4"));
304 }
305
306 #[test]
307 fn empty_filter_keeps_everything_in_order() {
308 let cases = vec![case("a", &["x"]), case("b", &[]), case("c", &["y"])];
309 let sel = select_cases(&cases, &CaseFilter::default());
310 assert_eq!(sel.selected(), 3);
311 assert!(!CaseFilter::default().is_active());
312 }
313
314 #[test]
317 fn count_sample_is_evenly_spread_and_deterministic() {
318 let cases: Vec<GoldenCase> = (0..10).map(|i| case(&format!("c{i}"), &["t"])).collect();
319 let f = CaseFilter::from_flags("", Some("3")).unwrap();
320 let sel = select_cases(&cases, &f);
321 assert_eq!(ids(&cases, &sel), vec!["c0", "c3", "c6"]);
323 assert_eq!(ids(&cases, &select_cases(&cases, &f)), ids(&cases, &sel));
325 }
326
327 #[test]
328 fn count_larger_than_pool_keeps_all() {
329 let cases = vec![case("a", &["t"]), case("b", &["t"])];
330 let f = CaseFilter::from_flags("", Some("9")).unwrap();
331 assert_eq!(select_cases(&cases, &f).selected(), 2);
332 }
333
334 #[test]
335 fn ratio_sample_ceils_to_at_least_one() {
336 let cases: Vec<GoldenCase> = (0..10).map(|i| case(&format!("c{i}"), &["t"])).collect();
337 let sel = select_cases(&cases, &CaseFilter::from_flags("", Some("0.2")).unwrap());
338 assert_eq!(sel.selected(), 2); let one = select_cases(&cases, &CaseFilter::from_flags("", Some("1%")).unwrap());
340 assert_eq!(one.selected(), 1); }
342
343 #[test]
346 fn per_tag_one_collapses_multi_tag_cases_to_a_covering_set() {
347 let cases = vec![
350 case("g1", &["easy"]), case("g2", &["easy", "time"]), case("g3", &["time"]), case("g4", &["tld", "join"]), case("g5", &["join"]), ];
356 let sel = select_cases(
357 &cases,
358 &CaseFilter::from_flags("", Some("per-tag:1")).unwrap(),
359 );
360 assert_eq!(ids(&cases, &sel), vec!["g1", "g2", "g4"]);
361 assert!(sel.note.contains("per-tag:1"));
362 }
363
364 #[test]
365 fn per_tag_k_allows_k_representatives_per_tag() {
366 let cases = vec![
367 case("g1", &["easy"]),
368 case("g2", &["easy"]),
369 case("g3", &["easy"]),
370 ];
371 let sel = select_cases(
372 &cases,
373 &CaseFilter::from_flags("", Some("per-tag:2")).unwrap(),
374 );
375 assert_eq!(ids(&cases, &sel), vec!["g1", "g2"]);
376 }
377
378 #[test]
379 fn per_tag_keeps_untagged_cases_in_their_own_bucket() {
380 let cases = vec![case("a", &[]), case("b", &[]), case("c", &["x"])];
381 let sel = select_cases(
382 &cases,
383 &CaseFilter::from_flags("", Some("per-tag:1")).unwrap(),
384 );
385 assert_eq!(ids(&cases, &sel), vec!["a", "c"]);
387 }
388
389 #[test]
390 fn tags_then_sample_compose() {
391 let cases = vec![
392 case("a", &["easy"]),
393 case("b", &["easy", "join"]),
394 case("c", &["join"]),
395 case("d", &["timezone"]),
396 ];
397 let f = CaseFilter::from_flags("easy,join", Some("per-tag:1")).unwrap();
399 let sel = select_cases(&cases, &f);
400 assert_eq!(ids(&cases, &sel), vec!["a", "b"]);
401 assert!(sel.note.contains("tags∈{easy,join}"));
402 assert!(sel.note.contains("per-tag:1"));
403 }
404}