Skip to main content

cortexkit_model_batch/
estimate.rs

1//! The conservative cost estimate a runner checks against a batch's
2//! `max_cost_usd` before each provider call, and the money arithmetic it
3//! needs (`RULES.md` §5).
4//!
5//! Text tokens are bounded by UTF-8 byte length: a tokenizer that works on
6//! bytes never makes more tokens than there are bytes, so the bound never
7//! underestimates. A module that has the provider's tokenizer may pass
8//! exact counts instead ([`Tokens::Exact`]). The fixed costs a role cannot
9//! see (the module's prompt template, the tokens an image costs) are
10//! required inputs, never defaulted to zero. Money is integer nanodollars,
11//! the catalog's own unit ([`RateNanosPerMtok`]), with checked arithmetic:
12//! an overflow refuses, it never wraps.
13
14use serde::Serialize;
15use serde_json::Number;
16
17/// A price in integer nanodollars per million tokens. It is the same type as
18/// the model catalog's rate (`cortexkit-model-catalog`'s `RateNanosPerMtok`,
19/// an `i64`), so a catalog price passes in as it is, without this crate
20/// depending on the catalog.
21pub type RateNanosPerMtok = i64;
22
23use crate::identity::{canonical_json, IdentityError};
24
25/// Nanodollars in one dollar.
26pub const NANOS_PER_USD: u64 = 1_000_000_000;
27
28/// Tokens in the unit a rate is priced per (one million).
29pub const TOKENS_PER_MTOK: u64 = 1_000_000;
30
31/// A token count for one part of a request.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum Tokens {
34    /// Counted with the provider's own tokenizer.
35    Exact(u64),
36    /// An upper bound, from the part's byte length.
37    Bound(u64),
38}
39
40impl Tokens {
41    /// The byte bound of `text`.
42    pub fn bound_of_text(text: &str) -> Self {
43        Self::Bound(text.len() as u64)
44    }
45
46    /// The byte bound of `value`'s RFC 8785 (JCS) canonical JSON: the bound
47    /// of a schema (a role's fields or questions) as the provider receives
48    /// it.
49    pub fn bound_of_json<T: Serialize + ?Sized>(value: &T) -> Result<Self, IdentityError> {
50        Ok(Self::Bound(canonical_json(value)?.len() as u64))
51    }
52
53    pub fn count(self) -> u64 {
54        match self {
55            Self::Exact(count) | Self::Bound(count) => count,
56        }
57    }
58
59    pub fn is_exact(self) -> bool {
60        matches!(self, Self::Exact(_))
61    }
62}
63
64/// What one item's provider call is estimated from.
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66#[non_exhaustive]
67pub struct ItemCostInput {
68    /// The item's state, billed on every pass.
69    pub state: Tokens,
70    /// The schema sent with every item (fields or questions), billed once
71    /// across all passes.
72    pub schema: Tokens,
73    /// How many images the item carries.
74    pub images: u64,
75    /// The module's bound on the tokens one image costs. Required; a
76    /// module that does not know it states a bound, never zero.
77    pub image_tokens_each: u64,
78    /// The module's bound on its per-request overhead (prompt template,
79    /// system text). Required; never defaulted to zero.
80    pub overhead_tokens: u64,
81    /// The module's upper bound on how many passes the provider makes for
82    /// the item, at least 1. 1 for a provider that answers in one pass.
83    pub max_passes: u64,
84    /// The provider's per-pass token budget, when it states one. Used only
85    /// to check that an exactly counted state fits in a pass; it does not
86    /// decide how many passes are billed.
87    pub pass_tokens: Option<u64>,
88}
89
90impl ItemCostInput {
91    pub fn new(
92        state: Tokens,
93        schema: Tokens,
94        image_tokens_each: u64,
95        overhead_tokens: u64,
96    ) -> Self {
97        Self {
98            state,
99            schema,
100            images: 0,
101            image_tokens_each,
102            overhead_tokens,
103            max_passes: 1,
104            pass_tokens: None,
105        }
106    }
107
108    pub fn with_images(mut self, images: u64) -> Self {
109        self.images = images;
110        self
111    }
112
113    /// The module's upper bound on the passes the provider makes. Zero is
114    /// refused when the item is estimated ([`EstimateError::NoPasses`]).
115    pub fn with_max_passes(mut self, max_passes: u64) -> Self {
116        self.max_passes = max_passes;
117        self
118    }
119
120    pub fn with_pass_tokens(mut self, pass_tokens: u64) -> Self {
121        self.pass_tokens = Some(pass_tokens);
122        self
123    }
124}
125
126/// An item's estimate: an upper bound on its input cost.
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128#[non_exhaustive]
129pub struct Estimate {
130    /// The passes the estimate bills: the input's `max_passes`.
131    pub max_passes: u64,
132    /// Input tokens billed over every pass.
133    pub input_tokens: u64,
134    /// Their cost, rounded up to a whole nanodollar.
135    pub cost_nanos: u64,
136}
137
138impl Estimate {
139    pub fn new(max_passes: u64, input_tokens: u64, cost_nanos: u64) -> Self {
140        Self {
141            max_passes,
142            input_tokens,
143            cost_nanos,
144        }
145    }
146}
147
148/// Why an item could not be estimated.
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub enum EstimateError {
151    /// The state's exact count reaches the pass budget: the item cannot fit
152    /// in a pass, and is refused `invalid_item` before any call.
153    DoesNotFit { state_tokens: u64, pass_tokens: u64 },
154    /// `max_passes` is zero: every provider call makes at least one pass,
155    /// so the module's bound is wrong, not the item.
156    NoPasses,
157    /// The arithmetic overflowed. The item is refused; a runner never wraps.
158    Overflow,
159    /// The catalog rate is negative, which no catalog publishes.
160    NegativeRate,
161}
162
163impl std::fmt::Display for EstimateError {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        match self {
166            Self::DoesNotFit {
167                state_tokens,
168                pass_tokens,
169            } => write!(
170                f,
171                "the state is {state_tokens} tokens; a pass holds {pass_tokens}"
172            ),
173            Self::NoPasses => {
174                f.write_str("max_passes is zero; a provider call makes at least one pass")
175            }
176            Self::Overflow => f.write_str("the cost estimate overflows"),
177            Self::NegativeRate => f.write_str("the catalog rate is negative"),
178        }
179    }
180}
181
182impl std::error::Error for EstimateError {}
183
184/// The cost of `tokens` input tokens at `rate` nanodollars per million,
185/// rounded up to a whole nanodollar.
186pub fn cost_nanos(tokens: u64, rate: RateNanosPerMtok) -> Result<u64, EstimateError> {
187    let rate = u64::try_from(rate).map_err(|_| EstimateError::NegativeRate)?;
188    let product = u128::from(tokens) * u128::from(rate);
189    let nanos = product.div_ceil(u128::from(TOKENS_PER_MTOK));
190    u64::try_from(nanos).map_err(|_| EstimateError::Overflow)
191}
192
193/// Estimate one item's input cost (`RULES.md` §5):
194///
195/// ```text
196/// input tokens = schema + max_passes x (state + images x image_tokens_each + overhead)
197/// cost         = input tokens x input rate
198/// ```
199///
200/// The schema bills once across all passes and the state bills on every
201/// pass, as Cloudflare's Clairic was measured to bill. Images and overhead
202/// are charged on every pass too, which can only overstate the cost.
203///
204/// `pass_tokens` decides nothing here but the fit check: a state counted
205/// exactly at or past it is [`EstimateError::DoesNotFit`]. A byte-bound
206/// state is estimated whatever its size, since the formula bounds its cost
207/// whether the provider refuses the state or truncates it.
208pub fn estimate_item(
209    input: &ItemCostInput,
210    input_rate: RateNanosPerMtok,
211) -> Result<Estimate, EstimateError> {
212    let state = input.state.count();
213    if let (Some(pass_tokens), true) = (input.pass_tokens, input.state.is_exact()) {
214        if state >= pass_tokens {
215            return Err(EstimateError::DoesNotFit {
216                state_tokens: state,
217                pass_tokens,
218            });
219        }
220    }
221    if input.max_passes == 0 {
222        return Err(EstimateError::NoPasses);
223    }
224    let image_tokens = input
225        .images
226        .checked_mul(input.image_tokens_each)
227        .ok_or(EstimateError::Overflow)?;
228    let per_pass = [image_tokens, input.overhead_tokens]
229        .into_iter()
230        .try_fold(state, u64::checked_add)
231        .ok_or(EstimateError::Overflow)?;
232    let input_tokens = per_pass
233        .checked_mul(input.max_passes)
234        .and_then(|passes| passes.checked_add(input.schema.count()))
235        .ok_or(EstimateError::Overflow)?;
236    Ok(Estimate {
237        max_passes: input.max_passes,
238        input_tokens,
239        cost_nanos: cost_nanos(input_tokens, input_rate)?,
240    })
241}
242
243/// Whether an item estimated at `estimate` would cross `ceiling_nanos` after
244/// `spent_nanos` already recorded for the batch. A sum that overflows
245/// crosses any ceiling.
246pub fn crosses_ceiling(spent_nanos: u64, estimate: &Estimate, ceiling_nanos: u64) -> bool {
247    spent_nanos
248        .checked_add(estimate.cost_nanos)
249        .is_none_or(|total| total > ceiling_nanos)
250}
251
252/// Which way [`usd_to_nanos`] rounds a value finer than a nanodollar.
253#[derive(Clone, Copy, Debug, PartialEq, Eq)]
254pub enum Rounding {
255    /// Toward zero: for a ceiling, so it is never raised.
256    Down,
257    /// Away from zero: for a cost, so it is never lowered.
258    Up,
259}
260
261/// A JSON number of USD as whole nanodollars, by decimal string scaling of
262/// the number's text (never through floating-point arithmetic). `None` for
263/// a negative number or one too large for `u64` nanodollars.
264pub fn usd_to_nanos(usd: &Number, rounding: Rounding) -> Option<u64> {
265    decimal_to_nanos(&usd.to_string(), rounding)
266}
267
268fn decimal_to_nanos(text: &str, rounding: Rounding) -> Option<u64> {
269    if let Some(magnitude) = text.strip_prefix('-') {
270        // Only a negative zero is a valid amount.
271        let zero = magnitude
272            .split(['e', 'E'])
273            .next()
274            .is_some_and(|mantissa| mantissa.bytes().all(|b| matches!(b, b'0' | b'.')));
275        return zero.then_some(0);
276    }
277    let (mantissa, exponent) = match text.find(['e', 'E']) {
278        Some(at) => (&text[..at], text[at + 1..].parse::<i64>().ok()?),
279        None => (text, 0),
280    };
281    let (int, frac) = mantissa.split_once('.').unwrap_or((mantissa, ""));
282    let digits: String = format!("{int}{frac}");
283    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
284        return None;
285    }
286    let digits = digits.trim_start_matches('0');
287    if digits.is_empty() {
288        return Some(0);
289    }
290    // value = digits x 10^(exponent - frac.len()); nanos = value x 10^9.
291    let scale = exponent.checked_sub(frac.len() as i64)?.checked_add(9)?;
292    let whole: u128 = if digits.len() > 38 {
293        return None;
294    } else {
295        digits.parse().ok()?
296    };
297    let nanos = if scale >= 0 {
298        let factor = 10u128.checked_pow(u32::try_from(scale).ok()?)?;
299        whole.checked_mul(factor)?
300    } else {
301        let shift = u32::try_from(-scale).ok()?;
302        match 10u128.checked_pow(shift) {
303            Some(divisor) => {
304                let (quotient, remainder) = (whole / divisor, whole % divisor);
305                if rounding == Rounding::Up && remainder > 0 {
306                    quotient + 1
307                } else {
308                    quotient
309                }
310            }
311            // Smaller than any nanodollar fraction `u128` can hold.
312            None => u128::from(rounding == Rounding::Up),
313        }
314    };
315    u64::try_from(nanos).ok()
316}
317
318/// Whole nanodollars as an exact decimal number of USD, for a message:
319/// `0.00110396`, never an exponent form.
320pub fn nanos_to_usd_text(nanos: u64) -> String {
321    let whole = nanos / NANOS_PER_USD;
322    let frac = nanos % NANOS_PER_USD;
323    if frac == 0 {
324        format!("{whole}")
325    } else {
326        let frac = format!("{frac:09}");
327        format!("{whole}.{}", frac.trim_end_matches('0'))
328    }
329}
330
331/// Whole nanodollars as a JSON number of USD, for a refusal's
332/// `estimate_usd`: the exact decimal text, read as a JSON number.
333pub fn nanos_to_usd(nanos: u64) -> Number {
334    serde_json::from_str(&nanos_to_usd_text(nanos)).expect("a plain decimal is a JSON number")
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    fn num(text: &str) -> Number {
342        serde_json::from_str(text).unwrap()
343    }
344
345    /// Cloudflare's Clairic, measured live on 2026-10-22 (probe 05): 40
346    /// text fields ran in 40 passes and billed `input_tokens` 29,888 for a
347    /// schema of 26,488 tokens and a state of 85, with no images.
348    fn probe_05(state: Tokens, schema: Tokens) -> ItemCostInput {
349        ItemCostInput::new(state, schema, 0, 0)
350            .with_max_passes(40)
351            .with_pass_tokens(8192)
352    }
353
354    #[test]
355    fn exact_counts_estimate_what_clairic_billed_for_probe_05() {
356        let input = probe_05(Tokens::Exact(85), Tokens::Exact(26_488));
357        // 26,488 + 40 x 85: the schema once, the state on every pass.
358        assert_eq!(
359            estimate_item(&input, 110_000_000),
360            Ok(Estimate::new(40, 29_888, 3_287_680))
361        );
362    }
363
364    #[test]
365    fn byte_bounds_never_underestimate_probe_05() {
366        // The UTF-8 byte lengths of probe 05's fields (as sent, and as
367        // canonical JSON: the same 51,089 bytes) and of its state.
368        let input = probe_05(Tokens::Bound(231), Tokens::Bound(51_089));
369        let estimate = estimate_item(&input, 110_000_000).unwrap();
370        assert_eq!(estimate.input_tokens, 51_089 + 40 * 231);
371        assert!(estimate.input_tokens >= 29_888);
372    }
373
374    #[test]
375    fn a_large_state_over_many_passes_bills_on_every_pass() {
376        // 40 small fields and a large state. Deriving passes from the pass
377        // budget gives ceil(400 / (8192 - 4000)) = 1 pass and bills
378        // (4000 + 400) x 1 = 4,400 tokens, while a provider that runs each
379        // field in its own pass bills the state 40 times.
380        let input = ItemCostInput::new(Tokens::Bound(4000), Tokens::Bound(400), 0, 0)
381            .with_max_passes(40)
382            .with_pass_tokens(8192);
383        let estimate = estimate_item(&input, 1_000).unwrap();
384        assert_eq!(estimate.input_tokens, 400 + 40 * 4000);
385        assert!(estimate.input_tokens > 4_400);
386    }
387
388    #[test]
389    fn images_and_overhead_bill_on_every_pass() {
390        let input = ItemCostInput::new(Tokens::Bound(1000), Tokens::Bound(9000), 300, 50)
391            .with_images(2)
392            .with_max_passes(2)
393            .with_pass_tokens(8192);
394        // 9000 + 2 x (1000 + 2 x 300 + 50).
395        assert_eq!(
396            estimate_item(&input, 110_000_000),
397            Ok(Estimate::new(2, 12_300, 1_353_000))
398        );
399        let one_pass = ItemCostInput::new(Tokens::Bound(1000), Tokens::Bound(9000), 300, 50);
400        assert_eq!(
401            one_pass.max_passes, 1,
402            "one pass unless the module says more"
403        );
404        assert_eq!(estimate_item(&one_pass, 0).unwrap().input_tokens, 10_050);
405    }
406
407    #[test]
408    fn a_byte_bound_past_the_pass_is_estimated_and_an_exact_count_does_not_fit() {
409        let bound = ItemCostInput::new(Tokens::Bound(9000), Tokens::Bound(10), 0, 0)
410            .with_max_passes(3)
411            .with_pass_tokens(8192);
412        assert_eq!(
413            estimate_item(&bound, 1000),
414            Ok(Estimate::new(3, 27_010, 28))
415        );
416        let exact =
417            ItemCostInput::new(Tokens::Exact(9000), Tokens::Bound(10), 0, 0).with_pass_tokens(8192);
418        assert_eq!(
419            estimate_item(&exact, 1000),
420            Err(EstimateError::DoesNotFit {
421                state_tokens: 9000,
422                pass_tokens: 8192
423            })
424        );
425        let at_the_budget =
426            ItemCostInput::new(Tokens::Exact(8192), Tokens::Bound(10), 0, 0).with_pass_tokens(8192);
427        assert!(matches!(
428            estimate_item(&at_the_budget, 1000),
429            Err(EstimateError::DoesNotFit { .. })
430        ));
431    }
432
433    #[test]
434    fn zero_passes_are_refused() {
435        let input = ItemCostInput::new(Tokens::Exact(1), Tokens::Exact(1), 0, 0).with_max_passes(0);
436        assert_eq!(estimate_item(&input, 1), Err(EstimateError::NoPasses));
437    }
438
439    #[test]
440    fn overflow_refuses() {
441        let input = ItemCostInput::new(Tokens::Exact(u64::MAX), Tokens::Exact(1), 0, 0);
442        assert_eq!(estimate_item(&input, 1), Err(EstimateError::Overflow));
443        let passes =
444            ItemCostInput::new(Tokens::Exact(2), Tokens::Exact(1), 0, 0).with_max_passes(u64::MAX);
445        assert_eq!(estimate_item(&passes, 1), Err(EstimateError::Overflow));
446        assert_eq!(cost_nanos(1, -1), Err(EstimateError::NegativeRate));
447        assert!(crosses_ceiling(u64::MAX, &Estimate::new(1, 1, 1), u64::MAX));
448    }
449
450    #[test]
451    fn cost_rounds_up_to_a_nanodollar() {
452        assert_eq!(cost_nanos(1, 1), Ok(1));
453        assert_eq!(cost_nanos(1_000_000, 3), Ok(3));
454        assert_eq!(cost_nanos(0, 5), Ok(0));
455    }
456
457    #[test]
458    fn usd_scales_by_decimal_text() {
459        assert_eq!(
460            usd_to_nanos(&num("0.25"), Rounding::Down),
461            Some(250_000_000)
462        );
463        assert_eq!(usd_to_nanos(&num("1e-7"), Rounding::Down), Some(100));
464        assert_eq!(usd_to_nanos(&num("3"), Rounding::Down), Some(3_000_000_000));
465        assert_eq!(usd_to_nanos(&num("1e-10"), Rounding::Down), Some(0));
466        assert_eq!(usd_to_nanos(&num("1e-10"), Rounding::Up), Some(1));
467        assert_eq!(usd_to_nanos(&num("0"), Rounding::Up), Some(0));
468        assert_eq!(usd_to_nanos(&num("-1"), Rounding::Down), None);
469        assert_eq!(usd_to_nanos(&num("1e300"), Rounding::Down), None);
470        assert_eq!(nanos_to_usd(420_000_000).to_string(), "0.42");
471        assert_eq!(nanos_to_usd(3_000_000_000).to_string(), "3");
472        assert_eq!(nanos_to_usd(2_343_000).to_string(), "0.002343");
473        assert_eq!(nanos_to_usd_text(36_960), "0.00003696");
474    }
475}