contextgraph_types/token.rs
1//! Canonical token accounting — the rule that makes budget honesty checkable
2//! (`SPEC.md` §B3, [ADR 0003](../../docs/adr/0003-canonical-token-accounting.md)).
3//!
4//! Budget honesty is CGP's flagship guarantee, but before this rule existed the
5//! conformance suite could only verify *arithmetic*: it summed the costs a
6//! provider declared and compared the total to the budget. A provider reporting
7//! `token_cost: 1` on a ten-thousand-token frame satisfied that check perfectly
8//! while destroying the host's actual budget. The one lie that mattered was the
9//! one lie the suite could not catch.
10//!
11//! [`budget_tokens`] closes that hole by making cost a function of bytes both
12//! sides observe:
13//!
14//! ```text
15//! budget_tokens(content) = ceil(utf8_byte_length(content) / 4)
16//! ```
17//!
18//! # This is an accounting unit, not a tokenizer
19//!
20//! A budget token is deliberately **not** a prediction of any model's
21//! tokenizer. Its job is to make every provider's cost claims comparable and
22//! verifiable, which no real tokenizer can do without being mandated in every
23//! language an implementation might be written in.
24//!
25//! The approximation is honest about its direction. At roughly four bytes per
26//! token it tracks English prose closely, and it **under-estimates** dense
27//! source code (≈3–3.5 bytes/token) and CJK text (≈3 bytes/token). A host
28//! therefore **MUST NOT** treat one budget token as one model token: it maps
29//! its real model budget into budget tokens with a safety factor. See
30//! [`SUGGESTED_HOST_SAFETY_FACTOR`].
31//!
32//! # Scope
33//!
34//! The count covers `ContextFrame::content` only — not `title`, not
35//! `citation_label`, not provenance, and not the fences and labels a host wraps
36//! around a frame. `content` is the one field the provider fully controls and
37//! whose exact bytes both sides observe identically, so it is the only input on
38//! which a byte-exact check can be built. The host's own rendering chrome is
39//! the host's cost to budget.
40
41/// Bytes per budget token. See the module docs for why this constant is an
42/// accounting convention rather than an empirical tokenizer ratio.
43pub const BYTES_PER_BUDGET_TOKEN: usize = 4;
44
45/// The factor a host is advised to apply when converting a real model context
46/// budget into budget tokens, compensating for the under-estimate on source
47/// code and CJK text.
48///
49/// Advisory, not normative: a host that knows its corpus is English prose can
50/// safely use less headroom, and one serving minified JSON may want more. It is
51/// stated as a constant so the reference host's choice is inspectable rather
52/// than buried in a literal.
53pub const SUGGESTED_HOST_SAFETY_FACTOR: f32 = 1.35;
54
55/// The canonical budget-token cost of a piece of frame content.
56///
57/// This is the value `ContextFrame::token_cost` **MUST** carry (`SPEC.md` §B3).
58/// Exact equality is required — there is no tolerance band, because any band
59/// wide enough to absorb genuine tokenizer disagreement is also wide enough to
60/// hide meaningful under-reporting, which puts the suite back to guessing. A
61/// provider cannot "disagree" with a byte count.
62///
63/// ```
64/// use contextgraph_types::budget_tokens;
65///
66/// assert_eq!(budget_tokens(""), 0);
67/// assert_eq!(budget_tokens("abcd"), 1);
68/// assert_eq!(budget_tokens("abcde"), 2); // ceil, never floor
69/// ```
70pub fn budget_tokens(content: &str) -> u32 {
71 // `str::len` is the UTF-8 byte length, which is exactly what the rule
72 // specifies — not `chars().count()`, which would make the count depend on
73 // Unicode normalization and diverge across implementations.
74 let bytes = content.len();
75 bytes.div_ceil(BYTES_PER_BUDGET_TOKEN) as u32
76}
77
78/// Convert a real model context budget into budget tokens, applying `factor` as
79/// headroom against the under-estimate documented on [`budget_tokens`].
80///
81/// A host asking for `model_tokens` worth of real context should request this
82/// many budget tokens, so that honest providers filling the budget exactly do
83/// not overflow the model window.
84pub fn budget_from_model_tokens(model_tokens: u32, factor: f32) -> u32 {
85 if factor <= 0.0 {
86 return model_tokens;
87 }
88 (model_tokens as f32 / factor) as u32
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn empty_content_costs_nothing() {
97 assert_eq!(budget_tokens(""), 0);
98 }
99
100 #[test]
101 fn the_count_rounds_up_so_a_partial_token_is_never_free() {
102 // The rounding direction is the whole point: floor would let a
103 // provider shave a token off every frame and call it arithmetic.
104 assert_eq!(budget_tokens("a"), 1);
105 assert_eq!(budget_tokens("abc"), 1);
106 assert_eq!(budget_tokens("abcd"), 1);
107 assert_eq!(budget_tokens("abcde"), 2);
108 assert_eq!(budget_tokens("abcdefgh"), 2);
109 }
110
111 #[test]
112 fn the_count_is_over_utf8_bytes_not_characters() {
113 // A single emoji is 4 UTF-8 bytes but 1 char. Counting characters
114 // would let a provider serve 4x the bytes it declared, and would make
115 // the count depend on Unicode normalization — so the rule names bytes
116 // explicitly and this test pins it.
117 let emoji = "😀";
118 assert_eq!(emoji.chars().count(), 1);
119 assert_eq!(emoji.len(), 4);
120 assert_eq!(budget_tokens(emoji), 1);
121
122 // Three-byte CJK: 3 chars, 9 bytes, 3 budget tokens (not 1).
123 let cjk = "文脈図";
124 assert_eq!(cjk.chars().count(), 3);
125 assert_eq!(cjk.len(), 9);
126 assert_eq!(budget_tokens(cjk), 3);
127 }
128
129 #[test]
130 fn the_rule_is_reproducible_from_bytes_alone() {
131 // The property that makes conformance possible in any language: the
132 // count depends on nothing but the bytes.
133 let content = "fn main() { println!(\"hello\"); }";
134 assert_eq!(budget_tokens(content), content.len().div_ceil(4) as u32);
135 }
136
137 #[test]
138 fn host_safety_factor_shrinks_the_requested_budget() {
139 // 10_000 real model tokens with 1.35x headroom means asking providers
140 // for ~7_407 budget tokens, so an honest fill does not overflow.
141 let budget = budget_from_model_tokens(10_000, SUGGESTED_HOST_SAFETY_FACTOR);
142 assert!(budget < 10_000);
143 assert_eq!(budget, 7407);
144 }
145
146 #[test]
147 fn a_nonsense_safety_factor_degrades_to_identity_rather_than_panicking() {
148 assert_eq!(budget_from_model_tokens(500, 0.0), 500);
149 assert_eq!(budget_from_model_tokens(500, -1.0), 500);
150 }
151}