Skip to main content

ruvector_sona/
auto_tuner.rs

1//! Online auto-tuner machinery for SONA config (ADR-271, Ornith-1.0 borrow #4).
2//!
3//! Offline evolution tunes a config to a *fixed* benchmark. Real workloads drift
4//! (non-stationary trajectory streams), so a fixed config goes stale. This module
5//! provides the **staleness-weighted** primitives for an *online* tuner that
6//! re-optimizes against the live stream, weighting recent observations over old
7//! ones via Ornith-1.0's staleness weight `w(d_t)`:
8//!
9//! ```text
10//! w(d) = 1                       if d <= k1        (fresh — full weight)
11//!      = exp(-lambda*(d - k1))   if k1 < d <= k2   (decaying)
12//!      = 0                       if d  > k2        (too stale — dropped)
13//! ```
14//!
15//! where `d` is the *age* (clock ticks since the observation). The
16//! [`StalenessWindow`] maintains a staleness-weighted running estimate of "how
17//! well the current config is doing lately"; a `(1+1)`-ES on top of it (see
18//! `examples/darwin_autotuner.rs`) accepts a perturbed config only when its
19//! recent, freshness-weighted score beats the incumbent — so the tuner tracks a
20//! drifting optimum instead of averaging over a stale past.
21
22use std::collections::VecDeque;
23
24/// Ornith-1.0 staleness schedule `w(d_t)`.
25#[derive(Clone, Copy, Debug)]
26pub struct StalenessSchedule {
27    /// Ages `<= k1` keep full weight 1.0.
28    pub k1: u64,
29    /// Ages `> k2` are dropped (weight 0).
30    pub k2: u64,
31    /// Exponential decay rate in the `(k1, k2]` band.
32    pub lambda: f32,
33}
34
35impl StalenessSchedule {
36    /// A sensible default: full weight for 16 ticks, decay to ~0 by 64.
37    #[must_use]
38    pub fn new(k1: u64, k2: u64, lambda: f32) -> Self {
39        Self { k1, k2, lambda }
40    }
41
42    /// `w(d)` for an observation of age `d` ticks.
43    #[must_use]
44    pub fn weight(&self, age: u64) -> f32 {
45        if age <= self.k1 {
46            1.0
47        } else if age <= self.k2 {
48            (-self.lambda * (age - self.k1) as f32).exp()
49        } else {
50            0.0
51        }
52    }
53}
54
55impl Default for StalenessSchedule {
56    fn default() -> Self {
57        Self {
58            k1: 16,
59            k2: 64,
60            lambda: 0.08,
61        }
62    }
63}
64
65/// A staleness-weighted window of recent scalar observations (e.g. per-step loss
66/// under the current config). `push` advances the clock; `weighted_mean` reports
67/// the freshness-weighted average; observations past `k2` are evicted.
68#[derive(Clone, Debug)]
69pub struct StalenessWindow {
70    schedule: StalenessSchedule,
71    /// `(value, recorded_at_clock)` newest-last.
72    samples: VecDeque<(f32, u64)>,
73    clock: u64,
74    cap: usize,
75}
76
77impl StalenessWindow {
78    /// New window with the given schedule and a hard capacity cap.
79    #[must_use]
80    pub fn new(schedule: StalenessSchedule, cap: usize) -> Self {
81        Self {
82            schedule,
83            samples: VecDeque::with_capacity(cap),
84            clock: 0,
85            cap: cap.max(1),
86        }
87    }
88
89    /// Record an observation under the current config; advances the clock and
90    /// evicts samples that are too stale (`age > k2`) or over capacity.
91    pub fn push(&mut self, value: f32) {
92        self.samples.push_back((value, self.clock));
93        self.clock += 1;
94        while self.samples.len() > self.cap {
95            self.samples.pop_front();
96        }
97        // Evict fully-stale observations from the front.
98        while let Some(&(_, t)) = self.samples.front() {
99            if self.clock.saturating_sub(t) > self.schedule.k2 {
100                self.samples.pop_front();
101            } else {
102                break;
103            }
104        }
105    }
106
107    /// Reset the recorded observations but keep the clock running — used after a
108    /// config switch so the new config is scored on *its own* fresh samples.
109    pub fn clear_samples(&mut self) {
110        self.samples.clear();
111    }
112
113    /// Staleness-weighted mean of the window, or `None` if empty / all-stale.
114    #[must_use]
115    pub fn weighted_mean(&self) -> Option<f32> {
116        let mut num = 0.0f32;
117        let mut den = 0.0f32;
118        for &(v, t) in &self.samples {
119            let w = self.schedule.weight(self.clock.saturating_sub(t));
120            num += w * v;
121            den += w;
122        }
123        if den > 0.0 {
124            Some(num / den)
125        } else {
126            None
127        }
128    }
129
130    /// Current clock (number of observations ever pushed).
131    #[must_use]
132    pub fn clock(&self) -> u64 {
133        self.clock
134    }
135
136    /// Number of live (non-evicted) observations.
137    #[must_use]
138    pub fn len(&self) -> usize {
139        self.samples.len()
140    }
141
142    /// Whether the window holds no live observations.
143    #[must_use]
144    pub fn is_empty(&self) -> bool {
145        self.samples.is_empty()
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn weight_is_fresh_then_decays_then_drops() {
155        let s = StalenessSchedule::new(4, 10, 0.5);
156        assert_eq!(s.weight(0), 1.0);
157        assert_eq!(s.weight(4), 1.0);
158        let w5 = s.weight(5);
159        assert!(w5 < 1.0 && w5 > 0.0); // decaying
160        assert!(s.weight(9) < w5); // monotone decay
161        assert_eq!(s.weight(11), 0.0); // dropped past k2
162    }
163
164    #[test]
165    fn weighted_mean_favors_recent() {
166        // Old samples = 1.0, recent samples = 0.0; the weighted mean must sit
167        // well below the unweighted 0.5 because recent dominates.
168        let mut w = StalenessWindow::new(StalenessSchedule::new(2, 32, 0.3), 64);
169        for _ in 0..20 {
170            w.push(1.0);
171        }
172        for _ in 0..20 {
173            w.push(0.0);
174        }
175        let m = w.weighted_mean().unwrap();
176        assert!(
177            m < 0.25,
178            "recent-weighted mean {m} should be near the recent 0.0"
179        );
180    }
181
182    #[test]
183    fn stale_observations_are_evicted() {
184        let mut w = StalenessWindow::new(StalenessSchedule::new(2, 8, 0.5), 1000);
185        for _ in 0..50 {
186            w.push(1.0);
187        }
188        // Only observations within k2=8 ticks of the clock survive.
189        assert!(w.len() <= 9, "expected <=9 live samples, got {}", w.len());
190        assert!(w.weighted_mean().is_some());
191    }
192
193    #[test]
194    fn empty_window_has_no_mean() {
195        let w = StalenessWindow::new(StalenessSchedule::default(), 8);
196        assert!(w.weighted_mean().is_none());
197        assert!(w.is_empty());
198    }
199}