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}