Skip to main content

layover_core/cost/
ledger.rs

1//! The cost ledger: an append-only record of what every run spent.
2//!
3//! Fuel answers "may this itinerary continue?" and forgets everything else. The ledger is what
4//! makes the answer explicable afterwards — which agent, which model, which chain, and how much
5//! of the total is actually measured rather than guessed.
6//!
7//! Every total carries its own [`CostSource`], taken as the *weakest* source that contributed to
8//! it. A figure that is 90% measured and 10% estimated reports as an estimate, because that is
9//! what it is.
10
11use jiff::Timestamp;
12use std::collections::BTreeMap;
13
14use super::{CostSource, RunCost, TokenUsage};
15use crate::agent::AgentName;
16use crate::flight::ItineraryId;
17use crate::pipeline::PipelineName;
18
19/// Totals over some set of runs.
20#[derive(Debug, Clone, Default, PartialEq)]
21pub struct Summary {
22    /// How many runs contributed.
23    pub runs: u32,
24    /// Total cost in US dollars.
25    pub usd: f64,
26    /// Total tokens.
27    pub usage: TokenUsage,
28    /// How many runs reported nothing at all.
29    pub unreported_runs: u32,
30    /// How many runs were priced from a rate card rather than measured.
31    pub estimated_runs: u32,
32}
33
34impl Summary {
35    /// Folds one run into the totals.
36    fn add(&mut self, cost: &RunCost) {
37        self.runs = self.runs.saturating_add(1);
38        self.usd += cost.usd;
39        self.usage = self.usage.saturating_add(cost.usage);
40
41        match cost.source {
42            CostSource::Reported => {}
43            CostSource::RateCard => self.estimated_runs = self.estimated_runs.saturating_add(1),
44            CostSource::Unreported => {
45                self.unreported_runs = self.unreported_runs.saturating_add(1);
46            }
47        }
48    }
49
50    /// How much of this total is measured rather than inferred.
51    ///
52    /// The weakest source wins: one unreported run in a hundred makes the whole figure a lower
53    /// bound, and saying so is the point.
54    #[must_use]
55    pub fn confidence(&self) -> CostSource {
56        if self.unreported_runs > 0 {
57            CostSource::Unreported
58        } else if self.estimated_runs > 0 {
59            CostSource::RateCard
60        } else {
61            CostSource::Reported
62        }
63    }
64
65    /// Fraction of runs whose cost the runner actually reported, from 0.0 to 1.0.
66    ///
67    /// Returns 1.0 for an empty summary: nothing is unaccounted for when nothing has run.
68    #[must_use]
69    pub fn measured_share(&self) -> f64 {
70        if self.runs == 0 {
71            return 1.0;
72        }
73        let inferred = self.unreported_runs.saturating_add(self.estimated_runs);
74        f64::from(self.runs.saturating_sub(inferred)) / f64::from(self.runs)
75    }
76
77    /// Returns `true` when every figure in this total came from a runner.
78    #[must_use]
79    pub fn is_fully_measured(&self) -> bool {
80        self.confidence().is_measured()
81    }
82}
83
84/// An append-only record of run costs.
85///
86/// Held in memory here; persisting it is the store crate's job. Ordering is insertion order, which
87/// is also chronological as long as the Tower records a run when it finishes.
88#[derive(Debug, Clone, Default)]
89pub struct Ledger {
90    entries: Vec<RunCost>,
91}
92
93impl Ledger {
94    /// Creates an empty ledger.
95    #[must_use]
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// Records what a run cost.
101    pub fn record(&mut self, cost: RunCost) {
102        self.entries.push(cost);
103    }
104
105    /// Every recorded run, oldest first.
106    pub fn entries(&self) -> impl Iterator<Item = &RunCost> {
107        self.entries.iter()
108    }
109
110    /// Number of recorded runs.
111    #[must_use]
112    pub fn len(&self) -> usize {
113        self.entries.len()
114    }
115
116    /// Returns `true` when nothing has been recorded.
117    #[must_use]
118    pub fn is_empty(&self) -> bool {
119        self.entries.is_empty()
120    }
121
122    /// Totals over everything recorded.
123    #[must_use]
124    pub fn total(&self) -> Summary {
125        self.summarise(|_| true)
126    }
127
128    /// Totals over the runs matching `keep`.
129    #[must_use]
130    pub fn summarise(&self, keep: impl Fn(&RunCost) -> bool) -> Summary {
131        let mut summary = Summary::default();
132        for cost in self.entries.iter().filter(|cost| keep(cost)) {
133            summary.add(cost);
134        }
135        summary
136    }
137
138    /// Totals for one chain.
139    #[must_use]
140    pub fn for_itinerary(&self, itinerary: &ItineraryId) -> Summary {
141        self.summarise(|cost| &cost.itinerary == itinerary)
142    }
143
144    /// Totals for runs that finished at or after `since`.
145    #[must_use]
146    pub fn since(&self, since: Timestamp) -> Summary {
147        self.summarise(|cost| cost.at >= since)
148    }
149
150    /// Totals per agent.
151    ///
152    /// This is the breakdown that answers "what is expensive here", which a single Fuel figure
153    /// never could.
154    #[must_use]
155    pub fn by_agent(&self) -> BTreeMap<AgentName, Summary> {
156        let mut grouped: BTreeMap<AgentName, Summary> = BTreeMap::new();
157        for cost in &self.entries {
158            grouped.entry(cost.agent.clone()).or_default().add(cost);
159        }
160        grouped
161    }
162
163    /// Totals per model, skipping runs whose model was never reported.
164    #[must_use]
165    pub fn by_model(&self) -> BTreeMap<String, Summary> {
166        let mut grouped: BTreeMap<String, Summary> = BTreeMap::new();
167        for cost in &self.entries {
168            if let Some(model) = &cost.model {
169                grouped.entry(model.clone()).or_default().add(cost);
170            }
171        }
172        grouped
173    }
174
175    /// Totals per workflow, skipping runs no pipeline began.
176    ///
177    /// The breakdown a factory with several pipelines actually needs. Per-agent totals cannot
178    /// answer "what does the nightly sweep cost" once an agent belongs to more than one workflow,
179    /// and in a real factory most of them do.
180    #[must_use]
181    pub fn by_pipeline(&self) -> BTreeMap<PipelineName, Summary> {
182        let mut grouped: BTreeMap<PipelineName, Summary> = BTreeMap::new();
183        for cost in &self.entries {
184            if let Some(pipeline) = &cost.pipeline {
185                grouped.entry(pipeline.clone()).or_default().add(cost);
186            }
187        }
188        grouped
189    }
190
191    /// The agents that cost the most, most expensive first.
192    #[must_use]
193    pub fn top_agents(&self, limit: usize) -> Vec<(AgentName, Summary)> {
194        let mut ranked: Vec<(AgentName, Summary)> = self.by_agent().into_iter().collect();
195        // Totals are money, so compare with `total_cmp` rather than risking a partial order.
196        ranked.sort_by(|(left_name, left), (right_name, right)| {
197            right
198                .usd
199                .total_cmp(&left.usd)
200                .then_with(|| left_name.cmp(right_name))
201        });
202        ranked.truncate(limit);
203        ranked
204    }
205
206    /// Drops entries older than `before`, returning how many were removed.
207    ///
208    /// The ledger grows without bound otherwise, and an unattended factory runs for weeks.
209    pub fn prune(&mut self, before: Timestamp) -> usize {
210        let was = self.entries.len();
211        self.entries.retain(|cost| cost.at >= before);
212        was - self.entries.len()
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::cost::TokenUsage;
220    use crate::flight::RunId;
221    use std::time::Duration;
222
223    fn usage(output: u64) -> TokenUsage {
224        TokenUsage {
225            input: 100,
226            output,
227            ..TokenUsage::default()
228        }
229    }
230
231    fn reported(agent: &str, model: &str, usd: f64) -> RunCost {
232        RunCost::reported(
233            RunId::generate(),
234            ItineraryId::generate(),
235            agent.into(),
236            Some(model.to_owned()),
237            usage(10),
238            usd,
239        )
240    }
241
242    fn ledger() -> Ledger {
243        let mut ledger = Ledger::new();
244        ledger.record(reported("developer", "claude-opus-5", 4.00));
245        ledger.record(reported("tester", "codex-mini", 0.50));
246        ledger.record(reported("developer", "claude-opus-5", 2.00));
247        ledger.record(reported("reviewer", "codex-mini", 1.00));
248        ledger
249    }
250
251    #[test]
252    fn totals_add_up_across_runs() {
253        let total = ledger().total();
254
255        assert_eq!(total.runs, 4);
256        assert!((total.usd - 7.50).abs() < 1e-9);
257        assert_eq!(total.usage.output, 40);
258    }
259
260    #[test]
261    fn spend_breaks_down_by_agent() {
262        let by_agent = ledger().by_agent();
263
264        assert!((by_agent[&AgentName::from("developer")].usd - 6.00).abs() < 1e-9);
265        assert_eq!(by_agent[&AgentName::from("developer")].runs, 2);
266        assert!((by_agent[&AgentName::from("tester")].usd - 0.50).abs() < 1e-9);
267    }
268
269    #[test]
270    fn spend_breaks_down_by_model() {
271        let by_model = ledger().by_model();
272
273        assert!((by_model["claude-opus-5"].usd - 6.00).abs() < 1e-9);
274        assert!((by_model["codex-mini"].usd - 1.50).abs() < 1e-9);
275    }
276
277    #[test]
278    fn the_biggest_spender_is_identifiable() {
279        let top = ledger().top_agents(2);
280
281        assert_eq!(top[0].0, AgentName::from("developer"));
282        assert_eq!(top[1].0, AgentName::from("reviewer"));
283        assert_eq!(top.len(), 2);
284    }
285
286    #[test]
287    fn a_run_with_no_model_is_left_out_of_the_model_breakdown() {
288        // Bucketing it under "unknown" would invent a model that does not exist; the run still
289        // counts in the agent breakdown and the overall total.
290        let mut ledger = Ledger::new();
291        ledger.record(RunCost::reported(
292            RunId::generate(),
293            ItineraryId::generate(),
294            "analyst".into(),
295            None,
296            usage(10),
297            1.00,
298        ));
299
300        assert!(ledger.by_model().is_empty());
301        assert_eq!(ledger.total().runs, 1);
302    }
303
304    #[test]
305    fn one_unreported_run_makes_the_whole_total_a_lower_bound() {
306        // The weakest source wins. A total that is mostly measured is still not measured.
307        let mut ledger = ledger();
308        ledger.record(RunCost::unreported(
309            RunId::generate(),
310            ItineraryId::generate(),
311            "kusto".into(),
312            None,
313        ));
314
315        let total = ledger.total();
316        assert_eq!(total.confidence(), CostSource::Unreported);
317        assert!(!total.is_fully_measured());
318        assert_eq!(total.unreported_runs, 1);
319        assert!((total.measured_share() - 0.8).abs() < 1e-9);
320    }
321
322    #[test]
323    fn an_estimate_downgrades_confidence_without_hiding_the_money() {
324        let mut ledger = Ledger::new();
325        ledger.record(reported("developer", "claude-opus-5", 4.00));
326        ledger.record(RunCost {
327            source: CostSource::RateCard,
328            ..reported("tester", "codex-mini", 1.00)
329        });
330
331        let total = ledger.total();
332        assert_eq!(total.confidence(), CostSource::RateCard);
333        assert_eq!(total.estimated_runs, 1);
334        assert!(
335            (total.usd - 5.00).abs() < 1e-9,
336            "an estimate still counts towards the bill"
337        );
338        assert!((total.measured_share() - 0.5).abs() < 1e-9);
339    }
340
341    #[test]
342    fn an_empty_ledger_is_fully_measured() {
343        let total = Ledger::new().total();
344
345        assert!(total.is_fully_measured());
346        assert!((total.measured_share() - 1.0).abs() < f64::EPSILON);
347        assert!(Ledger::new().is_empty());
348    }
349
350    #[test]
351    fn spend_is_attributable_to_one_chain() {
352        let itinerary = ItineraryId::generate();
353        let mut ledger = ledger();
354        ledger.record(RunCost {
355            itinerary: itinerary.clone(),
356            ..reported("publisher", "codex-mini", 3.00)
357        });
358
359        let summary = ledger.for_itinerary(&itinerary);
360        assert_eq!(summary.runs, 1);
361        assert!((summary.usd - 3.00).abs() < 1e-9);
362    }
363
364    #[test]
365    fn old_entries_can_be_pruned() {
366        let now = Timestamp::now();
367        let mut ledger = Ledger::new();
368        ledger.record(reported("old", "codex-mini", 1.00).at(now - Duration::from_secs(7_200)));
369        ledger.record(reported("new", "codex-mini", 2.00).at(now));
370
371        let cutoff = now - Duration::from_secs(3_600);
372        assert_eq!(ledger.since(cutoff).runs, 1);
373        assert_eq!(ledger.prune(cutoff), 1);
374        assert_eq!(ledger.len(), 1);
375    }
376}