|
1 | 1 | // SPDX-License-Identifier: PMPL-1.0-or-later |
2 | 2 | // Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
3 | | -// ABI module for eclexiaiser — Idris2 proof types for Eclexia interface correctness. |
| 3 | +// |
| 4 | +// ABI module for eclexiaiser — core types for energy budgets, carbon intensity, |
| 5 | +// sustainability reporting, and Eclexia constraint generation. |
| 6 | +// |
| 7 | +// These types form the contract between manifest parsing, code instrumentation, |
| 8 | +// and report generation. In the full ABI-FFI architecture, corresponding Idris2 |
| 9 | +// definitions in `src/abi/*.idr` would provide formal proofs of interface |
| 10 | +// correctness, and Zig FFI in `ffi/zig/` would expose C-ABI bindings. |
| 11 | + |
| 12 | +use serde::{Deserialize, Serialize}; |
| 13 | + |
| 14 | +/// Energy budget for a single function, expressed in millijoules (mJ). |
| 15 | +/// |
| 16 | +/// An `EnergyBudget` constrains how much energy a single invocation of a |
| 17 | +/// function is allowed to consume. Exceeding the budget triggers a warning |
| 18 | +/// or error in the sustainability report. |
| 19 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 20 | +pub struct EnergyBudget { |
| 21 | + /// Maximum energy consumption per call, in millijoules. |
| 22 | + pub max_millijoules: f64, |
| 23 | +} |
| 24 | + |
| 25 | +impl EnergyBudget { |
| 26 | + /// Create a new energy budget with the given millijoule limit. |
| 27 | + /// |
| 28 | + /// # Panics |
| 29 | + /// Panics if `max_millijoules` is negative. |
| 30 | + pub fn new(max_millijoules: f64) -> Self { |
| 31 | + assert!( |
| 32 | + max_millijoules >= 0.0, |
| 33 | + "Energy budget cannot be negative: {max_millijoules}" |
| 34 | + ); |
| 35 | + Self { max_millijoules } |
| 36 | + } |
| 37 | + |
| 38 | + /// Check whether a measured energy value exceeds this budget. |
| 39 | + pub fn is_exceeded_by(&self, measured_millijoules: f64) -> bool { |
| 40 | + measured_millijoules > self.max_millijoules |
| 41 | + } |
| 42 | + |
| 43 | + /// Calculate the percentage of budget used by a measurement. |
| 44 | + pub fn usage_percent(&self, measured_millijoules: f64) -> f64 { |
| 45 | + if self.max_millijoules == 0.0 { |
| 46 | + if measured_millijoules > 0.0 { |
| 47 | + return f64::INFINITY; |
| 48 | + } |
| 49 | + return 0.0; |
| 50 | + } |
| 51 | + (measured_millijoules / self.max_millijoules) * 100.0 |
| 52 | + } |
| 53 | +} |
| 54 | + |
| 55 | +/// Carbon budget for a single function, expressed in milligrams of CO2 equivalent. |
| 56 | +/// |
| 57 | +/// This constrains the carbon emissions attributable to a function call, computed |
| 58 | +/// from energy consumption multiplied by grid carbon intensity. |
| 59 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 60 | +pub struct CarbonBudget { |
| 61 | + /// Maximum CO2 emissions per call, in milligrams. |
| 62 | + pub max_mg_co2: f64, |
| 63 | +} |
| 64 | + |
| 65 | +impl CarbonBudget { |
| 66 | + /// Create a new carbon budget with the given mg CO2 limit. |
| 67 | + pub fn new(max_mg_co2: f64) -> Self { |
| 68 | + assert!( |
| 69 | + max_mg_co2 >= 0.0, |
| 70 | + "Carbon budget cannot be negative: {max_mg_co2}" |
| 71 | + ); |
| 72 | + Self { max_mg_co2 } |
| 73 | + } |
| 74 | + |
| 75 | + /// Check whether a measured carbon value exceeds this budget. |
| 76 | + pub fn is_exceeded_by(&self, measured_mg_co2: f64) -> bool { |
| 77 | + measured_mg_co2 > self.max_mg_co2 |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +/// Carbon intensity provider — the source of grid carbon intensity data. |
| 82 | +/// |
| 83 | +/// Eclexia supports multiple providers for real-time or static carbon intensity |
| 84 | +/// values, enabling accurate carbon accounting per grid region. |
| 85 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 86 | +#[serde(rename_all = "lowercase")] |
| 87 | +pub enum CarbonProvider { |
| 88 | + /// WattTime API — real-time marginal emissions data. |
| 89 | + Watttime, |
| 90 | + /// Electricity Maps API — real-time lifecycle emissions. |
| 91 | + ElectricityMaps, |
| 92 | + /// Static intensity value, useful for offline or testing scenarios. |
| 93 | + Static, |
| 94 | +} |
| 95 | + |
| 96 | +impl CarbonProvider { |
| 97 | + /// Return a human-readable display name for the provider. |
| 98 | + pub fn display_name(&self) -> &'static str { |
| 99 | + match self { |
| 100 | + CarbonProvider::Watttime => "WattTime", |
| 101 | + CarbonProvider::ElectricityMaps => "Electricity Maps", |
| 102 | + CarbonProvider::Static => "Static", |
| 103 | + } |
| 104 | + } |
| 105 | +} |
| 106 | + |
| 107 | +/// Carbon configuration section — provider, region, and static fallback. |
| 108 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 109 | +pub struct CarbonConfig { |
| 110 | + /// Which carbon intensity provider to use. |
| 111 | + pub provider: CarbonProvider, |
| 112 | + /// Grid region code (e.g., "GB", "DE", "US-CAL-CISO"). |
| 113 | + pub region: String, |
| 114 | + /// Static intensity in mg CO2 per kWh (used when provider is Static). |
| 115 | + #[serde(rename = "static-intensity", default)] |
| 116 | + pub static_intensity: f64, |
| 117 | +} |
| 118 | + |
| 119 | +impl CarbonConfig { |
| 120 | + /// Compute carbon emissions in mg CO2 from energy in millijoules. |
| 121 | + /// |
| 122 | + /// Uses the static intensity value. For real-time providers, the actual |
| 123 | + /// intensity would be fetched from the API at measurement time; this method |
| 124 | + /// serves as the fallback calculation. |
| 125 | + /// |
| 126 | + /// Formula: mg_co2 = (millijoules / 3_600_000) * (mg_co2_per_kwh) |
| 127 | + /// Because 1 kWh = 3,600,000,000 mJ, but intensity is in mg/kWh: |
| 128 | + /// mg_co2 = millijoules * intensity / 3_600_000_000 |
| 129 | + pub fn estimate_carbon_mg(&self, millijoules: f64) -> f64 { |
| 130 | + millijoules * self.static_intensity / 3_600_000_000.0 |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +/// Report format for sustainability output. |
| 135 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 136 | +#[serde(rename_all = "lowercase")] |
| 137 | +pub enum ReportFormat { |
| 138 | + /// Plain text report, human-readable. |
| 139 | + Text, |
| 140 | + /// JSON report, machine-readable. |
| 141 | + Json, |
| 142 | + /// EU CSRD (Corporate Sustainability Reporting Directive) compliant format. |
| 143 | + Csrd, |
| 144 | +} |
| 145 | + |
| 146 | +/// Report configuration section. |
| 147 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 148 | +pub struct ReportConfig { |
| 149 | + /// Output format for the sustainability report. |
| 150 | + pub format: ReportFormat, |
| 151 | + /// Whether to include actionable recommendations for reducing impact. |
| 152 | + #[serde(rename = "include-recommendations", default)] |
| 153 | + pub include_recommendations: bool, |
| 154 | +} |
| 155 | + |
| 156 | +/// A single function's energy/carbon budget configuration, as declared in the manifest. |
| 157 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 158 | +pub struct FunctionBudget { |
| 159 | + /// Function name (fully qualified or short). |
| 160 | + pub name: String, |
| 161 | + /// Source file containing the function. |
| 162 | + pub source: String, |
| 163 | + /// Energy budget in millijoules per call (None = unbounded). |
| 164 | + #[serde(rename = "energy-budget-mj")] |
| 165 | + pub energy_budget_mj: Option<f64>, |
| 166 | + /// Carbon budget in mg CO2 per call (None = unbounded). |
| 167 | + #[serde(rename = "carbon-budget-mg")] |
| 168 | + pub carbon_budget_mg: Option<f64>, |
| 169 | +} |
| 170 | + |
| 171 | +impl FunctionBudget { |
| 172 | + /// Build an `EnergyBudget` from this function's config, if specified. |
| 173 | + pub fn energy_budget(&self) -> Option<EnergyBudget> { |
| 174 | + self.energy_budget_mj.map(EnergyBudget::new) |
| 175 | + } |
| 176 | + |
| 177 | + /// Build a `CarbonBudget` from this function's config, if specified. |
| 178 | + pub fn carbon_budget(&self) -> Option<CarbonBudget> { |
| 179 | + self.carbon_budget_mg.map(CarbonBudget::new) |
| 180 | + } |
| 181 | +} |
| 182 | + |
| 183 | +/// Compliance status for a single function measurement against its budget. |
| 184 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 185 | +pub enum ComplianceStatus { |
| 186 | + /// Within budget (usage <= 100%). |
| 187 | + Compliant, |
| 188 | + /// Approaching budget (usage > 80% but <= 100%). |
| 189 | + Warning, |
| 190 | + /// Over budget (usage > 100%). |
| 191 | + Exceeded, |
| 192 | + /// No budget was set, so compliance is not applicable. |
| 193 | + Unbounded, |
| 194 | +} |
| 195 | + |
| 196 | +impl ComplianceStatus { |
| 197 | + /// Determine compliance status from a budget usage percentage. |
| 198 | + pub fn from_usage_percent(percent: f64) -> Self { |
| 199 | + if percent > 100.0 { |
| 200 | + ComplianceStatus::Exceeded |
| 201 | + } else if percent > 80.0 { |
| 202 | + ComplianceStatus::Warning |
| 203 | + } else { |
| 204 | + ComplianceStatus::Compliant |
| 205 | + } |
| 206 | + } |
| 207 | + |
| 208 | + /// Return a short label for display purposes. |
| 209 | + pub fn label(&self) -> &'static str { |
| 210 | + match self { |
| 211 | + ComplianceStatus::Compliant => "COMPLIANT", |
| 212 | + ComplianceStatus::Warning => "WARNING", |
| 213 | + ComplianceStatus::Exceeded => "EXCEEDED", |
| 214 | + ComplianceStatus::Unbounded => "UNBOUNDED", |
| 215 | + } |
| 216 | + } |
| 217 | +} |
| 218 | + |
| 219 | +/// A measurement result for a single function, including energy, carbon, and compliance. |
| 220 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 221 | +pub struct FunctionMeasurement { |
| 222 | + /// Name of the measured function. |
| 223 | + pub function_name: String, |
| 224 | + /// Measured energy in millijoules. |
| 225 | + pub measured_energy_mj: f64, |
| 226 | + /// Estimated carbon in mg CO2. |
| 227 | + pub estimated_carbon_mg: f64, |
| 228 | + /// Energy budget compliance. |
| 229 | + pub energy_compliance: ComplianceStatus, |
| 230 | + /// Carbon budget compliance. |
| 231 | + pub carbon_compliance: ComplianceStatus, |
| 232 | +} |
| 233 | + |
| 234 | +/// A complete sustainability report for a project. |
| 235 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 236 | +pub struct SustainabilityReport { |
| 237 | + /// Project name. |
| 238 | + pub project_name: String, |
| 239 | + /// Grid region used for carbon calculations. |
| 240 | + pub region: String, |
| 241 | + /// Carbon intensity provider name. |
| 242 | + pub provider: String, |
| 243 | + /// Per-function measurement results. |
| 244 | + pub measurements: Vec<FunctionMeasurement>, |
| 245 | + /// Total energy across all measured functions, in millijoules. |
| 246 | + pub total_energy_mj: f64, |
| 247 | + /// Total estimated carbon across all measured functions, in mg CO2. |
| 248 | + pub total_carbon_mg: f64, |
| 249 | + /// Overall compliance: true if all functions are compliant or warning. |
| 250 | + pub all_compliant: bool, |
| 251 | + /// Actionable recommendations (empty if not requested). |
| 252 | + pub recommendations: Vec<String>, |
| 253 | +} |
| 254 | + |
| 255 | +impl SustainabilityReport { |
| 256 | + /// Return the count of functions that exceeded their energy budget. |
| 257 | + pub fn energy_violations(&self) -> usize { |
| 258 | + self.measurements |
| 259 | + .iter() |
| 260 | + .filter(|m| m.energy_compliance == ComplianceStatus::Exceeded) |
| 261 | + .count() |
| 262 | + } |
| 263 | + |
| 264 | + /// Return the count of functions that exceeded their carbon budget. |
| 265 | + pub fn carbon_violations(&self) -> usize { |
| 266 | + self.measurements |
| 267 | + .iter() |
| 268 | + .filter(|m| m.carbon_compliance == ComplianceStatus::Exceeded) |
| 269 | + .count() |
| 270 | + } |
| 271 | +} |
| 272 | + |
| 273 | +#[cfg(test)] |
| 274 | +mod tests { |
| 275 | + use super::*; |
| 276 | + |
| 277 | + #[test] |
| 278 | + fn test_energy_budget_compliance() { |
| 279 | + let budget = EnergyBudget::new(50.0); |
| 280 | + assert!(!budget.is_exceeded_by(49.9)); |
| 281 | + assert!(!budget.is_exceeded_by(50.0)); |
| 282 | + assert!(budget.is_exceeded_by(50.1)); |
| 283 | + assert!((budget.usage_percent(25.0) - 50.0).abs() < f64::EPSILON); |
| 284 | + } |
| 285 | + |
| 286 | + #[test] |
| 287 | + fn test_carbon_budget_compliance() { |
| 288 | + let budget = CarbonBudget::new(10.0); |
| 289 | + assert!(!budget.is_exceeded_by(10.0)); |
| 290 | + assert!(budget.is_exceeded_by(10.001)); |
| 291 | + } |
| 292 | + |
| 293 | + #[test] |
| 294 | + fn test_carbon_estimation() { |
| 295 | + let config = CarbonConfig { |
| 296 | + provider: CarbonProvider::Static, |
| 297 | + region: "GB".to_string(), |
| 298 | + static_intensity: 200.0, |
| 299 | + }; |
| 300 | + // 3,600,000,000 mJ = 1 kWh => at 200 mg/kWh => 200 mg |
| 301 | + // 3,600,000 mJ => 0.001 kWh => 0.2 mg |
| 302 | + let carbon = config.estimate_carbon_mg(3_600_000.0); |
| 303 | + assert!((carbon - 0.2).abs() < 1e-6); |
| 304 | + } |
| 305 | + |
| 306 | + #[test] |
| 307 | + fn test_compliance_status_from_percent() { |
| 308 | + assert_eq!( |
| 309 | + ComplianceStatus::from_usage_percent(50.0), |
| 310 | + ComplianceStatus::Compliant |
| 311 | + ); |
| 312 | + assert_eq!( |
| 313 | + ComplianceStatus::from_usage_percent(85.0), |
| 314 | + ComplianceStatus::Warning |
| 315 | + ); |
| 316 | + assert_eq!( |
| 317 | + ComplianceStatus::from_usage_percent(101.0), |
| 318 | + ComplianceStatus::Exceeded |
| 319 | + ); |
| 320 | + } |
| 321 | + |
| 322 | + #[test] |
| 323 | + fn test_function_budget_builders() { |
| 324 | + let fb = FunctionBudget { |
| 325 | + name: "process".to_string(), |
| 326 | + source: "src/lib.rs".to_string(), |
| 327 | + energy_budget_mj: Some(50.0), |
| 328 | + carbon_budget_mg: None, |
| 329 | + }; |
| 330 | + assert!(fb.energy_budget().is_some()); |
| 331 | + assert!(fb.carbon_budget().is_none()); |
| 332 | + } |
| 333 | +} |
0 commit comments