velesdb_memory/context/estimator.rs
1//! Pluggable token estimation, with a deterministic char-class default.
2//!
3//! Same shape as [`crate::embedder`]: a small trait, one dependency-free
4//! default, and a boxed alias for non-generic holders. Estimates here are
5//! **local approximations** — distinct from a provider's exact tokenizer
6//! count, from billed tokens, and from cache-read tokens. Budget packing
7//! treats them as an over-approximation on purpose: refusing a borderline
8//! fragment is recoverable (it becomes a retrieval handle), overflowing the
9//! window is not.
10
11/// Turns text into an estimated token count.
12pub trait TokenEstimator {
13 /// Estimated number of tokens `text` occupies in a model prompt.
14 fn estimate(&self, text: &str) -> u64;
15
16 /// Rough bytes-per-token ratio of this estimator, used only as a *hint*
17 /// to size chunk pieces near the budget (every piece is still measured
18 /// by [`Self::estimate`] during packing, so a wrong hint costs
19 /// granularity, never correctness). The default matches the char-class
20 /// estimator's prose rate; a model-exact tokenizer for dense scripts
21 /// (CJK) should lower it.
22 fn bytes_per_token_hint(&self) -> u64 {
23 3
24 }
25}
26
27/// A boxed, object-safe estimator, mirroring [`crate::embedder::DynEmbedder`].
28pub type DynTokenEstimator = Box<dyn TokenEstimator + Send + Sync>;
29
30/// Forward [`TokenEstimator`] through a box so a non-generic compiler can
31/// hold [`DynTokenEstimator`].
32impl<T: TokenEstimator + ?Sized> TokenEstimator for Box<T> {
33 fn estimate(&self, text: &str) -> u64 {
34 (**self).estimate(text)
35 }
36
37 fn bytes_per_token_hint(&self) -> u64 {
38 (**self).bytes_per_token_hint()
39 }
40}
41
42/// Deterministic char-class estimator, calibrated against a real BPE
43/// (cl100k) on a mixed corpus. Per whitespace-separated word, each char
44/// costs: CJK **5/6** token, ASCII digit **1** token, anything else
45/// **3/10** token; the word's cost is the ceiling of the sum. Inter-word
46/// spaces and tabs are free (BPE folds them into the following token), but
47/// each **newline** costs half a token — cl100k spends ~one token per
48/// newline run — added on top of the per-word sum (see `estimate`).
49///
50/// Measured margins vs cl100k (estimate − real, positive = safe over-count):
51/// English prose **+55 %**, French prose **+38 %**, repetitive logs
52/// **+52 %**, Rust code **+19 %**, URLs **+20 %**, Markdown **+16 %**, JSON
53/// **+13 %**, digit-dense ids/dates **+29 %**, CJK **+14 %**. The per-word
54/// ceiling keeps the estimate superadditive (summing piece estimates bounds
55/// the estimate of their concatenation), which is what makes the packing
56/// budget guarantee hold.
57///
58/// Known adversarial bias: words made purely of hex *letters*
59/// (`deadbeef cafebabe …`) tokenize like digits but cost like prose, and a
60/// corpus made of them measures ~18 % *under*. For id-dense corpora against
61/// a tight budget, inject a model-exact [`TokenEstimator`] instead.
62#[derive(Debug, Clone, Copy, Default)]
63pub struct HeuristicEstimator;
64
65/// Per-char costs in thirtieths of a token (common denominator of the
66/// calibrated 5/6, 1, and 3/10 rates).
67const CJK_THIRTIETHS: u64 = 25;
68const DIGIT_THIRTIETHS: u64 = 30;
69const OTHER_THIRTIETHS: u64 = 9;
70
71impl TokenEstimator for HeuristicEstimator {
72 fn estimate(&self, text: &str) -> u64 {
73 let words = text
74 .split_whitespace()
75 .map(word_cost)
76 .fold(0, u64::saturating_add);
77 // Spaces and tabs are free (BPE folds them into the next token), but
78 // newlines are not: cl100k spends ~one token per newline run, so each
79 // '\n' costs half a token (a lone '\n' rounds up to 1, "\n\n" is 1).
80 let newlines =
81 u64::try_from(text.bytes().filter(|&b| b == b'\n').count()).unwrap_or(u64::MAX);
82 words.saturating_add(newlines.saturating_mul(NEWLINE_THIRTIETHS).div_ceil(30))
83 }
84}
85
86/// Per-newline cost in thirtieths of a token (half a token).
87const NEWLINE_THIRTIETHS: u64 = 15;
88
89/// The ceiling of one word's summed per-char costs.
90fn word_cost(word: &str) -> u64 {
91 let thirtieths = word
92 .chars()
93 .map(|ch| {
94 if is_cjk(ch) {
95 CJK_THIRTIETHS
96 } else if ch.is_ascii_digit() {
97 DIGIT_THIRTIETHS
98 } else {
99 OTHER_THIRTIETHS
100 }
101 })
102 .fold(0, u64::saturating_add);
103 thirtieths.div_ceil(30)
104}
105
106/// Hiragana/Katakana, CJK Unified Ideographs (+ ext. A), Hangul syllables,
107/// and CJK compatibility ideographs — the scripts that tokenize to roughly
108/// one token per char.
109fn is_cjk(ch: char) -> bool {
110 matches!(
111 u32::from(ch),
112 0x3040..=0x30FF | 0x3400..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF
113 )
114}
115
116/// Header-only image dimension sniff + a single token-cost formula for
117/// inline media fragments (US-009, PR1: images only).
118///
119/// This is deliberately *not* a [`TokenEstimator`] impl: it does not take
120/// text, it takes a mime and raw decoded bytes, and its cost model has
121/// nothing in common with the char-class heuristic. It parses just enough of
122/// PNG (the `IHDR` chunk) and JPEG (the first `SOF0`/`SOF2` marker) to read
123/// pixel dimensions — no other chunks/markers, no color data, no CRC
124/// verification. The cost formula (`ceil(width * height / 750)`) is Claude's
125/// published image-token constant; picking one formula at launch is a
126/// deliberate simplification — a per-provider cost model is a documented
127/// future seam, not built here.
128///
129/// Any mime this module does not recognize, or bytes whose header cannot be
130/// read (too short, bad signature, no `SOF` marker found), fall back to the
131/// crate's default text estimator run over `bytes_b64` — the base64 text is
132/// always longer than a tight token count would be, so this is a safe
133/// over-count, never a silent under-count of a real image's cost.
134#[derive(Debug, Clone, Copy, Default)]
135pub struct ImageTokenEstimator;
136
137/// Claude's published pixels-per-token constant for image inputs.
138const CLAUDE_PIXELS_PER_TOKEN: u64 = 750;
139
140impl ImageTokenEstimator {
141 /// Estimated prompt-token cost of one image fragment. `bytes` are the
142 /// *decoded* raw media; `bytes_b64` is the original base64 text, read
143 /// only by the fallback path.
144 #[must_use]
145 pub fn estimate(mime: &str, bytes: &[u8], bytes_b64: &str) -> u64 {
146 match image_dimensions(mime, bytes) {
147 Some((width, height)) => {
148 let pixels = u64::from(width).saturating_mul(u64::from(height));
149 pixels.div_ceil(CLAUDE_PIXELS_PER_TOKEN)
150 }
151 None => HeuristicEstimator.estimate(bytes_b64),
152 }
153 }
154}
155
156/// Sniff pixel dimensions from a mime-tagged image payload, or `None` when
157/// the mime is unsupported or the header cannot be parsed.
158fn image_dimensions(mime: &str, bytes: &[u8]) -> Option<(u32, u32)> {
159 match mime {
160 "image/png" => png_dimensions(bytes),
161 "image/jpeg" | "image/jpg" => jpeg_dimensions(bytes),
162 _ => None,
163 }
164}
165
166/// Read `(width, height)` from a PNG's leading `IHDR` chunk. Bounds-checked
167/// throughout (`slice::get`, never a panicking index) — a truncated or
168/// corrupt payload yields `None`, never a panic, since this runs over
169/// caller-controlled bytes.
170fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
171 const SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
172 if bytes.get(0..8)? != SIGNATURE {
173 return None;
174 }
175 // Chunk layout: 4-byte length, 4-byte type ("IHDR" for the first chunk),
176 // then IHDR's own data: 4-byte width, 4-byte height (both big-endian),
177 // followed by bit depth/color type/etc. (unread here).
178 if bytes.get(12..16)? != b"IHDR" {
179 return None;
180 }
181 let width = u32::from_be_bytes(bytes.get(16..20)?.try_into().ok()?);
182 let height = u32::from_be_bytes(bytes.get(20..24)?.try_into().ok()?);
183 // A forged zero dimension would price a multi-MiB payload at 0 tokens —
184 // a silent under-count. Treat it as unparseable: the caller falls back
185 // to the safe over-counting text estimate.
186 if width == 0 || height == 0 {
187 return None;
188 }
189 Some((width, height))
190}
191
192/// Read `(width, height)` from a JPEG's first baseline (`SOF0`, `0xC0`) or
193/// progressive (`SOF2`, `0xC2`) marker segment, walking past any other
194/// marker segment (`APPn`, `DQT`, `DHT`, …) that precedes it. Bounds-checked
195/// throughout; a truncated, malformed, or `SOF`-less stream yields `None`.
196fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
197 const SOF0: u8 = 0xC0;
198 const SOF2: u8 = 0xC2;
199 if bytes.get(0..2)? != [0xFF, 0xD8] {
200 return None;
201 }
202 let mut pos = 2_usize;
203 while let Some(&marker_byte) = bytes.get(pos) {
204 if marker_byte != 0xFF {
205 return None;
206 }
207 let marker = *bytes.get(pos + 1)?;
208 // Standalone markers (RSTn, and the SOI/EOI we may re-encounter)
209 // carry no length or payload.
210 if (0xD0..=0xD9).contains(&marker) {
211 pos += 2;
212 continue;
213 }
214 let seg_len = usize::from(u16::from_be_bytes(
215 bytes.get(pos + 2..pos + 4)?.try_into().ok()?,
216 ));
217 if seg_len < 2 {
218 return None;
219 }
220 if marker == SOF0 || marker == SOF2 {
221 // Payload: 1-byte precision, 2-byte height, 2-byte width
222 // (both big-endian) — component data follows, unread here.
223 let payload = bytes.get(pos + 4..pos + 9)?;
224 let height = u16::from_be_bytes([payload[1], payload[2]]);
225 let width = u16::from_be_bytes([payload[3], payload[4]]);
226 // height == 0 is legal JPEG (DNL-deferred) but unpriceable, and
227 // width == 0 is forged either way: fall back to the safe
228 // over-counting text estimate rather than a 0-token under-count.
229 if width == 0 || height == 0 {
230 return None;
231 }
232 return Some((u32::from(width), u32::from(height)));
233 }
234 pos += 2 + seg_len;
235 }
236 None
237}
238
239#[cfg(test)]
240#[path = "estimator_tests.rs"]
241mod tests;
242
243#[cfg(test)]
244#[path = "image_estimator_tests.rs"]
245mod image_estimator_tests;