Skip to main content

zeph_context/
manager.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Context lifecycle state machine for the Zeph agent.
5//!
6//! [`ContextManager`] tracks per-session compaction state and token budgets.
7//! It decides when soft (pruning) or hard (LLM summarization) compaction should fire,
8//! and builds the memory router used for query-aware store selection.
9//!
10//! [`CompactionState`] is the core state machine — see its doc comment for the
11//! full transition map.
12
13use std::sync::Arc;
14
15use zeph_config::{CompressionConfig, StoreRoutingConfig};
16
17use crate::budget::ContextBudget;
18
19/// Lifecycle state of the compaction subsystem within a single session.
20///
21/// Replaces four independent boolean/u8 fields with an explicit state machine that makes
22/// invalid states unrepresentable (e.g., warned-without-exhausted).
23///
24/// # Transition map
25///
26/// ```text
27/// Ready
28///   → CompactedThisTurn { cooldown } when hard compaction succeeds (pruning or LLM)
29///   → CompactedThisTurn { cooldown: 0 } when focus truncation, eviction, or proactive
30///     compression fires (these callers do not want post-compaction cooldown)
31///   → Exhausted { warned: false } when compaction is counterproductive (too few messages,
32///     zero net freed tokens, or still above hard threshold after LLM compaction)
33///
34/// CompactedThisTurn { cooldown }
35///   → Cooling { turns_remaining: cooldown } when cooldown > 0  (via advance_turn)
36///   → Ready                                 when cooldown == 0 (via advance_turn)
37///
38/// Cooling { turns_remaining }
39///   → Cooling { turns_remaining - 1 } decremented inside maybe_compact each turn
40///   → Ready                           when turns_remaining reaches 0
41///   NOTE: Exhausted is NOT reachable from Cooling — all exhaustion-setting sites in
42///   summarization.rs are guarded by an early-return when in_cooldown is true.
43///
44/// Exhausted { warned: false }
45///   → Exhausted { warned: true } after the user warning is sent (one-shot)
46///
47/// Exhausted { warned: true }  (terminal — no further transitions)
48/// ```
49///
50/// `turns_since_last_hard_compaction` is a **metric counter**, not part of this state machine,
51/// and remains a separate field on `ContextManager`.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum CompactionState {
55    /// Normal state — compaction may fire if context exceeds thresholds.
56    Ready,
57    /// Hard compaction (or focus truncation / eviction / proactive compression) ran this turn.
58    /// No further compaction until `advance_turn()` is called at the next turn boundary.
59    /// `cooldown` carries the number of cooling turns to enforce after this turn ends.
60    CompactedThisTurn {
61        /// Cooling turns to enforce after this turn ends.
62        cooldown: u8,
63    },
64    /// Cooling down after a recent hard compaction. Hard tier is skipped; soft is still allowed.
65    /// Counter decrements inside `maybe_compact` each turn until it reaches 0.
66    Cooling {
67        /// Remaining cooling turns before returning to `Ready`.
68        turns_remaining: u8,
69    },
70    /// Compaction cannot reduce context further. No more attempts will be made.
71    /// `warned` tracks whether the one-shot user warning has been sent.
72    Exhausted {
73        /// Whether the user has already been notified of context exhaustion.
74        warned: bool,
75    },
76}
77
78impl CompactionState {
79    /// Whether hard compaction (or a compaction-equivalent operation) already ran this turn.
80    ///
81    /// When `true`, `maybe_compact`, `maybe_proactive_compress`, and
82    /// `maybe_soft_compact_mid_iteration` all skip execution (CRIT-03).
83    #[must_use]
84    pub fn is_compacted_this_turn(self) -> bool {
85        matches!(self, Self::CompactedThisTurn { .. })
86    }
87
88    /// Whether compaction is permanently disabled for this session.
89    #[must_use]
90    pub fn is_exhausted(self) -> bool {
91        matches!(self, Self::Exhausted { .. })
92    }
93
94    /// Remaining cooldown turns (0 when not in `Cooling` state).
95    #[must_use]
96    pub fn cooldown_remaining(self) -> u8 {
97        match self {
98            Self::Cooling { turns_remaining } => turns_remaining,
99            _ => 0,
100        }
101    }
102
103    /// Transition to the next-turn state at the start of each user turn.
104    ///
105    /// **Must be called exactly once per turn, before any compaction, eviction, or
106    /// focus truncation can run.** This guarantees that `is_compacted_this_turn()`
107    /// returns `false` when the sidequest check executes — preserving the invariant
108    /// that the sidequest only sees same-turn compaction set by eviction which runs
109    /// *after* this call.
110    ///
111    /// Transitions:
112    /// - `CompactedThisTurn { cooldown: 0 }` → `Ready`
113    /// - `CompactedThisTurn { cooldown: n }` → `Cooling { turns_remaining: n }`
114    /// - All other states are returned unchanged.
115    #[must_use]
116    pub fn advance_turn(self) -> Self {
117        match self {
118            Self::CompactedThisTurn { cooldown } if cooldown > 0 => Self::Cooling {
119                turns_remaining: cooldown,
120            },
121            Self::CompactedThisTurn { .. } => Self::Ready,
122            other => other,
123        }
124    }
125}
126
127/// Indicates which compaction tier applies for the current context size.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum CompactionTier {
130    /// Context is within budget — no compaction needed.
131    None,
132    /// Soft tier: prune tool outputs + apply deferred summaries. No LLM call.
133    Soft,
134    /// Hard tier: full LLM-based summarization.
135    Hard,
136}
137
138/// Per-session context lifecycle manager.
139///
140/// Holds the token budget, compaction lifecycle state, and routing configuration.
141/// Callers in `zeph-core` drive the state machine via `advance_turn`, `compaction_tier`,
142/// and related accessors; the assembler reads the budget via `build_router` and field access.
143pub struct ContextManager {
144    /// Token budget for this session. `None` until configured via `apply_budget_config`.
145    pub budget: Option<ContextBudget>,
146    /// Soft compaction threshold (default 0.70): prune tool outputs + apply deferred summaries.
147    pub soft_compaction_threshold: f32,
148    /// Hard compaction threshold (default 0.90): full LLM-based summarization.
149    pub hard_compaction_threshold: f32,
150    /// Number of recent messages preserved during hard compaction.
151    pub compaction_preserve_tail: usize,
152    /// Token count protected from pruning during soft compaction.
153    pub prune_protect_tokens: usize,
154    /// Compression configuration for proactive compression.
155    pub compression: CompressionConfig,
156    /// Routing configuration for query-aware memory routing.
157    pub routing: StoreRoutingConfig,
158    /// Resolved provider for LLM/hybrid routing. `None` when strategy is `Heuristic`
159    /// or when the named provider could not be resolved from the pool.
160    pub store_routing_provider: Option<Arc<zeph_llm::any::AnyProvider>>,
161    /// Compaction lifecycle state. Replaces four independent boolean/u8 fields to make
162    /// invalid states unrepresentable. See [`CompactionState`] for the full transition map.
163    pub(crate) compaction: CompactionState,
164    /// Number of cooling turns to enforce after a successful hard compaction.
165    pub(crate) compaction_cooldown_turns: u8,
166    /// Counts user-message turns since the last hard compaction event.
167    /// `None` = no hard compaction has occurred yet in this session.
168    /// `Some(n)` = n turns have elapsed since the last hard compaction.
169    pub(crate) turns_since_last_hard_compaction: Option<u64>,
170    /// Whether a proactive fidelity regrade has already fired this turn (INV-06).
171    ///
172    /// Reset to `false` by `advance_turn()` at each turn boundary.
173    pub(crate) regraded_this_turn: bool,
174}
175
176impl ContextManager {
177    /// Create a new `ContextManager` with default thresholds and no budget.
178    #[must_use]
179    pub fn new() -> Self {
180        Self {
181            budget: None,
182            soft_compaction_threshold: 0.60,
183            hard_compaction_threshold: 0.90,
184            compaction_preserve_tail: 6,
185            prune_protect_tokens: 40_000,
186            compression: CompressionConfig::default(),
187            routing: StoreRoutingConfig::default(),
188            store_routing_provider: None,
189            compaction: CompactionState::Ready,
190            compaction_cooldown_turns: 2,
191            turns_since_last_hard_compaction: None,
192            regraded_this_turn: false,
193        }
194    }
195
196    /// Apply budget and compaction thresholds from config.
197    ///
198    /// Must be called once after config is resolved. Safe to call again when config reloads.
199    #[allow(clippy::too_many_arguments)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
200    pub fn apply_budget_config(
201        &mut self,
202        budget_tokens: usize,
203        reserve_ratio: f32,
204        hard_compaction_threshold: f32,
205        compaction_preserve_tail: usize,
206        prune_protect_tokens: usize,
207        soft_compaction_threshold: f32,
208        compaction_cooldown_turns: u8,
209    ) {
210        if budget_tokens == 0 {
211            tracing::warn!("context budget is 0 — agent will have no token tracking");
212        }
213        if budget_tokens > 0 {
214            self.budget = Some(ContextBudget::new(budget_tokens, reserve_ratio));
215        }
216        self.hard_compaction_threshold = hard_compaction_threshold;
217        self.compaction_preserve_tail = compaction_preserve_tail;
218        self.prune_protect_tokens = prune_protect_tokens;
219        self.soft_compaction_threshold = soft_compaction_threshold;
220        self.compaction_cooldown_turns = compaction_cooldown_turns;
221    }
222
223    /// Reset compaction state for a new conversation.
224    ///
225    /// Clears cooldown, exhaustion, and turn counters so the new conversation starts
226    /// with a clean compaction slate.
227    pub fn reset_compaction(&mut self) {
228        self.compaction = CompactionState::Ready;
229        self.turns_since_last_hard_compaction = None;
230    }
231
232    /// Determine which compaction tier applies for the given token count.
233    ///
234    /// Compares the current cached token count against the configured thresholds to decide
235    /// whether hard compaction, soft compaction, or no compaction should be triggered.
236    /// This method is typically called by the context assembler during a turn to proactively
237    /// compress older messages if token usage grows too large.
238    ///
239    /// - `Hard` when `cached_tokens > budget * hard_compaction_threshold` — triggers
240    ///   aggressive summarization and compaction
241    /// - `Soft` when `cached_tokens > budget * soft_compaction_threshold` — triggers
242    ///   lighter compaction without full summarization
243    /// - `None` otherwise (or when no budget is set) — no compaction needed
244    ///
245    /// # Parameters
246    ///
247    /// * `cached_tokens` — current token count in the cached context (e.g., message history)
248    ///
249    /// # Returns
250    ///
251    /// The compaction tier that should be applied (`Hard`, `Soft`, or `None`).
252    ///
253    /// # Examples
254    ///
255    /// ```no_run
256    /// use zeph_context::manager::ContextManager;
257    /// use zeph_context::budget::ContextBudget;
258    ///
259    /// let budget = ContextBudget::new(128_000, 0.15);
260    /// let mut manager = ContextManager::new();
261    /// manager.soft_compaction_threshold = 0.6;
262    /// manager.hard_compaction_threshold = 0.8;
263    /// manager.budget = Some(budget);
264    ///
265    /// // Check tier for 96k cached tokens (75% of 128k)
266    /// let tier = manager.compaction_tier(96_000);
267    /// // Returns Soft (75% is between 60% and 80%)
268    /// ```
269    #[allow(
270        clippy::cast_precision_loss,
271        clippy::cast_possible_truncation,
272        clippy::cast_sign_loss
273    )]
274    pub fn compaction_tier(&self, cached_tokens: u64) -> CompactionTier {
275        let Some(ref budget) = self.budget else {
276            return CompactionTier::None;
277        };
278        let used = usize::try_from(cached_tokens).unwrap_or(usize::MAX);
279        let max = budget.max_tokens();
280        let hard = (max as f32 * self.hard_compaction_threshold) as usize;
281        if used > hard {
282            tracing::debug!(
283                cached_tokens,
284                hard_threshold = hard,
285                "context budget check: Hard tier"
286            );
287            return CompactionTier::Hard;
288        }
289        let soft = (max as f32 * self.soft_compaction_threshold) as usize;
290        if used > soft {
291            tracing::debug!(
292                cached_tokens,
293                soft_threshold = soft,
294                "context budget check: Soft tier"
295            );
296            return CompactionTier::Soft;
297        }
298        tracing::debug!(
299            cached_tokens,
300            soft_threshold = soft,
301            "context budget check: None"
302        );
303        CompactionTier::None
304    }
305
306    /// Check if proactive compression should fire for the current turn.
307    ///
308    /// Returns `Some((threshold_tokens, max_summary_tokens))` when proactive compression
309    /// should be triggered, `None` otherwise.
310    ///
311    /// For `CompressionStrategy::Focus`, the threshold is the soft-compaction fraction
312    /// of the budget (same gate used by mid-iteration soft compaction). The
313    /// `max_summary_tokens` element is unused on the Focus path — the auto-consolidation
314    /// function uses `FocusConfig.max_knowledge_tokens / 2` instead.
315    ///
316    /// Returns the current compaction lifecycle state.
317    #[must_use]
318    pub fn compaction_state(&self) -> CompactionState {
319        self.compaction
320    }
321
322    /// Returns a mutable reference to the compaction lifecycle state.
323    pub fn compaction_state_mut(&mut self) -> &mut CompactionState {
324        &mut self.compaction
325    }
326
327    /// Replaces the compaction lifecycle state.
328    pub fn set_compaction_state(&mut self, state: CompactionState) {
329        self.compaction = state;
330    }
331
332    /// Returns the number of cooling turns enforced after a hard compaction.
333    #[must_use]
334    pub fn compaction_cooldown_turns(&self) -> u8 {
335        self.compaction_cooldown_turns
336    }
337
338    /// Sets the number of cooling turns enforced after a hard compaction.
339    pub fn set_compaction_cooldown_turns(&mut self, turns: u8) {
340        self.compaction_cooldown_turns = turns;
341    }
342
343    /// Returns the number of user-message turns since the last hard compaction, if any.
344    #[must_use]
345    pub fn turns_since_last_hard_compaction(&self) -> Option<u64> {
346        self.turns_since_last_hard_compaction
347    }
348
349    /// Returns a mutable reference to the turns-since-last-hard-compaction counter.
350    pub fn turns_since_last_hard_compaction_mut(&mut self) -> &mut Option<u64> {
351        &mut self.turns_since_last_hard_compaction
352    }
353
354    /// Sets the turns-since-last-hard-compaction counter.
355    pub fn set_turns_since_last_hard_compaction(&mut self, value: Option<u64>) {
356        self.turns_since_last_hard_compaction = value;
357    }
358
359    /// Reset the per-turn regrade flag at the start of a new user turn.
360    ///
361    /// Must be called alongside `CompactionState::advance_turn()` at each turn boundary.
362    ///
363    /// # Examples
364    ///
365    /// ```
366    /// use zeph_context::manager::ContextManager;
367    ///
368    /// let mut cm = ContextManager::new();
369    /// cm.set_regraded_this_turn(true);
370    /// cm.advance_turn();
371    /// // regraded_this_turn is reset to false — proactive regrade is available again
372    /// assert!(!cm.should_proactively_regrade(0, 0.6, false));
373    /// ```
374    pub fn advance_turn(&mut self) {
375        self.regraded_this_turn = false;
376        self.compaction = self.compaction.advance_turn();
377    }
378
379    /// Mark that a proactive fidelity regrade has fired this turn (INV-06).
380    ///
381    /// Called by the caller after `should_proactively_regrade` returns `true` and the scorer
382    /// has been applied. Prevents a second regrade in the same turn.
383    ///
384    /// # Examples
385    ///
386    /// ```
387    /// use zeph_context::manager::ContextManager;
388    /// use zeph_context::budget::ContextBudget;
389    ///
390    /// let mut cm = ContextManager::new();
391    /// cm.set_regraded_this_turn(true);
392    /// assert!(!cm.should_proactively_regrade(0, 0.6, false)); // guarded by regraded flag
393    /// cm.advance_turn();
394    /// assert!(!cm.should_proactively_regrade(0, 0.6, false)); // resets after advance_turn
395    /// ```
396    pub fn set_regraded_this_turn(&mut self, value: bool) {
397        self.regraded_this_turn = value;
398    }
399
400    /// Whether a proactive fidelity regrade should fire for the current context state.
401    ///
402    /// Returns `true` only when all of the following hold:
403    /// 1. No regrade has fired this turn yet (`regraded_this_turn == false`).
404    /// 2. The compaction subsystem is not exhausted.
405    /// 3. If server compaction is active, budget usage is below 95%.
406    /// 4. Budget usage exceeds `regrade_threshold`.
407    ///
408    /// # Parameters
409    ///
410    /// - `cached_tokens` — current token count in the message window.
411    /// - `regrade_threshold` — fraction of max tokens at which regrade triggers (e.g. `0.6`).
412    /// - `server_compaction_active` — whether Claude server-side compaction is in use.
413    ///
414    /// # Examples
415    ///
416    /// ```
417    /// use zeph_context::manager::ContextManager;
418    /// use zeph_context::budget::ContextBudget;
419    ///
420    /// let mut cm = ContextManager::new();
421    /// cm.budget = Some(ContextBudget::new(100_000, 0.1));
422    /// // At 70% budget with threshold 0.6 → should regrade.
423    /// assert!(cm.should_proactively_regrade(70_000, 0.6, false));
424    /// ```
425    #[must_use]
426    #[allow(clippy::cast_precision_loss)]
427    pub fn should_proactively_regrade(
428        &self,
429        cached_tokens: u64,
430        regrade_threshold: f32,
431        server_compaction_active: bool,
432    ) -> bool {
433        if self.regraded_this_turn {
434            return false;
435        }
436        if self.compaction.is_exhausted() {
437            return false;
438        }
439        let Some(ref budget) = self.budget else {
440            return false;
441        };
442        let max = budget.max_tokens() as f64;
443        if max <= 0.0 {
444            return false;
445        }
446        let ratio = cached_tokens as f64 / max;
447        if server_compaction_active && ratio < 0.95 {
448            return false;
449        }
450        ratio > f64::from(regrade_threshold)
451    }
452
453    /// Will return `None` if compaction already happened this turn (CRIT-03 fix).
454    #[must_use]
455    pub fn should_proactively_compress(&self, current_tokens: u64) -> Option<(usize, usize)> {
456        use zeph_config::CompressionStrategy;
457        if self.compaction.is_compacted_this_turn() {
458            return None;
459        }
460        match &self.compression.strategy {
461            CompressionStrategy::Proactive {
462                threshold_tokens,
463                max_summary_tokens,
464            } if usize::try_from(current_tokens).unwrap_or(usize::MAX) > *threshold_tokens => {
465                Some((*threshold_tokens, *max_summary_tokens))
466            }
467            CompressionStrategy::Focus => {
468                // Focus fires at the soft-compaction threshold (same as tier machinery).
469                let budget = self.budget.as_ref()?.max_tokens();
470                #[allow(
471                    clippy::cast_precision_loss,
472                    clippy::cast_sign_loss,
473                    clippy::cast_possible_truncation
474                )]
475                let threshold = (budget as f32 * self.soft_compaction_threshold) as usize;
476                if usize::try_from(current_tokens).unwrap_or(usize::MAX) > threshold {
477                    // NOTE: the second tuple element (max_summary_tokens) is a placeholder
478                    // on the Focus path — the auto-consolidation function ignores it and uses
479                    // FocusConfig.max_knowledge_tokens / 2 instead.
480                    Some((threshold, threshold / 4))
481                } else {
482                    None
483                }
484            }
485            _ => None,
486        }
487    }
488}
489
490impl Default for ContextManager {
491    fn default() -> Self {
492        Self::new()
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use zeph_config::CompressionStrategy;
500
501    #[test]
502    fn new_defaults() {
503        let cm = ContextManager::new();
504        assert!(cm.budget.is_none());
505        assert!((cm.soft_compaction_threshold - 0.60).abs() < f32::EPSILON);
506        assert!((cm.hard_compaction_threshold - 0.90).abs() < f32::EPSILON);
507        assert_eq!(cm.compaction_preserve_tail, 6);
508        assert_eq!(cm.prune_protect_tokens, 40_000);
509        assert_eq!(cm.compaction, CompactionState::Ready);
510    }
511
512    #[test]
513    fn compaction_tier_no_budget() {
514        let cm = ContextManager::new();
515        assert_eq!(cm.compaction_tier(1_000_000), CompactionTier::None);
516    }
517
518    #[test]
519    fn compaction_tier_below_soft() {
520        let mut cm = ContextManager::new();
521        cm.budget = Some(ContextBudget::new(100_000, 0.1));
522        assert_eq!(cm.compaction_tier(50_000), CompactionTier::None);
523    }
524
525    #[test]
526    fn compaction_tier_between_soft_and_hard() {
527        let mut cm = ContextManager::new();
528        cm.budget = Some(ContextBudget::new(100_000, 0.1));
529        assert_eq!(cm.compaction_tier(75_000), CompactionTier::Soft);
530    }
531
532    #[test]
533    fn compaction_tier_above_hard() {
534        let mut cm = ContextManager::new();
535        cm.budget = Some(ContextBudget::new(100_000, 0.1));
536        assert_eq!(cm.compaction_tier(95_000), CompactionTier::Hard);
537    }
538
539    #[test]
540    fn proactive_compress_above_threshold_returns_params() {
541        let mut cm = ContextManager::new();
542        cm.compression.strategy = CompressionStrategy::Proactive {
543            threshold_tokens: 80_000,
544            max_summary_tokens: 4_000,
545        };
546        let result = cm.should_proactively_compress(90_000);
547        assert_eq!(result, Some((80_000, 4_000)));
548    }
549
550    #[test]
551    fn proactive_compress_blocked_if_compacted_this_turn() {
552        let mut cm = ContextManager::new();
553        cm.compression.strategy = CompressionStrategy::Proactive {
554            threshold_tokens: 80_000,
555            max_summary_tokens: 4_000,
556        };
557        cm.compaction = CompactionState::CompactedThisTurn { cooldown: 0 };
558        assert!(cm.should_proactively_compress(100_000).is_none());
559    }
560
561    #[test]
562    fn compaction_state_ready_is_not_compacted_this_turn() {
563        assert!(!CompactionState::Ready.is_compacted_this_turn());
564    }
565
566    #[test]
567    fn compaction_state_compacted_this_turn_flag() {
568        assert!(CompactionState::CompactedThisTurn { cooldown: 2 }.is_compacted_this_turn());
569        assert!(CompactionState::CompactedThisTurn { cooldown: 0 }.is_compacted_this_turn());
570    }
571
572    #[test]
573    fn compaction_state_cooling_is_not_compacted_this_turn() {
574        assert!(!CompactionState::Cooling { turns_remaining: 1 }.is_compacted_this_turn());
575    }
576
577    #[test]
578    fn advance_turn_compacted_with_cooldown_enters_cooling() {
579        let state = CompactionState::CompactedThisTurn { cooldown: 3 };
580        assert_eq!(
581            state.advance_turn(),
582            CompactionState::Cooling { turns_remaining: 3 }
583        );
584    }
585
586    #[test]
587    fn advance_turn_compacted_zero_cooldown_returns_ready() {
588        let state = CompactionState::CompactedThisTurn { cooldown: 0 };
589        assert_eq!(state.advance_turn(), CompactionState::Ready);
590    }
591
592    #[test]
593    fn should_proactively_compress_focus_fires_above_soft_threshold() {
594        let mut cm = ContextManager::new();
595        cm.budget = Some(ContextBudget::new(100_000, 0.1));
596        cm.compression.strategy = CompressionStrategy::Focus;
597        // Default soft threshold is 0.60 → 60_000 tokens.
598        // 75_000 > 60_000 → should fire.
599        let result = cm.should_proactively_compress(75_000);
600        assert!(result.is_some(), "Focus must fire above soft threshold");
601        let (threshold, _) = result.unwrap();
602        assert_eq!(threshold, 60_000);
603    }
604
605    #[test]
606    fn should_proactively_compress_focus_returns_none_below_threshold() {
607        let mut cm = ContextManager::new();
608        cm.budget = Some(ContextBudget::new(100_000, 0.1));
609        cm.compression.strategy = CompressionStrategy::Focus;
610        // 50_000 < 60_000 → should not fire.
611        assert!(cm.should_proactively_compress(50_000).is_none());
612    }
613
614    #[test]
615    fn should_proactively_compress_focus_returns_none_without_budget() {
616        let mut cm = ContextManager::new();
617        cm.compression.strategy = CompressionStrategy::Focus;
618        // No budget set → cannot compute threshold → None.
619        assert!(cm.should_proactively_compress(999_999).is_none());
620    }
621
622    // AC-07: regraded_this_turn resets to false after advance_turn().
623    #[test]
624    fn advance_turn_resets_regraded_this_turn() {
625        let mut cm = ContextManager::new();
626        cm.regraded_this_turn = true;
627        cm.advance_turn();
628        assert!(
629            !cm.regraded_this_turn,
630            "regraded_this_turn must reset after advance_turn"
631        );
632    }
633
634    // AC-08: should_proactively_regrade returns false if already regraded this turn.
635    #[test]
636    fn regrade_blocked_if_already_regraded_this_turn() {
637        let mut cm = ContextManager::new();
638        cm.budget = Some(ContextBudget::new(100_000, 0.1));
639        cm.regraded_this_turn = true;
640        assert!(
641            !cm.should_proactively_regrade(70_000, 0.6, false),
642            "must not regrade twice in the same turn"
643        );
644    }
645
646    #[test]
647    fn regrade_fires_above_threshold() {
648        let mut cm = ContextManager::new();
649        cm.budget = Some(ContextBudget::new(100_000, 0.1));
650        assert!(
651            cm.should_proactively_regrade(70_000, 0.6, false),
652            "must fire when budget ratio > threshold"
653        );
654    }
655
656    #[test]
657    fn regrade_does_not_fire_below_threshold() {
658        let mut cm = ContextManager::new();
659        cm.budget = Some(ContextBudget::new(100_000, 0.1));
660        assert!(
661            !cm.should_proactively_regrade(50_000, 0.6, false),
662            "must not fire when budget ratio <= threshold"
663        );
664    }
665
666    #[test]
667    fn regrade_blocked_when_exhausted() {
668        let mut cm = ContextManager::new();
669        cm.budget = Some(ContextBudget::new(100_000, 0.1));
670        cm.compaction = CompactionState::Exhausted { warned: false };
671        assert!(
672            !cm.should_proactively_regrade(80_000, 0.6, false),
673            "must not fire when compaction is exhausted"
674        );
675    }
676
677    #[test]
678    fn regrade_blocked_by_server_compaction_at_sub_95() {
679        let mut cm = ContextManager::new();
680        cm.budget = Some(ContextBudget::new(100_000, 0.1));
681        // 80% budget, server_compaction_active=true → ratio < 0.95 → blocked.
682        assert!(
683            !cm.should_proactively_regrade(80_000, 0.6, true),
684            "must not fire with server compaction active below 95%"
685        );
686    }
687
688    #[test]
689    fn regrade_fires_with_server_compaction_at_95() {
690        let mut cm = ContextManager::new();
691        cm.budget = Some(ContextBudget::new(100_000, 0.1));
692        // 96% budget, server_compaction_active=true → ratio >= 0.95 → fires.
693        assert!(
694            cm.should_proactively_regrade(96_000, 0.6, true),
695            "must fire with server compaction active at >= 95%"
696        );
697    }
698}