use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EpisodeKind {
Constraint,
UserTurn,
AgentReasoning,
ActionResult,
Observation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextEpisode {
pub id: String,
pub kind: EpisodeKind,
#[serde(default)]
pub tokens: u64,
#[serde(default)]
pub persisted: bool,
#[serde(default)]
pub pinned: bool,
#[serde(default)]
pub recency: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EvictionPlan {
pub evicted: Vec<String>,
pub retained_tokens: u64,
pub pinned_tokens: u64,
pub within_budget: bool,
}
fn tier(e: &ContextEpisode) -> Option<u8> {
if e.pinned || e.kind == EpisodeKind::Constraint {
return None; }
Some(match e.kind {
EpisodeKind::ActionResult | EpisodeKind::Observation if e.persisted => 0,
EpisodeKind::ActionResult | EpisodeKind::Observation => 1,
EpisodeKind::AgentReasoning => 2,
EpisodeKind::UserTurn => 3,
EpisodeKind::Constraint => unreachable!("constraints are pinned above"),
})
}
pub fn plan_eviction(episodes: &[ContextEpisode], budget: u64) -> EvictionPlan {
let total: u64 = episodes.iter().map(|e| e.tokens).sum();
let pinned_tokens: u64 = episodes
.iter()
.filter(|e| tier(e).is_none())
.map(|e| e.tokens)
.sum();
let mut candidates: Vec<(&ContextEpisode, u8)> = episodes
.iter()
.filter_map(|e| tier(e).map(|t| (e, t)))
.collect();
candidates.sort_by(|a, b| {
a.1.cmp(&b.1)
.then(a.0.recency.cmp(&b.0.recency))
.then(a.0.id.cmp(&b.0.id))
});
let mut retained = total;
let mut evicted = Vec::new();
for (e, _) in candidates {
if retained <= budget {
break;
}
evicted.push(e.id.clone());
retained -= e.tokens;
}
EvictionPlan {
evicted,
retained_tokens: retained,
pinned_tokens,
within_budget: retained <= budget,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ep(
id: &str,
kind: EpisodeKind,
tokens: u64,
persisted: bool,
recency: u64,
) -> ContextEpisode {
ContextEpisode {
id: id.into(),
kind,
tokens,
persisted,
pinned: false,
recency,
}
}
#[test]
fn under_budget_evicts_nothing() {
let eps = vec![ep("a", EpisodeKind::UserTurn, 10, false, 1)];
let plan = plan_eviction(&eps, 100);
assert!(plan.evicted.is_empty());
assert!(plan.within_budget);
assert_eq!(plan.retained_tokens, 10);
}
#[test]
fn persisted_action_results_evicted_first() {
let eps = vec![
ep("user", EpisodeKind::UserTurn, 30, false, 5),
ep("persisted_act", EpisodeKind::ActionResult, 40, true, 2),
ep("reasoning", EpisodeKind::AgentReasoning, 20, false, 4),
];
let plan = plan_eviction(&eps, 60);
assert_eq!(plan.evicted, vec!["persisted_act".to_string()]);
assert!(plan.within_budget);
assert_eq!(plan.retained_tokens, 50);
}
#[test]
fn user_turns_kept_over_reasoning_and_actions() {
let eps = vec![
ep("user", EpisodeKind::UserTurn, 50, false, 9),
ep("reasoning", EpisodeKind::AgentReasoning, 50, false, 8),
ep("act", EpisodeKind::ActionResult, 50, false, 7),
];
let plan = plan_eviction(&eps, 60);
assert_eq!(
plan.evicted,
vec!["act".to_string(), "reasoning".to_string()]
);
assert_eq!(plan.retained_tokens, 50);
assert!(plan.within_budget);
}
#[test]
fn oldest_within_tier_evicted_first() {
let eps = vec![
ep("new_act", EpisodeKind::ActionResult, 30, true, 9),
ep("old_act", EpisodeKind::ActionResult, 30, true, 1),
];
let plan = plan_eviction(&eps, 40);
assert_eq!(plan.evicted, vec!["old_act".to_string()]);
}
#[test]
fn constraints_are_never_evicted() {
let eps = vec![
ep("c", EpisodeKind::Constraint, 80, false, 1),
ep("act", EpisodeKind::ActionResult, 40, true, 2),
];
let plan = plan_eviction(&eps, 10);
assert_eq!(plan.evicted, vec!["act".to_string()]);
assert_eq!(plan.pinned_tokens, 80);
assert!(!plan.within_budget, "pinned floor exceeds budget");
assert_eq!(plan.retained_tokens, 80);
}
#[test]
fn explicit_pin_protects_any_kind() {
let mut e = ep("act", EpisodeKind::ActionResult, 50, true, 1);
e.pinned = true;
let plan = plan_eviction(&[e], 10);
assert!(plan.evicted.is_empty());
assert_eq!(plan.pinned_tokens, 50);
assert!(!plan.within_budget);
}
#[test]
fn within_budget_false_when_floor_too_high_then_caller_summarizes() {
let eps = vec![
ep("u1", EpisodeKind::UserTurn, 60, false, 1),
ep("u2", EpisodeKind::UserTurn, 60, false, 2),
];
let plan = plan_eviction(&eps, 50);
assert!(plan.within_budget);
assert_eq!(plan.evicted.len(), 2);
}
}