Skip to main content

somatize_runtime/
pruner.rs

1//! Early stopping strategies for optimization studies.
2//!
3//! A [`Pruner`] decides whether a trial should be stopped based on
4//! intermediate metric values. Implementations: [`MedianPruner`],
5//! [`PercentilePruner`].
6
7use somatize_core::event::MetricRecord;
8
9/// A pruner decides whether to stop a trial early based on intermediate metrics.
10pub trait Pruner: Send + Sync {
11    /// Decide whether to prune given the current trial's metrics and
12    /// the history of completed trials' metrics at the same step.
13    ///
14    /// Returns `Some(reason)` if the trial should be pruned.
15    fn should_prune(
16        &self,
17        metric_name: &str,
18        current_value: f64,
19        step: usize,
20        history: &[TrialMetricHistory],
21    ) -> Option<String>;
22}
23
24/// A completed trial's metric history (for comparing against).
25pub struct TrialMetricHistory {
26    /// Trial the history belongs to.
27    pub trial_id: String,
28    /// Intermediate objective values the trial reported, in step order.
29    pub metrics: Vec<MetricRecord>,
30}
31
32/// Prune if current value is below the median of completed trials at the same step.
33pub struct MedianPruner {
34    /// Don't prune before this many steps.
35    pub n_warmup_steps: usize,
36    /// Minimum completed trials needed before pruning starts.
37    pub min_trials: usize,
38}
39
40impl MedianPruner {
41    /// A median pruner that holds off for `n_warmup_steps` steps and
42    /// starts pruning as soon as one completed trial exists.
43    pub fn new(n_warmup_steps: usize) -> Self {
44        Self {
45            n_warmup_steps,
46            min_trials: 1,
47        }
48    }
49
50    /// Require at least `min_trials` completed trials before pruning —
51    /// a median over one trial is that trial.
52    pub fn with_min_trials(mut self, min_trials: usize) -> Self {
53        self.min_trials = min_trials;
54        self
55    }
56}
57
58impl Pruner for MedianPruner {
59    fn should_prune(
60        &self,
61        metric_name: &str,
62        current_value: f64,
63        step: usize,
64        history: &[TrialMetricHistory],
65    ) -> Option<String> {
66        if step < self.n_warmup_steps {
67            return None;
68        }
69
70        // Collect values at this step from completed trials
71        let mut values_at_step: Vec<f64> = history
72            .iter()
73            .filter_map(|h| {
74                h.metrics
75                    .iter()
76                    .filter(|m| m.name == metric_name && m.step == step)
77                    .map(|m| m.value)
78                    .next_back()
79            })
80            .collect();
81
82        if values_at_step.len() < self.min_trials {
83            return None;
84        }
85
86        values_at_step.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
87        let median = if values_at_step.len().is_multiple_of(2) {
88            let mid = values_at_step.len() / 2;
89            (values_at_step[mid - 1] + values_at_step[mid]) / 2.0
90        } else {
91            values_at_step[values_at_step.len() / 2]
92        };
93
94        // Prune if below median (assuming maximize; for minimize, caller inverts)
95        if current_value < median {
96            Some(format!(
97                "value {current_value:.4} below median {median:.4} at step {step}"
98            ))
99        } else {
100            None
101        }
102    }
103}
104
105/// Prune if current value is below the given percentile.
106pub struct PercentilePruner {
107    /// Cutoff percentile in `[0, 100]` — e.g. 25.0 prunes anything below
108    /// the 25th percentile of completed trials at the same step.
109    pub percentile: f64,
110    /// Don't prune before this many steps.
111    pub n_warmup_steps: usize,
112    /// Minimum completed trials needed before pruning starts.
113    pub min_trials: usize,
114}
115
116impl PercentilePruner {
117    /// A percentile pruner with `min_trials = 1`.
118    pub fn new(percentile: f64, n_warmup_steps: usize) -> Self {
119        Self {
120            percentile,
121            n_warmup_steps,
122            min_trials: 1,
123        }
124    }
125}
126
127impl Pruner for PercentilePruner {
128    fn should_prune(
129        &self,
130        metric_name: &str,
131        current_value: f64,
132        step: usize,
133        history: &[TrialMetricHistory],
134    ) -> Option<String> {
135        if step < self.n_warmup_steps {
136            return None;
137        }
138
139        let mut values_at_step: Vec<f64> = history
140            .iter()
141            .filter_map(|h| {
142                h.metrics
143                    .iter()
144                    .filter(|m| m.name == metric_name && m.step == step)
145                    .map(|m| m.value)
146                    .next_back()
147            })
148            .collect();
149
150        if values_at_step.len() < self.min_trials {
151            return None;
152        }
153
154        values_at_step.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
155        let idx = ((self.percentile / 100.0) * values_at_step.len() as f64).floor() as usize;
156        let idx = idx.min(values_at_step.len() - 1);
157        let threshold = values_at_step[idx];
158
159        if current_value < threshold {
160            Some(format!(
161                "value {current_value:.4} below p{:.0} threshold {threshold:.4} at step {step}",
162                self.percentile
163            ))
164        } else {
165            None
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use chrono::Utc;
174
175    fn make_history(values_per_step: &[Vec<f64>]) -> Vec<TrialMetricHistory> {
176        values_per_step[0]
177            .iter()
178            .enumerate()
179            .map(|(trial_idx, _)| {
180                let metrics: Vec<MetricRecord> = values_per_step
181                    .iter()
182                    .enumerate()
183                    .filter_map(|(step, vals)| {
184                        vals.get(trial_idx).map(|&v| MetricRecord {
185                            name: "f1".into(),
186                            value: v,
187                            step,
188                            timestamp: Utc::now(),
189                        })
190                    })
191                    .collect();
192                TrialMetricHistory {
193                    trial_id: format!("t{trial_idx}"),
194                    metrics,
195                }
196            })
197            .collect()
198    }
199
200    // ── Median pruner ──
201
202    #[test]
203    fn median_no_prune_during_warmup() {
204        let pruner = MedianPruner::new(5);
205        let history = make_history(&[vec![0.9, 0.8, 0.7]]);
206        assert!(pruner.should_prune("f1", 0.1, 3, &history).is_none());
207    }
208
209    #[test]
210    fn median_prunes_below_median() {
211        let pruner = MedianPruner::new(0);
212        // At step 0: values are [0.7, 0.8, 0.9]. Median = 0.8
213        let history = make_history(&[vec![0.7, 0.8, 0.9]]);
214        // Current = 0.5, below median 0.8
215        assert!(pruner.should_prune("f1", 0.5, 0, &history).is_some());
216    }
217
218    #[test]
219    fn median_keeps_above_median() {
220        let pruner = MedianPruner::new(0);
221        let history = make_history(&[vec![0.7, 0.8, 0.9]]);
222        // Current = 0.85, above median 0.8
223        assert!(pruner.should_prune("f1", 0.85, 0, &history).is_none());
224    }
225
226    #[test]
227    fn median_no_prune_insufficient_history() {
228        let pruner = MedianPruner::new(0).with_min_trials(5);
229        let history = make_history(&[vec![0.7, 0.8]]);
230        // Only 2 trials, need 5
231        assert!(pruner.should_prune("f1", 0.1, 0, &history).is_none());
232    }
233
234    #[test]
235    fn median_empty_history() {
236        let pruner = MedianPruner::new(0);
237        assert!(pruner.should_prune("f1", 0.5, 0, &[]).is_none());
238    }
239
240    // ── Percentile pruner ──
241
242    #[test]
243    fn percentile_prunes_below_threshold() {
244        let pruner = PercentilePruner::new(25.0, 0);
245        // At step 0: sorted = [0.3, 0.5, 0.7, 0.9]. p25 idx=1 → threshold=0.5
246        let history = make_history(&[vec![0.5, 0.9, 0.3, 0.7]]);
247        // Current = 0.2, below p25 threshold
248        assert!(pruner.should_prune("f1", 0.2, 0, &history).is_some());
249    }
250
251    #[test]
252    fn percentile_keeps_above_threshold() {
253        let pruner = PercentilePruner::new(25.0, 0);
254        let history = make_history(&[vec![0.5, 0.9, 0.3, 0.7]]);
255        assert!(pruner.should_prune("f1", 0.6, 0, &history).is_none());
256    }
257
258    #[test]
259    fn percentile_warmup_respected() {
260        let pruner = PercentilePruner::new(50.0, 10);
261        let history = make_history(&[vec![0.9]]);
262        assert!(pruner.should_prune("f1", 0.1, 5, &history).is_none());
263    }
264}