Skip to main content

ix/
cache_policy.rs

1//! Adaptive cache policy driven by llmosafe's `ResourceGuard` pressure signal.
2//!
3//! Turns `ResourceGuard` from a binary safety gate into a continuous cache manager.
4//! Reads `ResourceGuard::pressure()` (0–100) and produces `CacheDirective`s that
5//! tell cache layers how aggressively to cache, evict, or pin pages.
6
7use llmosafe::ResourceGuard;
8
9/// Pressure zone classification based on memory pressure percentage.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum PressureZone {
12    /// 0–40 %: cache aggressively.
13    Green,
14    /// 41–70 %: cache normally, evict 10 % cold entries.
15    Yellow,
16    /// 71–90 %: evict 50 %, stop new caching.
17    Orange,
18    /// 91–100 %: flush everything.
19    Red,
20}
21
22/// Directive produced by [`AdaptiveCachePolicy::directive`].
23#[derive(Debug, Clone)]
24pub struct CacheDirective {
25    /// Current pressure zone.
26    pub zone: PressureZone,
27    /// Raw pressure value (0–100).
28    pub pressure: u8,
29    /// Fraction of cache to evict (0.0–1.0).
30    pub evict_fraction: f64,
31    /// Whether to admit new cache entries.
32    pub allow_new_entries: bool,
33    /// Whether to `madvise(MADV_WILLNEED)` on hot pages.
34    pub allow_mmap_pin: bool,
35}
36
37/// Read-only policy object that reads `ResourceGuard::pressure()` and produces
38/// cache directives. Does **not** own any cache — it only tells other code what
39/// to do.
40///
41/// Use [`AdaptiveCachePolicy::new_with_guard`] when sharing a `ResourceGuard` with a builder or
42/// other subsystem (recommended). Use [`AdaptiveCachePolicy::new`] when the cache policy needs its
43/// own independent ceiling.
44#[allow(clippy::missing_fields_in_debug)]
45pub struct AdaptiveCachePolicy {
46    guard: ResourceGuard,
47    ceiling: usize,
48}
49
50impl AdaptiveCachePolicy {
51    /// Creates a new policy with the given ceiling fraction of system memory.
52    ///
53    /// For example, `0.6` means use 60 % of system memory as the ceiling.
54    /// Internally delegates to `ResourceGuard::auto(ceiling_fraction)`.
55    #[must_use]
56    #[allow(
57        clippy::cast_precision_loss,
58        clippy::cast_sign_loss,
59        clippy::cast_possible_truncation,
60        clippy::as_conversions
61    )]
62    pub fn new(ceiling_fraction: f64) -> Self {
63        let guard = ResourceGuard::auto(ceiling_fraction);
64        let ceiling = (ResourceGuard::system_memory_bytes() as f64 * ceiling_fraction) as usize;
65        Self { guard, ceiling }
66    }
67
68    /// Creates a policy sharing an existing `ResourceGuard`.
69    ///
70    /// This is the recommended constructor for daemons: one guard governs
71    /// both the builder's safety checks AND the cache's pressure readings,
72    /// so they never disagree about how much memory is available.
73    ///
74    /// `ceiling_bytes` must match the guard's internal ceiling
75    /// (i.e., the value passed to `ResourceGuard::new` or computed by `auto`).
76    #[must_use]
77    pub fn new_with_guard(guard: ResourceGuard, ceiling_bytes: usize) -> Self {
78        Self {
79            guard,
80            ceiling: ceiling_bytes,
81        }
82    }
83
84    /// Returns a reference to the underlying `ResourceGuard`.
85    #[must_use]
86    pub fn guard(&self) -> &ResourceGuard {
87        &self.guard
88    }
89
90    /// Returns a [`CacheDirective`] reflecting the current memory pressure.
91    ///
92    /// **⚠ BLOCKING:** This call internally invokes `ResourceGuard::pressure()`
93    /// which reads `/proc/stat` twice with a 100 ms sleep between reads on Linux
94    /// (via `raw_entropy()` / `delta_iowait_ratio()`). Do **not** call this in
95    /// an async context without `spawn_blocking`.
96    ///
97    /// # Zone mapping
98    ///
99    /// | Zone   | Pressure | `evict_fraction` | `allow_new_entries` | `allow_mmap_pin` |
100    /// |--------|----------|-------------------|---------------------|------------------|
101    /// | Green  | 0–40     | 0.0               | true                | true             |
102    /// | Yellow | 41–70    | 0.1               | true                | true             |
103    /// | Orange | 71–90    | 0.5               | false               | false            |
104    /// | Red    | 91–100   | 1.0               | false               | false            |
105    #[must_use]
106    pub fn directive(&self) -> CacheDirective {
107        let pressure = self.guard.pressure();
108        let (zone, evict_fraction, allow_new_entries, allow_mmap_pin) = match pressure {
109            0..=40 => (PressureZone::Green, 0.0_f64, true, true),
110            41..=70 => (PressureZone::Yellow, 0.1_f64, true, true),
111            71..=90 => (PressureZone::Orange, 0.5_f64, false, false),
112            _ => (PressureZone::Red, 1.0_f64, false, false),
113        };
114        CacheDirective {
115            zone,
116            pressure,
117            evict_fraction,
118            allow_new_entries,
119            allow_mmap_pin,
120        }
121    }
122
123    /// Convenience wrapper for `ResourceGuard::pressure()`.
124    ///
125    /// **⚠ BLOCKING:** See [`directive`](Self::directive) for blocking details.
126    #[must_use]
127    pub fn pressure(&self) -> u8 {
128        self.guard.pressure()
129    }
130
131    /// Returns the current RSS in bytes.
132    #[must_use]
133    pub fn rss_bytes(&self) -> usize {
134        ResourceGuard::current_rss_bytes()
135    }
136
137    /// Returns the memory ceiling in bytes that this policy is configured with.
138    #[must_use]
139    pub fn ceiling_bytes(&self) -> usize {
140        self.ceiling
141    }
142}
143
144impl std::fmt::Debug for AdaptiveCachePolicy {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        f.debug_struct("AdaptiveCachePolicy")
147            .field("ceiling", &self.ceiling)
148            .finish_non_exhaustive()
149    }
150}
151
152impl PressureZone {
153    /// Returns the zone corresponding to the given pressure percentage.
154    #[must_use]
155    pub fn from_pressure(pressure: u8) -> Self {
156        match pressure {
157            0..=40 => Self::Green,
158            41..=70 => Self::Yellow,
159            71..=90 => Self::Orange,
160            _ => Self::Red,
161        }
162    }
163}
164
165#[cfg(test)]
166#[allow(clippy::as_conversions, clippy::unwrap_used, clippy::indexing_slicing)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn zone_classification_all_zones() {
172        assert_eq!(PressureZone::from_pressure(0), PressureZone::Green);
173        assert_eq!(PressureZone::from_pressure(20), PressureZone::Green);
174        assert_eq!(PressureZone::from_pressure(40), PressureZone::Green);
175        assert_eq!(PressureZone::from_pressure(41), PressureZone::Yellow);
176        assert_eq!(PressureZone::from_pressure(55), PressureZone::Yellow);
177        assert_eq!(PressureZone::from_pressure(70), PressureZone::Yellow);
178        assert_eq!(PressureZone::from_pressure(71), PressureZone::Orange);
179        assert_eq!(PressureZone::from_pressure(80), PressureZone::Orange);
180        assert_eq!(PressureZone::from_pressure(90), PressureZone::Orange);
181        assert_eq!(PressureZone::from_pressure(91), PressureZone::Red);
182        assert_eq!(PressureZone::from_pressure(100), PressureZone::Red);
183    }
184
185    #[test]
186    fn directive_fields_per_zone() {
187        let policy = AdaptiveCachePolicy::new(0.75);
188
189        let green = CacheDirective {
190            zone: PressureZone::Green,
191            pressure: 25,
192            evict_fraction: 0.0,
193            allow_new_entries: true,
194            allow_mmap_pin: true,
195        };
196        let yellow = CacheDirective {
197            zone: PressureZone::Yellow,
198            pressure: 55,
199            evict_fraction: 0.1,
200            allow_new_entries: true,
201            allow_mmap_pin: true,
202        };
203        let orange = CacheDirective {
204            zone: PressureZone::Orange,
205            pressure: 80,
206            evict_fraction: 0.5,
207            allow_new_entries: false,
208            allow_mmap_pin: false,
209        };
210        let red = CacheDirective {
211            zone: PressureZone::Red,
212            pressure: 95,
213            evict_fraction: 1.0,
214            allow_new_entries: false,
215            allow_mmap_pin: false,
216        };
217
218        for (label, pressure, expected) in [
219            ("green", 25, &green),
220            ("yellow", 55, &yellow),
221            ("orange", 80, &orange),
222            ("red", 95, &red),
223        ] {
224            let zone = PressureZone::from_pressure(pressure);
225            let (evict_fraction, allow_new_entries, allow_mmap_pin) = match zone {
226                PressureZone::Green => (0.0_f64, true, true),
227                PressureZone::Yellow => (0.1_f64, true, true),
228                PressureZone::Orange => (0.5_f64, false, false),
229                PressureZone::Red => (1.0_f64, false, false),
230            };
231            assert_eq!(
232                zone, expected.zone,
233                "{label}: zone mismatch for pressure {pressure}"
234            );
235            assert!(
236                (evict_fraction - expected.evict_fraction).abs() < f64::EPSILON,
237                "{label}: evict_fraction mismatch"
238            );
239            assert_eq!(
240                allow_new_entries, expected.allow_new_entries,
241                "{label}: allow_new_entries mismatch"
242            );
243            assert_eq!(
244                allow_mmap_pin, expected.allow_mmap_pin,
245                "{label}: allow_mmap_pin mismatch"
246            );
247        }
248
249        let directive = policy.directive();
250        assert!(directive.pressure <= 100);
251        assert_eq!(
252            directive.zone,
253            PressureZone::from_pressure(directive.pressure)
254        );
255    }
256
257    #[test]
258    #[expect(
259        clippy::integer_division,
260        reason = "intentional ceiling via floor(sys_mem * fraction)"
261    )]
262    fn ceiling_fraction_applied_correctly() {
263        let sys_mem = ResourceGuard::system_memory_bytes();
264        if sys_mem == 0 {
265            return;
266        }
267
268        // 0.5 = 1/2
269        let expected_ceiling = sys_mem / 2;
270        let policy = AdaptiveCachePolicy::new(0.5_f64);
271        assert_eq!(
272            policy.ceiling_bytes(),
273            expected_ceiling,
274            "ceiling_bytes should equal system_memory * 0.5"
275        );
276
277        // 0.75 = 3/4
278        let expected_75 = sys_mem.saturating_mul(3) / 4;
279        let policy_75 = AdaptiveCachePolicy::new(0.75_f64);
280        assert_eq!(
281            policy_75.ceiling_bytes(),
282            expected_75,
283            "ceiling_bytes with 0.75 fraction"
284        );
285    }
286
287    #[test]
288    fn pressure_returns_bounded_value() {
289        let policy = AdaptiveCachePolicy::new(0.75);
290        let pressure = policy.pressure();
291        assert!(pressure <= 100, "pressure {pressure} should be <= 100");
292    }
293
294    #[test]
295    fn rss_bytes_is_non_negative() {
296        let policy = AdaptiveCachePolicy::new(0.75);
297        let rss = policy.rss_bytes();
298        assert!(
299            rss <= ResourceGuard::system_memory_bytes(),
300            "RSS {rss} should not exceed system memory"
301        );
302    }
303
304    #[test]
305    fn debug_impl_works() {
306        let policy = AdaptiveCachePolicy::new(0.75);
307        let debug_str = format!("{policy:?}");
308        assert!(
309            debug_str.contains("AdaptiveCachePolicy"),
310            "Debug output should contain type name"
311        );
312        assert!(
313            debug_str.contains("ceiling"),
314            "Debug output should contain ceiling field"
315        );
316    }
317
318    #[test]
319    #[expect(clippy::integer_division, reason = "intentional ceiling computation")]
320    fn new_with_guard_shares_ceiling() {
321        let sys_mem = ResourceGuard::system_memory_bytes();
322        if sys_mem == 0 {
323            return;
324        }
325        let ceiling = sys_mem / 2;
326        let guard = ResourceGuard::new(ceiling);
327        let policy = AdaptiveCachePolicy::new_with_guard(guard, ceiling);
328        assert_eq!(
329            policy.ceiling_bytes(),
330            ceiling,
331            "new_with_guard should use the provided ceiling"
332        );
333        assert_eq!(
334            policy.guard().pressure(),
335            ResourceGuard::new(ceiling).pressure(),
336            "guard pressure should match"
337        );
338    }
339}