Skip to main content

boundgraph_core/
cost.rs

1//! What a reasoner charges — the input to every cost claim the system makes.
2//!
3//! # Why this is data and not a provider method
4//!
5//! The headline claim is that a flow's price can be bounded *before it runs*
6//! (`ARCHITECTURE.md` §10). That is only possible if per-token pricing and the
7//! context ceiling are knowable at build time, which means they cannot be
8//! something you learn by calling a model. So a [`CostModel`] is a value the
9//! compiler reads, and the provider that will eventually charge it is required
10//! to declare one (US-0601).
11//!
12//! It lives here rather than beside the providers for the same reason
13//! [`Budget`](crate::Budget) does: the compiler needs to reason about price
14//! long before there is a provider to ask, and a type in `boundgraph-core` is one
15//! both ends can see without either depending on the other.
16//!
17//! # Why pricing is loaded, not compiled in
18//!
19//! Model prices change on the vendor's schedule, not on ours. A constant in a
20//! binary means every price change is a release; a [`Pricing`] catalogue read
21//! from a file means it is a config edit. The catalogue is also what makes
22//! staleness checkable — every entry carries the date it was correct on
23//! ([`AsOf`]), so "this flow was priced against nine-month-old numbers" is a
24//! question the compiler can answer instead of a thing nobody notices.
25//!
26//! # Cached reads are a different price, not a discount
27//!
28//! Prompt caching does not make input tokens cheaper — it makes *some* of them
29//! a different line item. A cached read is roughly a tenth of the uncached
30//! rate and a cache write is more than the uncached rate, so a model that
31//! collapsed them into one number would misprice both directions. Hence four
32//! rates rather than two.
33
34use std::collections::BTreeMap;
35use std::fmt;
36use std::str::FromStr;
37
38use rust_decimal::Decimal;
39use serde::{Deserialize, Serialize};
40
41/// Tokens per pricing unit. Vendors quote per million; so do we.
42const TOKENS_PER_UNIT: u64 = 1_000_000;
43
44/// The date a price was correct on.
45///
46/// A plain calendar date with no timezone, because pricing changes on a
47/// published date rather than at an instant. Ordering is chronological, which
48/// is the only thing a staleness check needs.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
50#[serde(try_from = "String", into = "String")]
51pub struct AsOf {
52    /// Four-digit year.
53    pub year: u16,
54    /// Month, 1–12.
55    pub month: u8,
56    /// Day, 1–31.
57    pub day: u8,
58}
59
60impl AsOf {
61    /// A date, checked.
62    ///
63    /// # Errors
64    ///
65    /// [`AsOfParseError::OutOfRange`] if the month or day is not a calendar
66    /// value. Day-of-month is checked against the month's real length,
67    /// including February in leap years — a date that cannot exist is a typo,
68    /// and a typo in a pricing date is how stale pricing goes unnoticed.
69    pub fn new(year: u16, month: u8, day: u8) -> Result<Self, AsOfParseError> {
70        let length = match month {
71            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
72            4 | 6 | 9 | 11 => 30,
73            2 if is_leap(year) => 29,
74            2 => 28,
75            _ => return Err(AsOfParseError::OutOfRange),
76        };
77        if day == 0 || day > length {
78            return Err(AsOfParseError::OutOfRange);
79        }
80        Ok(Self { year, month, day })
81    }
82
83    /// Whether this date is older than `horizon`.
84    ///
85    /// The horizon is supplied rather than computed from the clock: reading the
86    /// wall clock here would make compilation nondeterministic, and a flow that
87    /// compiles today and fails tomorrow with no change to its bytes is exactly
88    /// what the determinism audit exists to prevent.
89    pub fn is_older_than(self, horizon: Self) -> bool {
90        self < horizon
91    }
92}
93
94impl fmt::Display for AsOf {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
97    }
98}
99
100const fn is_leap(year: u16) -> bool {
101    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
102}
103
104/// A pricing date could not be read.
105#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
106pub enum AsOfParseError {
107    /// Not `YYYY-MM-DD`.
108    #[error("a pricing date must be YYYY-MM-DD")]
109    Malformed,
110    /// Parsed, but not a date that exists.
111    #[error("that is not a calendar date")]
112    OutOfRange,
113}
114
115impl FromStr for AsOf {
116    type Err = AsOfParseError;
117
118    fn from_str(s: &str) -> Result<Self, Self::Err> {
119        let mut parts = s.split('-');
120        let (Some(y), Some(m), Some(d), None) =
121            (parts.next(), parts.next(), parts.next(), parts.next())
122        else {
123            return Err(AsOfParseError::Malformed);
124        };
125        if (y.len(), m.len(), d.len()) != (4, 2, 2) {
126            return Err(AsOfParseError::Malformed);
127        }
128        let year = y.parse().map_err(|_| AsOfParseError::Malformed)?;
129        let part = |t: &str| t.parse::<u8>().map_err(|_| AsOfParseError::Malformed);
130        Self::new(year, part(m)?, part(d)?)
131    }
132}
133
134impl TryFrom<String> for AsOf {
135    type Error = AsOfParseError;
136
137    fn try_from(s: String) -> Result<Self, Self::Error> {
138        s.parse()
139    }
140}
141
142impl From<AsOf> for String {
143    fn from(date: AsOf) -> Self {
144        date.to_string()
145    }
146}
147
148/// What a call actually consumed, split by how each token was billed.
149///
150/// The four counts are disjoint: a prompt token is billed exactly once, as
151/// uncached input, as a cache write, or as a cache read.
152#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
153pub struct TokenUsage {
154    /// Prompt tokens charged at the full input rate.
155    pub input: u64,
156    /// Completion tokens.
157    pub output: u64,
158    /// Prompt tokens written to the cache this call.
159    pub cache_write: u64,
160    /// Prompt tokens served from the cache this call.
161    pub cache_read: u64,
162}
163
164impl TokenUsage {
165    /// Nothing consumed.
166    pub const NONE: Self = Self {
167        input: 0,
168        output: 0,
169        cache_write: 0,
170        cache_read: 0,
171    };
172
173    /// Every prompt token, however it was billed.
174    pub fn prompt_tokens(&self) -> u64 {
175        self.input
176            .saturating_add(self.cache_write)
177            .saturating_add(self.cache_read)
178    }
179
180    /// Prompt plus completion.
181    pub fn total_tokens(&self) -> u64 {
182        self.prompt_tokens().saturating_add(self.output)
183    }
184
185    /// What fraction of the prompt was served from cache, as a percentage.
186    ///
187    /// The number to watch when a cache is supposed to be working: a prefix
188    /// that should be hitting and reads zero means something upstream is
189    /// changing bytes that were meant to be stable.
190    pub fn cache_hit_rate(&self) -> Option<Decimal> {
191        let prompt = self.prompt_tokens();
192        if prompt == 0 {
193            return None;
194        }
195        Some(Decimal::from(self.cache_read) * Decimal::ONE_HUNDRED / Decimal::from(prompt))
196    }
197
198    /// Add another call's usage to this one.
199    #[must_use]
200    pub fn plus(self, other: Self) -> Self {
201        Self {
202            input: self.input.saturating_add(other.input),
203            output: self.output.saturating_add(other.output),
204            cache_write: self.cache_write.saturating_add(other.cache_write),
205            cache_read: self.cache_read.saturating_add(other.cache_read),
206        }
207    }
208}
209
210/// What one reasoner charges, and how much it can be given.
211///
212/// # Example
213///
214/// ```
215/// use boundgraph_core::{AsOf, CostModel, TokenUsage};
216/// use rust_decimal::Decimal;
217///
218/// let usd = |s: &str| s.parse::<Decimal>().unwrap();
219///
220/// let model = CostModel::new(
221///     usd("5.00"),   // input, per million tokens
222///     usd("25.00"),  // output
223///     AsOf::new(2026, 6, 24).unwrap(),
224/// )
225/// .with_context_window(1_000_000)
226/// .with_cache_rates(usd("0.50"), usd("6.25"));
227///
228/// // A cached read is a tenth of the uncached rate, and the price says so.
229/// let cold = model.price(&TokenUsage { input: 1_000_000, ..TokenUsage::NONE });
230/// let warm = model.price(&TokenUsage { cache_read: 1_000_000, ..TokenUsage::NONE });
231/// assert_eq!(cold, usd("5.00"));
232/// assert_eq!(warm, usd("0.50"));
233/// ```
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct CostModel {
236    /// US dollars per million uncached prompt tokens.
237    pub input: Decimal,
238    /// US dollars per million completion tokens.
239    pub output: Decimal,
240    /// US dollars per million prompt tokens served from the cache.
241    ///
242    /// Substantially below [`input`](Self::input) — that difference is the
243    /// whole point of caching, and collapsing the two would hide it.
244    pub cache_read: Decimal,
245    /// US dollars per million prompt tokens written to the cache.
246    ///
247    /// Above [`input`](Self::input): a write costs more than not caching at
248    /// all, which is why a prefix has to be read back at least twice before
249    /// caching it pays for itself.
250    pub cache_write: Decimal,
251    /// The same, for a provider's longer-lived cache tier, if it has one.
252    pub cache_write_long: Option<Decimal>,
253    /// The largest prompt this model will accept, in tokens.
254    ///
255    /// A ceiling the compiler checks a flow's declared context against, so
256    /// "this step asks for more window than the model has" is a build error
257    /// rather than a run-time refusal.
258    pub context_window: u32,
259    /// The largest completion it will produce, in tokens.
260    pub max_output: u32,
261    /// The date these figures were correct on.
262    pub as_of: AsOf,
263}
264
265impl CostModel {
266    /// A model priced per million tokens, with no cache tier.
267    ///
268    /// Cache rates default to the uncached input rate, which is the
269    /// conservative reading: a provider that has not told us caching is
270    /// cheaper is priced as though it is not.
271    pub fn new(input: Decimal, output: Decimal, as_of: AsOf) -> Self {
272        Self {
273            input,
274            output,
275            cache_read: input,
276            cache_write: input,
277            cache_write_long: None,
278            context_window: 0,
279            max_output: 0,
280            as_of,
281        }
282    }
283
284    /// Declare the cache tier's rates.
285    #[must_use]
286    pub fn with_cache_rates(mut self, read: Decimal, write: Decimal) -> Self {
287        self.cache_read = read;
288        self.cache_write = write;
289        self
290    }
291
292    /// Declare a longer-lived cache tier's write rate.
293    #[must_use]
294    pub fn with_long_cache_write(mut self, write: Decimal) -> Self {
295        self.cache_write_long = Some(write);
296        self
297    }
298
299    /// Declare the prompt ceiling.
300    #[must_use]
301    pub fn with_context_window(mut self, tokens: u32) -> Self {
302        self.context_window = tokens;
303        self
304    }
305
306    /// Declare the completion ceiling.
307    #[must_use]
308    pub fn with_max_output(mut self, tokens: u32) -> Self {
309        self.max_output = tokens;
310        self
311    }
312
313    /// What a call consuming `usage` costs, in US dollars.
314    ///
315    /// Exact decimal arithmetic throughout: this figure ends up in the ledger
316    /// and gets summed across a run, and a rounding error that compounds is
317    /// worse than no figure at all.
318    pub fn price(&self, usage: &TokenUsage) -> Decimal {
319        let per = |tokens: u64, rate: Decimal| Decimal::from(tokens) * rate;
320        (per(usage.input, self.input)
321            + per(usage.output, self.output)
322            + per(usage.cache_read, self.cache_read)
323            + per(usage.cache_write, self.cache_write))
324            / Decimal::from(TOKENS_PER_UNIT)
325    }
326
327    /// Whether a prompt of this size fits.
328    pub fn admits(&self, prompt_tokens: u32) -> bool {
329        self.context_window == 0 || prompt_tokens <= self.context_window
330    }
331
332    /// Whether these figures are older than `horizon`.
333    pub fn is_stale(&self, horizon: AsOf) -> bool {
334        self.as_of.is_older_than(horizon)
335    }
336
337    /// Whether two models would price the same call identically.
338    ///
339    /// What a failover has to satisfy: a fallback that charges differently
340    /// changes the ceiling the flow was compiled against, so the compiler
341    /// refuses it (US-0604) rather than letting the proved bound quietly stop
342    /// being true.
343    pub fn prices_alike(&self, other: &Self) -> bool {
344        self.input == other.input
345            && self.output == other.output
346            && self.cache_read == other.cache_read
347            && self.cache_write == other.cache_write
348    }
349
350    /// Whichever of two models is more expensive on every rate.
351    ///
352    /// Used to bound a choice the compiler cannot resolve — a ladder or a
353    /// failover list — by pricing it at its worst case rather than its
354    /// expected one.
355    pub fn costlier(&self, other: &Self) -> Self {
356        Self {
357            input: self.input.max(other.input),
358            output: self.output.max(other.output),
359            cache_read: self.cache_read.max(other.cache_read),
360            cache_write: self.cache_write.max(other.cache_write),
361            cache_write_long: match (self.cache_write_long, other.cache_write_long) {
362                (Some(a), Some(b)) => Some(a.max(b)),
363                (a, b) => a.or(b),
364            },
365            // The tighter ceiling binds: a chain can only accept what its most
366            // limited member accepts.
367            context_window: match (self.context_window, other.context_window) {
368                (0, w) | (w, 0) => w,
369                (a, b) => a.min(b),
370            },
371            max_output: match (self.max_output, other.max_output) {
372                (0, w) | (w, 0) => w,
373                (a, b) => a.min(b),
374            },
375            as_of: self.as_of.min(other.as_of),
376        }
377    }
378}
379
380/// Every model's pricing, keyed by model id.
381///
382/// Loaded rather than compiled in — see the module documentation.
383#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(transparent)]
385pub struct Pricing {
386    models: BTreeMap<String, CostModel>,
387}
388
389impl Pricing {
390    /// An empty catalogue.
391    pub fn new() -> Self {
392        Self::default()
393    }
394
395    /// Read a catalogue from JSON.
396    ///
397    /// # Errors
398    ///
399    /// [`PricingError::Malformed`] if the document is not a map of model id to
400    /// cost model.
401    pub fn from_json(json: &str) -> Result<Self, PricingError> {
402        serde_json::from_str(json).map_err(|e| PricingError::Malformed(e.to_string()))
403    }
404
405    /// Render the catalogue back to JSON.
406    ///
407    /// # Errors
408    ///
409    /// [`PricingError::Malformed`] if the catalogue cannot be encoded.
410    pub fn to_json(&self) -> Result<String, PricingError> {
411        serde_json::to_string_pretty(self).map_err(|e| PricingError::Malformed(e.to_string()))
412    }
413
414    /// Add or replace one model's pricing.
415    #[must_use]
416    pub fn with(mut self, model: impl Into<String>, cost: CostModel) -> Self {
417        self.models.insert(model.into(), cost);
418        self
419    }
420
421    /// What a model charges, if the catalogue knows it.
422    pub fn get(&self, model: &str) -> Option<&CostModel> {
423        self.models.get(model)
424    }
425
426    /// Every model priced, in id order.
427    pub fn models(&self) -> impl Iterator<Item = (&str, &CostModel)> {
428        self.models.iter().map(|(k, v)| (k.as_str(), v))
429    }
430
431    /// How many models are priced.
432    pub fn len(&self) -> usize {
433        self.models.len()
434    }
435
436    /// Whether nothing is priced.
437    pub fn is_empty(&self) -> bool {
438        self.models.is_empty()
439    }
440
441    /// The models whose figures predate `horizon`, in id order.
442    pub fn stale(&self, horizon: AsOf) -> Vec<&str> {
443        self.models
444            .iter()
445            .filter(|(_, cost)| cost.is_stale(horizon))
446            .map(|(id, _)| id.as_str())
447            .collect()
448    }
449}
450
451/// A pricing catalogue could not be read.
452#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
453pub enum PricingError {
454    /// The document is not a pricing catalogue.
455    #[error("the pricing catalogue is malformed: {0}")]
456    Malformed(String),
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    fn usd(s: &str) -> Decimal {
464        s.parse().unwrap()
465    }
466
467    fn opus() -> CostModel {
468        CostModel::new(usd("5.00"), usd("25.00"), AsOf::new(2026, 6, 24).unwrap())
469            .with_cache_rates(usd("0.50"), usd("6.25"))
470            .with_long_cache_write(usd("10.00"))
471            .with_context_window(1_000_000)
472            .with_max_output(128_000)
473    }
474
475    // ── the four rates ──────────────────────────────────────────────────────
476
477    #[test]
478    fn a_cost_model_carries_input_output_and_the_context_ceiling() {
479        let m = opus();
480        assert_eq!(m.input, usd("5.00"));
481        assert_eq!(m.output, usd("25.00"));
482        assert_eq!(m.context_window, 1_000_000);
483        assert_eq!(m.max_output, 128_000);
484    }
485
486    /// The distinction the story turns on: a cached read is its own rate, not a
487    /// discount applied to the input rate.
488    #[test]
489    fn cached_reads_are_priced_separately_from_uncached_input() {
490        let m = opus();
491        assert_ne!(m.cache_read, m.input);
492        assert!(
493            m.cache_read < m.input,
494            "a read should be cheaper than a miss"
495        );
496        assert!(
497            m.cache_write > m.input,
498            "a write should cost more than not caching"
499        );
500
501        let million = 1_000_000;
502        assert_eq!(
503            m.price(&TokenUsage {
504                input: million,
505                ..TokenUsage::NONE
506            }),
507            usd("5.00")
508        );
509        assert_eq!(
510            m.price(&TokenUsage {
511                cache_read: million,
512                ..TokenUsage::NONE
513            }),
514            usd("0.50")
515        );
516        assert_eq!(
517            m.price(&TokenUsage {
518                cache_write: million,
519                ..TokenUsage::NONE
520            }),
521            usd("6.25")
522        );
523    }
524
525    /// A model that never told us caching is cheaper is priced as though it is
526    /// not — conservative by default, like everything else that guesses.
527    #[test]
528    fn a_model_with_no_cache_tier_prices_reads_at_the_input_rate() {
529        let m = CostModel::new(usd("3.00"), usd("15.00"), AsOf::new(2026, 6, 24).unwrap());
530        assert_eq!(m.cache_read, m.input);
531        assert_eq!(m.cache_write, m.input);
532    }
533
534    #[test]
535    fn a_priced_call_sums_every_rate() {
536        let m = opus();
537        let usage = TokenUsage {
538            input: 100_000,
539            output: 20_000,
540            cache_write: 40_000,
541            cache_read: 800_000,
542        };
543        // 0.5 + 0.5 + 0.25 + 0.4
544        assert_eq!(m.price(&usage), usd("1.65"));
545    }
546
547    #[test]
548    fn money_is_exact() {
549        let m = CostModel::new(usd("0.1"), usd("0.2"), AsOf::new(2026, 1, 1).unwrap());
550        let a = m.price(&TokenUsage {
551            input: 1_000_000,
552            ..TokenUsage::NONE
553        });
554        let b = m.price(&TokenUsage {
555            output: 1_000_000,
556            ..TokenUsage::NONE
557        });
558        assert_eq!(a + b, usd("0.3"), "0.1 + 0.2 must be exactly 0.3");
559    }
560
561    // ── usage ───────────────────────────────────────────────────────────────
562
563    #[test]
564    fn the_four_counts_are_disjoint_halves_of_one_total() {
565        let u = TokenUsage {
566            input: 10,
567            output: 5,
568            cache_write: 3,
569            cache_read: 90,
570        };
571        assert_eq!(u.prompt_tokens(), 103);
572        assert_eq!(u.total_tokens(), 108);
573    }
574
575    #[test]
576    fn the_cache_hit_rate_is_reported_over_the_whole_prompt() {
577        let u = TokenUsage {
578            input: 100,
579            cache_read: 900,
580            ..TokenUsage::NONE
581        };
582        assert_eq!(u.cache_hit_rate(), Some(usd("90")));
583        assert_eq!(TokenUsage::NONE.cache_hit_rate(), None, "nothing to divide");
584    }
585
586    #[test]
587    fn usage_accumulates_across_calls() {
588        let a = TokenUsage {
589            input: 1,
590            output: 2,
591            cache_write: 3,
592            cache_read: 4,
593        };
594        assert_eq!(a.plus(a).total_tokens(), 20);
595    }
596
597    // ── staleness ───────────────────────────────────────────────────────────
598
599    #[test]
600    fn pricing_dates_order_chronologically() {
601        let older = AsOf::new(2025, 12, 31).unwrap();
602        let newer = AsOf::new(2026, 1, 1).unwrap();
603        assert!(older < newer);
604        assert!(older.is_older_than(newer));
605        assert!(!newer.is_older_than(older));
606        assert!(!newer.is_older_than(newer), "a horizon is not yet stale");
607    }
608
609    /// Compilation must not depend on the wall clock, so the horizon is passed
610    /// in. A flow that compiles today and fails tomorrow with the same bytes is
611    /// the exact nondeterminism the audit exists to catch.
612    #[test]
613    fn staleness_is_measured_against_a_supplied_horizon() {
614        let m = opus();
615        assert!(!m.is_stale(AsOf::new(2026, 1, 1).unwrap()));
616        assert!(m.is_stale(AsOf::new(2027, 1, 1).unwrap()));
617    }
618
619    #[test]
620    fn a_pricing_date_round_trips_through_its_iso_form() {
621        let d = AsOf::new(2026, 6, 24).unwrap();
622        assert_eq!(d.to_string(), "2026-06-24");
623        assert_eq!("2026-06-24".parse::<AsOf>().unwrap(), d);
624    }
625
626    #[test]
627    fn a_date_that_cannot_exist_is_refused() {
628        for bad in ["2026-02-30", "2026-13-01", "2026-00-10", "2026-01-00"] {
629            assert_eq!(
630                bad.parse::<AsOf>(),
631                Err(AsOfParseError::OutOfRange),
632                "{bad} should not parse"
633            );
634        }
635        for bad in ["2026-6-24", "26-06-24", "2026/06/24", "2026-06", ""] {
636            assert_eq!(
637                bad.parse::<AsOf>(),
638                Err(AsOfParseError::Malformed),
639                "{bad} should not parse"
640            );
641        }
642    }
643
644    #[test]
645    fn february_29_exists_only_in_a_leap_year() {
646        assert!("2024-02-29".parse::<AsOf>().is_ok());
647        assert!("2000-02-29".parse::<AsOf>().is_ok(), "2000 is a leap year");
648        assert!("1900-02-29".parse::<AsOf>().is_err(), "1900 is not");
649        assert!("2026-02-29".parse::<AsOf>().is_err());
650    }
651
652    // ── the catalogue ───────────────────────────────────────────────────────
653
654    #[test]
655    fn a_catalogue_round_trips_through_json() {
656        let catalogue = Pricing::new().with("claude-opus-5", opus()).with(
657            "claude-haiku-4-5",
658            CostModel::new(usd("1.00"), usd("5.00"), AsOf::new(2026, 6, 24).unwrap()),
659        );
660
661        let json = catalogue.to_json().unwrap();
662        assert_eq!(Pricing::from_json(&json).unwrap(), catalogue);
663        assert_eq!(catalogue.len(), 2);
664        assert_eq!(catalogue.get("claude-opus-5").unwrap().input, usd("5.00"));
665        assert!(catalogue.get("nothing-by-that-name").is_none());
666    }
667
668    #[test]
669    fn a_malformed_catalogue_is_refused_rather_than_half_read() {
670        assert!(matches!(
671            Pricing::from_json("{\"a\": 3}"),
672            Err(PricingError::Malformed(_))
673        ));
674        assert!(Pricing::from_json("not json at all").is_err());
675    }
676
677    #[test]
678    fn the_catalogue_reports_which_models_have_gone_stale() {
679        let catalogue = Pricing::new().with("fresh", opus()).with(
680            "ancient",
681            CostModel::new(usd("1"), usd("1"), AsOf::new(2024, 1, 1).unwrap()),
682        );
683        assert_eq!(catalogue.stale(AsOf::new(2026, 1, 1).unwrap()), ["ancient"]);
684        assert!(catalogue.stale(AsOf::new(2020, 1, 1).unwrap()).is_empty());
685    }
686
687    // ── comparing models ────────────────────────────────────────────────────
688
689    #[test]
690    fn two_models_price_alike_only_when_every_rate_matches() {
691        let a = opus();
692        let mut b = opus();
693        assert!(a.prices_alike(&b));
694
695        b.output = usd("30.00");
696        assert!(!a.prices_alike(&b), "a different output rate is a mismatch");
697
698        let mut c = opus();
699        c.cache_read = usd("0.60");
700        assert!(!a.prices_alike(&c), "a different cache rate is a mismatch");
701
702        // The date is metadata about the figures, not one of them.
703        let mut d = opus();
704        d.as_of = AsOf::new(2026, 1, 1).unwrap();
705        assert!(a.prices_alike(&d));
706    }
707
708    /// Bounding a choice the compiler cannot resolve: the price is the dearest
709    /// option's, and the ceiling is the most limited option's.
710    #[test]
711    fn the_costlier_of_two_models_takes_the_worst_of_each() {
712        let cheap = CostModel::new(usd("1.00"), usd("5.00"), AsOf::new(2026, 6, 1).unwrap())
713            .with_context_window(200_000);
714        let dear = opus();
715
716        let worst = cheap.costlier(&dear);
717        assert_eq!(worst.input, usd("5.00"));
718        assert_eq!(worst.output, usd("25.00"));
719        assert_eq!(
720            worst.context_window, 200_000,
721            "the tighter window binds the chain"
722        );
723        assert_eq!(
724            worst.as_of,
725            AsOf::new(2026, 6, 1).unwrap(),
726            "the older date"
727        );
728    }
729
730    #[test]
731    fn a_prompt_is_admitted_only_within_the_window() {
732        let m = opus();
733        assert!(m.admits(999_999));
734        assert!(m.admits(1_000_000));
735        assert!(!m.admits(1_000_001));
736
737        // A model that never declared a window does not refuse anything.
738        let unbounded = CostModel::new(usd("1"), usd("1"), AsOf::new(2026, 1, 1).unwrap());
739        assert!(unbounded.admits(u32::MAX));
740    }
741}