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