Skip to main content

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}