#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Role {
System,
User,
Assistant,
Tool,
}
#[derive(Debug, Clone)]
pub struct Turn {
pub role: Role,
pub tokens: usize,
pub original_tokens: usize,
pub importance: f64,
pub compressed: bool,
pub pinned: bool,
}
impl Turn {
pub fn new(role: Role, tokens: usize) -> Self {
Self {
role,
tokens,
original_tokens: tokens,
importance: 0.0,
compressed: false,
pinned: false,
}
}
pub fn with_importance(mut self, w: f64) -> Self {
self.importance = w.clamp(0.0, 1.0);
self
}
pub fn pinned(mut self) -> Self {
self.pinned = true;
self
}
pub fn compression_ratio(&self) -> f64 {
if self.original_tokens == 0 {
1.0
} else {
self.tokens as f64 / self.original_tokens as f64
}
}
}
#[derive(Debug, Clone)]
pub struct CompressionEntry {
pub turn_index: usize,
pub tokens: usize,
pub score: f64,
}
#[derive(Debug)]
pub struct ContextCompressor {
budget: usize,
turns: Vec<Turn>,
}
impl ContextCompressor {
pub fn new(budget: usize) -> Self {
Self {
budget,
turns: Vec::new(),
}
}
pub fn push(&mut self, turn: Turn) {
self.turns.push(turn);
}
pub fn total_tokens(&self) -> usize {
self.turns.iter().map(|t| t.tokens).sum()
}
pub fn budget(&self) -> usize {
self.budget
}
pub fn overflow(&self) -> usize {
self.total_tokens().saturating_sub(self.budget)
}
pub fn len(&self) -> usize {
self.turns.len()
}
pub fn is_empty(&self) -> bool {
self.turns.is_empty()
}
pub fn get(&self, index: usize) -> Option<&Turn> {
self.turns.get(index)
}
pub fn plan_compression(&self) -> Vec<CompressionEntry> {
if self.total_tokens() <= self.budget {
return Vec::new();
}
let n = self.turns.len();
let mut scored: Vec<(usize, f64)> = self
.turns
.iter()
.enumerate()
.filter(|(_, t)| !t.pinned && !t.compressed)
.map(|(i, t)| {
let recency = if n <= 1 {
1.0
} else {
i as f64 / (n - 1) as f64
};
let score = recency * (1.0 + t.importance);
(i, score)
})
.collect();
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut plan = Vec::new();
let mut hypothetical_freed: usize = 0;
let overflow = self.overflow();
for (idx, score) in scored {
if hypothetical_freed >= overflow {
break;
}
let t = &self.turns[idx];
let expected_savings = t.tokens.saturating_sub(t.tokens / 4);
hypothetical_freed += expected_savings;
plan.push(CompressionEntry {
turn_index: idx,
tokens: t.tokens,
score,
});
}
plan
}
pub fn mark_compressed(&mut self, turn_index: usize, new_tokens: usize) -> bool {
if let Some(turn) = self.turns.get_mut(turn_index) {
turn.tokens = new_tokens;
turn.compressed = true;
true
} else {
false
}
}
pub fn set_budget(&mut self, budget: usize) {
self.budget = budget;
}
pub fn iter(&self) -> impl Iterator<Item = &Turn> {
self.turns.iter()
}
pub fn compressed_fraction(&self) -> f64 {
let total: usize = self.turns.iter().map(|t| t.original_tokens).sum();
if total == 0 {
return 0.0;
}
let compressed_original: usize = self
.turns
.iter()
.filter(|t| t.compressed)
.map(|t| t.original_tokens)
.sum();
compressed_original as f64 / total as f64
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn within_budget_no_plan() {
let mut cc = ContextCompressor::new(1000);
cc.push(Turn::new(Role::User, 200));
cc.push(Turn::new(Role::Assistant, 300));
assert_eq!(cc.total_tokens(), 500);
assert!(cc.plan_compression().is_empty());
}
#[test]
fn overflow_produces_plan() {
let mut cc = ContextCompressor::new(500);
cc.push(Turn::new(Role::User, 200));
cc.push(Turn::new(Role::Assistant, 200));
cc.push(Turn::new(Role::User, 200));
assert_eq!(cc.overflow(), 100);
let plan = cc.plan_compression();
assert!(!plan.is_empty());
assert_eq!(plan[0].turn_index, 0);
}
#[test]
fn mark_compressed_reduces_tokens() {
let mut cc = ContextCompressor::new(500);
cc.push(Turn::new(Role::User, 400));
cc.push(Turn::new(Role::Assistant, 300));
assert_eq!(cc.overflow(), 200);
cc.mark_compressed(0, 50);
assert_eq!(cc.total_tokens(), 350);
assert_eq!(cc.overflow(), 0);
assert!(cc.get(0).unwrap().compressed);
}
#[test]
fn pinned_turns_excluded() {
let mut cc = ContextCompressor::new(500);
cc.push(Turn::new(Role::System, 400).pinned());
cc.push(Turn::new(Role::User, 200));
let plan = cc.plan_compression();
for entry in &plan {
assert_ne!(entry.turn_index, 0);
}
}
#[test]
fn already_compressed_excluded() {
let mut cc = ContextCompressor::new(500);
cc.push(Turn::new(Role::User, 300));
cc.push(Turn::new(Role::Assistant, 300));
cc.mark_compressed(0, 50);
assert!(cc.plan_compression().is_empty());
}
#[test]
fn importance_affects_selection_order() {
let mut cc = ContextCompressor::new(300);
cc.push(Turn::new(Role::User, 200));
cc.push(Turn::new(Role::User, 200).with_importance(0.9));
let plan = cc.plan_compression();
assert!(!plan.is_empty());
assert_eq!(plan[0].turn_index, 0);
}
#[test]
fn compression_ratio() {
let mut turn = Turn::new(Role::User, 400);
assert_eq!(turn.compression_ratio(), 1.0);
turn.tokens = 100;
assert!((turn.compression_ratio() - 0.25).abs() < 1e-9);
}
#[test]
fn compressed_fraction() {
let mut cc = ContextCompressor::new(1000);
cc.push(Turn::new(Role::User, 200));
cc.push(Turn::new(Role::Assistant, 300));
assert_eq!(cc.compressed_fraction(), 0.0);
cc.mark_compressed(0, 50);
assert!((cc.compressed_fraction() - 0.4).abs() < 1e-9);
}
#[test]
fn empty_compressor() {
let cc = ContextCompressor::new(100);
assert!(cc.is_empty());
assert_eq!(cc.len(), 0);
assert_eq!(cc.total_tokens(), 0);
assert!(cc.plan_compression().is_empty());
}
#[test]
fn set_budget() {
let mut cc = ContextCompressor::new(1000);
cc.push(Turn::new(Role::User, 500));
assert_eq!(cc.overflow(), 0);
cc.set_budget(400);
assert_eq!(cc.overflow(), 100);
}
#[test]
fn role_variants() {
let roles = [Role::System, Role::User, Role::Assistant, Role::Tool];
let mut cc = ContextCompressor::new(10000);
for role in roles {
cc.push(Turn::new(role, 100));
}
assert_eq!(cc.len(), 4);
}
#[test]
fn recency_favours_newer_turns() {
let mut cc = ContextCompressor::new(100);
for _ in 0..5 {
cc.push(Turn::new(Role::User, 100));
}
let plan = cc.plan_compression();
for window in plan.windows(2) {
assert!(window[0].turn_index < window[1].turn_index);
}
}
}