Skip to main content

lean_ctx/core/finops_export/
mod.rs

1//! FinOps cost/savings export (GL #402): CloudZero CBF, Vantage, FOCUS CSV.
2//!
3//! Turns the tamper-evident savings ledger into daily cost rows a FinOps
4//! platform can ingest for showback/chargeback. The ledger is the *only*
5//! source — every exported number is backed by a hash-chained event with the
6//! model price pinned at recording time (`unit_price_per_m_usd`), so a price
7//! change never rewrites history. No separate pricing table to maintain.
8//!
9//! Savings representation (ADR, per platform):
10//! - CBF: `lineitem/type=Discount` rows with negative `cost/cost` — CBF's
11//!   documented mechanism for rate reductions.
12//! - FOCUS / Vantage: `ChargeCategory=Credit` rows with negative
13//!   `BilledCost` — FOCUS's category for granted reductions; keeps Usage
14//!   spend clean for budgeting while savings stay drillable.
15
16pub mod aliases;
17pub mod cbf;
18pub mod focus;
19pub mod vantage;
20
21use std::collections::BTreeMap;
22
23/// One day × project × agent × model × tool aggregate from the ledger.
24#[derive(Debug, Clone, PartialEq)]
25pub struct DailyCostRow {
26    /// `YYYY-MM-DD` (UTC, from the event timestamp).
27    pub date: String,
28    /// Privacy-preserving project identifier (truncated repo hash — the
29    /// ledger never stores paths). Map to readable names downstream.
30    pub project: String,
31    /// Recording agent identity (role attribution).
32    pub agent_role: String,
33    pub model: String,
34    pub tool: String,
35    /// Tokens actually sent through lean-ctx (the billed reality).
36    pub tokens_actual: u64,
37    /// Verified tokens saved (bounce-adjusted).
38    pub tokens_saved: u64,
39    /// Cost of the actual tokens at the event-pinned model price.
40    pub cost_usd: f64,
41    /// Verified savings valued at the same pinned price.
42    pub savings_usd: f64,
43}
44
45/// Inclusive date-range filter, `YYYY-MM-DD` strings (lexicographic compare
46/// is correct for ISO dates).
47#[derive(Debug, Clone, Default)]
48pub struct DateRange {
49    pub from: Option<String>,
50    pub to: Option<String>,
51}
52
53impl DateRange {
54    fn contains(&self, date: &str) -> bool {
55        if let Some(f) = &self.from
56            && date < f.as_str()
57        {
58            return false;
59        }
60        if let Some(t) = &self.to
61            && date > t.as_str()
62        {
63            return false;
64        }
65        true
66    }
67}
68
69/// Aggregate the ledger into daily rows. Events outside the range (or with
70/// a malformed timestamp) are skipped.
71pub fn aggregate(range: &DateRange) -> Vec<DailyCostRow> {
72    let Some(path) = crate::core::savings_ledger::store::default_path() else {
73        return Vec::new();
74    };
75    aggregate_events(&crate::core::savings_ledger::store::load(&path), range)
76}
77
78fn aggregate_events(
79    events: &[crate::core::savings_ledger::SavingsEvent],
80    range: &DateRange,
81) -> Vec<DailyCostRow> {
82    let mut map: BTreeMap<(String, String, String, String, String), DailyCostRow> = BTreeMap::new();
83
84    for ev in events {
85        let Some(date) = ev.ts.get(..10) else {
86            continue;
87        };
88        if date.len() != 10 || !range.contains(date) {
89            continue;
90        }
91        let key = (
92            date.to_string(),
93            ev.repo_hash.clone(),
94            ev.agent_id.clone(),
95            ev.model_id.clone(),
96            ev.tool.clone(),
97        );
98        let row = map.entry(key).or_insert_with(|| DailyCostRow {
99            date: date.to_string(),
100            project: ev.repo_hash.clone(),
101            agent_role: ev.agent_id.clone(),
102            model: ev.model_id.clone(),
103            tool: ev.tool.clone(),
104            tokens_actual: 0,
105            tokens_saved: 0,
106            cost_usd: 0.0,
107            savings_usd: 0.0,
108        });
109        let net_saved = ev.saved_tokens.saturating_sub(ev.bounce_adjustment);
110        row.tokens_actual += ev.actual_tokens;
111        row.tokens_saved += net_saved;
112        row.cost_usd += ev.actual_tokens as f64 / 1_000_000.0 * ev.unit_price_per_m_usd;
113        row.savings_usd += ev.saved_usd;
114    }
115
116    map.into_values().collect()
117}
118
119/// RFC 4180 CSV field quoting (quote when the value contains `, " \n`).
120pub(crate) fn csv_field(v: &str) -> String {
121    if v.contains([',', '"', '\n']) {
122        format!("\"{}\"", v.replace('"', "\"\""))
123    } else {
124        v.to_string()
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::core::savings_ledger::SavingsEvent;
132
133    pub(crate) fn event(
134        ts: &str,
135        repo: &str,
136        model: &str,
137        actual: u64,
138        saved: u64,
139    ) -> SavingsEvent {
140        SavingsEvent {
141            ts: ts.into(),
142            tool: "ctx_read".into(),
143            mechanism: crate::core::savings_ledger::MECHANISM_COMPRESSION.into(),
144            model_id: model.into(),
145            tokenizer: "o200k_base".into(),
146            baseline_tokens: actual + saved,
147            actual_tokens: actual,
148            saved_tokens: saved,
149            bounce_adjustment: 0,
150            unit_price_per_m_usd: 2.5,
151            saved_usd: saved as f64 / 1_000_000.0 * 2.5,
152            repo_hash: repo.into(),
153            agent_id: "coder".into(),
154            prev_hash: String::new(),
155            entry_hash: String::new(),
156            version: "3.9.0".into(),
157        }
158    }
159
160    #[test]
161    fn aggregates_per_day_and_dimensions() {
162        let events = vec![
163            event("2026-06-01T08:00:00+00:00", "proj_a", "claude", 300, 700),
164            event("2026-06-01T09:00:00+00:00", "proj_a", "claude", 100, 900),
165            event("2026-06-02T08:00:00+00:00", "proj_a", "claude", 50, 50),
166            event("2026-06-01T08:00:00+00:00", "proj_b", "gpt", 10, 90),
167        ];
168        let rows = aggregate_events(&events, &DateRange::default());
169        assert_eq!(rows.len(), 3, "day×project×model groups");
170
171        let a1 = rows
172            .iter()
173            .find(|r| r.project == "proj_a" && r.date == "2026-06-01")
174            .unwrap();
175        assert_eq!(a1.tokens_actual, 400);
176        assert_eq!(a1.tokens_saved, 1600);
177        assert!((a1.cost_usd - 400.0 / 1e6 * 2.5).abs() < 1e-12);
178        assert!((a1.savings_usd - 1600.0 / 1e6 * 2.5).abs() < 1e-12);
179    }
180
181    #[test]
182    fn date_range_filters_inclusively() {
183        let events = vec![
184            event("2026-06-01T08:00:00+00:00", "p", "m", 1, 1),
185            event("2026-06-02T08:00:00+00:00", "p", "m", 1, 1),
186            event("2026-06-03T08:00:00+00:00", "p", "m", 1, 1),
187        ];
188        let range = DateRange {
189            from: Some("2026-06-02".into()),
190            to: Some("2026-06-02".into()),
191        };
192        let rows = aggregate_events(&events, &range);
193        assert_eq!(rows.len(), 1);
194        assert_eq!(rows[0].date, "2026-06-02");
195    }
196
197    #[test]
198    fn bounce_adjustment_reduces_savings() {
199        let mut ev = event("2026-06-01T08:00:00+00:00", "p", "m", 100, 1000);
200        ev.bounce_adjustment = 400;
201        let rows = aggregate_events(&[ev], &DateRange::default());
202        assert_eq!(rows[0].tokens_saved, 600);
203    }
204
205    #[test]
206    fn csv_field_quotes_specials() {
207        assert_eq!(csv_field("plain"), "plain");
208        assert_eq!(csv_field("a,b"), "\"a,b\"");
209        assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
210    }
211}