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}