Skip to main content

aristo_cli/nudge/
throttle.rs

1//! Anti-nag throttle for the human-facing nudge surface (Phase 18 #9, S0d).
2//!
3//! A fired signal still has to clear the throttle before it actually
4//! surfaces, so the engine doesn't repeat the same nudge every turn. Two
5//! ways to clear it (D8 / SPINE-PLAN "throttle"):
6//!
7//! 1. **Cooldown elapsed** — at least `cooldown_secs(level)` since this
8//!    signal last surfaced. Higher aggressiveness = shorter cooldown; `off`
9//!    never surfaces (it is already silenced upstream, but the cooldown is
10//!    `None` here too as a belt-and-braces guard).
11//! 2. **Material increase** — even inside the cooldown, a signal whose metric
12//!    has grown by at least `step(level, base)` since it last surfaced
13//!    re-arms (a genuinely worse backlog shouldn't wait out the timer).
14//!
15//! Cadence is wall-clock here (the SPINE-PLAN's "edit-window" notion is
16//! approximated by minutes — simpler and robust against missing SessionStart
17//! events; the per-signal record carries the last-fired epoch + last metric).
18//! The Agent-audience surface (authoring debt) is NOT throttled here — it is
19//! same-turn only and cheap; this module governs the consolidated human nudge.
20
21use aristo_core::config::Aggressiveness;
22
23use super::state::ThrottleRecord;
24
25/// Seconds a human signal must wait between surfacings, by aggressiveness.
26/// `None` = never surface (`off`).
27pub 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
36/// The metric increase that re-arms a signal inside its cooldown. Scales with
37/// the signal's base (so a count-signal needs ~a few more, a fraction-signal a
38/// bit more fraction) and shrinks at higher aggressiveness (re-arms sooner).
39/// Floored at 1.0 so a count signal always needs at least one more.
40pub fn rearm_step(level: Aggressiveness, base: f64) -> f64 {
41    let k = match level {
42        Aggressiveness::Off => return f64::INFINITY, // never re-arms
43        Aggressiveness::Low => 1.0,
44        Aggressiveness::Medium => 0.66,
45        Aggressiveness::High => 0.33,
46    };
47    (base * k).max(1.0)
48}
49
50/// Whether a fired signal may surface now: cooldown elapsed OR a material
51/// increase since it last surfaced. A signal that never surfaced always may.
52pub 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; // off → never
61    };
62    let Some(record) = record else {
63        return true; // never surfaced → surface
64    };
65    let elapsed = now_epoch.saturating_sub(record.last_fired_epoch);
66    if elapsed >= cooldown {
67        return true;
68    }
69    // Inside the cooldown: re-arm only on a material increase.
70    current_metric >= record.last_surfaced_metric + rearm_step(level, base)
71}
72
73/// The record to store after a signal surfaces.
74pub 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        // 1 minute later under medium (10m cooldown), same metric → blocked.
104        assert!(!may_surface(
105            Some(&rec),
106            HOUR + 60,
107            Aggressiveness::Medium,
108            3.0,
109            3.0
110        ));
111        // 11 minutes later → cooldown elapsed → may.
112        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        // Still inside cooldown (1 min later). base 3.0, medium k=0.66 →
128        // step = max(1.98, 1.0) = 1.98. 3.0 + 1.98 = 4.98.
129        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        // base 3.0: high k=0.33 → step max(0.99,1.0)=1.0 → threshold 4.0;
146        // low k=1.0 → step 3.0 → threshold 6.0. metric 4.5 inside cooldown:
147        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}