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            intent_tag: None,
158            outcome: None,
159            model_original: None,
160            model_routed: None,
161            routing_savings: None,
162            response_original_tokens: None,
163            response_delivered_tokens: None,
164            agent_chain_id: None,
165            chain_depth: None,
166            measurement_method: None,
167            evidence_class: None,
168            confidence: None,
169            quality_signal: None,
170            attribution_group: None,
171            attribution_id: None,
172            baseline_ref: None,
173            price_version: None,
174            customer_approval: None,
175            settlement_status: None,
176            is_first_inject: None,
177            cache_read_per_m_usd: None,
178            cache_write_per_m_usd: None,
179        }
180    }
181
182    #[test]
183    fn aggregates_per_day_and_dimensions() {
184        let events = vec![
185            event("2026-06-01T08:00:00+00:00", "proj_a", "claude", 300, 700),
186            event("2026-06-01T09:00:00+00:00", "proj_a", "claude", 100, 900),
187            event("2026-06-02T08:00:00+00:00", "proj_a", "claude", 50, 50),
188            event("2026-06-01T08:00:00+00:00", "proj_b", "gpt", 10, 90),
189        ];
190        let rows = aggregate_events(&events, &DateRange::default());
191        assert_eq!(rows.len(), 3, "day×project×model groups");
192
193        let a1 = rows
194            .iter()
195            .find(|r| r.project == "proj_a" && r.date == "2026-06-01")
196            .unwrap();
197        assert_eq!(a1.tokens_actual, 400);
198        assert_eq!(a1.tokens_saved, 1600);
199        assert!((a1.cost_usd - 400.0 / 1e6 * 2.5).abs() < 1e-12);
200        assert!((a1.savings_usd - 1600.0 / 1e6 * 2.5).abs() < 1e-12);
201    }
202
203    #[test]
204    fn date_range_filters_inclusively() {
205        let events = vec![
206            event("2026-06-01T08:00:00+00:00", "p", "m", 1, 1),
207            event("2026-06-02T08:00:00+00:00", "p", "m", 1, 1),
208            event("2026-06-03T08:00:00+00:00", "p", "m", 1, 1),
209        ];
210        let range = DateRange {
211            from: Some("2026-06-02".into()),
212            to: Some("2026-06-02".into()),
213        };
214        let rows = aggregate_events(&events, &range);
215        assert_eq!(rows.len(), 1);
216        assert_eq!(rows[0].date, "2026-06-02");
217    }
218
219    #[test]
220    fn bounce_adjustment_reduces_savings() {
221        let mut ev = event("2026-06-01T08:00:00+00:00", "p", "m", 100, 1000);
222        ev.bounce_adjustment = 400;
223        let rows = aggregate_events(&[ev], &DateRange::default());
224        assert_eq!(rows[0].tokens_saved, 600);
225    }
226
227    #[test]
228    fn csv_field_quotes_specials() {
229        assert_eq!(csv_field("plain"), "plain");
230        assert_eq!(csv_field("a,b"), "\"a,b\"");
231        assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
232    }
233}