Skip to main content

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;