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