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}