Skip to main content

rlmctl_core/guard/
policy.rs

1//! Pure policy state machine: the self-healing circuit breaker at the heart of
2//! the freeze guard.
3//!
4//! Contract: [`PolicyEngine::tick`] is pure given `(now_ms, sample, targets,
5//! live_cgroups)` plus the engine's own internal state. It performs **no**
6//! syscalls and reads **no** clock; `now_ms` (monotonic milliseconds) is
7//! injected by the caller. That is what makes the whole escalation/recovery
8//! ladder unit-testable without root.
9
10use std::collections::{BTreeMap, HashMap, HashSet};
11
12use super::resolve::{Coverage, Resolution, Verdict};
13use super::types::{Action, Intervention, Level, Sample, Target};
14use common::GuardConfig;
15
16/// PSI `full` avg10 (%) that, on its own, forces at least the High level. Mirrors
17/// the design doc's "or `full.avg10 >= 3`" High trigger.
18const FULL_HIGH_RISE: f64 = 3.0;
19/// Rate-limit window for `Notify` actions (ms): at most one notification a minute.
20const NOTIFY_INTERVAL_MS: u64 = 60_000;
21
22/// Escalation gate (ms) after an action that only partly covered its app.
23/// PSI avg10 is a 10 s average, so re-measuring after 1 s still sees the old
24/// pressure; waiting at least this long stops a partial action from
25/// cascading into several more within seconds.
26pub const PARTIAL_GATE_MS: u64 = 3_000;
27/// Most apps the guard holds (frozen or capped) at the same time.
28pub const MAX_HELD_APPS: usize = 3;
29/// Growth rate (bytes/s of `memory.current`) an app must reach to be picked
30/// as the one causing pressure. Below it, the largest app is picked instead.
31pub const MIN_GROWTH_BPS: f64 = 1_048_576.0;
32/// Most consecutive ticks victim selection waits for growth data when every
33/// eligible cgroup is newly seen. After that the largest app is picked, so a
34/// stream of short-lived cgroups cannot keep the guard from acting.
35pub const MAX_COLD_DEFER_TICKS: u32 = 3;
36
37/// The level a sample reaches on its own, from the rise thresholds alone
38/// (no hysteresis): Critical when PSI `full` reaches `psi_full_critical` or
39/// free memory is below `mem_available_floor_mb`, High when `some` reaches
40/// `psi_some_high` or `full` reaches [`FULL_HIGH_RISE`], Warn when `some`
41/// reaches `psi_some_warn`. The engine enters a level on exactly these rules;
42/// callers that only show the pressure (the GUI) use this to agree with it.
43pub fn rise_level(s: &Sample, t: &common::GuardTrigger) -> Level {
44    if s.full_avg10 >= t.psi_full_critical || s.mem_available_mb < t.mem_available_floor_mb {
45        Level::Critical
46    } else if s.some_avg10 >= t.psi_some_high || s.full_avg10 >= FULL_HIGH_RISE {
47        Level::High
48    } else if s.some_avg10 >= t.psi_some_warn {
49        Level::Warn
50    } else {
51        Level::Calm
52    }
53}
54
55/// True when the host is actually short of memory: below the hard floor, or
56/// below `act_below_available_pct` percent of RAM. A stall confined to one
57/// cgroup's memory.max leaves MemAvailable high, so it never passes this gate.
58pub fn is_scarce(s: &Sample, t: &common::GuardTrigger) -> bool {
59    s.mem_available_mb < t.mem_available_floor_mb
60        || (s.mem_total_mb > 0
61            && s.mem_available_mb.saturating_mul(100)
62                < s.mem_total_mb.saturating_mul(t.act_below_available_pct))
63}
64
65/// Smoothed `memory.current` growth for one cgroup.
66struct Growth {
67    last_bytes: u64,
68    last_ms: u64,
69    /// EWMA of bytes per second (negative while shrinking).
70    rate_bps: f64,
71    /// True once a second sample has been seen, so `rate_bps` is a measured
72    /// rate and not the zero placeholder of a first sighting.
73    warm: bool,
74}
75
76/// Self-healing circuit-breaker policy engine.
77///
78/// On a memory spike it drives the ladder *notify, freeze (short), auto-thaw,
79/// if still high a soft cap, once calm is sustained a lift*, never issuing a kill. All
80/// of that lives in [`tick`](Self::tick); the struct just holds the state needed
81/// to make decisions stable across ticks (hysteresis, cooldowns, growth).
82///
83/// The unit of choice is an app: every cgroup of the chosen app is frozen or
84/// capped together as one escalation step.
85pub struct PolicyEngine {
86    cfg: GuardConfig,
87    /// Current pressure level (carried across ticks so hysteresis works).
88    level: Level,
89    /// Active interventions keyed by resolved cgroup path, with the
90    /// [`Resolution`] (so `Thaw`/`LiftCap` can be built without re-resolving)
91    /// and the app the cgroup was acted on as.
92    interventions: HashMap<String, (Intervention, Resolution, String)>,
93    /// Last time each app was frozen. Drives the per-app freeze cooldown that
94    /// decides freeze-vs-cap, and is intentionally kept after a thaw.
95    last_freeze_ms: HashMap<String, u64>,
96    /// Per-cgroup `memory.current` growth estimate.
97    growth: HashMap<String, Growth>,
98    /// When the level last became `Calm` (None while not calm). Gates cap lifts.
99    calm_since_ms: Option<u64>,
100    /// When we last emitted a new freeze/cap: the global escalation gate.
101    /// `None` means "never acted", so the gate is open on the first action.
102    last_action_ms: Option<u64>,
103    /// Whether the most recent escalation acted under `Coverage::Partial`.
104    /// When true the gate is shortened to [`PARTIAL_GATE_MS`] (capped at the
105    /// freeze hold) so the guard can re-assess sooner, but never instantly.
106    last_action_partial: bool,
107    /// When we last emitted a `Notify`; drives notification rate-limiting.
108    /// `None` means "never notified", so the first eligible notify fires.
109    last_notify_ms: Option<u64>,
110    /// Consecutive ticks on which selection deferred for lack of growth
111    /// data. Bounded by [`MAX_COLD_DEFER_TICKS`].
112    cold_defer_ticks: u32,
113}
114
115impl PolicyEngine {
116    pub fn new(cfg: GuardConfig) -> Self {
117        Self {
118            cfg,
119            level: Level::Calm,
120            interventions: HashMap::new(),
121            last_freeze_ms: HashMap::new(),
122            growth: HashMap::new(),
123            calm_since_ms: None,
124            last_action_ms: None,
125            last_action_partial: false,
126            last_notify_ms: None,
127            cold_defer_ticks: 0,
128        }
129    }
130
131    /// Advance the state machine one tick and return the actions to apply.
132    ///
133    /// `targets` are the cgroups eligible for action this tick (already
134    /// filtered for uid, protect list and min RSS by the Sampler).
135    ///
136    /// `live_cgroups` is the subset of the engine's intervened cgroups that
137    /// still hold a process (see `sampler::live_cgroups`), with no min-RSS
138    /// or protect filtering applied; it is deliberately independent of
139    /// `targets`. Pruning checks liveness against
140    /// this set, not against `targets`: `memory.high` also bounds
141    /// file-backed pages, so capping
142    /// a mapped-file-heavy process can push its `rss_kb` below the min-RSS
143    /// floor on the very next tick, dropping it out of `targets` even though
144    /// the cgroup is very much still alive. Pruning against `targets` there
145    /// would lift the cap while pressure is still Critical and immediately
146    /// re-trigger it. Victim *selection* deliberately keeps using `targets`.
147    pub fn tick(
148        &mut self,
149        now_ms: u64,
150        sample: Sample,
151        targets: &[Target],
152        live_cgroups: &HashSet<String>,
153    ) -> Vec<Action> {
154        // 1. Disabled guard is inert.
155        if !self.cfg.enabled {
156            return Vec::new();
157        }
158
159        let mut actions = Vec::new();
160
161        // 2. Recompute the level with hysteresis and track how long we've been calm.
162        self.level = self.next_level(sample);
163        match self.level {
164            Level::Calm => {
165                // Start the calm clock on the *transition* into calm, then leave it.
166                if self.calm_since_ms.is_none() {
167                    self.calm_since_ms = Some(now_ms);
168                }
169                self.cold_defer_ticks = 0;
170            }
171            _ => self.calm_since_ms = None,
172        }
173
174        // 3. Track memory.current growth per cgroup.
175        self.update_growth(now_ms, targets);
176
177        // 4. Prune interventions whose cgroup is no longer live (see
178        //    `live_cgroups` doc above). LiftCap doubles as "tear down the
179        //    cap", so it's the right cleanup for both frozen and capped dead
180        //    cgroups; the effector's LiftCap tolerates a missing cgroup.
181        let dead: Vec<String> = self
182            .interventions
183            .keys()
184            .filter(|cg| !live_cgroups.contains(cg.as_str()))
185            .cloned()
186            .collect();
187        for cg in dead {
188            let (_, res, _) = self.interventions.remove(&cg).expect("just found key");
189            actions.push(Action::LiftCap { res });
190        }
191
192        // 5. Recover: auto-thaw held freezes, and lift caps once calm has held.
193        let freeze_hold_ms = self.cfg.timing.freeze_hold_secs.saturating_mul(1000);
194        let calm_hold_ms = self.cfg.timing.calm_hold_secs.saturating_mul(1000);
195        let mut recovered = Vec::new();
196        // Apps thawed on this tick must not be re-targeted by escalation in
197        // the same tick; they need a re-measure first.
198        let mut thawed_apps: HashSet<String> = HashSet::new();
199        for (cg, (intervention, res, app)) in &self.interventions {
200            match *intervention {
201                Intervention::Frozen { since_ms } => {
202                    if now_ms.saturating_sub(since_ms) >= freeze_hold_ms {
203                        actions.push(Action::Thaw { res: res.clone() });
204                        recovered.push(cg.clone());
205                        thawed_apps.insert(app.clone());
206                    }
207                }
208                Intervention::Capped { .. } => {
209                    // Only lift a cap when pressure is calm *and* has stayed calm
210                    // long enough; this prevents re-capping churn. `calm_since_ms` is
211                    // the transition timestamp, so this measures *sustained* calm.
212                    if self.level == Level::Calm {
213                        if let Some(calm_since) = self.calm_since_ms {
214                            if now_ms.saturating_sub(calm_since) >= calm_hold_ms {
215                                actions.push(Action::LiftCap { res: res.clone() });
216                                recovered.push(cg.clone());
217                            }
218                        }
219                    }
220                }
221            }
222        }
223        for cg in recovered {
224            // Note: last_freeze_ms is intentionally retained for cooldown logic.
225            self.interventions.remove(&cg);
226        }
227
228        // 6. Escalate, but only when apps feel pressure, memory is actually
229        //    short (see `is_scarce`), the gate is open, and fewer than
230        //    MAX_HELD_APPS apps are already held.
231        let mut victim_name: Option<String> = None;
232        if matches!(self.level, Level::High | Level::Critical)
233            && is_scarce(&sample, &self.cfg.trigger)
234            && self.held_apps().len() < MAX_HELD_APPS
235        {
236            // Global gate: after acting on one app, wait a freeze-hold before
237            // acting again so we re-measure instead of cascading. After a
238            // partial action wait a shorter, but never zero, interval.
239            let gate_ms = if self.last_action_partial {
240                PARTIAL_GATE_MS.min(freeze_hold_ms)
241            } else {
242                freeze_hold_ms
243            };
244            let gate_open = match self.last_action_ms {
245                None => true,
246                Some(last) => now_ms.saturating_sub(last) >= gate_ms,
247            };
248            if gate_open {
249                if let Some((app, members)) = self.select_app(targets, &thawed_apps) {
250                    let cap_only = members
251                        .iter()
252                        .any(|m| m.resolution.verdict == Verdict::CapOnly);
253                    let partial = members
254                        .iter()
255                        .any(|m| m.resolution.coverage == Coverage::Partial);
256                    let cooldown_ms = self.cfg.timing.freeze_cooldown_secs.saturating_mul(1000);
257                    let in_cooldown = self
258                        .last_freeze_ms
259                        .get(&app)
260                        .is_some_and(|&last| now_ms.saturating_sub(last) < cooldown_ms);
261                    // CapOnly never freezes; a recently frozen app that is
262                    // still hot escalates to a soft cap.
263                    let freeze = !cap_only && !in_cooldown;
264
265                    for m in members {
266                        let res = m.resolution.clone();
267                        let intervention = if freeze {
268                            actions.push(Action::Freeze {
269                                res: res.clone(),
270                                name: app.clone(),
271                            });
272                            Intervention::Frozen { since_ms: now_ms }
273                        } else {
274                            actions.push(Action::Cap {
275                                res: res.clone(),
276                                name: app.clone(),
277                            });
278                            Intervention::Capped { since_ms: now_ms }
279                        };
280                        self.interventions
281                            .insert(res.cgroup.clone(), (intervention, res, app.clone()));
282                    }
283                    if freeze {
284                        self.last_freeze_ms.insert(app.clone(), now_ms);
285                    }
286                    self.last_action_ms = Some(now_ms);
287                    self.last_action_partial = partial;
288                    victim_name = Some(app);
289                }
290            }
291        }
292
293        // 7. Notify (rate-limited) while there's anything to report.
294        let notify_due = match self.last_notify_ms {
295            None => true,
296            Some(last) => now_ms.saturating_sub(last) >= NOTIFY_INTERVAL_MS,
297        };
298        if self.cfg.notify
299            && matches!(self.level, Level::Warn | Level::High | Level::Critical)
300            && notify_due
301        {
302            let message = match &victim_name {
303                Some(name) => format!(
304                    "rlm-guard: memory pressure {:?}, acting on {}",
305                    self.level, name
306                ),
307                None => format!("rlm-guard: memory pressure {:?}", self.level),
308            };
309            actions.push(Action::Notify { message });
310            self.last_notify_ms = Some(now_ms);
311        }
312
313        actions
314    }
315
316    /// True when the caller should gather targets for the next tick: the
317    /// level would be above Calm, or memory is already scarce. Scanning while
318    /// scarce keeps growth rates warm, so a sudden drop below the floor can
319    /// pick the app that is growing instead of the largest one. A disabled
320    /// engine never wants them.
321    pub fn wants_candidates(&self, sample: Sample) -> bool {
322        self.cfg.enabled
323            && (self.next_level(sample) != Level::Calm || is_scarce(&sample, &self.cfg.trigger))
324    }
325
326    /// The pressure level as of the last tick.
327    pub fn level(&self) -> Level {
328        self.level
329    }
330
331    /// Currently active interventions (cgroup path and intervention), sorted by
332    /// cgroup path so callers get a deterministic order.
333    pub fn interventions(&self) -> Vec<(String, Intervention)> {
334        let mut out: Vec<(String, Intervention)> = self
335            .interventions
336            .iter()
337            .map(|(cg, (iv, _, _))| (cg.clone(), *iv))
338            .collect();
339        out.sort_by(|(a, _), (b, _)| a.cmp(b));
340        out
341    }
342
343    /// Cgroup paths the engine currently holds an intervention on, frozen
344    /// *or* capped. Interventions are already keyed by cgroup, so this is
345    /// just the key set. For external callers (e.g.
346    /// `RulesEnforcer::reconcile`, D1) that must not fight/revert an active
347    /// guard action: rewriting `memory.high` on a cgroup the engine just
348    /// capped would silently no-op the cap and leave `PolicyEngine` holding
349    /// a stale `Capped` intervention that then blocks victim re-selection
350    /// for the rest of the pressure episode.
351    pub fn intervened_cgroups(&self) -> Vec<String> {
352        self.interventions.keys().cloned().collect()
353    }
354
355    /// Distinct apps with at least one active intervention.
356    fn held_apps(&self) -> HashSet<&str> {
357        self.interventions
358            .values()
359            .map(|(_, _, app)| app.as_str())
360            .collect()
361    }
362
363    /// Compute the next level from the current level + a fresh sample, applying
364    /// rise/fall hysteresis. The fall threshold is half the rise threshold, and
365    /// we only ever *step down* when below the fall threshold, so a sample that
366    /// sits between fall and rise leaves the level unchanged (no flapping).
367    fn next_level(&self, s: Sample) -> Level {
368        let t = &self.cfg.trigger;
369        let floor = t.mem_available_floor_mb;
370
371        // Rise predicates (cross the upper threshold to enter a level).
372        let rise = rise_level(&s, t);
373        let crit_rise = rise == Level::Critical;
374        let high_rise = crit_rise || rise == Level::High;
375        let warn_rise = high_rise || rise == Level::Warn;
376
377        // Stay predicates (above the lower/fall threshold: keep the level).
378        let warn_stay = s.some_avg10 >= t.psi_some_warn / 2.0;
379        let high_stay =
380            s.some_avg10 >= t.psi_some_high / 2.0 || s.full_avg10 >= FULL_HIGH_RISE / 2.0;
381        let crit_stay = s.full_avg10 >= t.psi_full_critical / 2.0 || s.mem_available_mb < floor;
382
383        // Highest level we're allowed to be at, given current level + hysteresis.
384        // For each tier: enter if its rise fires; otherwise remain if we're
385        // already at/above it and its stay predicate still holds.
386        let at_critical = self.level == Level::Critical;
387        let at_high = matches!(self.level, Level::High | Level::Critical);
388        let at_warn = matches!(self.level, Level::Warn | Level::High | Level::Critical);
389
390        if crit_rise || (at_critical && crit_stay) {
391            Level::Critical
392        } else if high_rise || (at_high && high_stay) {
393            Level::High
394        } else if warn_rise || (at_warn && warn_stay) {
395            Level::Warn
396        } else {
397            Level::Calm
398        }
399    }
400
401    /// Update each target cgroup's `memory.current` growth rate (an EWMA
402    /// with weight 0.5 per tick) and forget cgroups no longer present.
403    fn update_growth(&mut self, now_ms: u64, targets: &[Target]) {
404        let mut seen = HashSet::new();
405        for t in targets {
406            let Some(cur) = t.current_bytes else {
407                continue;
408            };
409            let cg = &t.resolution.cgroup;
410            seen.insert(cg.clone());
411            match self.growth.get_mut(cg) {
412                Some(g) if now_ms > g.last_ms => {
413                    let dt = (now_ms - g.last_ms) as f64 / 1000.0;
414                    let inst = (cur as f64 - g.last_bytes as f64) / dt;
415                    g.rate_bps = 0.5 * g.rate_bps + 0.5 * inst;
416                    g.last_bytes = cur;
417                    g.last_ms = now_ms;
418                    g.warm = true;
419                }
420                Some(_) => {}
421                None => {
422                    self.growth.insert(
423                        cg.clone(),
424                        Growth {
425                            last_bytes: cur,
426                            last_ms: now_ms,
427                            rate_bps: 0.0,
428                            warm: false,
429                        },
430                    );
431                }
432            }
433        }
434        self.growth.retain(|cg, _| seen.contains(cg));
435    }
436
437    /// Pick the app to act on and its member targets. Eligible apps are not
438    /// held, not thawed this tick (`blocked`), have a member at or above the
439    /// min-RSS floor, and have no member cgroup under an intervention. The
440    /// app whose cgroups grow fastest wins when that growth is at least
441    /// [`MIN_GROWTH_BPS`]; otherwise the largest app. Ties go to the
442    /// lexicographically smaller app name, so the choice is deterministic.
443    ///
444    /// Cold start: when no eligible cgroup has a measured growth rate yet but
445    /// at least one was first seen this tick, nothing is picked. The next
446    /// tick (one sample interval later) has rates, so an idle large app is
447    /// not frozen in place of a smaller one that is growing. The deferral
448    /// lasts one tick at most per new cgroup, and at most
449    /// [`MAX_COLD_DEFER_TICKS`] ticks in a row; after that the largest app is
450    /// picked. If no eligible cgroup reports `memory.current` at all, growth
451    /// can never be measured and the largest app is picked at once.
452    fn select_app<'a>(
453        &mut self,
454        targets: &'a [Target],
455        blocked: &HashSet<String>,
456    ) -> Option<(String, Vec<&'a Target>)> {
457        let min_rss_kb = self.cfg.selection.min_rss_mb.saturating_mul(1024);
458        let held = self.held_apps();
459        let mut groups: BTreeMap<&str, Vec<&Target>> = BTreeMap::new();
460        for t in targets {
461            groups.entry(t.app.as_str()).or_default().push(t);
462        }
463        let eligible: Vec<(&str, Vec<&Target>)> = groups
464            .into_iter()
465            .filter(|(app, ms)| {
466                !held.contains(app)
467                    && !blocked.contains(*app)
468                    && ms.iter().any(|m| m.rss_kb >= min_rss_kb)
469                    && ms
470                        .iter()
471                        .all(|m| !self.interventions.contains_key(&m.resolution.cgroup))
472            })
473            .collect();
474        let any_warm = eligible.iter().any(|(_, ms)| {
475            ms.iter().any(|m| {
476                self.growth
477                    .get(&m.resolution.cgroup)
478                    .is_some_and(|g| g.warm)
479            })
480        });
481        let any_cold = eligible.iter().any(|(_, ms)| {
482            ms.iter().any(|m| {
483                self.growth
484                    .get(&m.resolution.cgroup)
485                    .is_some_and(|g| !g.warm)
486            })
487        });
488        if !any_warm && any_cold && self.cold_defer_ticks < MAX_COLD_DEFER_TICKS {
489            self.cold_defer_ticks += 1;
490            return None;
491        }
492        self.cold_defer_ticks = 0;
493        let growth = |ms: &[&Target]| -> f64 {
494            ms.iter()
495                .map(|m| {
496                    self.growth
497                        .get(&m.resolution.cgroup)
498                        .map_or(0.0, |g| g.rate_bps.max(0.0))
499                })
500                .sum()
501        };
502        let size = |ms: &[&Target]| -> u64 {
503            ms.iter()
504                .map(|m| m.current_bytes.unwrap_or(m.rss_kb.saturating_mul(1024)))
505                .sum()
506        };
507        let pick = eligible
508            .iter()
509            .filter(|(_, ms)| growth(ms) >= MIN_GROWTH_BPS)
510            .max_by(|a, b| {
511                growth(&a.1)
512                    .total_cmp(&growth(&b.1))
513                    .then_with(|| b.0.cmp(a.0))
514            })
515            .or_else(|| {
516                eligible
517                    .iter()
518                    .max_by(|a, b| size(&a.1).cmp(&size(&b.1)).then_with(|| b.0.cmp(a.0)))
519            })?;
520        Some((pick.0.to_string(), pick.1.clone()))
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::super::resolve::Mechanism;
527    use super::super::types::PsiSource;
528    use super::*;
529
530    /// Default config = the documented zero-config defaults.
531    fn cfg() -> GuardConfig {
532        GuardConfig::default()
533    }
534
535    fn sample(some: f64, full: f64, avail_mb: u64) -> Sample {
536        Sample {
537            some_avg10: some,
538            full_avg10: full,
539            mem_available_mb: avail_mb,
540            mem_total_mb: 16_000,
541            source: PsiSource::AppSlice,
542        }
543    }
544
545    /// Build a default (unprotected, full-coverage) resolution for `cg`.
546    fn res(cg: &str) -> Resolution {
547        Resolution {
548            cgroup: cg.into(),
549            unit: Some(format!("{}.scope", cg.rsplit('/').next().unwrap())),
550            verdict: Verdict::Freeze,
551            coverage: Coverage::Full,
552            mechanism: Mechanism::Unit,
553        }
554    }
555
556    const MIB: u64 = 1024 * 1024;
557
558    /// Build a `Target` for app `app` in cgroup `cg`, with `mb` MB resident
559    /// and the same amount charged to the cgroup.
560    fn target(app: &str, cg: &str, mb: u64) -> Target {
561        Target {
562            app: app.into(),
563            resolution: res(cg),
564            rss_kb: mb * 1024,
565            current_bytes: Some(mb * MIB),
566        }
567    }
568
569    /// Shorthand: one app per scope, `/app.slice/app-<name>-<pid>.scope`.
570    fn proc(pid: u32, name: &str, rss_mb: u64) -> Target {
571        target(name, &format!("/app.slice/app-{name}-{pid}.scope"), rss_mb)
572    }
573
574    fn proc_at(_pid: u32, name: &str, rss_mb: u64, cg: &str) -> Target {
575        target(name, cg, rss_mb)
576    }
577
578    /// A comfortably-calm sample (no pressure, lots of memory).
579    fn calm() -> Sample {
580        sample(0.0, 0.0, 8000)
581    }
582
583    /// High PSI while only 12.5% of RAM is available: a real shortage.
584    fn high() -> Sample {
585        sample(50.0, 0.0, 2_000)
586    }
587
588    #[test]
589    fn high_pressure_with_plenty_of_free_memory_never_escalates() {
590        let mut e = PolicyEngine::new(cfg());
591        let procs = vec![proc(2, "chrome", 4000)];
592        // Half of RAM available: this stall is local to some memory.max, not a shortage.
593        let a = e.tick(0, sample(80.0, 20.0, 8_000), &procs, &live_from(&procs));
594        assert_eq!(e.level, Level::Critical);
595        assert!(
596            !a.iter()
597                .any(|x| matches!(x, Action::Freeze { .. } | Action::Cap { .. })),
598            "escalated without scarcity: {a:?}"
599        );
600    }
601
602    #[test]
603    fn is_scarce_uses_floor_or_percentage() {
604        let t = common::GuardTrigger::default(); // floor 400 MB, 20%
605        assert!(is_scarce(&sample(0.0, 0.0, 300), &t));
606        assert!(is_scarce(&sample(0.0, 0.0, 3_000), &t)); // 18.75%
607        assert!(!is_scarce(&sample(0.0, 0.0, 3_300), &t)); // 20.6%
608        let unknown = Sample {
609            mem_total_mb: 0,
610            mem_available_mb: u64::MAX,
611            ..sample(0.0, 0.0, 0)
612        };
613        assert!(
614            !is_scarce(&unknown, &t),
615            "unreadable meminfo must not enable actions"
616        );
617    }
618
619    fn freeze_targets(actions: &[Action]) -> Vec<String> {
620        actions
621            .iter()
622            .filter_map(|a| match a {
623                Action::Freeze { res, .. } => Some(res.cgroup.clone()),
624                _ => None,
625            })
626            .collect()
627    }
628
629    fn has_cap_target(actions: &[Action], cg: &str) -> bool {
630        actions
631            .iter()
632            .any(|a| matches!(a, Action::Cap { res, .. } if res.cgroup == cg))
633    }
634
635    fn has_freeze_target(actions: &[Action], cg: &str) -> bool {
636        actions
637            .iter()
638            .any(|a| matches!(a, Action::Freeze { res, .. } if res.cgroup == cg))
639    }
640
641    fn has_thaw_target(actions: &[Action], cg: &str) -> bool {
642        actions
643            .iter()
644            .any(|a| matches!(a, Action::Thaw { res } if res.cgroup == cg))
645    }
646
647    fn has_liftcap_target(actions: &[Action], cg: &str) -> bool {
648        actions
649            .iter()
650            .any(|a| matches!(a, Action::LiftCap { res } if res.cgroup == cg))
651    }
652
653    /// Give every target a measured (zero) growth rate, as if the engine had
654    /// already scanned them on an earlier tick. Tests of the ladder use this
655    /// so the cold-start deferral does not shift their first action.
656    fn prime(e: &mut PolicyEngine, ts: &[Target]) {
657        for t in ts {
658            e.growth.insert(
659                t.resolution.cgroup.clone(),
660                Growth {
661                    last_bytes: t.current_bytes.unwrap_or(0),
662                    last_ms: 0,
663                    rate_bps: 0.0,
664                    warm: true,
665                },
666            );
667        }
668    }
669
670    /// Default `live_cgroups` for tests that aren't specifically exercising
671    /// the liveness-vs-eligibility distinction: every target's cgroup.
672    fn live_from(ts: &[Target]) -> std::collections::HashSet<String> {
673        ts.iter().map(|t| t.resolution.cgroup.clone()).collect()
674    }
675
676    #[test]
677    fn rise_level_follows_the_engine_rise_rules() {
678        let t = common::GuardTrigger::default();
679        assert_eq!(rise_level(&sample(0.0, 0.0, 8000), &t), Level::Calm);
680        assert_eq!(rise_level(&sample(12.0, 0.0, 8000), &t), Level::Warn);
681        assert_eq!(rise_level(&sample(5.0, 4.0, 2000), &t), Level::High);
682        assert_eq!(rise_level(&sample(31.0, 0.0, 8000), &t), Level::High);
683        assert_eq!(rise_level(&sample(0.0, 10.0, 8000), &t), Level::Critical);
684        // Below the free-memory floor is Critical whatever PSI says.
685        assert_eq!(rise_level(&sample(0.0, 0.0, 300), &t), Level::Critical);
686        // An unreadable MemAvailable is never below the floor.
687        assert_eq!(rise_level(&sample(0.0, 0.0, u64::MAX), &t), Level::Calm);
688        // From Calm, one tick lands on the same level.
689        for s in [
690            sample(5.0, 4.0, 2000),
691            sample(0.0, 0.0, 300),
692            sample(12.0, 0.0, 8000),
693        ] {
694            let e = PolicyEngine::new(cfg());
695            assert_eq!(e.next_level(s), rise_level(&s, &t), "{s:?}");
696        }
697    }
698
699    #[test]
700    fn calm_yields_no_actions() {
701        let mut e = PolicyEngine::new(cfg());
702        let procs = vec![proc(100, "firefox", 2000)];
703        let actions = e.tick(1000, calm(), &procs, &live_from(&procs));
704        assert!(actions.is_empty(), "calm produced actions: {actions:?}");
705        assert_eq!(e.level, Level::Calm);
706    }
707
708    #[test]
709    fn full_signal_has_fall_hysteresis() {
710        // Enter High purely via PSI `full` (some stays low): full=4.0 >= FULL_HIGH_RISE(3.0).
711        let mut e = PolicyEngine::new(cfg());
712        let procs = vec![proc(100, "firefox", 4000)];
713        e.tick(1_000, sample(0.0, 4.0, 8000), &procs, &live_from(&procs));
714        assert_eq!(e.level, Level::High, "full=4.0 should enter High");
715
716        // full drifts to 2.0, between the fall (1.5) and rise (3.0) thresholds.
717        // With hysteresis it must HOLD High, not flap back to Calm.
718        e.tick(2_000, sample(0.0, 2.0, 8000), &procs, &live_from(&procs));
719        assert_eq!(
720            e.level,
721            Level::High,
722            "full=2.0 (between fall and rise) must hold High, not flap"
723        );
724
725        // full drops below the fall threshold (1.0 < 1.5): now it may step down.
726        e.tick(3_000, sample(0.0, 1.0, 8000), &procs, &live_from(&procs));
727        assert_eq!(
728            e.level,
729            Level::Calm,
730            "full below fall threshold drops to Calm"
731        );
732    }
733
734    #[test]
735    fn disabled_engine_is_inert() {
736        let mut c = cfg();
737        c.enabled = false;
738        let mut e = PolicyEngine::new(c);
739        let procs = vec![proc(100, "firefox", 4000)];
740        assert!(e.tick(1000, high(), &procs, &live_from(&procs)).is_empty());
741    }
742
743    #[test]
744    fn high_freezes_largest_eligible_process() {
745        let mut e = PolicyEngine::new(cfg());
746        let procs = vec![
747            proc(1, "small", 300),
748            proc(2, "biggest", 4000),
749            proc(3, "medium", 1000),
750        ];
751        prime(&mut e, &procs);
752        let actions = e.tick(1000, high(), &procs, &live_from(&procs));
753        // Only the single largest hog is frozen, not the smaller ones.
754        assert_eq!(
755            freeze_targets(&actions),
756            vec!["/app.slice/app-biggest-2.scope"]
757        );
758        assert_eq!(e.level, Level::High);
759    }
760
761    #[test]
762    fn process_below_min_rss_is_never_selected() {
763        let mut e = PolicyEngine::new(cfg());
764        // Both below the 200 MB default floor.
765        let procs = vec![proc(1, "tiny", 50), proc(2, "small", 150)];
766        let actions = e.tick(1000, high(), &procs, &live_from(&procs));
767        assert!(
768            freeze_targets(&actions).is_empty(),
769            "froze a sub-min-rss process: {actions:?}"
770        );
771    }
772
773    #[test]
774    fn frozen_process_thaws_after_freeze_hold() {
775        let mut e = PolicyEngine::new(cfg()); // freeze_hold = 5s
776        let procs = vec![proc(2, "hog", 4000)];
777        prime(&mut e, &procs);
778        let cg = "/app.slice/app-hog-2.scope";
779
780        let a0 = e.tick(0, high(), &procs, &live_from(&procs));
781        assert_eq!(freeze_targets(&a0), vec![cg]);
782
783        // Before the hold elapses: no thaw yet (and escalation gate keeps it quiet).
784        let a1 = e.tick(4_000, high(), &procs, &live_from(&procs));
785        assert!(!has_thaw_target(&a1, cg), "thawed too early: {a1:?}");
786
787        // At/after 5s the freeze auto-thaws.
788        let a2 = e.tick(5_000, high(), &procs, &live_from(&procs));
789        assert!(has_thaw_target(&a2, cg), "expected thaw at hold: {a2:?}");
790        assert!(e.interventions().is_empty());
791    }
792
793    #[test]
794    fn still_high_within_cooldown_caps_instead_of_refreezing() {
795        let mut e = PolicyEngine::new(cfg()); // hold=5s, cooldown=60s
796        let procs = vec![proc(2, "hog", 4000)];
797        prime(&mut e, &procs);
798        let cg = "/app.slice/app-hog-2.scope";
799
800        // Freeze at t=0.
801        assert_eq!(
802            freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
803            vec![cg]
804        );
805        // Auto-thaw at t=5s.
806        assert!(has_thaw_target(
807            &e.tick(5_000, high(), &procs, &live_from(&procs)),
808            cg
809        ));
810
811        // Still high, and within the 60s freeze cooldown: Cap, not re-Freeze.
812        // t must clear the escalation gate (>= last_action 5000 + 5000 hold).
813        let a = e.tick(10_000, high(), &procs, &live_from(&procs));
814        assert!(
815            has_cap_target(&a, cg),
816            "expected cap within cooldown: {a:?}"
817        );
818        assert!(freeze_targets(&a).is_empty(), "should not re-freeze: {a:?}");
819        assert!(matches!(
820            e.interventions().as_slice(),
821            [(c, Intervention::Capped { .. })] if c == cg
822        ));
823    }
824
825    #[test]
826    fn hysteresis_holds_level_between_fall_and_rise() {
827        let mut e = PolicyEngine::new(cfg());
828        let procs = vec![proc(2, "hog", 4000)];
829        let cg = "/app.slice/app-hog-2.scope";
830
831        // Rise to High.
832        e.tick(0, high(), &procs, &live_from(&procs));
833        assert_eq!(e.level, Level::High);
834
835        // some=20 is below rise(30) but above fall(15): stay High, no lift.
836        let a = e.tick(20_000, sample(20.0, 0.0, 8000), &procs, &live_from(&procs));
837        assert_eq!(e.level, Level::High, "dropped out of High prematurely");
838        // A thaw here is expected (the freeze hold elapsed), but the cap must not
839        // be lifted while we're still High.
840        assert!(
841            !has_liftcap_target(&a, cg),
842            "should not lift while still High: {a:?}"
843        );
844
845        // Drop below the fall threshold (some < 15 and full < 3): fall to Warn.
846        e.tick(21_000, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
847        assert_eq!(e.level, Level::Warn);
848    }
849
850    #[test]
851    fn capped_process_lifted_only_after_sustained_calm() {
852        let mut e = PolicyEngine::new(cfg()); // calm_hold = 30s
853        let procs = vec![proc(2, "hog", 4000)];
854        prime(&mut e, &procs);
855        let cg = "/app.slice/app-hog-2.scope";
856
857        // Drive a freeze, thaw, then a cap (still hot within cooldown).
858        e.tick(0, high(), &procs, &live_from(&procs));
859        e.tick(5_000, high(), &procs, &live_from(&procs)); // thaw
860        let a = e.tick(10_000, high(), &procs, &live_from(&procs)); // cap
861        assert!(has_cap_target(&a, cg));
862
863        // Calm starts at t=15s. Before 30s of calm: no lift.
864        let a1 = e.tick(15_000, calm(), &procs, &live_from(&procs));
865        assert!(
866            !has_liftcap_target(&a1, cg),
867            "lifted before calm sustained: {a1:?}"
868        );
869        let a2 = e.tick(44_000, calm(), &procs, &live_from(&procs)); // 29s of calm
870        assert!(
871            !has_liftcap_target(&a2, cg),
872            "lifted just before hold: {a2:?}"
873        );
874
875        // 30s of sustained calm lifts the cap.
876        let a3 = e.tick(45_000, calm(), &procs, &live_from(&procs));
877        assert!(
878            has_liftcap_target(&a3, cg),
879            "expected lift after calm hold: {a3:?}"
880        );
881        assert!(e.interventions().is_empty());
882    }
883
884    #[test]
885    fn cap_lift_resets_if_calm_is_interrupted() {
886        let mut e = PolicyEngine::new(cfg());
887        let procs = vec![proc(2, "hog", 4000)];
888        prime(&mut e, &procs);
889        let cg = "/app.slice/app-hog-2.scope";
890        e.tick(0, high(), &procs, &live_from(&procs));
891        e.tick(5_000, high(), &procs, &live_from(&procs));
892        assert!(has_cap_target(
893            &e.tick(10_000, high(), &procs, &live_from(&procs)),
894            cg
895        ));
896
897        e.tick(15_000, calm(), &procs, &live_from(&procs)); // calm clock starts
898        e.tick(20_000, high(), &procs, &live_from(&procs)); // pressure returns, calm clock cleared
899                                                            // New calm window starts at 25s; at 50s only 25s have passed, so no lift.
900        e.tick(25_000, calm(), &procs, &live_from(&procs));
901        let a = e.tick(50_000, calm(), &procs, &live_from(&procs));
902        assert!(
903            !has_liftcap_target(&a, cg),
904            "calm clock should have reset: {a:?}"
905        );
906    }
907
908    #[test]
909    fn escalation_gate_limits_to_one_freeze_per_hold_window() {
910        let mut e = PolicyEngine::new(cfg());
911        let procs = vec![proc(1, "hog-a", 4000), proc(2, "hog-b", 3000)];
912        prime(&mut e, &procs);
913        let cg_a = "/app.slice/app-hog-a-1.scope";
914        let cg_b = "/app.slice/app-hog-b-2.scope";
915
916        // First High tick freezes hog-a.
917        let a0 = e.tick(0, high(), &procs, &live_from(&procs));
918        assert_eq!(freeze_targets(&a0), vec![cg_a]);
919
920        // Second High tick within the 5s hold: gate closed, no new freeze.
921        let a1 = e.tick(2_000, high(), &procs, &live_from(&procs));
922        assert!(
923            freeze_targets(&a1).is_empty(),
924            "gate should suppress second freeze: {a1:?}"
925        );
926
927        // After the gate reopens, the next hog can be frozen.
928        let a2 = e.tick(5_000, high(), &procs, &live_from(&procs));
929        assert_eq!(freeze_targets(&a2), vec![cg_b]);
930    }
931
932    #[test]
933    fn dead_pid_is_pruned_with_liftcap() {
934        let mut e = PolicyEngine::new(cfg());
935        let procs = vec![proc(2, "hog", 4000)];
936        prime(&mut e, &procs);
937        let cg = "/app.slice/app-hog-2.scope";
938
939        // Freeze the hog's cgroup.
940        assert_eq!(
941            freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
942            vec![cg]
943        );
944        assert_eq!(e.interventions().len(), 1);
945
946        // Next tick the process (and its resolution) has vanished, so LiftCap
947        // cleanup, intervention dropped.
948        let a = e.tick(1_000, calm(), &[], &live_from(&[]));
949        assert!(
950            has_liftcap_target(&a, cg),
951            "expected LiftCap for dead cgroup: {a:?}"
952        );
953        assert!(e.interventions().is_empty());
954    }
955
956    /// D2 regression: a cgroup absent from `procs` (e.g. the cap evicted
957    /// enough file pages to drop the process below `min_rss_mb`) but still
958    /// present in `live_cgroups` must NOT be pruned: the cgroup is real and
959    /// alive, just not currently eligible for (re-)selection. A cgroup
960    /// absent from *both* sets must still be pruned with a LiftCap.
961    #[test]
962    fn intervention_survives_in_live_cgroups_but_absent_from_procs() {
963        let mut e = PolicyEngine::new(cfg());
964        let procs = vec![proc(2, "hog", 4000)];
965        prime(&mut e, &procs);
966        let cg = "/app.slice/app-hog-2.scope";
967
968        // Freeze the hog's cgroup.
969        assert_eq!(
970            freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
971            vec![cg]
972        );
973        assert_eq!(e.interventions().len(), 1);
974
975        // Next tick: the process no longer appears in `procs` (as if the cap
976        // evicted its file pages below the min-RSS floor), but its cgroup is
977        // still in `live_cgroups`, so it must NOT be pruned.
978        let live: std::collections::HashSet<String> = [cg.to_string()].into();
979        let a1 = e.tick(1_000, calm(), &[], &live);
980        assert!(
981            !has_liftcap_target(&a1, cg),
982            "must not prune a cgroup still present in live_cgroups: {a1:?}"
983        );
984        assert_eq!(
985            e.interventions().len(),
986            1,
987            "intervention must survive while the cgroup is live"
988        );
989
990        // Now the cgroup is gone from both sets entirely, so it is pruned.
991        let a2 = e.tick(2_000, calm(), &[], &std::collections::HashSet::new());
992        assert!(
993            has_liftcap_target(&a2, cg),
994            "expected LiftCap once absent from live_cgroups too: {a2:?}"
995        );
996        assert!(e.interventions().is_empty());
997    }
998
999    #[test]
1000    fn interventions_reflect_state_sorted_by_cgroup() {
1001        let mut e = PolicyEngine::new(cfg());
1002        // pid 5 ("a") is the larger hog; pid 3 ("b") the smaller. We build a
1003        // Capped "a" and a "b" capped after a freeze, both active, then check
1004        // ordering + content. "/app.slice/app-a-5.scope" sorts before
1005        // "/app.slice/app-b-3.scope" lexicographically.
1006        let procs = vec![proc(5, "a", 4000), proc(3, "b", 3500)];
1007        prime(&mut e, &procs);
1008        let cg_a = "/app.slice/app-a-5.scope";
1009        let cg_b = "/app.slice/app-b-3.scope";
1010
1011        // Walk both cgroups down the freeze, thaw, (still hot) cap ladder
1012        // so two Capped interventions coexist. Caps persist while High (never
1013        // auto-thaw), which is what lets two interventions overlap under
1014        // default timing.
1015        assert_eq!(
1016            freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
1017            vec![cg_a]
1018        ); // freeze a
1019        let a1 = e.tick(5_000, high(), &procs, &live_from(&procs)); // thaw a, freeze b
1020        assert!(has_thaw_target(&a1, cg_a));
1021        assert_eq!(freeze_targets(&a1), vec![cg_b]);
1022        let a2 = e.tick(10_000, high(), &procs, &live_from(&procs)); // thaw b, cap a (in cooldown)
1023        assert!(has_thaw_target(&a2, cg_b));
1024        assert!(has_cap_target(&a2, cg_a));
1025        let a3 = e.tick(15_000, high(), &procs, &live_from(&procs)); // cap b (in cooldown)
1026        assert!(has_cap_target(&a3, cg_b));
1027
1028        let ivs = e.interventions();
1029        assert_eq!(ivs.len(), 2, "expected a + b both Capped: {ivs:?}");
1030        // Sorted ascending by cgroup path.
1031        assert_eq!(ivs[0].0, cg_a);
1032        assert_eq!(ivs[1].0, cg_b);
1033        assert!(ivs
1034            .iter()
1035            .all(|(_, iv)| matches!(iv, Intervention::Capped { .. })));
1036    }
1037
1038    #[test]
1039    fn critical_via_mem_floor_triggers_action() {
1040        let mut e = PolicyEngine::new(cfg());
1041        let procs = vec![proc(2, "hog", 4000)];
1042        prime(&mut e, &procs);
1043        // No PSI pressure, but MemAvailable below the 400 MB floor, so Critical.
1044        let a = e.tick(0, sample(0.0, 0.0, 100), &procs, &live_from(&procs));
1045        assert_eq!(e.level, Level::Critical);
1046        assert_eq!(freeze_targets(&a), vec!["/app.slice/app-hog-2.scope"]);
1047    }
1048
1049    #[test]
1050    fn notify_emitted_and_rate_limited() {
1051        let mut e = PolicyEngine::new(cfg());
1052        let procs = vec![proc(2, "hog", 4000)];
1053
1054        // Warn level: some>=10 but below high; just notify, no freeze.
1055        let a0 = e.tick(0, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1056        assert!(
1057            a0.iter().any(|x| matches!(x, Action::Notify { .. })),
1058            "expected a notify at Warn: {a0:?}"
1059        );
1060        assert!(freeze_targets(&a0).is_empty());
1061
1062        // Within 60s: no second notify.
1063        let a1 = e.tick(30_000, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1064        assert!(
1065            !a1.iter().any(|x| matches!(x, Action::Notify { .. })),
1066            "notify should be rate-limited: {a1:?}"
1067        );
1068
1069        // After 60s: notify again.
1070        let a2 = e.tick(60_000, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1071        assert!(a2.iter().any(|x| matches!(x, Action::Notify { .. })));
1072    }
1073
1074    #[test]
1075    fn notify_disabled_suppresses_notifications() {
1076        let mut c = cfg();
1077        c.notify = false;
1078        let mut e = PolicyEngine::new(c);
1079        let procs = vec![proc(2, "hog", 4000)];
1080        let a = e.tick(0, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1081        assert!(!a.iter().any(|x| matches!(x, Action::Notify { .. })));
1082    }
1083
1084    // ---- Task 5 new behaviors --------------------------------------------
1085
1086    #[test]
1087    fn caponly_verdict_never_freezes() {
1088        let mut e = PolicyEngine::new(cfg());
1089        let mut p = proc_at(2, "script", 4000, "/app.slice/app-alacritty-9.scope");
1090        let r = &mut p.resolution;
1091        r.verdict = Verdict::CapOnly;
1092        r.coverage = Coverage::Partial;
1093        let procs = [p];
1094        prime(&mut e, &procs);
1095        let a = e.tick(0, high(), &procs, &live_from(&procs));
1096        assert!(
1097            freeze_targets(&a).is_empty(),
1098            "CapOnly must not freeze: {a:?}"
1099        );
1100        assert!(has_cap_target(&a, "/app.slice/app-alacritty-9.scope"));
1101    }
1102
1103    #[test]
1104    fn partial_action_waits_at_least_three_seconds() {
1105        let mut e = PolicyEngine::new(cfg());
1106        let mut term = target("python3", "/app.slice/term.scope", 4000);
1107        term.resolution.verdict = Verdict::CapOnly;
1108        term.resolution.coverage = Coverage::Partial;
1109        let ts = vec![term, target("hog", "/app.slice/hog.scope", 3000)];
1110        prime(&mut e, &ts);
1111        assert!(has_cap_target(
1112            &e.tick(0, high(), &ts, &live_from(&ts)),
1113            "/app.slice/term.scope"
1114        ));
1115        assert!(freeze_targets(&e.tick(1_000, high(), &ts, &live_from(&ts))).is_empty());
1116        assert!(freeze_targets(&e.tick(2_000, high(), &ts, &live_from(&ts))).is_empty());
1117        assert!(has_freeze_target(
1118            &e.tick(3_000, high(), &ts, &live_from(&ts)),
1119            "/app.slice/hog.scope"
1120        ));
1121    }
1122
1123    #[test]
1124    fn two_scopes_of_one_app_are_acted_on_together() {
1125        let mut e = PolicyEngine::new(cfg());
1126        let ts = vec![
1127            target("chrome", "/app.slice/app-chrome-1.scope", 1500),
1128            target("chrome", "/app.slice/app-chrome-2.scope", 900),
1129            target("hog", "/app.slice/app-hog-3.scope", 2000),
1130        ];
1131        prime(&mut e, &ts);
1132        let mut frozen = freeze_targets(&e.tick(0, high(), &ts, &live_from(&ts)));
1133        frozen.sort();
1134        assert_eq!(
1135            frozen,
1136            vec![
1137                "/app.slice/app-chrome-1.scope",
1138                "/app.slice/app-chrome-2.scope"
1139            ]
1140        );
1141        let a1 = e.tick(1_000, high(), &ts, &live_from(&ts));
1142        assert!(
1143            freeze_targets(&a1).is_empty(),
1144            "one app is one escalation step: {a1:?}"
1145        );
1146    }
1147
1148    #[test]
1149    fn fastest_growing_app_is_chosen_over_largest() {
1150        let mut e = PolicyEngine::new(cfg());
1151        let warn = sample(12.0, 0.0, 2_000);
1152        let t0 = vec![
1153            target("firefox", "/app.slice/ff.scope", 4000),
1154            target("script", "/app.slice/sh.scope", 1000),
1155        ];
1156        assert!(freeze_targets(&e.tick(0, warn, &t0, &live_from(&t0))).is_empty());
1157        let t1 = vec![
1158            target("firefox", "/app.slice/ff.scope", 4000),
1159            target("script", "/app.slice/sh.scope", 1600),
1160        ];
1161        assert_eq!(
1162            freeze_targets(&e.tick(1_000, high(), &t1, &live_from(&t1))),
1163            vec!["/app.slice/sh.scope"]
1164        );
1165    }
1166
1167    #[test]
1168    fn largest_app_is_the_fallback_when_nothing_grows() {
1169        let mut e = PolicyEngine::new(cfg());
1170        let ts = vec![
1171            target("small", "/app.slice/s.scope", 300),
1172            target("big", "/app.slice/b.scope", 3000),
1173        ];
1174        e.tick(0, sample(12.0, 0.0, 2_000), &ts, &live_from(&ts));
1175        assert_eq!(
1176            freeze_targets(&e.tick(1_000, high(), &ts, &live_from(&ts))),
1177            vec!["/app.slice/b.scope"]
1178        );
1179    }
1180
1181    #[test]
1182    fn at_most_three_apps_are_held_at_once() {
1183        let mut e = PolicyEngine::new(cfg());
1184        let ts: Vec<Target> = (0..5u64)
1185            .map(|i| {
1186                target(
1187                    &format!("app{i}"),
1188                    &format!("/app.slice/a{i}.scope"),
1189                    1000 + i * 100,
1190                )
1191            })
1192            .collect();
1193        for step in 0..40u64 {
1194            e.tick(step * 5_000, high(), &ts, &live_from(&ts));
1195            assert!(
1196                e.interventions().len() <= MAX_HELD_APPS,
1197                "held {:?}",
1198                e.interventions()
1199            );
1200        }
1201    }
1202
1203    #[test]
1204    fn held_limit_counts_apps_not_cgroups() {
1205        let mut e = PolicyEngine::new(cfg());
1206        let ts = vec![
1207            target("a", "/app.slice/a1.scope", 2000),
1208            target("a", "/app.slice/a2.scope", 2000),
1209            target("b", "/app.slice/b.scope", 1500),
1210            target("c", "/app.slice/c.scope", 1200),
1211            target("d", "/app.slice/d.scope", 1100),
1212        ];
1213        for step in 0..40u64 {
1214            e.tick(step * 5_000, high(), &ts, &live_from(&ts));
1215        }
1216        let held: Vec<String> = e.interventions().into_iter().map(|(cg, _)| cg).collect();
1217        assert_eq!(
1218            held,
1219            vec![
1220                "/app.slice/a1.scope",
1221                "/app.slice/a2.scope",
1222                "/app.slice/b.scope",
1223                "/app.slice/c.scope"
1224            ],
1225            "4 cgroups across 3 apps are allowed, a 4th app is refused"
1226        );
1227    }
1228
1229    #[test]
1230    fn candidates_are_wanted_above_calm_or_when_scarce() {
1231        let e = PolicyEngine::new(cfg());
1232        assert!(!e.wants_candidates(calm()));
1233        assert!(e.wants_candidates(sample(12.0, 0.0, 8_000)));
1234        // Calm PSI but under 20% of RAM available: scan so growth stays warm.
1235        assert!(e.wants_candidates(sample(0.0, 0.0, 3_000)));
1236    }
1237
1238    /// Regression (final review I1): available memory drops below the floor
1239    /// in one step with no stall first, so the Critical tick is the first
1240    /// scan. An idle 6 GB app must not be frozen in place of a 3 GB app
1241    /// that is growing: the first tick defers, the next picks the grower.
1242    #[test]
1243    fn cold_start_defers_then_picks_grower_not_largest() {
1244        let mut e = PolicyEngine::new(cfg());
1245        for step in 0..5u64 {
1246            e.tick(step * 1_000, calm(), &[], &HashSet::new());
1247        }
1248        let crit = sample(0.0, 0.0, 300);
1249        let t0 = vec![
1250            target("chrome", "/app.slice/chrome.scope", 6000),
1251            target("hog", "/app.slice/hog.scope", 3000),
1252        ];
1253        let a0 = e.tick(5_000, crit, &t0, &live_from(&t0));
1254        assert_eq!(e.level, Level::Critical);
1255        assert!(
1256            freeze_targets(&a0).is_empty(),
1257            "no growth data yet, must defer: {a0:?}"
1258        );
1259        let t1 = vec![
1260            target("chrome", "/app.slice/chrome.scope", 6000),
1261            target("hog", "/app.slice/hog.scope", 3200),
1262        ];
1263        assert_eq!(
1264            freeze_targets(&e.tick(6_000, crit, &t1, &live_from(&t1))),
1265            vec!["/app.slice/hog.scope"]
1266        );
1267    }
1268
1269    /// With scarce-but-calm ticks feeding growth, the grower is picked on
1270    /// the very first Critical tick.
1271    #[test]
1272    fn scarce_calm_ticks_warm_growth_for_first_critical_tick() {
1273        let mut e = PolicyEngine::new(cfg());
1274        let scarce_calm = sample(0.0, 0.0, 3_000);
1275        assert!(e.wants_candidates(scarce_calm));
1276        let t0 = vec![
1277            target("chrome", "/app.slice/chrome.scope", 6000),
1278            target("hog", "/app.slice/hog.scope", 2000),
1279        ];
1280        assert!(freeze_targets(&e.tick(0, scarce_calm, &t0, &live_from(&t0))).is_empty());
1281        let t1 = vec![
1282            target("chrome", "/app.slice/chrome.scope", 6000),
1283            target("hog", "/app.slice/hog.scope", 3000),
1284        ];
1285        assert_eq!(
1286            freeze_targets(&e.tick(1_000, sample(0.0, 0.0, 300), &t1, &live_from(&t1))),
1287            vec!["/app.slice/hog.scope"]
1288        );
1289    }
1290
1291    /// A new cgroup on every tick never gets a measured growth rate. The
1292    /// deferral is bounded, so the guard still acts by the fourth tick and
1293    /// falls back to the largest eligible app.
1294    #[test]
1295    fn cold_start_deferral_is_bounded_when_cgroups_keep_changing() {
1296        let mut e = PolicyEngine::new(cfg());
1297        let crit = sample(0.0, 0.0, 300);
1298        for tick in 0..4u64 {
1299            let ts = vec![
1300                target(
1301                    &format!("big{tick}"),
1302                    &format!("/app.slice/b{tick}.scope"),
1303                    3000,
1304                ),
1305                target(
1306                    &format!("small{tick}"),
1307                    &format!("/app.slice/s{tick}.scope"),
1308                    1000,
1309                ),
1310            ];
1311            let frozen = freeze_targets(&e.tick(tick * 1_000, crit, &ts, &live_from(&ts)));
1312            if tick < u64::from(MAX_COLD_DEFER_TICKS) {
1313                assert!(frozen.is_empty(), "tick {tick} should defer: {frozen:?}");
1314            } else {
1315                assert_eq!(frozen, vec![format!("/app.slice/b{tick}.scope")]);
1316            }
1317        }
1318    }
1319
1320    /// Without any memory.current reading, growth can never be measured, so
1321    /// the guard does not wait and falls back to the largest app.
1322    #[test]
1323    fn no_current_bytes_does_not_defer() {
1324        let mut e = PolicyEngine::new(cfg());
1325        let mut big = target("big", "/app.slice/b.scope", 3000);
1326        big.current_bytes = None;
1327        let mut small = target("small", "/app.slice/s.scope", 1000);
1328        small.current_bytes = None;
1329        let ts = vec![big, small];
1330        assert_eq!(
1331            freeze_targets(&e.tick(0, high(), &ts, &live_from(&ts))),
1332            vec!["/app.slice/b.scope"]
1333        );
1334    }
1335}