use crate::message::{Block, Message};
pub const BYTES_PER_TOKEN: f64 = 3.0;
const MAX_TOKENS_PER_BYTE: f64 = 1.0;
const MIN_SAMPLE_BYTES: f64 = 512.0;
#[derive(Debug, Clone, Copy, PartialEq)]
struct Observation {
tokens: u64,
bytes: usize,
}
#[derive(Debug, Clone, Default)]
pub struct ContextTracker {
recent: std::collections::VecDeque<Observation>,
stale: bool,
peak_tokens: u64,
surface: Option<u64>,
}
const RECENT: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Forecast {
pub used: u64,
pub limit: u64,
pub headroom: u64,
pub per_turn: Option<u64>,
pub turns_left: Option<u64>,
}
impl std::fmt::Display for Forecast {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let pct = if self.limit > 0 {
(self.used as f64 / self.limit as f64 * 100.0).round() as u64
} else {
0
};
write!(
f,
"context: {}k of {}k before compaction ({pct}%)",
self.used / 1000,
self.limit / 1000
)?;
match (self.per_turn, self.turns_left) {
(Some(rate), Some(turns)) if rate >= 1000 => write!(
f,
"; recent turns cost ~{}k each, so about {turns} more at this pace",
rate / 1000
),
(Some(rate), Some(turns)) => write!(
f,
"; recent turns cost ~{rate} tokens each, so about {turns} more at this pace"
),
_ => Ok(()),
}
}
}
pub fn surface_fingerprint<'a>(
model: &str,
system: Option<&str>,
tools: impl Iterator<Item = &'a str>,
) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
model.hash(&mut h);
system.hash(&mut h);
for name in tools {
name.hash(&mut h);
}
h.finish()
}
impl ContextTracker {
pub fn new() -> ContextTracker {
ContextTracker::default()
}
pub fn carry_into(&mut self, surface: u64) {
if self.surface != Some(surface) {
*self = ContextTracker {
surface: Some(surface),
..ContextTracker::default()
};
}
}
pub fn observe(&mut self, tokens: u64, bytes: usize) {
if self.recent.len() == RECENT {
self.recent.pop_front();
}
self.recent.push_back(Observation { tokens, bytes });
self.stale = false;
self.peak_tokens = self.peak_tokens.max(tokens);
}
fn last(&self) -> Option<Observation> {
self.recent.back().copied()
}
fn prev(&self) -> Option<Observation> {
let n = self.recent.len();
(n >= 2).then(|| self.recent[n - 2])
}
pub fn invalidate(&mut self) {
self.stale = true;
}
pub fn reported(&self) -> Option<u64> {
(!self.stale).then_some(self.last()?.tokens)
}
pub fn peak_tokens(&self) -> u64 {
self.peak_tokens
}
fn tokens_per_byte(&self) -> f64 {
let floor = 1.0 / BYTES_PER_TOKEN;
let (Some(last), Some(prev)) = (self.last(), self.prev()) else {
return floor;
};
let d_bytes = last.bytes as f64 - prev.bytes as f64;
let d_tokens = last.tokens as f64 - prev.tokens as f64;
if d_bytes < MIN_SAMPLE_BYTES || d_tokens <= 0.0 {
return floor;
}
(d_tokens / d_bytes).clamp(floor, MAX_TOKENS_PER_BYTE)
}
pub fn predict(&self, bytes: usize) -> Option<u64> {
let last = self.last()?;
let delta = (bytes as f64 - last.bytes as f64) * self.tokens_per_byte();
Some((last.tokens as f64 + delta).max(0.0) as u64)
}
pub fn over(&self, limit: u64, bytes: usize) -> bool {
self.reported().is_some_and(|t| t >= limit)
|| self.predict(bytes).is_some_and(|t| t >= limit)
}
pub fn affordable_output_bytes(&self, limit: u64, current_bytes: usize) -> Option<usize> {
let predicted = self.predict(current_bytes)?;
let room = limit.saturating_sub(predicted) as f64;
Some((room / self.tokens_per_byte()) as usize)
}
pub fn forecast(&self, limit: u64, current_bytes: usize) -> Option<Forecast> {
let used = self.predict(current_bytes)?;
let headroom = limit.saturating_sub(used);
let steps: Vec<u64> = self
.recent
.iter()
.zip(self.recent.iter().skip(1))
.filter_map(|(a, b)| b.tokens.checked_sub(a.tokens))
.filter(|d| *d > 0)
.collect();
let per_turn = (!steps.is_empty())
.then(|| steps.iter().sum::<u64>() / steps.len() as u64)
.filter(|rate| *rate > 0);
Some(Forecast {
used,
limit,
headroom,
per_turn,
turns_left: per_turn.map(|rate| headroom / rate),
})
}
pub fn peak_pressure(&self, window: Option<u64>) -> Option<f32> {
let window = window.filter(|w| *w > 0)?;
(self.peak_tokens > 0).then(|| self.peak_tokens as f32 / window as f32)
}
}
pub fn message_bytes(messages: &[Message]) -> usize {
messages
.iter()
.flat_map(|m| &m.content)
.map(|b| match b {
Block::Text { text } => text.len(),
Block::Thinking { text, signature } => {
text.len() + signature.as_ref().map_or(0, String::len)
}
Block::ToolUse { id, name, input } => id.len() + name.len() + input.to_string().len(),
Block::ToolResult {
tool_use_id,
content,
..
} => tool_use_id.len() + content.len(),
Block::Image {
media_type, source, ..
} => media_type.len() + source.as_ref().map_or(0, String::len),
})
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{Role, Usage};
fn msg(text: &str) -> Message {
Message {
role: Role::User,
content: vec![Block::text(text)],
}
}
#[test]
fn with_no_measurement_there_is_no_prediction() {
let t = ContextTracker::new();
assert_eq!(t.predict(10_000), None);
assert!(!t.over(1, 10_000), "and nothing to compact on");
}
#[test]
fn the_prediction_anchors_on_the_last_real_measurement() {
let mut t = ContextTracker::new();
t.observe(2_000, 1_000);
assert_eq!(t.predict(1_300), Some(2_100));
}
#[test]
fn a_measured_rate_inside_the_band_is_used_and_one_outside_it_is_not() {
let mut dense = ContextTracker::new();
dense.observe(1_000, 1_000);
dense.observe(1_450, 1_900);
assert_eq!(dense.predict(2_900), Some(1_950), "0.5 tok/byte carried on");
let mut cheap = ContextTracker::new();
cheap.observe(1_000, 1_000);
cheap.observe(1_010, 9_000);
assert_eq!(cheap.predict(12_000), Some(2_010), "floored at 1/3");
}
#[test]
fn a_delta_too_small_to_be_a_sample_does_not_set_the_rate() {
let mut t = ContextTracker::new();
t.observe(49_000, 149_960);
t.observe(50_000, 150_000); assert_eq!(t.predict(162_000), Some(54_000), "the floor, not 25x");
}
#[test]
fn an_impossible_rate_cannot_credit_a_rewrite_with_a_saving_it_did_not_make() {
let mut t = ContextTracker::new();
t.observe(20_000, 100_000);
t.observe(50_000, 101_000); assert!(t.over(40_000, 101_000), "50,000 is over the limit");
t.invalidate();
assert_eq!(
t.predict(99_000),
Some(48_000),
"a 2 KB cut may be credited with at most 2,000 tokens"
);
assert!(
t.over(40_000, 99_000),
"so the summary is still taken, which is the point"
);
}
#[test]
fn a_prediction_can_only_ever_add_a_reason_to_compact() {
for (tokens, bytes, now) in [
(100u64, 100usize, 100usize),
(5_000, 10_000, 10_000),
(5_000, 10_000, 1_000),
(5_000, 10_000, 90_000),
] {
let mut t = ContextTracker::new();
t.observe(tokens, bytes);
for limit in [1u64, 100, 4_999, 5_000, 5_001, 1_000_000] {
let reactive = tokens >= limit;
assert!(
!reactive || t.over(limit, now),
"reactive fired at limit {limit} and the tracker did not"
);
}
}
}
#[test]
fn the_predicted_change_is_bounded_by_the_byte_change() {
let pairs = [
(1_000u64, 1_000usize, 2_000u64, 2_000usize),
(20_000, 100_000, 50_000, 101_000), (49_000, 149_960, 50_000, 150_000), (5_000, 50_000, 5_010, 90_000), (5_000, 50_000, 4_000, 40_000), ];
for (t0, b0, t1, b1) in pairs {
for now in [0usize, 1, 500, b1 / 2, b1, b1 + 10_000, 500_000] {
let mut t = ContextTracker::new();
t.observe(t0, b0);
t.observe(t1, b1);
for tracker in [&t, &{
let mut c = t.clone();
c.invalidate();
c
}] {
let predicted = tracker.predict(now).unwrap() as f64;
let moved = (now as f64 - b1 as f64).abs() * MAX_TOKENS_PER_BYTE;
let anchor = t1 as f64;
assert!(
predicted <= anchor + moved + 1.0,
"{predicted} overshot {anchor} by more than {moved} bytes allow"
);
assert!(
predicted + 1.0 >= (anchor - moved).max(0.0),
"{predicted} undershot {anchor} by more than {moved} bytes allow"
);
}
}
}
}
#[test]
fn a_rewrite_retires_the_reading_it_invalidated() {
let mut t = ContextTracker::new();
t.observe(21_000, 60_000);
assert!(t.over(20_000, 60_000));
t.invalidate();
assert_eq!(t.reported(), None, "a rewritten list has no measured size");
assert!(
!t.over(20_000, 30_000),
"the free passes freed enough; the summary is not paid for"
);
assert_eq!(t.predict(30_000), Some(11_000));
assert!(t.over(20_000, 58_000));
}
#[test]
fn the_series_survives_a_run_boundary_under_the_same_surface() {
let surface =
surface_fingerprint("opus", Some("be helpful"), ["fs_read", "shell"].into_iter());
let mut t = ContextTracker::new();
t.carry_into(surface);
t.observe(50_000, 150_000);
t.carry_into(surface);
assert_eq!(t.reported(), Some(50_000), "the anchor is still there");
assert_eq!(t.predict(153_000), Some(51_000), "and still predicts");
}
#[test]
fn a_changed_request_shape_discards_the_anchor_rather_than_converting_it() {
let base = ["fs_read", "shell"];
let before = surface_fingerprint("opus", Some("be helpful"), base.into_iter());
let mut t = ContextTracker::new();
t.carry_into(before);
t.observe(50_000, 150_000);
for after in [
surface_fingerprint("haiku", Some("be helpful"), base.into_iter()),
surface_fingerprint("opus", Some("be terse"), base.into_iter()),
surface_fingerprint("opus", Some("be helpful"), ["fs_read"].into_iter()),
] {
let mut switched = t.clone();
switched.carry_into(after);
assert_eq!(switched.reported(), None, "the anchor is gone");
assert_eq!(switched.predict(153_000), None, "not converted, discarded");
assert_eq!(switched.peak_tokens(), 0, "and the run's peak with it");
}
}
#[test]
fn a_fresh_measurement_ends_the_staleness() {
let mut t = ContextTracker::new();
t.observe(21_000, 60_000);
t.invalidate();
t.observe(9_000, 30_000);
assert_eq!(t.reported(), Some(9_000));
}
#[test]
fn the_peak_is_the_largest_request_actually_sent() {
let mut t = ContextTracker::new();
t.observe(1_000, 1_000);
t.observe(9_000, 9_000);
t.observe(4_000, 4_000);
assert_eq!(t.peak_tokens(), 9_000, "not the last, and not the current");
assert_eq!(t.peak_pressure(Some(36_000)), Some(0.25));
assert_eq!(t.peak_pressure(None), None, "no window, no fraction");
assert_eq!(
ContextTracker::new().peak_pressure(Some(100)),
None,
"and a run that sent nothing has no pressure, rather than zero"
);
}
#[test]
fn what_a_turn_can_afford_shrinks_as_the_transcript_grows() {
let mut t = ContextTracker::new();
t.observe(10_000, 30_000);
assert_eq!(t.affordable_output_bytes(30_000, 30_000), Some(60_000));
assert_eq!(t.affordable_output_bytes(12_000, 30_000), Some(6_000));
assert_eq!(t.affordable_output_bytes(9_000, 30_000), Some(0));
assert_eq!(
ContextTracker::new().affordable_output_bytes(30_000, 30_000),
None
);
}
#[test]
fn a_denser_rate_affords_less() {
let mut dense = ContextTracker::new();
dense.observe(10_000, 30_000);
dense.observe(20_000, 40_000); let dense_room = dense.affordable_output_bytes(30_000, 40_000).unwrap();
let mut prose = ContextTracker::new();
prose.observe(10_000, 30_000);
prose.observe(20_000, 60_000); let prose_room = prose.affordable_output_bytes(30_000, 60_000).unwrap();
assert!(
dense_room < prose_room,
"dense {dense_room} should afford less than prose {prose_room}"
);
}
#[test]
fn the_forecast_is_arithmetic_on_measurements() {
let mut t = ContextTracker::new();
for (tok, by) in [
(10_000u64, 30_000usize),
(20_000, 60_000),
(24_000, 72_000),
(30_000, 90_000),
(38_000, 114_000),
] {
t.observe(tok, by);
}
let f = t.forecast(100_000, 114_000).unwrap();
assert_eq!(f.used, 38_000);
assert_eq!(f.headroom, 62_000);
assert_eq!(f.per_turn, Some(7_000));
assert_eq!(f.turns_left, Some(8));
}
#[test]
fn no_growth_means_no_estimate_rather_than_a_large_one() {
let mut t = ContextTracker::new();
t.observe(10_000, 30_000);
t.observe(10_000, 30_000);
let f = t.forecast(100_000, 30_000).unwrap();
assert_eq!(f.per_turn, None);
assert_eq!(f.turns_left, None);
assert_eq!(f.headroom, 90_000, "the headroom is still a fact");
assert!(
ContextTracker::new().forecast(100_000, 30_000).is_none(),
"and with nothing measured there is no forecast at all"
);
}
#[test]
fn a_rewrite_inside_the_window_does_not_flatten_the_pace() {
let mut t = ContextTracker::new();
t.observe(10_000, 30_000);
t.observe(20_000, 60_000); t.observe(6_000, 18_000); t.observe(16_000, 48_000); let f = t.forecast(100_000, 48_000).unwrap();
assert_eq!(
f.per_turn,
Some(10_000),
"the two real steps, not averaged with the drop"
);
}
#[test]
fn a_sub_1k_growth_rate_reads_as_a_cost_the_turn_count_agrees_with() {
let mut t = ContextTracker::new();
t.observe(10_000, 30_000);
t.observe(10_400, 31_000);
let f = t.forecast(100_000, 31_000).unwrap();
assert_eq!(f.per_turn, Some(400), "the rate under test is sub-1k");
let line = f.to_string();
assert!(
line.contains("~400 tokens each"),
"a sub-1k pace is printed as itself, not rounded to a thousand it is \
not: {line}"
);
assert!(!line.contains("~0k"), "and never as free: {line}");
let turns = f.turns_left.expect("a known pace gives a turn count");
assert!(
f.per_turn.unwrap() * turns <= f.headroom,
"the line promises {turns} turns at {} each, which is more than the \
{} of headroom it states in the same breath",
f.per_turn.unwrap(),
f.headroom
);
}
#[test]
fn the_line_the_model_reads_states_facts_and_asks_for_nothing() {
let mut t = ContextTracker::new();
t.observe(10_000, 30_000);
t.observe(40_000, 120_000);
let line = t.forecast(100_000, 120_000).unwrap().to_string();
assert_eq!(
line,
"context: 40k of 100k before compaction (40%); recent turns cost \
~30k each, so about 2 more at this pace"
);
for word in ["should", "must", "consider", "prefer", "avoid"] {
assert!(!line.contains(word), "the line instructs: {line}");
}
}
#[test]
fn image_payloads_are_not_counted_as_growth() {
let text = vec![msg("hello")];
let with_image = vec![Message {
role: Role::User,
content: vec![
Block::text("hello"),
Block::Image {
media_type: "image/png".into(),
data: "A".repeat(200_000),
source: None,
},
],
}];
assert_eq!(message_bytes(&text), 5);
assert_eq!(
message_bytes(&with_image),
5 + "image/png".len(),
"the base64 is in the anchor, not in the growth"
);
}
#[test]
fn every_other_block_kind_counts_toward_the_size() {
let m = vec![Message {
role: Role::Assistant,
content: vec![
Block::Text { text: "ab".into() },
Block::Thinking {
text: "cde".into(),
signature: Some("fg".into()),
},
Block::ToolUse {
id: "h".into(),
name: "ij".into(),
input: serde_json::json!({}),
},
Block::ToolResult {
tool_use_id: "k".into(),
content: "lmno".into(),
is_error: false,
},
],
}];
assert_eq!(message_bytes(&m), 17);
}
#[test]
fn the_observed_size_is_the_whole_prompt_including_cache() {
let u = Usage {
input_tokens: 8,
cache_creation_input_tokens: 1_000,
cache_read_input_tokens: 17_000,
..Usage::default()
};
assert_eq!(u.total_input(), 18_008);
}
}