Skip to main content

trellis_runner/engine/policy/
mod.rs

1//! Engine termination and control policies.
2//!
3//! Policies inspect the stream of progress events produced by the engine
4//! together with the current execution context and decide whether execution
5//! should continue, emit a checkpoint, or terminate.
6//!
7//! Multiple policies may be combined using [`PolicyStack`].
8//!
9//! # Built-in policies
10//!
11//! - [`CancellationPolicy`] — terminates when cancellation is requested.
12//! - [`MaxIterationPolicy`] — terminates after a fixed number of iterations.
13//! - [`AbsoluteTolerancePolicy`] — terminates when the reported error falls
14//!   below a tolerance.
15//! - [`TargetValuePolicy`] — terminates when an objective reaches a target.
16//! - [`StagnationPolicy`] — terminates when no new best value is observed for
17//!   a specified number of iterations.
18//! - [`NoProgressPolicy`] — terminates when progress falls below a threshold.
19//! - [`TimeoutPolicy`] — terminates after a wall-clock duration.
20//!
21//! # Examples
22//!
23//! ```ignore
24//! let policy = PolicyStack::optimisation(
25//!     10_000,
26//!     1e-8,
27//!     200,
28//! );
29//! ```
30//!
31//! Policies are evaluated in order. The first policy requesting termination
32//! immediately stops evaluation.
33use super::EngineContext;
34
35use num_traits::float::FloatCore;
36use std::time::Duration;
37
38mod absolute_tolerance;
39mod cancellation;
40mod checkpoint;
41mod complete;
42mod max_iter;
43mod no_progress;
44mod relative_tolerance;
45mod stagnation;
46mod target_value;
47mod timeout;
48
49pub use absolute_tolerance::AbsoluteTolerancePolicy;
50pub use cancellation::CancellationPolicy;
51pub use checkpoint::CheckpointPolicy;
52pub use complete::CompletionPolicy;
53pub use max_iter::MaxIterationPolicy;
54pub use no_progress::NoProgressPolicy;
55pub use relative_tolerance::RelativeTolerancePolicy;
56pub use stagnation::StagnationPolicy;
57pub use target_value::TargetValuePolicy;
58pub use timeout::TimeoutPolicy;
59
60use crate::engine::{event::CheckpointReason, EngineAction, EventBatch};
61
62pub trait EnginePolicy<F> {
63    fn decide(&mut self, batch: &EventBatch<F>, context: &EngineContext) -> EngineAction;
64}
65
66pub trait PolicyExt<F>: EnginePolicy<F> + Sized + 'static {
67    fn boxed(self) -> Box<dyn EnginePolicy<F>> {
68        Box::new(self)
69    }
70}
71
72impl<F, T> PolicyExt<F> for T where T: EnginePolicy<F> + Sized + 'static {}
73
74pub struct PolicyStack<F> {
75    policies: Vec<Box<dyn EnginePolicy<F>>>,
76}
77
78impl<F> Default for PolicyStack<F> {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl<F> PolicyStack<F> {
85    pub fn new() -> Self {
86        Self { policies: vec![] }
87    }
88
89    pub fn is_empty(&self) -> bool {
90        self.policies.is_empty()
91    }
92
93    pub fn add<P>(mut self, p: P) -> Self
94    where
95        P: EnginePolicy<F> + 'static,
96    {
97        self.policies.push(Box::new(p));
98        self
99    }
100
101    pub fn merge(mut self, other: Self) -> Self {
102        for each in other.policies.into_iter() {
103            self.policies.push(each);
104        }
105        self
106    }
107}
108
109impl<F> EnginePolicy<F> for PolicyStack<F> {
110    fn decide(&mut self, batch: &EventBatch<F>, ctx: &EngineContext) -> EngineAction {
111        let mut checkpoint = false;
112        for p in &mut self.policies {
113            match p.decide(batch, ctx) {
114                EngineAction::Stop(t) => return EngineAction::Stop(t),
115                EngineAction::Continue => {}
116                EngineAction::EmitCheckpoint(_) => {
117                    checkpoint = true;
118                }
119            }
120        }
121
122        if checkpoint {
123            return EngineAction::EmitCheckpoint(CheckpointReason::Scheduled);
124        }
125
126        EngineAction::Continue
127    }
128}
129
130impl<F> PolicyStack<F> {
131    pub fn standard(max_iter: usize, atol: F, window_size: usize) -> PolicyStack<F>
132    where
133        F: FloatCore + 'static,
134    {
135        PolicyStack::new()
136            .add(CancellationPolicy)
137            .add(MaxIterationPolicy::new(max_iter))
138            .add(AbsoluteTolerancePolicy::new(atol, window_size))
139    }
140
141    pub fn optimisation(max_iter: usize, atol: F, window_size: usize) -> PolicyStack<F>
142    where
143        F: FloatCore + 'static + num_traits::FromPrimitive + std::iter::Sum<F>,
144    {
145        PolicyStack::new()
146            .add(CancellationPolicy)
147            .add(MaxIterationPolicy::new(max_iter))
148            .add(AbsoluteTolerancePolicy::new(atol, window_size))
149            .add(StagnationPolicy::new(window_size))
150    }
151
152    pub fn global_optimisation(
153        max_iter: usize,
154        target: F,
155        tol: F,
156        window_size: usize,
157    ) -> PolicyStack<F>
158    where
159        F: FloatCore + 'static + num_traits::FromPrimitive + std::iter::Sum<F>,
160    {
161        PolicyStack::new()
162            .add(CancellationPolicy)
163            .add(MaxIterationPolicy::new(max_iter))
164            .add(TargetValuePolicy::new(target, tol, window_size))
165            .add(StagnationPolicy::new(window_size))
166            .add(NoProgressPolicy::new(F::epsilon(), window_size))
167    }
168
169    pub fn monte_carlo(max_iter: usize) -> PolicyStack<F>
170    where
171        F: 'static,
172    {
173        PolicyStack::new()
174            .add(CancellationPolicy)
175            .add(MaxIterationPolicy::new(max_iter))
176    }
177
178    pub fn timed(timeout: Duration) -> PolicyStack<F>
179    where
180        F: 'static,
181    {
182        PolicyStack::new()
183            .add(CancellationPolicy)
184            .add(TimeoutPolicy::new(timeout))
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    use crate::progress::Progress;
193
194    #[test]
195    fn empty_stack_continues() {
196        let mut stack = PolicyStack::<f64>::new();
197
198        let batch: EventBatch<f64> = EventBatch::new();
199
200        let ctx = EngineContext::default();
201
202        assert!(matches!(stack.decide(&batch, &ctx), EngineAction::Continue));
203    }
204
205    #[test]
206    fn checkpoint_request_is_propagated() {
207        let mut stack = PolicyStack::<f64>::new()
208            .add(CheckpointPolicy::every(10))
209            .add(MaxIterationPolicy::new(500));
210
211        let batch: EventBatch<f64> = EventBatch::new().add(Progress::Complete);
212        let ctx = EngineContext {
213            iter: 10,
214            ..Default::default()
215        };
216
217        assert!(matches!(
218            stack.decide(&batch, &ctx),
219            EngineAction::EmitCheckpoint(_)
220        ));
221    }
222
223    #[test]
224    fn stop_takes_precedence_over_checkpoint() {
225        let mut stack = PolicyStack::<f64>::new()
226            .add(CheckpointPolicy::every(10))
227            .add(MaxIterationPolicy::new(0));
228
229        let batch: EventBatch<f64> = EventBatch::new().add(Progress::Complete);
230        let ctx = EngineContext {
231            iter: 10,
232            ..Default::default()
233        };
234
235        assert!(matches!(stack.decide(&batch, &ctx), EngineAction::Stop(_)));
236    }
237
238    #[test]
239    fn policy_stack_stop_overrides_all() {
240        let mut stack = PolicyStack::new()
241            .add(NoProgressPolicy::new(0.1, 10))
242            .add(MaxIterationPolicy::new(100));
243
244        let batch = EventBatch::new().add(Progress::Complete);
245        let ctx = EngineContext {
246            iter: 101,
247            ..Default::default()
248        };
249
250        let action = stack.decide(&batch, &ctx);
251
252        if let EngineAction::Stop(_) = action {
253        } else {
254            panic!("Stop must dominate all policies");
255        }
256    }
257
258    #[test]
259    fn integration_converges_via_tolerance() {
260        let mut stack = PolicyStack::<f64>::optimisation(100, 0.01, 5);
261
262        let ctx = EngineContext::default();
263
264        let batch = EventBatch::new().add(Progress::Report {
265            measure: 1.0,
266            diagnostics: crate::progress::ProgressDiagnostics {
267                absolute_error: Some(0.001),
268                ..Default::default()
269            },
270        });
271
272        for _ in 0..10 {
273            stack.decide(&batch, &ctx);
274        }
275
276        let action = stack.decide(&batch, &ctx);
277
278        assert!(matches!(
279            action,
280            EngineAction::Stop(crate::Termination::Converged)
281        ));
282    }
283
284    #[test]
285    fn integration_stagnation_overrides_no_progress() {
286        let mut stack = PolicyStack::<f64>::new()
287            .add(NoProgressPolicy::new(0.01, 3))
288            .add(StagnationPolicy::new(5));
289
290        let ctx = EngineContext::default();
291
292        for _ in 0..10 {
293            let batch = EventBatch::new().add(Progress::Measure(1.0));
294
295            let action = stack.decide(&batch, &ctx);
296
297            if let EngineAction::Stop(_) = action {
298                break;
299            }
300        }
301    }
302
303    #[test]
304    fn integration_timeout_trumps_all() {
305        let mut stack = PolicyStack::<f64>::timed(Duration::from_secs(1));
306
307        let batch = EventBatch::new().add(Progress::Complete);
308
309        let ctx = EngineContext {
310            elapsed: Duration::from_secs(10),
311            ..Default::default()
312        };
313
314        assert!(matches!(
315            stack.decide(&batch, &ctx),
316            EngineAction::Stop(crate::Termination::Timeout)
317        ));
318    }
319}