aristo_cli/nudge/
throttle.rs1use aristo_core::config::Aggressiveness;
22
23use super::state::ThrottleRecord;
24
25pub fn cooldown_secs(level: Aggressiveness) -> Option<u64> {
28 match level {
29 Aggressiveness::Off => None,
30 Aggressiveness::Low => Some(30 * 60),
31 Aggressiveness::Medium => Some(10 * 60),
32 Aggressiveness::High => Some(3 * 60),
33 }
34}
35
36pub fn rearm_step(level: Aggressiveness, base: f64) -> f64 {
41 let k = match level {
42 Aggressiveness::Off => return f64::INFINITY, Aggressiveness::Low => 1.0,
44 Aggressiveness::Medium => 0.66,
45 Aggressiveness::High => 0.33,
46 };
47 (base * k).max(1.0)
48}
49
50pub fn may_surface(
53 record: Option<&ThrottleRecord>,
54 now_epoch: u64,
55 level: Aggressiveness,
56 current_metric: f64,
57 base: f64,
58) -> bool {
59 let Some(cooldown) = cooldown_secs(level) else {
60 return false; };
62 let Some(record) = record else {
63 return true; };
65 let elapsed = now_epoch.saturating_sub(record.last_fired_epoch);
66 if elapsed >= cooldown {
67 return true;
68 }
69 current_metric >= record.last_surfaced_metric + rearm_step(level, base)
71}
72
73pub fn record_after_surface(now_epoch: u64, current_metric: f64) -> ThrottleRecord {
75 ThrottleRecord {
76 last_fired_epoch: now_epoch,
77 last_surfaced_metric: current_metric,
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 const HOUR: u64 = 3600;
86
87 #[test]
88 fn off_never_surfaces() {
89 assert!(!may_surface(None, HOUR, Aggressiveness::Off, 100.0, 3.0));
90 }
91
92 #[test]
93 fn never_surfaced_always_may() {
94 assert!(may_surface(None, 0, Aggressiveness::Low, 1.0, 3.0));
95 }
96
97 #[test]
98 fn waits_out_the_cooldown() {
99 let rec = ThrottleRecord {
100 last_fired_epoch: HOUR,
101 last_surfaced_metric: 3.0,
102 };
103 assert!(!may_surface(
105 Some(&rec),
106 HOUR + 60,
107 Aggressiveness::Medium,
108 3.0,
109 3.0
110 ));
111 assert!(may_surface(
113 Some(&rec),
114 HOUR + 11 * 60,
115 Aggressiveness::Medium,
116 3.0,
117 3.0
118 ));
119 }
120
121 #[test]
122 fn material_increase_rearms_inside_cooldown() {
123 let rec = ThrottleRecord {
124 last_fired_epoch: HOUR,
125 last_surfaced_metric: 3.0,
126 };
127 assert!(
130 !may_surface(Some(&rec), HOUR + 60, Aggressiveness::Medium, 4.0, 3.0),
131 "metric 4.0 is below the re-arm threshold 4.98 → still throttled"
132 );
133 assert!(
134 may_surface(Some(&rec), HOUR + 60, Aggressiveness::Medium, 5.0, 3.0),
135 "metric 5.0 clears the 4.98 re-arm threshold"
136 );
137 }
138
139 #[test]
140 fn higher_aggressiveness_rearms_on_smaller_increase() {
141 let rec = ThrottleRecord {
142 last_fired_epoch: HOUR,
143 last_surfaced_metric: 3.0,
144 };
145 assert!(may_surface(
148 Some(&rec),
149 HOUR + 60,
150 Aggressiveness::High,
151 4.5,
152 3.0
153 ));
154 assert!(!may_surface(
155 Some(&rec),
156 HOUR + 60,
157 Aggressiveness::Low,
158 4.5,
159 3.0
160 ));
161 }
162
163 #[test]
164 fn cooldown_shrinks_with_aggressiveness() {
165 assert_eq!(cooldown_secs(Aggressiveness::Off), None);
166 let low = cooldown_secs(Aggressiveness::Low).unwrap();
167 let med = cooldown_secs(Aggressiveness::Medium).unwrap();
168 let high = cooldown_secs(Aggressiveness::High).unwrap();
169 assert!(low > med && med > high, "{low} > {med} > {high}");
170 }
171}