Skip to main content

trellis_runner/engine/policy/
no_progress.rs

1//! # No-progress (stagnation by tolerance) policy
2//!
3//! Detects lack of meaningful improvement over time.
4//!
5//! This policy tracks the last observed value and counts how many consecutive
6//! iterations fail to improve beyond a given tolerance.
7//!
8//! ## Behaviour
9//!
10//! - Extracts numeric values from `Progress::Measure`
11//! - Computes absolute improvement:
12//!   `|current - previous|`
13//! - If improvement < `tolerance`, increments stagnation counter.
14//! - Otherwise resets counter.
15//!
16//! ## Termination
17//!
18//! Stops with [`Termination::Stagnated`] if:
19//!
20//! - stagnation counter >= `patience`
21//!
22//! ## Notes
23//!
24//! This is a *local window heuristic*, not a global stagnation detector.
25//! It is sensitive to noise and should be paired with smoothing policies
26//! for noisy objectives.
27use super::EnginePolicy;
28
29use crate::{
30    engine::{EngineAction, EngineContext, EventBatch},
31    progress::Progress,
32    Termination,
33};
34
35use num_traits::float::FloatCore;
36
37pub struct NoProgressPolicy<F> {
38    tolerance: F,
39    patience: usize,
40
41    best_so_far: Option<F>,
42    counter: usize,
43}
44
45impl<F> NoProgressPolicy<F> {
46    pub fn new(tolerance: F, patience: usize) -> Self {
47        Self {
48            tolerance,
49            patience,
50            best_so_far: None,
51            counter: 0,
52        }
53    }
54}
55
56impl<F> EnginePolicy<F> for NoProgressPolicy<F>
57where
58    F: FloatCore,
59{
60    fn decide(&mut self, batch: &EventBatch<F>, _ctx: &EngineContext) -> EngineAction {
61        let mut batch_best: Option<F> = None;
62
63        for e in &batch.events {
64            let value = match e {
65                Progress::Measure(v) => *v,
66                Progress::Report { measure, .. } => *measure,
67                _ => continue,
68            };
69
70            batch_best = Some(match batch_best {
71                Some(v) => v.min(value),
72                None => value,
73            });
74        }
75
76        let Some(batch_best) = batch_best else {
77            return EngineAction::Continue;
78        };
79
80        match self.best_so_far {
81            None => {
82                self.best_so_far = Some(batch_best);
83                self.counter = 0;
84                return EngineAction::Continue;
85            }
86
87            Some(prev_best) => {
88                let denom = prev_best.abs().max(F::one());
89                let improvement = (prev_best - batch_best) / denom;
90
91                if improvement > self.tolerance {
92                    // meaningful improvement → reset patience
93                    self.best_so_far = Some(batch_best);
94                    self.counter = 0;
95                } else {
96                    // no meaningful improvement
97                    self.counter += 1;
98                }
99            }
100        }
101
102        if self.counter >= self.patience {
103            return EngineAction::Stop(Termination::NoProgress);
104        }
105
106        EngineAction::Continue
107    }
108}
109
110#[cfg(test)]
111mod test {
112    use super::*;
113    use crate::engine::policy::PolicyStack;
114    use crate::engine::{EngineAction, EngineContext};
115    use crate::progress::Progress;
116
117    fn batch(v: f64) -> EventBatch<f64> {
118        EventBatch::new().add(Progress::Measure(v))
119    }
120
121    #[test]
122    fn no_progress_resets_with_improvement() {
123        let mut stack = PolicyStack::<f64>::new().add(NoProgressPolicy::new(0.1, 3));
124
125        // first: establish baseline
126        let batch1 = EventBatch::new().add(Progress::Measure(10.0));
127
128        let ctx = EngineContext::default();
129        let _ = stack.decide(&batch1, &ctx);
130
131        // repeated poor improvement
132        for _ in 0..2 {
133            let batch = EventBatch::new().add(Progress::Measure(9.95)); // small improvement
134
135            let res = stack.decide(&batch, &ctx);
136            assert!(matches!(res, crate::engine::EngineAction::Continue));
137        }
138
139        // real improvement resets
140        let reset_batch = EventBatch::new().add(Progress::Measure(8.0));
141
142        let res = stack.decide(&reset_batch, &ctx);
143        assert!(matches!(res, crate::engine::EngineAction::Continue));
144    }
145
146    #[test]
147    fn no_progress_triggers_stagnation() {
148        let mut p = NoProgressPolicy::new(0.1, 2);
149
150        let ctx = EngineContext::default();
151
152        let _ = p.decide(&batch(10.0), &ctx);
153        let _ = p.decide(&batch(10.01), &ctx);
154        let _ = p.decide(&batch(10.02), &ctx);
155
156        assert!(matches!(
157            p.decide(&batch(10.02), &ctx),
158            EngineAction::Stop(_)
159        ));
160    }
161
162    #[test]
163    fn ignores_non_numeric_events() {
164        let mut p = NoProgressPolicy::new(0.1, 2);
165
166        let mut b = EventBatch::new();
167        b.events.push(Progress::Complete);
168
169        let ctx = EngineContext::default();
170
171        assert!(matches!(p.decide(&b, &ctx), EngineAction::Continue));
172    }
173}