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