somatize_runtime/
pruner.rs1use somatize_core::event::MetricRecord;
8
9pub trait Pruner: Send + Sync {
11 fn should_prune(
16 &self,
17 metric_name: &str,
18 current_value: f64,
19 step: usize,
20 history: &[TrialMetricHistory],
21 ) -> Option<String>;
22}
23
24pub struct TrialMetricHistory {
26 pub trial_id: String,
28 pub metrics: Vec<MetricRecord>,
30}
31
32pub struct MedianPruner {
34 pub n_warmup_steps: usize,
36 pub min_trials: usize,
38}
39
40impl MedianPruner {
41 pub fn new(n_warmup_steps: usize) -> Self {
44 Self {
45 n_warmup_steps,
46 min_trials: 1,
47 }
48 }
49
50 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 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 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
105pub struct PercentilePruner {
107 pub percentile: f64,
110 pub n_warmup_steps: usize,
112 pub min_trials: usize,
114}
115
116impl PercentilePruner {
117 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 #[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 let history = make_history(&[vec![0.7, 0.8, 0.9]]);
214 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 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 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 #[test]
243 fn percentile_prunes_below_threshold() {
244 let pruner = PercentilePruner::new(25.0, 0);
245 let history = make_history(&[vec![0.5, 0.9, 0.3, 0.7]]);
247 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}