Skip to main content

cbtop/cost_tracker/
mod.rs

1//! Cost and Energy Efficiency Tracker (PMAT-042)
2//!
3//! Track inference cost per token and energy consumption per operation.
4//!
5//! # Features
6//!
7//! - Energy consumption tracking (joules, kWh)
8//! - Cost calculation with provider pricing
9//! - Cost per token and throughput metrics
10//! - Carbon emissions estimation
11//!
12//! # Falsification Criteria (F1341-F1350)
13//!
14//! See `tests/cost_tracker_f1341.rs` for falsification tests.
15
16use std::collections::HashMap;
17
18/// Joules per kWh
19pub const JOULES_PER_KWH: f64 = 3_600_000.0;
20
21/// Default grid carbon intensity (gCO2/kWh)
22pub const DEFAULT_CARBON_INTENSITY: f64 = 400.0;
23
24/// Cloud provider
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum CloudProvider {
27    /// Amazon Web Services
28    Aws,
29    /// Google Cloud Platform
30    Gcp,
31    /// Microsoft Azure
32    Azure,
33    /// On-premise / Self-hosted
34    OnPrem,
35}
36
37impl CloudProvider {
38    /// Get provider name
39    pub fn name(&self) -> &'static str {
40        match self {
41            Self::Aws => "AWS",
42            Self::Gcp => "GCP",
43            Self::Azure => "Azure",
44            Self::OnPrem => "On-Premise",
45        }
46    }
47}
48
49/// GPU pricing tier
50#[derive(Debug, Clone)]
51pub struct GpuPricing {
52    /// Provider
53    pub provider: CloudProvider,
54    /// GPU type (e.g., "A100", "H100")
55    pub gpu_type: String,
56    /// Price per hour (USD)
57    pub price_per_hour: f64,
58    /// Energy consumption (watts)
59    pub power_watts: f64,
60}
61
62impl GpuPricing {
63    /// Create new pricing
64    pub fn new(
65        provider: CloudProvider,
66        gpu_type: &str,
67        price_per_hour: f64,
68        power_watts: f64,
69    ) -> Self {
70        Self {
71            provider,
72            gpu_type: gpu_type.to_string(),
73            price_per_hour,
74            power_watts,
75        }
76    }
77
78    /// Price per second
79    pub fn price_per_second(&self) -> f64 {
80        self.price_per_hour / 3600.0
81    }
82
83    /// Energy per second (joules)
84    pub fn joules_per_second(&self) -> f64 {
85        self.power_watts
86    }
87}
88
89/// Pre-defined GPU pricing (approximate, January 2026)
90pub fn default_gpu_pricing() -> Vec<GpuPricing> {
91    vec![
92        // AWS
93        GpuPricing::new(CloudProvider::Aws, "A100-40GB", 4.10, 400.0),
94        GpuPricing::new(CloudProvider::Aws, "A100-80GB", 5.12, 400.0),
95        GpuPricing::new(CloudProvider::Aws, "H100", 8.22, 700.0),
96        // GCP
97        GpuPricing::new(CloudProvider::Gcp, "A100-40GB", 3.67, 400.0),
98        GpuPricing::new(CloudProvider::Gcp, "A100-80GB", 4.87, 400.0),
99        GpuPricing::new(CloudProvider::Gcp, "H100", 7.65, 700.0),
100        // Azure
101        GpuPricing::new(CloudProvider::Azure, "A100-40GB", 3.85, 400.0),
102        GpuPricing::new(CloudProvider::Azure, "A100-80GB", 4.95, 400.0),
103        GpuPricing::new(CloudProvider::Azure, "H100", 8.00, 700.0),
104        // On-prem (electricity only, ~$0.10/kWh)
105        GpuPricing::new(CloudProvider::OnPrem, "A100-40GB", 0.04, 400.0),
106        GpuPricing::new(CloudProvider::OnPrem, "H100", 0.07, 700.0),
107    ]
108}
109
110/// Energy measurement
111#[derive(Debug, Clone, Default)]
112pub struct EnergyMeasurement {
113    /// Joules consumed
114    pub joules: f64,
115    /// Duration in seconds
116    pub duration_sec: f64,
117    /// Power in watts
118    pub power_watts: f64,
119}
120
121impl EnergyMeasurement {
122    /// Create from power and duration
123    pub fn from_power_duration(power_watts: f64, duration_sec: f64) -> Self {
124        Self {
125            joules: power_watts * duration_sec,
126            duration_sec,
127            power_watts,
128        }
129    }
130
131    /// Create from joules and duration
132    pub fn from_joules_duration(joules: f64, duration_sec: f64) -> Self {
133        let power_watts = if duration_sec > 0.0 {
134            joules / duration_sec
135        } else {
136            0.0
137        };
138        Self {
139            joules,
140            duration_sec,
141            power_watts,
142        }
143    }
144
145    /// Get kWh
146    pub fn kwh(&self) -> f64 {
147        self.joules / JOULES_PER_KWH
148    }
149}
150
151/// Cost calculation result
152#[derive(Debug, Clone)]
153pub struct CostResult {
154    /// Total cost (USD)
155    pub total_cost: f64,
156    /// Cost per token
157    pub cost_per_token: f64,
158    /// Cost per million tokens
159    pub cost_per_million_tokens: f64,
160    /// Energy consumed (joules)
161    pub energy_joules: f64,
162    /// Energy consumed (kWh)
163    pub energy_kwh: f64,
164    /// Carbon emissions (gCO2)
165    pub carbon_g: f64,
166    /// Duration (seconds)
167    pub duration_sec: f64,
168    /// Token count
169    pub token_count: u64,
170}
171
172impl CostResult {
173    /// Create from components
174    pub fn new(
175        cost: f64,
176        energy_joules: f64,
177        carbon_g: f64,
178        duration_sec: f64,
179        token_count: u64,
180    ) -> Self {
181        let cost_per_token = if token_count > 0 {
182            cost / token_count as f64
183        } else {
184            0.0
185        };
186
187        Self {
188            total_cost: cost,
189            cost_per_token,
190            cost_per_million_tokens: cost_per_token * 1_000_000.0,
191            energy_joules,
192            energy_kwh: energy_joules / JOULES_PER_KWH,
193            carbon_g,
194            duration_sec,
195            token_count,
196        }
197    }
198
199    /// Format as JSON
200    pub fn to_json(&self) -> String {
201        format!(
202            r#"{{"total_cost":{:.6},"cost_per_million_tokens":{:.4},"energy_kwh":{:.6},"carbon_g":{:.2},"duration_sec":{:.2},"token_count":{}}}"#,
203            self.total_cost,
204            self.cost_per_million_tokens,
205            self.energy_kwh,
206            self.carbon_g,
207            self.duration_sec,
208            self.token_count
209        )
210    }
211}
212
213/// Cost comparison between baseline and current
214#[derive(Debug, Clone)]
215pub struct CostComparison {
216    /// Baseline cost
217    pub baseline: CostResult,
218    /// Current cost
219    pub current: CostResult,
220    /// Cost change percent
221    pub cost_change_percent: f64,
222    /// Energy change percent
223    pub energy_change_percent: f64,
224    /// Is regression (cost increased)
225    pub is_regression: bool,
226}
227
228impl CostComparison {
229    /// Create comparison
230    pub fn new(baseline: CostResult, current: CostResult) -> Self {
231        let cost_change_percent = if baseline.total_cost > 0.0 {
232            ((current.total_cost - baseline.total_cost) / baseline.total_cost) * 100.0
233        } else {
234            0.0
235        };
236
237        let energy_change_percent = if baseline.energy_joules > 0.0 {
238            ((current.energy_joules - baseline.energy_joules) / baseline.energy_joules) * 100.0
239        } else {
240            0.0
241        };
242
243        Self {
244            is_regression: cost_change_percent > 5.0, // >5% cost increase is regression
245            baseline,
246            current,
247            cost_change_percent,
248            energy_change_percent,
249        }
250    }
251}
252
253/// Budget alert
254#[derive(Debug, Clone)]
255pub struct BudgetAlert {
256    /// Alert message
257    pub message: String,
258    /// Current spend
259    pub current_spend: f64,
260    /// Budget limit
261    pub budget_limit: f64,
262    /// Percent used
263    pub percent_used: f64,
264}
265
266/// Cost tracker
267#[derive(Debug)]
268pub struct CostTracker {
269    /// GPU pricing database
270    pricing: HashMap<String, GpuPricing>,
271    /// Current GPU type
272    current_gpu: String,
273    /// Current provider
274    current_provider: CloudProvider,
275    /// Carbon intensity (gCO2/kWh)
276    carbon_intensity: f64,
277    /// Historical cost records
278    history: Vec<CostResult>,
279    /// Max history size
280    max_history: usize,
281    /// Budget limit (USD)
282    budget_limit: Option<f64>,
283    /// Total spend
284    total_spend: f64,
285}
286
287impl Default for CostTracker {
288    fn default() -> Self {
289        Self::new()
290    }
291}
292
293impl CostTracker {
294    /// Create new tracker
295    pub fn new() -> Self {
296        let pricing: HashMap<String, GpuPricing> = default_gpu_pricing()
297            .into_iter()
298            .map(|p| (format!("{}-{}", p.provider.name(), p.gpu_type), p))
299            .collect();
300
301        Self {
302            pricing,
303            current_gpu: "A100-40GB".to_string(),
304            current_provider: CloudProvider::Aws,
305            carbon_intensity: DEFAULT_CARBON_INTENSITY,
306            history: Vec::new(),
307            max_history: 1000,
308            budget_limit: None,
309            total_spend: 0.0,
310        }
311    }
312
313    /// Set current GPU
314    pub fn with_gpu(mut self, provider: CloudProvider, gpu_type: &str) -> Self {
315        self.current_provider = provider;
316        self.current_gpu = gpu_type.to_string();
317        self
318    }
319
320    /// Set carbon intensity
321    pub fn with_carbon_intensity(mut self, intensity: f64) -> Self {
322        self.carbon_intensity = intensity;
323        self
324    }
325
326    /// Set budget limit
327    pub fn with_budget(mut self, limit: f64) -> Self {
328        self.budget_limit = Some(limit);
329        self
330    }
331
332    /// Get current pricing
333    fn current_pricing(&self) -> Option<&GpuPricing> {
334        let key = format!("{}-{}", self.current_provider.name(), self.current_gpu);
335        self.pricing.get(&key)
336    }
337
338    /// Calculate cost for duration and tokens
339    pub fn calculate_cost(&mut self, duration_sec: f64, token_count: u64) -> CostResult {
340        let pricing = self.current_pricing().cloned().unwrap_or_else(|| {
341            GpuPricing::new(self.current_provider, &self.current_gpu, 5.0, 400.0)
342        });
343
344        let cost = pricing.price_per_second() * duration_sec;
345        let energy_joules = pricing.joules_per_second() * duration_sec;
346        let energy_kwh = energy_joules / JOULES_PER_KWH;
347        let carbon_g = energy_kwh * self.carbon_intensity;
348
349        let result = CostResult::new(cost, energy_joules, carbon_g, duration_sec, token_count);
350
351        // Track spend
352        self.total_spend += cost;
353
354        // Store in history
355        self.history.push(result.clone());
356        while self.history.len() > self.max_history {
357            self.history.remove(0);
358        }
359
360        result
361    }
362
363    /// Calculate cost from energy measurement
364    pub fn calculate_from_energy(
365        &mut self,
366        energy: &EnergyMeasurement,
367        token_count: u64,
368    ) -> CostResult {
369        let pricing = self.current_pricing().cloned().unwrap_or_else(|| {
370            GpuPricing::new(self.current_provider, &self.current_gpu, 5.0, 400.0)
371        });
372
373        let cost = pricing.price_per_second() * energy.duration_sec;
374        let energy_kwh = energy.kwh();
375        let carbon_g = energy_kwh * self.carbon_intensity;
376
377        let result = CostResult::new(
378            cost,
379            energy.joules,
380            carbon_g,
381            energy.duration_sec,
382            token_count,
383        );
384
385        self.total_spend += cost;
386
387        self.history.push(result.clone());
388        while self.history.len() > self.max_history {
389            self.history.remove(0);
390        }
391
392        result
393    }
394
395    /// Get total spend
396    pub fn total_spend(&self) -> f64 {
397        self.total_spend
398    }
399
400    /// Check budget
401    pub fn check_budget(&self) -> Option<BudgetAlert> {
402        let limit = self.budget_limit?;
403
404        let percent_used = (self.total_spend / limit) * 100.0;
405
406        if percent_used >= 80.0 {
407            Some(BudgetAlert {
408                message: format!(
409                    "Budget alert: {:.1}% used (${:.2} of ${:.2})",
410                    percent_used, self.total_spend, limit
411                ),
412                current_spend: self.total_spend,
413                budget_limit: limit,
414                percent_used,
415            })
416        } else {
417            None
418        }
419    }
420
421    /// Detect cost creep (trend analysis)
422    pub fn detect_cost_creep(&self) -> Option<f64> {
423        if self.history.len() < 10 {
424            return None;
425        }
426
427        // Compare last 10 to previous 10
428        let recent: f64 = self
429            .history
430            .iter()
431            .rev()
432            .take(10)
433            .map(|r| r.cost_per_million_tokens)
434            .sum::<f64>()
435            / 10.0;
436
437        let older_start = self.history.len().saturating_sub(20);
438        let older: f64 = self.history[older_start..older_start + 10.min(self.history.len() - 10)]
439            .iter()
440            .map(|r| r.cost_per_million_tokens)
441            .sum::<f64>()
442            / 10.0;
443
444        if older > 0.0 {
445            let change = ((recent - older) / older) * 100.0;
446            if change > 10.0 {
447                return Some(change);
448            }
449        }
450
451        None
452    }
453
454    /// Get cost history
455    pub fn history(&self) -> &[CostResult] {
456        &self.history
457    }
458
459    /// Export history to CSV
460    pub fn export_csv(&self) -> String {
461        let mut lines = vec![
462            "duration_sec,token_count,total_cost,cost_per_million,energy_kwh,carbon_g".to_string(),
463        ];
464
465        for result in &self.history {
466            lines.push(format!(
467                "{:.2},{},{:.6},{:.4},{:.6},{:.2}",
468                result.duration_sec,
469                result.token_count,
470                result.total_cost,
471                result.cost_per_million_tokens,
472                result.energy_kwh,
473                result.carbon_g
474            ));
475        }
476
477        lines.join("\n")
478    }
479
480    /// Export history to JSON
481    pub fn export_json(&self) -> String {
482        let entries: Vec<String> = self.history.iter().map(|r| r.to_json()).collect();
483        format!("[{}]", entries.join(","))
484    }
485
486    /// Clear history
487    pub fn clear_history(&mut self) {
488        self.history.clear();
489        self.total_spend = 0.0;
490    }
491}
492
493#[cfg(test)]
494mod tests;