Skip to main content

supercode_runtime/
tokens.rs

1//! The shared token estimator (SPEC.md C9). The repo has no tokenizer — only
2//! provider-reported `Usage` (`provider.rs:153-164`) and the turn/output
3//! counters `agent.rs` already tracks. Every other UX figure (the banner,
4//! `/status`, `/tokens`, `show-reductions`, notices) derives from one
5//! documented heuristic here, and is always printed with a `~` prefix so it
6//! reads as an estimate, never a measurement. Acceptance criteria never
7//! assert against these numbers directly — ACs assert exact byte counts
8//! (`SessionInfo::full_bytes`/`view_bytes`, C9/D15) and only *derive* a
9//! token/dollar figure from them for the test log.
10//!
11//! Provider-reported figures (`Usage.completion_tokens`, B7's optional
12//! `cached_tokens`, `agent.total_output_tokens()`) are real counts and are
13//! never routed through this module — they print un-tilded.
14
15use supercode_interchange::{format_commas, ChatMessage};
16
17use crate::ToolSchema;
18pub use supercode_interchange::{estimate_tokens, estimate_view_tokens};
19
20/// Deterministic token estimate: `ceil(utf8_bytes / 4)`. Documented
21/// heuristic — all UX figures derived from it are printed with a `~`
22/// prefix (see [`fmt_approx_tokens`]). Never used in acceptance criteria
23/// (ACs assert exact byte counts).
24/// PARITY-18 D2 — conservative safety margin folded into the context-guard
25/// boundary only (never into the plain `~`-prefixed UX estimates
26/// themselves, which stay the documented `ceil(bytes/4)` heuristic
27/// unmodified). `ceil(utf8_bytes/4)` under-counts real tokenizer output on
28/// CJK text, base64/binary-ish blobs, and dense code — informally by 25%+
29/// against common tokenizers for those corpora, since multi-byte UTF-8
30/// sequences and non-whitespace-delimited runs pack more real tokens per
31/// byte than the heuristic assumes. 25% is a round number comfortably above
32/// that observed skew: applying it can only make the guard MORE
33/// conservative (refuse sooner), never let an over-context request through
34/// that a real tokenizer would also have refused.
35const GUARD_MARGIN_NUM: u64 = 5;
36const GUARD_MARGIN_DEN: u64 = 4;
37
38/// Apply the runtime's 5/4 context-guard safety margin to a raw token
39/// estimate. Rounds up (`div_ceil`), never down —
40/// the margin only ever pushes the boundary check to be more cautious.
41pub fn with_guard_margin(tokens: u64) -> u64 {
42    tokens
43        .saturating_mul(GUARD_MARGIN_NUM)
44        .div_ceil(GUARD_MARGIN_DEN)
45}
46
47/// PARITY-18 — headroom reserved for the model's own completion, folded
48/// into the context-guard boundary alongside [`with_guard_margin`]. Neither
49/// [`estimate_view_tokens`] nor [`estimate_request_tokens`] counts anything
50/// for the reply the model is about to generate — this is a flat token
51/// budget carved out of the model's context window for it, since the
52/// completion shares the same window as the request on every provider this
53/// crate targets.
54///
55/// PARITY-18 v3 NOTE-AND-DECIDE (NF6, owner-recorded, not fixed here): this
56/// is a FLAT reserve — it does not read `Config::max_tokens`
57/// (`crates/harness/src/config.rs`, user-settable via `--max-tokens`, wired
58/// into the actual provider request at `provider.rs`'s
59/// `ChatRequest::max_tokens`). A user who passes `--max-tokens` greater than
60/// 16,384 can still pass this guard (`projected + 16_384 <= context_limit`)
61/// and then draw a provider-side `input_tokens + max_tokens > context_window`
62/// rejection the guard never anticipated — i.e. the guard's margin can be
63/// smaller than what the user actually asked the provider to reserve for the
64/// completion. Making the reserve `max(CONTEXT_RESPONSE_RESERVE_TOKENS,
65/// config.max_tokens)` would close this, but `context_guard` doesn't
66/// currently receive `Config` at all (only `messages`/`tools`/
67/// `context_limit`) — threading it through is a small but real signature
68/// change touching every call site (`resume_cmd`, `Agent::run_loop`, and
69/// this pass's new `reduce_to_fit` `fits` closures) that's out of scope for
70/// this pass's reducer/guard boundary fix. Recorded for the owner.
71pub const CONTEXT_RESPONSE_RESERVE_TOKENS: u64 = 16_384;
72
73/// PARITY-18 D1 — the full wire-request token estimate: every message in
74/// `messages` (including the system prompt at index 0) plus the serialized
75/// `tools` schema array, which is a real part of the provider request but — before
76/// PARITY-18's re-fix — was never counted by the preflight guard at all.
77/// A session whose messages alone fit comfortably could still carry a fat
78/// builtin/MCP tool-schema array that blows the real wire request; this is
79/// the fix.
80pub fn estimate_request_tokens(messages: &[ChatMessage], tools: &[ToolSchema]) -> u64 {
81    let tools_wire = serde_json::to_string(tools).unwrap_or_default();
82    estimate_view_tokens(messages).saturating_add(estimate_tokens(&tools_wire))
83}
84
85/// PARITY-18 D1/D2/D4 — the single context-guard decision, shared by every
86/// call site that must decide whether a request is safe to send: the CLI's
87/// `resume_cmd` preflight check AND `Agent::run_loop`'s per-send check
88/// (D4 — the guard is a session invariant, not a one-shot preflight, so
89/// turn 2+ and `/expand all` are covered too). Because both call through
90/// this one function, a request can never pass one gate and fail the
91/// other — there is only one formula.
92///
93/// `fits` is true iff the [`with_guard_margin`]-adjusted
94/// [`estimate_request_tokens`] estimate, plus the
95/// [`CONTEXT_RESPONSE_RESERVE_TOKENS`] completion reserve, is still within
96/// `context_limit` — i.e. gates on the reduce TARGET
97/// (`context_limit - CONTEXT_RESPONSE_RESERVE_TOKENS`), not the raw limit,
98/// closing the "blind band between reduce target and pass/fail boundary"
99/// gap. Returns the margin-adjusted projected total either way so callers
100/// can report it (dev/02) regardless of verdict.
101pub fn context_guard(
102    messages: &[ChatMessage],
103    tools: &[ToolSchema],
104    context_limit: u64,
105) -> (bool, u64) {
106    let raw = estimate_request_tokens(messages, tools);
107    let projected = with_guard_margin(raw);
108    let fits = projected.saturating_add(CONTEXT_RESPONSE_RESERVE_TOKENS) <= context_limit;
109    (fits, projected)
110}
111
112/// Schema-token estimate for the B6 "tools" banner line: the estimate over
113/// the serialized `full` [`ToolSchema`] list minus the estimate over the
114/// serialized `advertised` list — i.e. the token cost of what's currently
115/// deferred (hidden behind `tool_search`) rather than eagerly advertised.
116/// Saturates to `0` rather than underflow if `advertised` somehow estimates
117/// larger than `full` (e.g. formatting differences), since "negative
118/// deferred tokens" has no meaning for the banner.
119pub fn estimate_deferred_schema_tokens(full: &[ToolSchema], advertised: &[ToolSchema]) -> u64 {
120    let full_tokens = estimate_tokens(&serde_json::to_string(full).unwrap_or_default());
121    let advertised_tokens = estimate_tokens(&serde_json::to_string(advertised).unwrap_or_default());
122    full_tokens.saturating_sub(advertised_tokens)
123}
124
125/// Render an estimated token count in the shared UX style: `~21,904 tok`
126/// (tilde prefix + comma-grouped thousands, matching the stub-line comma
127/// style in `reduce.rs`). Every figure that flows through
128/// [`estimate_tokens`]/[`estimate_view_tokens`]/[`estimate_deferred_schema_tokens`]
129/// should be rendered through this helper so the `~` discipline (D11) is
130/// applied uniformly rather than ad hoc at each call site.
131pub fn fmt_approx_tokens(n: u64) -> String {
132    format!("~{} tok", format_commas(n as usize))
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn empty_string_is_zero_tokens() {
141        assert_eq!(estimate_tokens(""), 0);
142    }
143
144    #[test]
145    fn four_byte_ascii_is_one_token() {
146        assert_eq!(estimate_tokens("abcd"), 1);
147    }
148
149    #[test]
150    fn ceil_behavior_rounds_up() {
151        // 5 bytes / 4 = 1.25 -> ceil to 2.
152        assert_eq!(estimate_tokens("abcde"), 2);
153    }
154
155    #[test]
156    fn deterministic_same_input_same_output() {
157        let s = "the quick brown fox jumps over the lazy dog";
158        assert_eq!(estimate_tokens(s), estimate_tokens(s));
159    }
160
161    #[test]
162    fn monotone_under_concatenation() {
163        // Property-style: a few dozen generated strings, checking
164        // est(a+b) >= est(a) for each.
165        let words = [
166            "a",
167            "ab",
168            "abc",
169            "hello",
170            "world",
171            "",
172            "x",
173            "supercode",
174            "token",
175            "estimate",
176            "!",
177            "  ",
178            "\n",
179            "quick brown fox",
180            "z",
181            "1234567890",
182            "the",
183            "lazy",
184            "dog",
185            "jumps",
186            "over",
187            "sidecar",
188            "reduction",
189            "archive",
190            "delete",
191            "session",
192            "store",
193            "family",
194            "meta",
195            "json",
196        ];
197        for a in &words {
198            for b in &words {
199                let combined = format!("{a}{b}");
200                assert!(
201                    estimate_tokens(&combined) >= estimate_tokens(a),
202                    "est({a:?}+{b:?}) = {} should be >= est({a:?}) = {}",
203                    estimate_tokens(&combined),
204                    estimate_tokens(a)
205                );
206            }
207        }
208    }
209
210    #[test]
211    fn estimate_view_tokens_matches_serde_json_wire_form() {
212        let msgs = vec![
213            ChatMessage::user("hello there, this is a test message"),
214            ChatMessage::assistant("sure, here's a longer reply with more bytes in it"),
215        ];
216        let expected: u64 = msgs
217            .iter()
218            .map(|m| estimate_tokens(&serde_json::to_string(m).unwrap()))
219            .sum();
220        assert_eq!(estimate_view_tokens(&msgs), expected);
221    }
222
223    #[test]
224    fn estimate_view_tokens_empty_slice_is_zero() {
225        assert_eq!(estimate_view_tokens(&[]), 0);
226    }
227
228    fn fat_schema(name: &str, filler_len: usize) -> ToolSchema {
229        ToolSchema {
230            name: name.to_string(),
231            description: "x".repeat(filler_len),
232            parameters: serde_json::json!({"type": "object", "properties": {}}),
233        }
234    }
235
236    #[test]
237    fn deferred_schema_tokens_full_equals_advertised_is_zero() {
238        let full = vec![fat_schema("shell", 500), fat_schema("read_file", 200)];
239        let advertised = full.clone();
240        assert_eq!(estimate_deferred_schema_tokens(&full, &advertised), 0);
241    }
242
243    #[test]
244    fn deferred_schema_tokens_measures_the_difference() {
245        let builtin = fat_schema("shell", 50);
246        let mcp_fat = fat_schema("mcp__github__search_issues", 4000);
247        let full = vec![builtin.clone(), mcp_fat];
248        let advertised = vec![builtin];
249        let deferred = estimate_deferred_schema_tokens(&full, &advertised);
250        let expected = estimate_tokens(&serde_json::to_string(&full).unwrap()).saturating_sub(
251            estimate_tokens(&serde_json::to_string(&advertised).unwrap()),
252        );
253        assert_eq!(deferred, expected);
254        assert!(deferred > 0, "a fat deferred MCP schema should cost tokens");
255    }
256
257    #[test]
258    fn deferred_schema_tokens_advertised_larger_than_full_saturates_to_zero() {
259        // Pathological input (advertised isn't actually a subset of full) —
260        // must not panic or underflow.
261        let full = vec![fat_schema("a", 1)];
262        let advertised = vec![fat_schema("a", 1000)];
263        assert_eq!(estimate_deferred_schema_tokens(&full, &advertised), 0);
264    }
265
266    #[test]
267    fn fmt_approx_tokens_style() {
268        assert_eq!(fmt_approx_tokens(0), "~0 tok");
269        assert_eq!(fmt_approx_tokens(21904), "~21,904 tok");
270        assert_eq!(fmt_approx_tokens(1000000), "~1,000,000 tok");
271    }
272
273    // ---- PARITY-18 D2: guard margin ----
274
275    #[test]
276    fn guard_margin_adds_25_percent_and_rounds_up() {
277        assert_eq!(with_guard_margin(0), 0);
278        assert_eq!(with_guard_margin(4), 5); // 4 * 1.25 = 5.0 exact
279        assert_eq!(with_guard_margin(100), 125);
280        // 101 * 5 / 4 = 505/4 = 126.25 -> ceil to 127.
281        assert_eq!(with_guard_margin(101), 127);
282    }
283
284    #[test]
285    fn guard_margin_never_decreases() {
286        for n in [0u64, 1, 3, 4, 17, 1_000, 1_048_576] {
287            assert!(
288                with_guard_margin(n) >= n,
289                "margin must never make the estimate smaller: {n} -> {}",
290                with_guard_margin(n)
291            );
292        }
293    }
294
295    #[test]
296    fn guard_margin_saturates_instead_of_overflowing() {
297        // The important property: no panic/wraparound on the largest
298        // possible input — `saturating_mul` must clamp to `u64::MAX`
299        // rather than wrap around to something small (which would defeat
300        // the whole point of a "conservative" margin).
301        let result = with_guard_margin(u64::MAX);
302        assert_eq!(result, u64::MAX.div_ceil(4));
303    }
304
305    // ---- PARITY-18 D2: unserializable-message fallback never counts zero ----
306
307    #[test]
308    fn message_with_content_never_estimates_to_zero_tokens() {
309        let m = ChatMessage::user("hello world, this has real content in it");
310        assert!(estimate_view_tokens(std::slice::from_ref(&m)) > 0);
311    }
312
313    #[test]
314    fn debug_fallback_is_conservative_not_smaller_than_wire_form() {
315        // We can't force `serde_json::to_string` to fail on a real
316        // `ChatMessage` (its Serialize impl is infallible in practice), so
317        // this test instead pins the CONTRACT the fallback must uphold:
318        // the Debug rendering of a message is never a smaller byte count
319        // than its wire JSON form, which is what makes it safe to use as
320        // the "serialization failed" fallback (D2: conservative, not zero).
321        let m = ChatMessage::user("x".repeat(500));
322        let wire = serde_json::to_string(&m).unwrap();
323        let debug = format!("{m:?}");
324        assert!(
325            debug.len() >= wire.len(),
326            "Debug fallback ({} bytes) must be >= wire form ({} bytes) to stay conservative",
327            debug.len(),
328            wire.len()
329        );
330    }
331
332    // ---- PARITY-18 D1: full request-token accounting ----
333
334    fn schema(name: &str, desc_len: usize) -> ToolSchema {
335        ToolSchema::new(
336            name,
337            "x".repeat(desc_len),
338            serde_json::json!({"type":"object"}),
339        )
340    }
341
342    #[test]
343    fn estimate_request_tokens_includes_tool_schemas() {
344        let messages = vec![ChatMessage::user("hi")];
345        let no_tools = estimate_request_tokens(&messages, &[]);
346        let with_tools = estimate_request_tokens(&messages, &[schema("shell", 2000)]);
347        assert!(
348            with_tools > no_tools,
349            "a fat tool-schema array must increase the request-token estimate"
350        );
351    }
352
353    #[test]
354    fn estimate_request_tokens_matches_messages_plus_schema_sum() {
355        let messages = vec![
356            ChatMessage::system("system prompt"),
357            ChatMessage::user("user turn"),
358        ];
359        let tools = vec![schema("read_file", 100), schema("edit", 100)];
360        let expected = estimate_view_tokens(&messages)
361            + estimate_tokens(&serde_json::to_string(&tools).unwrap());
362        assert_eq!(estimate_request_tokens(&messages, &tools), expected);
363    }
364
365    // ---- PARITY-18 D1/D4: the shared context_guard decision ----
366
367    #[test]
368    fn context_guard_passes_a_small_request() {
369        let messages = vec![ChatMessage::user("hi")];
370        let (fits, projected) = context_guard(&messages, &[], 200_000);
371        assert!(fits, "a tiny request must fit a 200k-token limit");
372        assert!(projected < 200_000);
373    }
374
375    #[test]
376    fn context_guard_refuses_when_reserve_alone_exceeds_limit() {
377        // A limit smaller than the completion reserve can never be
378        // satisfied, no matter how small the request is.
379        let messages = vec![ChatMessage::user("hi")];
380        let (fits, _) = context_guard(&messages, &[], CONTEXT_RESPONSE_RESERVE_TOKENS - 1);
381        assert!(!fits);
382    }
383
384    #[test]
385    fn context_guard_messages_only_fit_but_overhead_pushes_over() {
386        // D1's exact defect shape: a messages-only estimate comfortably
387        // under `context_limit`, but once the (margin-adjusted) tool-schema
388        // overhead and completion reserve are added, the true projected
389        // request no longer fits. The OLD guard (`view_tokens >
390        // context_limit`) would have let this through.
391        let context_limit = 10_000u64;
392        // ~9,000 raw content tokens (36,000 bytes) — well under
393        // `context_limit` on a messages-only basis.
394        let big_text = "x".repeat(36_000);
395        let messages = vec![ChatMessage::user(big_text)];
396        let messages_only = estimate_view_tokens(&messages);
397        assert!(
398            messages_only < context_limit,
399            "fixture must fit messages-only for this test to prove anything: {messages_only} vs {context_limit}"
400        );
401        // A modest tool-schema array on top.
402        let tools = vec![
403            schema("shell", 200),
404            schema("read_file", 200),
405            schema("edit", 200),
406        ];
407        let (fits, projected) = context_guard(&messages, &tools, context_limit);
408        assert!(
409            !fits,
410            "messages alone fit but margin + schema overhead + reserve should push this over: projected={projected} limit={context_limit}"
411        );
412    }
413
414    #[test]
415    fn context_guard_is_the_single_formula_both_call_sites_share() {
416        // Sanity-pin the exact relationship documented on `context_guard`:
417        // fits iff with_guard_margin(estimate_request_tokens(..)) + reserve
418        // <= limit. If this ever drifts from the implementation, every
419        // caller (CLI preflight + Agent::run_loop per-turn guard) drifts
420        // silently apart with it.
421        let messages = vec![
422            ChatMessage::user("hello"),
423            ChatMessage::assistant("hi there"),
424        ];
425        let tools = vec![schema("shell", 50)];
426        let limit = 1_000u64;
427        let raw = estimate_request_tokens(&messages, &tools);
428        let expected_projected = with_guard_margin(raw);
429        let expected_fits =
430            expected_projected.saturating_add(CONTEXT_RESPONSE_RESERVE_TOKENS) <= limit;
431        let (fits, projected) = context_guard(&messages, &tools, limit);
432        assert_eq!(projected, expected_projected);
433        assert_eq!(fits, expected_fits);
434    }
435}