Skip to main content

warble_eval_runner/
filter.rs

1//! Golden case selection for stratified eval runs (roadmap "eval speed" P3).
2//!
3//! Smoke / full / matrix runs differ only in *which* golden cases they replay:
4//! - **full**   = every case, no filter — one config, the `eval gate` input (< 10 min).
5//! - **smoke**  = a representative subset — `--sample per-tag:1` (± `--tags`) — the inner loop
6//!   (< 2 min once parallel). One case per trap tag keeps coverage while collapsing driftwood's
7//!   19 tags to ~8 cases.
8//! - **matrix** = full cases × every config — scheduled/manual (no filter here; the config axis
9//!   lives in `--models` / ablation).
10//!
11//! This is the pure, host-agnostic selector that `--tags` / `--sample` drive. It never silently
12//! drops cases: [`select_cases`] returns a human-readable [`Selection::note`] recording how many
13//! it kept out of the total (the no-silent-caps invariant).
14
15use crate::GoldenCase;
16
17/// How to sub-select from the (tag-filtered) golden cases. Parsed from `--sample`.
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum Sample {
20    /// At most `N` cases total, spread evenly across the list in file order (`--sample 8`).
21    Count(usize),
22    /// A fraction `0 < f < 1` of the cases, spread evenly (ceil, at least 1) (`--sample 0.2`, `--sample 20%`).
23    Ratio(f64),
24    /// At most `K` cases per distinct tag — the smoke selector (`--sample per-tag:1`). Greedy in
25    /// file order: a case is kept iff one of its tags is still under the `K` quota, and keeping it
26    /// counts toward *all* its tags — so a multi-tag case covers several tags at once.
27    PerTag(usize),
28}
29
30impl Sample {
31    /// Parse a `--sample` value: `N` (count), a fraction `0.2` / `20%` (ratio), or `per-tag[:K]`.
32    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        // A bare integer is a count; a bare fraction in (0,1) is a ratio.
62        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/// A `--tags` + `--sample` selection over a golden file's cases.
83#[derive(Debug, Clone, Default)]
84pub struct CaseFilter {
85    /// Keep only cases carrying at least one of these tags (union). Empty = keep every tag.
86    pub tags: Vec<String>,
87    /// Sub-select from the tag-filtered cases. `None` = keep all (a full run).
88    pub sample: Option<Sample>,
89}
90
91impl CaseFilter {
92    /// Build from CLI strings: a comma-separated `--tags` and an optional `--sample`.
93    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    /// True when the filter can select a strict subset (any `--tags` or `--sample` given).
108    pub fn is_active(&self) -> bool {
109        !self.tags.is_empty() || self.sample.is_some()
110    }
111}
112
113/// The result of applying a [`CaseFilter`]: indices into the original slice (ascending), plus a
114/// note recording `selected/total` so a partial (smoke) run is always visible.
115#[derive(Debug, Clone)]
116pub struct Selection {
117    /// Indices into the input `cases` slice, ascending (file order preserved).
118    pub indices: Vec<usize>,
119    /// Cases before filtering.
120    pub total: usize,
121    /// One-line, no-silent-caps summary of what was kept and why.
122    pub note: String,
123}
124
125impl Selection {
126    /// Number of cases kept.
127    pub fn selected(&self) -> usize {
128        self.indices.len()
129    }
130}
131
132/// Apply `filter` to `cases`: tag-filter first (union over `--tags`), then `--sample`. Deterministic
133/// and order-preserving so re-runs are comparable.
134pub fn select_cases(cases: &[GoldenCase], filter: &CaseFilter) -> Selection {
135    let total = cases.len();
136
137    // 1. Tag filter: keep cases carrying at least one requested tag.
138    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    // 2. Sample within the tag-filtered pool.
152    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
170/// Pick `k` indices spread evenly across `pool`, preserving order. `k >= len` keeps everything.
171fn 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    // pos = i*n/k is strictly increasing for k <= n (step >= floor(n/k) >= 1), so indices are
180    // distinct; the `last` guard is a defensive dedup.
181    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
193/// Greedy per-tag selection: keep a case iff one of its tags is still under the `K` quota; keeping
194/// it counts toward every tag it carries. Untagged cases share a single `""` bucket so they are
195/// not silently dropped by a per-tag smoke.
196fn 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
218/// One-line summary of the selection (printed to stderr and, via the caller, recorded in the report).
219fn 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    // --- Sample::parse ---------------------------------------------------------------------------
264
265    #[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()); // not in (0,1) and not a count spelling
285        assert!(Sample::parse("banana").is_err());
286        assert!(Sample::parse("150%").is_err());
287    }
288
289    // --- tag filter ------------------------------------------------------------------------------
290
291    #[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    // --- even stride sampling --------------------------------------------------------------------
315
316    #[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        // 0, 3, 6 — spread across the range, not the first three.
322        assert_eq!(ids(&cases, &sel), vec!["c0", "c3", "c6"]);
323        // deterministic
324        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); // ceil(10*0.2)
339        let one = select_cases(&cases, &CaseFilter::from_flags("", Some("1%")).unwrap());
340        assert_eq!(one.selected(), 1); // ceil(10*0.01)=1, never zero
341    }
342
343    // --- per-tag smoke selector ------------------------------------------------------------------
344
345    #[test]
346    fn per_tag_one_collapses_multi_tag_cases_to_a_covering_set() {
347        // 5 cases, tags {easy, time, tld, join}. per-tag:1 should touch every tag with the
348        // fewest cases, greedily in file order.
349        let cases = vec![
350            case("g1", &["easy"]),         // covers easy
351            case("g2", &["easy", "time"]), // easy already covered, but time is new -> kept
352            case("g3", &["time"]),         // time covered by g2 -> dropped
353            case("g4", &["tld", "join"]),  // both new -> kept
354            case("g5", &["join"]),         // join covered -> dropped
355        ];
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        // one untagged (a) + the tagged (c); b dropped as the 2nd untagged.
386        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        // among {easy|join} cases (a,b,c), take 1 per tag: a(easy), b(join) -> c dropped.
398        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}