mecha_core/pressure.rs
1//! How big the *next* request will be, from what the last one actually cost.
2//!
3//! `compact_at` is checked between turns against `prompt_tokens` — the size
4//! the provider reported for the previous request. By the time that check
5//! runs, the loop has already appended the assistant turn and a batch of tool
6//! results that nobody has priced. So the reading the decision is made from
7//! describes a message list that is one turn out of date, and the gap is
8//! exactly the failure the overflow-recovery arm exists to catch: *"a turn's
9//! parallel tool results land all at once, so the size checked between turns
10//! can sit well under the limit while the next request is well over."*
11//!
12//! `docs/GOAL-SYSTEM-DESIGN.md` §4.4 calls that a control problem solved with
13//! a constant, and proposes predicting the next size from an observed growth
14//! rate. Building it corrected that in one useful way: **there is nothing to
15//! extrapolate.** The un-priced tail is sitting in `messages` and can be
16//! measured; all that is missing is the conversion to tokens, and the provider
17//! re-supplies that every turn by pricing a list whose size we know. So the
18//! prediction is arithmetic on two measurements, with no tuned parameter and
19//! no model call — which §7.4 requires, since anticipatory appraisal that
20//! costs an inference is a tax on every turn.
21//!
22//! ## The delta form, and why not a ratio
23//!
24//! A request costs `a + r·bytes`, where `a` is the system prompt and the tool
25//! specs — constant within a run and *not* in the message list. Predicting
26//! with the cumulative ratio `tokens/bytes` would smear `a` across the bytes
27//! and over-predict as the transcript grows. Anchoring instead on the last
28//! real measurement and adding only the marginal cost of what changed since
29//! removes `a` from the arithmetic entirely, because it is in the anchor.
30//!
31//! `r` is measured between the last two observations and **clamped into the
32//! band a real tokenizer can occupy** — never below the plain-text rate, never
33//! above one token per byte, and not measured at all from a delta too small to
34//! be a sample. The floor covers content that is genuinely cheap per byte
35//! (repeated characters, whitespace-heavy tool output); the ceiling covers
36//! everything that puts tokens on the numerator with no bytes on the
37//! denominator, which is the larger hazard and the one that bites in both
38//! directions. See `MAX_TOKENS_PER_BYTE`.
39//!
40//! Note what the ceiling is *not* for: an arriving image. `message_bytes`
41//! excludes image payloads, so an image does not produce the cheap-per-byte
42//! shape at all — it produces the opposite one, a large token delta over
43//! almost no bytes, which is the ceiling's business rather than the floor's.
44//!
45//! ## Monotonicity, and the one place it looks violated
46//!
47//! §7.3: a disposition may only narrow. *"Anxiety may compact early; relief
48//! may never compact late."* So [`ContextTracker::over`] is `reported ||
49//! predicted` and never `predicted` alone: no state of this type can make
50//! compaction fire later than the reactive check alone would.
51//!
52//! The exception is [`ContextTracker::invalidate`], and it is not one. A
53//! reported size is a measurement *of a particular message list*. When
54//! eviction or thinning rewrites that list, the number is no longer a reading
55//! of anything — the transcript it described does not exist. Continuing to
56//! honour it is not caution, it is arithmetic about a deleted object. So a
57//! rewrite marks it stale and the prediction becomes the only reading there
58//! is, until the provider prices the new list and supplies a real one.
59//!
60//! ## Known: the series does not cross a run boundary
61//!
62//! A tracker is created per `run_in`, so in `mecha chat` and the TUI — where
63//! one submission is one run — it starts empty on every user turn. On the
64//! first iteration of a run there is no anchor, so `over` is false whatever
65//! the transcript weighs, and a conversation that grew through user turns, or
66//! a resumed session already near the window, still discovers the overflow by
67//! being refused.
68//!
69//! Left as it is deliberately, and it is not a regression: `prompt_tokens`
70//! reset at exactly the same boundary before this existed. Closing it means
71//! bundling the series with `Conversation`, the way taint is bundled — keep
72//! the history and you keep what was learned about it — and that needs an
73//! answer for the `/model` switch first, because an anchor is a measurement
74//! under one tokenizer and one tool surface and means nothing under another.
75//! That is a design decision about where the state lives, not a fix.
76//!
77//! The loop already assumed exactly this: after eviction freed something it
78//! `continue`s, meaning to *"give it a turn to take effect before paying for a
79//! summary."* That has never worked — `prompt_tokens` is assigned in one place,
80//! after a response, so the re-entered check saw the same stale value, the
81//! three passes returned zero the second time (they are idempotent, with tests
82//! saying so), and the summary was paid for anyway one iteration later. The
83//! intent needed a reading the reactive check structurally cannot produce
84//! without spending a request. This is that reading.
85
86use crate::message::{Block, Message};
87
88/// Bytes per token for ordinary prose, and the floor on the measured rate.
89///
90/// The same constant `ToolsConfig::resolved_output_budget` converts with, kept
91/// at that value on purpose: the two are estimating the same quantity from
92/// opposite ends, and a budget that thinks results cost 3 bytes a token beside
93/// a predictor that thinks they cost 4 is two answers to one question.
94pub const BYTES_PER_TOKEN: f64 = 3.0;
95
96/// Hard ceiling on the measured rate: **no tokenizer emits more than one token
97/// per byte**, because a token is at least one byte.
98///
99/// So an apparent rate above this is not a property of the text — it is the
100/// delta measuring something that is not in the message list at all. That
101/// happens: `a` is only *approximately* constant within a run. The tool specs
102/// move when a skill narrows the surface or the phase changes, cache
103/// accounting shifts between turns, and a failover answers with a different
104/// tokenizer. Any of those puts tokens on the numerator with no bytes on the
105/// denominator.
106///
107/// Without the ceiling that is unbounded, and it breaks in both directions.
108/// Measured on a probe: `observe(49_000, 149_960)` then `observe(50_000,
109/// 150_000)` is a 40-byte delta against 1,000 tokens — `r` of 25 — after which
110/// an ordinary 12 KB tool result predicts 350,000 tokens on a transcript
111/// really near 54k, buying a summary request and a lossy rewrite for nothing.
112/// The same inflated rate then *under*-predicts once the free passes shave 2 KB
113/// off: the predicted saving is 50,000 tokens, the prediction lands at zero,
114/// and a transcript the provider had just priced at 50,000 skips its summary
115/// and goes out oversized. The second direction is the dangerous one, and it
116/// is why this is a clamp rather than a warning.
117const MAX_TOKENS_PER_BYTE: f64 = 1.0;
118
119/// Below this, an inter-turn delta is noise rather than a sample.
120///
121/// A turn can move very few message bytes — a `todo` call and a one-line
122/// result — while the priced total moves for reasons above. Dividing by a tiny
123/// denominator turns that into an arbitrarily large rate, so a short delta
124/// does not get a vote and the floor stands in until a real one arrives.
125const MIN_SAMPLE_BYTES: f64 = 512.0;
126
127#[derive(Debug, Clone, Copy, PartialEq)]
128struct Observation {
129 tokens: u64,
130 bytes: usize,
131}
132
133/// The size series for one conversation. In memory only; nothing is stored.
134#[derive(Debug, Clone, Default)]
135pub struct ContextTracker {
136 /// Newest last, capped at [`RECENT`].
137 ///
138 /// Two would do for the prediction — it needs one anchor and one delta —
139 /// but [`ContextTracker::forecast`] answers "how many turns of headroom is
140 /// that", and a single turn is a terrible estimate of a run's pace. The
141 /// turn that read one file and the turn that read eight differ by an order
142 /// of magnitude, and the model is being asked to decide *between steps*,
143 /// which is precisely where the last turn is least representative of the
144 /// next one.
145 recent: std::collections::VecDeque<Observation>,
146 /// The newest entry describes a message list that has since been rewritten.
147 stale: bool,
148 peak_tokens: u64,
149 /// What the anchor was measured under. See [`ContextTracker::carry_into`].
150 surface: Option<u64>,
151}
152
153/// How many observations the pace is averaged over.
154const RECENT: usize = 5;
155
156/// What the model is told, when it asks its plan a question.
157///
158/// Every field is a **measurement or arithmetic on measurements** — nothing
159/// here asks a model to estimate its own token use, which is a thing models
160/// are bad at and which would put the least reliable number in the most
161/// load-bearing place. The one judgement left is the one the model is
162/// genuinely better at: how much of its own plan remains.
163#[derive(Debug, Clone, Copy, PartialEq)]
164pub struct Forecast {
165 /// What the next request is predicted to cost — **excluding the results
166 /// of the turn now being executed**.
167 ///
168 /// The prediction closes one lag and cannot close the other. Reported
169 /// usage is a turn out of date, because the provider prices a request
170 /// after it is sent; adding the transcript bytes since that measurement
171 /// fixes that, which is what this type is for. But the number is handed
172 /// *into* `run_tools` so the `todo` result can carry it to the model, so
173 /// it is consumed by the very call that produces the results it would
174 /// need to include. A reading that waited for them would arrive a turn
175 /// later, which is the same lag moved rather than removed.
176 ///
177 /// Left understated rather than padded with the turn's output budget: the
178 /// budget is an upper bound a turn rarely reaches, and inflating every
179 /// reading by it would trade a small, well-understood undercount for a
180 /// large invented one on the number the model plans against. So the
181 /// contract is *at least this much has been used*, and the caller that
182 /// renders it says "recent turns cost about X" rather than promising a
183 /// total.
184 pub used: u64,
185 /// The ceiling being measured against — the compaction threshold when
186 /// there is one, else the context window.
187 pub limit: u64,
188 /// `limit - used`, floored at zero.
189 pub headroom: u64,
190 /// Mean growth per turn across the recent window, when there is more than
191 /// one observation to difference.
192 pub per_turn: Option<u64>,
193 /// `headroom / per_turn`. `None` when the pace is unknown or zero — a run
194 /// that has not grown has no meaningful number of turns left, and
195 /// reporting a huge one would be a lie in the reassuring direction.
196 pub turns_left: Option<u64>,
197}
198
199impl std::fmt::Display for Forecast {
200 /// One line, and deliberately a statement of fact with no instruction in
201 /// it. The model is being told what is true, not what to do about it —
202 /// §16's caution is that exposing a resource number invites reasoning
203 /// about resource use, and an imperative would guarantee it.
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 let pct = if self.limit > 0 {
206 (self.used as f64 / self.limit as f64 * 100.0).round() as u64
207 } else {
208 0
209 };
210 write!(
211 f,
212 "context: {}k of {}k before compaction ({pct}%)",
213 self.used / 1000,
214 self.limit / 1000
215 )?;
216 // **Both halves from the same number, or the line argues with
217 // itself.** The cost was rounded to whole thousands and floored at
218 // one, while the turn count came from the true rate — so at a pace of
219 // 400 with 89.6k of headroom the model read "~1k each, so about 224
220 // more" against a stated 100k limit, where 224 × 1k is more than twice
221 // the whole budget. A reading the model is asked to plan against has
222 // to survive being multiplied out, so a sub-1k pace is printed in
223 // tokens rather than rounded up to a thousand it is not.
224 //
225 // `(Some(rate), None)` is deliberately absent: `turns_left` is
226 // `per_turn.map(...)`, so it is `Some` whenever the rate is, and an
227 // arm for a state that cannot occur reads as a handled case and would
228 // quietly go stale if that derivation ever changed.
229 match (self.per_turn, self.turns_left) {
230 (Some(rate), Some(turns)) if rate >= 1000 => write!(
231 f,
232 "; recent turns cost ~{}k each, so about {turns} more at this pace",
233 rate / 1000
234 ),
235 (Some(rate), Some(turns)) => write!(
236 f,
237 "; recent turns cost ~{rate} tokens each, so about {turns} more at this pace"
238 ),
239 _ => Ok(()),
240 }
241 }
242}
243
244/// What a request looks like apart from its messages: the model, the system
245/// prompt, and the tool surface.
246///
247/// An anchor is a token count for a byte count *under a particular one of
248/// these*, and it means nothing under another — a different tokenizer prices
249/// the same transcript differently, and a narrowed tool surface changes the
250/// constant part of every request. Hashed rather than held so the tracker
251/// stays a handful of integers.
252pub fn surface_fingerprint<'a>(
253 model: &str,
254 system: Option<&str>,
255 tools: impl Iterator<Item = &'a str>,
256) -> u64 {
257 use std::hash::{Hash, Hasher};
258 let mut h = std::collections::hash_map::DefaultHasher::new();
259 model.hash(&mut h);
260 system.hash(&mut h);
261 for name in tools {
262 name.hash(&mut h);
263 }
264 h.finish()
265}
266
267impl ContextTracker {
268 pub fn new() -> ContextTracker {
269 ContextTracker::default()
270 }
271
272 /// Carry the series into a new run, or start clean if the request shape
273 /// changed underneath it.
274 ///
275 /// The series lives on the `Conversation` rather than on the run, for the
276 /// reason taint does: it is a fact about the messages, and bundling it with
277 /// them makes the right thing the default. It matters because in `mecha
278 /// chat` and the TUI **one submission is one run** — so a per-run tracker
279 /// started empty on every user turn, and the first request of each turn
280 /// went out unpredicted however heavy the transcript was.
281 ///
282 /// The reset is the other half. An anchor is a measurement under one
283 /// model, one system prompt and one tool surface; `/model` replaces all
284 /// three and would leave the next prediction extrapolating from a
285 /// tokenizer that is no longer answering. Discarding is the only safe
286 /// response — there is nothing to convert it *to* — and it costs one
287 /// unpredicted turn, which is exactly what every run cost before.
288 ///
289 /// Not fixed by this: a session resumed from disk starts unanchored,
290 /// because a transcript records what runs *cost in total* and never what
291 /// the last request weighed. It predicts from its second turn on.
292 pub fn carry_into(&mut self, surface: u64) {
293 if self.surface != Some(surface) {
294 *self = ContextTracker {
295 surface: Some(surface),
296 ..ContextTracker::default()
297 };
298 }
299 }
300
301 /// Record what the provider charged for a list of a known size.
302 pub fn observe(&mut self, tokens: u64, bytes: usize) {
303 if self.recent.len() == RECENT {
304 self.recent.pop_front();
305 }
306 self.recent.push_back(Observation { tokens, bytes });
307 self.stale = false;
308 self.peak_tokens = self.peak_tokens.max(tokens);
309 }
310
311 fn last(&self) -> Option<Observation> {
312 self.recent.back().copied()
313 }
314
315 fn prev(&self) -> Option<Observation> {
316 let n = self.recent.len();
317 (n >= 2).then(|| self.recent[n - 2])
318 }
319
320 /// The transcript was rewritten under the last reading, so it is no longer
321 /// a reading of it. See the module docs — this is the one thing here that
322 /// can move a decision *later*, and it does so by discarding a number
323 /// about a message list that no longer exists rather than by overriding a
324 /// live one.
325 pub fn invalidate(&mut self) {
326 self.stale = true;
327 }
328
329 /// The last measured prompt size, or `None` when there is not one that
330 /// describes the current transcript.
331 pub fn reported(&self) -> Option<u64> {
332 (!self.stale).then_some(self.last()?.tokens)
333 }
334
335 /// The largest prompt this run ever actually sent. A measurement
336 /// throughout — never a prediction — because it is recorded, and a
337 /// recorded estimate is indistinguishable from a recorded fact later.
338 pub fn peak_tokens(&self) -> u64 {
339 self.peak_tokens
340 }
341
342 /// Marginal tokens per byte, from the last inter-turn delta, clamped into
343 /// the band any real tokenizer can occupy.
344 ///
345 /// Both bounds fail toward predicting *more*, which is the side that
346 /// compacts early — except the ceiling, which also bounds how large a
347 /// saving a rewrite may be credited with, and that is the direction a
348 /// missing bound skips a summary that was needed.
349 fn tokens_per_byte(&self) -> f64 {
350 let floor = 1.0 / BYTES_PER_TOKEN;
351 let (Some(last), Some(prev)) = (self.last(), self.prev()) else {
352 return floor;
353 };
354 let d_bytes = last.bytes as f64 - prev.bytes as f64;
355 let d_tokens = last.tokens as f64 - prev.tokens as f64;
356 if d_bytes < MIN_SAMPLE_BYTES || d_tokens <= 0.0 {
357 return floor;
358 }
359 (d_tokens / d_bytes).clamp(floor, MAX_TOKENS_PER_BYTE)
360 }
361
362 /// What a request carrying `bytes` of messages would cost.
363 ///
364 /// `None` before the first response: with no anchor there is no
365 /// measurement to extrapolate from, and a guess made entirely of constants
366 /// would be a tuned parameter wearing a prediction's clothes.
367 pub fn predict(&self, bytes: usize) -> Option<u64> {
368 let last = self.last()?;
369 let delta = (bytes as f64 - last.bytes as f64) * self.tokens_per_byte();
370 Some((last.tokens as f64 + delta).max(0.0) as u64)
371 }
372
373 /// Is a transcript of this size due a compaction?
374 ///
375 /// `reported || predicted`, in that order and never the prediction alone.
376 /// That spelling is the monotonicity guarantee in one line: whatever this
377 /// type believes, it can only ever add a reason to compact.
378 pub fn over(&self, limit: u64, bytes: usize) -> bool {
379 self.reported().is_some_and(|t| t >= limit)
380 || self.predict(bytes).is_some_and(|t| t >= limit)
381 }
382
383 /// How many bytes of tool output the next turn can take before the
384 /// transcript crosses `limit`.
385 ///
386 /// The other half of §4.4's cliff-to-gradient: the compaction threshold
387 /// decides *when* to summarise, and this decides how much a single turn is
388 /// allowed to add in the first place. They serve one constraint —
389 /// `resolved_output_budget`'s docstring already states it — that "one
390 /// turn's results must not leap the gap between the threshold and the
391 /// window itself". That budget sizes the gap from the *window*, once, at
392 /// startup. This sizes it from where the transcript actually is.
393 ///
394 /// Converted at the measured rate rather than the floor, which is the
395 /// conservative direction: a higher rate buys fewer bytes.
396 ///
397 /// `None` before the first response, where there is no anchor and so no
398 /// claim worth making.
399 pub fn affordable_output_bytes(&self, limit: u64, current_bytes: usize) -> Option<usize> {
400 let predicted = self.predict(current_bytes)?;
401 let room = limit.saturating_sub(predicted) as f64;
402 Some((room / self.tokens_per_byte()) as usize)
403 }
404
405 /// What the model is shown when it looks at its plan.
406 ///
407 /// `None` before the first response — with no anchor there is no reading,
408 /// and inventing one would put a guess where the whole point is that every
409 /// number is measured.
410 ///
411 /// The pace is the mean growth across the recent window, not the last
412 /// turn's: a run alternates cheap turns and expensive ones, and the model
413 /// is deciding *between plan steps*, which is exactly where one turn is
414 /// least representative of the next.
415 pub fn forecast(&self, limit: u64, current_bytes: usize) -> Option<Forecast> {
416 let used = self.predict(current_bytes)?;
417 let headroom = limit.saturating_sub(used);
418
419 // Growth per turn, over the differences the window actually holds.
420 // A rewrite inside the window makes a difference negative; those are
421 // dropped rather than clamped, because a compaction is not a turn
422 // that cost nothing — it is a turn whose cost is not this measure's
423 // to report, and averaging a zero in would understate the pace.
424 let steps: Vec<u64> = self
425 .recent
426 .iter()
427 .zip(self.recent.iter().skip(1))
428 .filter_map(|(a, b)| b.tokens.checked_sub(a.tokens))
429 .filter(|d| *d > 0)
430 .collect();
431 let per_turn = (!steps.is_empty())
432 .then(|| steps.iter().sum::<u64>() / steps.len() as u64)
433 .filter(|rate| *rate > 0);
434
435 Some(Forecast {
436 used,
437 limit,
438 headroom,
439 per_turn,
440 // No pace, no estimate. A run that has not grown has no
441 // meaningful number of turns left, and reporting an enormous one
442 // would be a lie in the reassuring direction.
443 turns_left: per_turn.map(|rate| headroom / rate),
444 })
445 }
446
447 /// Share of the window the largest request used, for the record.
448 pub fn peak_pressure(&self, window: Option<u64>) -> Option<f32> {
449 let window = window.filter(|w| *w > 0)?;
450 (self.peak_tokens > 0).then(|| self.peak_tokens as f32 / window as f32)
451 }
452}
453
454/// Size of a message list, for the purpose of tracking how it *changes*.
455///
456/// Image payloads are deliberately excluded. Base64 is enormous per token —
457/// llama-server tiles an image to a fixed count regardless of its size, and a
458/// 5.7 MB screenshot and its 179 KB re-encoding both priced at 294 tokens — so
459/// counting those bytes would say a turn grew by megabytes when it grew by a
460/// few hundred tokens. The cost is real and it is already in the anchor, which
461/// is a measurement of the whole request; what this walk has to track is the
462/// part that grows every turn, which is text.
463pub fn message_bytes(messages: &[Message]) -> usize {
464 messages
465 .iter()
466 .flat_map(|m| &m.content)
467 .map(|b| match b {
468 Block::Text { text } => text.len(),
469 Block::Thinking { text, signature } => {
470 text.len() + signature.as_ref().map_or(0, String::len)
471 }
472 // `input` is a `Value`; its rendered length is what goes on the
473 // wire, and a tool call's arguments can be most of a turn.
474 Block::ToolUse { id, name, input } => id.len() + name.len() + input.to_string().len(),
475 Block::ToolResult {
476 tool_use_id,
477 content,
478 ..
479 } => tool_use_id.len() + content.len(),
480 // `data` excluded, `source` counted: it is a file path the
481 // model reads, and it is the only part of an image block whose
482 // length says anything about how much text is on the wire.
483 Block::Image {
484 media_type, source, ..
485 } => media_type.len() + source.as_ref().map_or(0, String::len),
486 })
487 .sum()
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use crate::message::{Role, Usage};
494
495 fn msg(text: &str) -> Message {
496 Message {
497 role: Role::User,
498 content: vec![Block::text(text)],
499 }
500 }
501
502 #[test]
503 fn with_no_measurement_there_is_no_prediction() {
504 let t = ContextTracker::new();
505 assert_eq!(t.predict(10_000), None);
506 assert!(!t.over(1, 10_000), "and nothing to compact on");
507 }
508
509 /// The anchor carries the system prompt and the tool specs, which are not
510 /// in the message list. A cumulative ratio would smear them across the
511 /// bytes; the delta form must not.
512 #[test]
513 fn the_prediction_anchors_on_the_last_real_measurement() {
514 let mut t = ContextTracker::new();
515 // 1,000 bytes of messages priced at 2,000 tokens: 1,700 of that is a
516 // system prompt and tool specs the message list does not contain.
517 t.observe(2_000, 1_000);
518 // 300 more bytes of prose is ~100 more tokens, not another 600 — which
519 // is what `tokens/bytes = 2.0` scaled up would have claimed.
520 assert_eq!(t.predict(1_300), Some(2_100));
521 }
522
523 #[test]
524 fn a_measured_rate_inside_the_band_is_used_and_one_outside_it_is_not() {
525 // Token-dense content: 900 bytes cost 450 tokens, twice the prose rate.
526 let mut dense = ContextTracker::new();
527 dense.observe(1_000, 1_000);
528 dense.observe(1_450, 1_900);
529 assert_eq!(dense.predict(2_900), Some(1_950), "0.5 tok/byte carried on");
530
531 // Genuinely cheap per byte — a result that is mostly repeated
532 // characters. Carrying that forward would under-predict the next
533 // thousand bytes of prose, so the floor takes over. Deliberately not
534 // an image: `message_bytes` excludes image payloads, so an image
535 // cannot produce this shape.
536 let mut cheap = ContextTracker::new();
537 cheap.observe(1_000, 1_000);
538 cheap.observe(1_010, 9_000);
539 assert_eq!(cheap.predict(12_000), Some(2_010), "floored at 1/3");
540 }
541
542 /// The rate is a ratio, and a ratio with a tiny denominator is not a
543 /// measurement. A turn can move almost no message bytes — a `todo` call
544 /// and a one-line result — while the priced total moves for reasons that
545 /// are not in the message list at all.
546 #[test]
547 fn a_delta_too_small_to_be_a_sample_does_not_set_the_rate() {
548 let mut t = ContextTracker::new();
549 t.observe(49_000, 149_960);
550 t.observe(50_000, 150_000); // 40 bytes, 1,000 tokens
551 // At the unguarded rate of 25 tok/byte this predicted 350,000.
552 assert_eq!(t.predict(162_000), Some(54_000), "the floor, not 25x");
553 }
554
555 /// The hole the ceiling closes, and it is the dangerous direction: an
556 /// inflated rate makes a small rewrite look like an enormous saving, and
557 /// the summary that was due is skipped.
558 #[test]
559 fn an_impossible_rate_cannot_credit_a_rewrite_with_a_saving_it_did_not_make() {
560 let mut t = ContextTracker::new();
561 // A sample large enough to be believed, but priced at a rate no
562 // tokenizer can produce — the shape a narrowed tool surface or a
563 // failover to a different tokenizer leaves behind.
564 t.observe(20_000, 100_000);
565 t.observe(50_000, 101_000); // 1,000 bytes, 30,000 tokens → r = 30
566 assert!(t.over(40_000, 101_000), "50,000 is over the limit");
567
568 // The free passes shave 2 KB. At r = 30 that is a 60,000-token saving
569 // and the prediction floors at zero, so the summary is skipped on a
570 // transcript the provider had just priced at 50,000.
571 t.invalidate();
572 assert_eq!(
573 t.predict(99_000),
574 Some(48_000),
575 "a 2 KB cut may be credited with at most 2,000 tokens"
576 );
577 assert!(
578 t.over(40_000, 99_000),
579 "so the summary is still taken, which is the point"
580 );
581 }
582
583 /// The guarantee §7.3 asks for, stated as a property rather than as a
584 /// comment: no state of this type makes compaction fire later than the
585 /// reactive check alone.
586 #[test]
587 fn a_prediction_can_only_ever_add_a_reason_to_compact() {
588 for (tokens, bytes, now) in [
589 (100u64, 100usize, 100usize),
590 (5_000, 10_000, 10_000),
591 (5_000, 10_000, 1_000),
592 (5_000, 10_000, 90_000),
593 ] {
594 let mut t = ContextTracker::new();
595 t.observe(tokens, bytes);
596 for limit in [1u64, 100, 4_999, 5_000, 5_001, 1_000_000] {
597 let reactive = tokens >= limit;
598 assert!(
599 !reactive || t.over(limit, now),
600 "reactive fired at limit {limit} and the tracker did not"
601 );
602 }
603 }
604 }
605
606 /// What bounds the *other* side, where the property above does not reach.
607 ///
608 /// Once `invalidate` retires the reported size the prediction is the only
609 /// reading, so nothing else stops it claiming a saving the rewrite did not
610 /// make. The bound is the ceiling: a prediction may differ from its anchor
611 /// by at most the byte change times one token per byte, in either
612 /// direction. Written as a sweep including hostile observation pairs,
613 /// because the version of this that only tested growth is the version that
614 /// shipped the hole.
615 #[test]
616 fn the_predicted_change_is_bounded_by_the_byte_change() {
617 let pairs = [
618 (1_000u64, 1_000usize, 2_000u64, 2_000usize),
619 (20_000, 100_000, 50_000, 101_000), // an impossible rate
620 (49_000, 149_960, 50_000, 150_000), // a delta too small to sample
621 (5_000, 50_000, 5_010, 90_000), // very cheap per byte
622 (5_000, 50_000, 4_000, 40_000), // the transcript shrank
623 ];
624 for (t0, b0, t1, b1) in pairs {
625 for now in [0usize, 1, 500, b1 / 2, b1, b1 + 10_000, 500_000] {
626 let mut t = ContextTracker::new();
627 t.observe(t0, b0);
628 t.observe(t1, b1);
629 for tracker in [&t, &{
630 let mut c = t.clone();
631 c.invalidate();
632 c
633 }] {
634 let predicted = tracker.predict(now).unwrap() as f64;
635 let moved = (now as f64 - b1 as f64).abs() * MAX_TOKENS_PER_BYTE;
636 let anchor = t1 as f64;
637 assert!(
638 predicted <= anchor + moved + 1.0,
639 "{predicted} overshot {anchor} by more than {moved} bytes allow"
640 );
641 assert!(
642 predicted + 1.0 >= (anchor - moved).max(0.0),
643 "{predicted} undershot {anchor} by more than {moved} bytes allow"
644 );
645 }
646 }
647 }
648 }
649
650 /// The deferral the loop has always meant to make and never could.
651 #[test]
652 fn a_rewrite_retires_the_reading_it_invalidated() {
653 let mut t = ContextTracker::new();
654 // Priced at the threshold: a summary is due.
655 t.observe(21_000, 60_000);
656 assert!(t.over(20_000, 60_000));
657
658 // Eviction and thinning cut the transcript in half. The reported size
659 // still says 21,000, and it is now a fact about a message list that no
660 // longer exists.
661 t.invalidate();
662 assert_eq!(t.reported(), None, "a rewritten list has no measured size");
663 assert!(
664 !t.over(20_000, 30_000),
665 "the free passes freed enough; the summary is not paid for"
666 );
667 // But the anchor is not thrown away — it is still the only real
668 // measurement, and the next turn is predicted from it.
669 assert_eq!(t.predict(30_000), Some(11_000));
670 // And a rewrite that freed too little still compacts.
671 assert!(t.over(20_000, 58_000));
672 }
673
674 /// The gap this closes: one submission is one run in chat and the TUI, so
675 /// a per-run series was empty at the top of every turn.
676 #[test]
677 fn the_series_survives_a_run_boundary_under_the_same_surface() {
678 let surface =
679 surface_fingerprint("opus", Some("be helpful"), ["fs_read", "shell"].into_iter());
680 let mut t = ContextTracker::new();
681 t.carry_into(surface);
682 t.observe(50_000, 150_000);
683
684 // Next run, same everything.
685 t.carry_into(surface);
686 assert_eq!(t.reported(), Some(50_000), "the anchor is still there");
687 assert_eq!(t.predict(153_000), Some(51_000), "and still predicts");
688 }
689
690 /// And is discarded when it would be extrapolating from a tokenizer that
691 /// is no longer answering.
692 #[test]
693 fn a_changed_request_shape_discards_the_anchor_rather_than_converting_it() {
694 let base = ["fs_read", "shell"];
695 let before = surface_fingerprint("opus", Some("be helpful"), base.into_iter());
696 let mut t = ContextTracker::new();
697 t.carry_into(before);
698 t.observe(50_000, 150_000);
699
700 for after in [
701 surface_fingerprint("haiku", Some("be helpful"), base.into_iter()),
702 surface_fingerprint("opus", Some("be terse"), base.into_iter()),
703 surface_fingerprint("opus", Some("be helpful"), ["fs_read"].into_iter()),
704 ] {
705 let mut switched = t.clone();
706 switched.carry_into(after);
707 assert_eq!(switched.reported(), None, "the anchor is gone");
708 assert_eq!(switched.predict(153_000), None, "not converted, discarded");
709 assert_eq!(switched.peak_tokens(), 0, "and the run's peak with it");
710 }
711 }
712
713 #[test]
714 fn a_fresh_measurement_ends_the_staleness() {
715 let mut t = ContextTracker::new();
716 t.observe(21_000, 60_000);
717 t.invalidate();
718 t.observe(9_000, 30_000);
719 assert_eq!(t.reported(), Some(9_000));
720 }
721
722 #[test]
723 fn the_peak_is_the_largest_request_actually_sent() {
724 let mut t = ContextTracker::new();
725 t.observe(1_000, 1_000);
726 t.observe(9_000, 9_000);
727 t.observe(4_000, 4_000);
728 assert_eq!(t.peak_tokens(), 9_000, "not the last, and not the current");
729 assert_eq!(t.peak_pressure(Some(36_000)), Some(0.25));
730 assert_eq!(t.peak_pressure(None), None, "no window, no fraction");
731 assert_eq!(
732 ContextTracker::new().peak_pressure(Some(100)),
733 None,
734 "and a run that sent nothing has no pressure, rather than zero"
735 );
736 }
737
738 #[test]
739 fn what_a_turn_can_afford_shrinks_as_the_transcript_grows() {
740 let mut t = ContextTracker::new();
741 t.observe(10_000, 30_000); // 1/3 tok per byte
742
743 // 20,000 tokens of room, at 3 bytes a token, is 60,000 bytes.
744 assert_eq!(t.affordable_output_bytes(30_000, 30_000), Some(60_000));
745 // Closer to the threshold, less is affordable — the gradient the flat
746 // budget cannot express.
747 assert_eq!(t.affordable_output_bytes(12_000, 30_000), Some(6_000));
748 // Past it, nothing is: the compaction check has already fired.
749 assert_eq!(t.affordable_output_bytes(9_000, 30_000), Some(0));
750 // And with no anchor there is no claim.
751 assert_eq!(
752 ContextTracker::new().affordable_output_bytes(30_000, 30_000),
753 None
754 );
755 }
756
757 /// A denser measured rate buys *fewer* bytes, which is the direction that
758 /// keeps the turn inside the gap rather than the one that flatters it.
759 #[test]
760 fn a_denser_rate_affords_less() {
761 let mut dense = ContextTracker::new();
762 dense.observe(10_000, 30_000);
763 dense.observe(20_000, 40_000); // 10k tokens over 10k bytes → r = 1.0
764 let dense_room = dense.affordable_output_bytes(30_000, 40_000).unwrap();
765
766 let mut prose = ContextTracker::new();
767 prose.observe(10_000, 30_000);
768 prose.observe(20_000, 60_000); // r floors at 1/3
769 let prose_room = prose.affordable_output_bytes(30_000, 60_000).unwrap();
770
771 assert!(
772 dense_room < prose_room,
773 "dense {dense_room} should afford less than prose {prose_room}"
774 );
775 }
776
777 #[test]
778 fn the_forecast_is_arithmetic_on_measurements() {
779 let mut t = ContextTracker::new();
780 // Four turns costing 10k, 4k, 6k and 8k more than the one before.
781 for (tok, by) in [
782 (10_000u64, 30_000usize),
783 (20_000, 60_000),
784 (24_000, 72_000),
785 (30_000, 90_000),
786 (38_000, 114_000),
787 ] {
788 t.observe(tok, by);
789 }
790 let f = t.forecast(100_000, 114_000).unwrap();
791 assert_eq!(f.used, 38_000);
792 assert_eq!(f.headroom, 62_000);
793 // (10 + 4 + 6 + 8) / 4 = 7k a turn.
794 assert_eq!(f.per_turn, Some(7_000));
795 assert_eq!(f.turns_left, Some(8));
796 }
797
798 /// A run that has not grown has no pace, and therefore no number of turns
799 /// left — reporting an enormous one would be a lie in the reassuring
800 /// direction, which is the null-run bug in a new place.
801 #[test]
802 fn no_growth_means_no_estimate_rather_than_a_large_one() {
803 let mut t = ContextTracker::new();
804 t.observe(10_000, 30_000);
805 t.observe(10_000, 30_000);
806 let f = t.forecast(100_000, 30_000).unwrap();
807 assert_eq!(f.per_turn, None);
808 assert_eq!(f.turns_left, None);
809 assert_eq!(f.headroom, 90_000, "the headroom is still a fact");
810
811 assert!(
812 ContextTracker::new().forecast(100_000, 30_000).is_none(),
813 "and with nothing measured there is no forecast at all"
814 );
815 }
816
817 /// A compaction inside the window is not a turn that cost nothing.
818 #[test]
819 fn a_rewrite_inside_the_window_does_not_flatten_the_pace() {
820 let mut t = ContextTracker::new();
821 t.observe(10_000, 30_000);
822 t.observe(20_000, 60_000); // +10k
823 t.observe(6_000, 18_000); // a summary landed
824 t.observe(16_000, 48_000); // +10k
825 let f = t.forecast(100_000, 48_000).unwrap();
826 assert_eq!(
827 f.per_turn,
828 Some(10_000),
829 "the two real steps, not averaged with the drop"
830 );
831 }
832
833 /// **A slow-growing run reports a cost, and the two halves of the line
834 /// agree with each other.**
835 ///
836 /// `rate.max(1) / 1000` reads as the guard against "~0k each" and is not
837 /// one: the rate is already filtered to `> 0`, and every rate under 1000
838 /// divides to zero. Flooring the *printed* cost at "~1k" fixed the free
839 /// turn and bought a worse defect — the turn count kept coming from the
840 /// true rate, so the line said "~1k each, so about 224 more" against a
841 /// stated 100k limit, and 224 × 1k is more than twice the whole budget.
842 ///
843 /// The first version of this test pinned only the "~1k" half and would
844 /// have gone on passing. A number the model is asked to plan against has
845 /// to survive being multiplied out, so that is what is asserted.
846 #[test]
847 fn a_sub_1k_growth_rate_reads_as_a_cost_the_turn_count_agrees_with() {
848 let mut t = ContextTracker::new();
849 t.observe(10_000, 30_000);
850 t.observe(10_400, 31_000);
851 let f = t.forecast(100_000, 31_000).unwrap();
852 assert_eq!(f.per_turn, Some(400), "the rate under test is sub-1k");
853 let line = f.to_string();
854 assert!(
855 line.contains("~400 tokens each"),
856 "a sub-1k pace is printed as itself, not rounded to a thousand it is \
857 not: {line}"
858 );
859 assert!(!line.contains("~0k"), "and never as free: {line}");
860
861 // The consistency the rounding broke: whatever cost the line states,
862 // times the turns it promises, must not exceed the headroom it also
863 // states. Fails on `(rate / 1000).max(1)`, where 224 × 1000 = 224k
864 // against 89.6k of headroom.
865 let turns = f.turns_left.expect("a known pace gives a turn count");
866 assert!(
867 f.per_turn.unwrap() * turns <= f.headroom,
868 "the line promises {turns} turns at {} each, which is more than the \
869 {} of headroom it states in the same breath",
870 f.per_turn.unwrap(),
871 f.headroom
872 );
873 }
874
875 #[test]
876 fn the_line_the_model_reads_states_facts_and_asks_for_nothing() {
877 let mut t = ContextTracker::new();
878 t.observe(10_000, 30_000);
879 t.observe(40_000, 120_000);
880 let line = t.forecast(100_000, 120_000).unwrap().to_string();
881 assert_eq!(
882 line,
883 "context: 40k of 100k before compaction (40%); recent turns cost \
884 ~30k each, so about 2 more at this pace"
885 );
886 // No imperative anywhere: the model is told what is true and left to
887 // decide, which is what keeps this a reading rather than a nudge.
888 for word in ["should", "must", "consider", "prefer", "avoid"] {
889 assert!(!line.contains(word), "the line instructs: {line}");
890 }
891 }
892
893 #[test]
894 fn image_payloads_are_not_counted_as_growth() {
895 let text = vec![msg("hello")];
896 let with_image = vec![Message {
897 role: Role::User,
898 content: vec![
899 Block::text("hello"),
900 Block::Image {
901 media_type: "image/png".into(),
902 data: "A".repeat(200_000),
903 source: None,
904 },
905 ],
906 }];
907 assert_eq!(message_bytes(&text), 5);
908 assert_eq!(
909 message_bytes(&with_image),
910 5 + "image/png".len(),
911 "the base64 is in the anchor, not in the growth"
912 );
913 }
914
915 #[test]
916 fn every_other_block_kind_counts_toward_the_size() {
917 let m = vec![Message {
918 role: Role::Assistant,
919 content: vec![
920 Block::Text { text: "ab".into() },
921 Block::Thinking {
922 text: "cde".into(),
923 signature: Some("fg".into()),
924 },
925 Block::ToolUse {
926 id: "h".into(),
927 name: "ij".into(),
928 input: serde_json::json!({}),
929 },
930 Block::ToolResult {
931 tool_use_id: "k".into(),
932 content: "lmno".into(),
933 is_error: false,
934 },
935 ],
936 }];
937 // 2 + (3+2) + (1+2+2) + (1+4)
938 assert_eq!(message_bytes(&m), 17);
939 }
940
941 /// Guards the one thing that could silently unhook the whole module: a
942 /// `Usage` whose `total_input` stopped counting the cached tiers would
943 /// make every observation an underestimate, and nothing here would notice.
944 #[test]
945 fn the_observed_size_is_the_whole_prompt_including_cache() {
946 let u = Usage {
947 input_tokens: 8,
948 cache_creation_input_tokens: 1_000,
949 cache_read_input_tokens: 17_000,
950 ..Usage::default()
951 };
952 assert_eq!(u.total_input(), 18_008);
953 }
954}