Skip to main content

sentinel_core/score/
carbon.rs

1//! `GreenOps` gCO₂eq conversion: static region-based carbon intensity
2//! table embedded at compile time, no network egress.
3//!
4//! See `docs/design/05-GREENOPS-AND-CARBON.md` for the SCI methodology,
5//! the per-region intensity sources (Cloud Carbon Footprint, Electricity
6//! Maps, ENTSO-E), the per-operation energy coefficients (Xu, Tsirogiannis,
7//! `DBJoules`) and the network transport model (Mytton et al. 2024).
8
9use std::collections::HashMap;
10use std::sync::Arc;
11
12use serde::{Deserialize, Serialize};
13
14use crate::event::SpanEvent;
15use crate::score::electricity_maps::config::{
16    ApiVersion, ElectricityMapsConfig, EmissionFactorType, TemporalGranularity,
17};
18
19pub use super::carbon_profiles::HourlyProfile;
20pub(crate) use super::carbon_profiles::HourlyProfileRef;
21
22/// Estimated energy consumed per I/O operation in kWh.
23///
24/// This is a rough order-of-magnitude approximation (~0.1 µWh per I/O op).
25/// It accounts for a typical database query or HTTP round-trip on cloud
26/// infrastructure, including CPU, memory, and network overhead.
27///
28/// Not a measured value. See `docs/design/05-GREENOPS-AND-CARBON.md`.
29pub const ENERGY_PER_IO_OP_KWH: f64 = 0.000_000_1;
30
31// Per-operation energy multipliers (proxy model only).
32// See docs/design/05-GREENOPS-AND-CARBON.md for sources and rationale.
33
34const SQL_SELECT_COEFF: f64 = 0.5; // read-only index lookup
35const SQL_INSERT_COEFF: f64 = 1.5; // WAL write + data page write
36const SQL_UPDATE_COEFF: f64 = 1.5; // read + write
37const SQL_DELETE_COEFF: f64 = 1.2; // mark + WAL
38const SQL_OTHER_COEFF: f64 = 1.0; // DDL, EXPLAIN, BEGIN, etc.
39
40const HTTP_SMALL_COEFF: f64 = 0.8; // payload < 10 KB
41const HTTP_MEDIUM_COEFF: f64 = 1.2; // payload 10 KB to 1 MB
42const HTTP_LARGE_COEFF: f64 = 2.0; // payload > 1 MB
43
44const HTTP_SMALL_THRESHOLD: u64 = 10 * 1024; // 10 KB
45const HTTP_LARGE_THRESHOLD: u64 = 1024 * 1024; // 1 MB
46
47/// Network transport energy per byte (kWh/byte). 0.04 kWh/GB, an upper
48/// bound for cross-region server traffic, applied only when
49/// `include_network_transport` is enabled. Sources and the power-model
50/// critique are in `docs/design/05-GREENOPS-AND-CARBON.md` § "Network
51/// transport energy".
52pub const DEFAULT_NETWORK_ENERGY_PER_BYTE_KWH: f64 = 0.000_000_000_04;
53
54/// Lower bound factor for the CO₂ confidence interval (`low = mid × 0.5`).
55/// 2x multiplicative uncertainty, log-symmetric.
56pub const CO2_LOW_FACTOR: f64 = 0.5;
57
58/// Upper bound factor for the CO₂ confidence interval (`high = mid × 2.0`).
59pub const CO2_HIGH_FACTOR: f64 = 2.0;
60
61/// Carbon estimation model: flat annual proxy.
62pub const CO2_MODEL: &str = "io_proxy_v1";
63
64/// Carbon estimation model: hourly carbon intensity profiles.
65pub const CO2_MODEL_V2: &str = "io_proxy_v2";
66
67/// Carbon estimation model: monthly x hourly carbon intensity profiles.
68/// Precedence: `alumet_rapl` > `scaphandre_rapl` > `kepler_ebpf` > `redfish_bmc` > `cloud_specpower` > `io_proxy_v3` > `io_proxy_v2` > `io_proxy_v1`.
69pub const CO2_MODEL_V3: &str = "io_proxy_v3";
70
71/// Carbon estimation model: Alumet RAPL measurement.
72/// Highest measured-energy precedence. Ranks above Scaphandre: both read
73/// RAPL, but Alumet's sampling is measurably less error-prone (Raffin
74/// and Trystram, "Dissecting the Software-Based Measurement of CPU
75/// Energy Consumption: A Comparative Analysis", IEEE Transactions on
76/// Parallel and Distributed Systems, 2024, <https://hal.science/hal-04420527v2>),
77/// and it attributes per cgroup rather than per process.
78pub const CO2_MODEL_ALUMET: &str = "alumet_rapl";
79
80/// Carbon estimation model: Scaphandre per-process RAPL measurement.
81/// Sits below Alumet (also RAPL) and above Kepler (`x86_64` only,
82/// RAPL-dependent).
83pub const CO2_MODEL_SCAPHANDRE: &str = "scaphandre_rapl";
84
85/// Carbon estimation model: Kepler eBPF + perf-counter measurement.
86/// Sits between Scaphandre (RAPL) and the cloud `SPECpower` interpolation.
87/// Works on ARM with degraded precision vs the Scaphandre x86 path.
88pub const CO2_MODEL_KEPLER: &str = "kepler_ebpf";
89
90/// Carbon estimation model: Redfish BMC wall-plug power reading.
91/// Bare-metal only, node-level granularity (single shared coefficient
92/// across services on the same chassis).
93pub const CO2_MODEL_REDFISH: &str = "redfish_bmc";
94
95/// Carbon estimation model: cloud CPU% + `SPECpower` interpolation.
96/// Precedence: `alumet_rapl` > `scaphandre_rapl` > `kepler_ebpf` > `redfish_bmc` > `cloud_specpower` > `io_proxy_v3` > `io_proxy_v2` > `io_proxy_v1`.
97pub const CO2_MODEL_CLOUD_SPECPOWER: &str = "cloud_specpower";
98
99/// Carbon intensity source: Electricity Maps real-time API data.
100/// Highest precedence for the intensity dimension (independent of the
101/// energy model tag which tracks Scaphandre/cloud/proxy).
102pub const CO2_MODEL_EMAPS: &str = "electricity_maps_api";
103
104/// Suffix appended to the proxy model tag when calibration factors are active.
105pub const CO2_MODEL_CAL_SUFFIX: &str = "+cal";
106
107/// Calibrated proxy model tags (static variants to avoid dynamic allocation).
108pub const CO2_MODEL_V1_CAL: &str = "io_proxy_v1+cal";
109pub const CO2_MODEL_V2_CAL: &str = "io_proxy_v2+cal";
110pub const CO2_MODEL_V3_CAL: &str = "io_proxy_v3+cal";
111
112/// Methodology tag: SCI v1.0 numerator `(E x I) + M` summed over traces.
113/// Not the per-R intensity. See design doc for SCI semantics.
114pub const METHODOLOGY_SCI_NUMERATOR: &str = "sci_v1_numerator";
115
116/// Methodology tag: SCI v1.0 numerator with network transport energy added.
117/// `(E x I) + M + T` where `T` is network transport CO2. Used when
118/// `[green] include_network_transport = true` and transport CO2 > 0.
119pub const METHODOLOGY_SCI_NUMERATOR_TRANSPORT: &str = "sci_v1_numerator+transport";
120
121/// Methodology tag: avoidable CO2 via `operational * (avoidable_ops / accounted_ops)`.
122/// Region-blind, excludes embodied.
123pub const METHODOLOGY_OPERATIONAL_RATIO: &str = "sci_v1_operational_ratio";
124
125/// Methodology tag: SCI v1.0 per-R intensity `((E x I) + M) / R`, R = 1 trace.
126/// The SCI score proper (an intensity), distinct from the numerator footprint.
127pub const METHODOLOGY_SCI_INTENSITY: &str = "sci_v1_intensity";
128
129/// SCI `M` term: embodied carbon per request in gCO₂eq. Conservative
130/// upper bound for lightly-loaded servers. Override via
131/// `[green] embodied_carbon_per_request_gco2`. Derivation in design doc.
132pub const DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2: f64 = 0.001;
133
134/// Generic PUE for regions not associated with a specific cloud
135/// provider, also the fallback for out-of-table regions with a custom
136/// hourly profile. Tracks the Uptime Institute survey average,
137/// deliberately rounded to one decimal (the survey plateau spans
138/// 1.5-1.6). Sources in `docs/design/05-GREENOPS-AND-CARBON.md`
139/// § "PUE values".
140pub const GENERIC_PUE: f64 = 1.5;
141
142/// Vintage of the per-provider PUE constants embedded in `Provider::pue`.
143/// Release procedure step 2.5 surfaces this string via `grep`. Bump when
144/// any provider's published sustainability report supersedes the value
145/// in the table.
146#[allow(dead_code)]
147pub(crate) const PUE_VINTAGE: &str = "2026 refresh (AWS 2024 global, GCP 2024 fleet, Azure FY25)";
148
149/// Synthetic region label for events with no resolved region.
150pub const UNKNOWN_REGION: &str = "unknown";
151
152/// Region is in the embedded carbon table.
153pub const REGION_STATUS_KNOWN: &str = "known";
154
155/// Region name resolved but not in the carbon table (`co2_gco2 = 0.0`).
156pub const REGION_STATUS_OUT_OF_TABLE: &str = "out_of_table";
157
158/// Synthetic "unknown" bucket for unresolved events (`co2_gco2 = 0.0`).
159pub const REGION_STATUS_UNRESOLVED: &str = "unresolved";
160
161/// Per-service measured energy-per-op with provenance tag.
162#[derive(Debug, Clone, Copy)]
163pub struct EnergyEntry {
164    /// Energy consumed per I/O operation, in kWh.
165    pub energy_per_op_kwh: f64,
166    /// Model tag identifying the measurement source. One of
167    /// [`CO2_MODEL_ALUMET`], [`CO2_MODEL_SCAPHANDRE`],
168    /// [`CO2_MODEL_KEPLER`], [`CO2_MODEL_REDFISH`], or
169    /// [`CO2_MODEL_CLOUD_SPECPOWER`].
170    pub model_tag: &'static str,
171}
172
173impl EnergyEntry {
174    /// Build an entry from an Alumet RAPL measurement.
175    #[must_use]
176    pub const fn alumet(energy_per_op_kwh: f64) -> Self {
177        Self {
178            energy_per_op_kwh,
179            model_tag: CO2_MODEL_ALUMET,
180        }
181    }
182
183    /// Build an entry from a Scaphandre RAPL measurement.
184    #[must_use]
185    pub const fn scaphandre(energy_per_op_kwh: f64) -> Self {
186        Self {
187            energy_per_op_kwh,
188            model_tag: CO2_MODEL_SCAPHANDRE,
189        }
190    }
191
192    /// Build an entry from a Kepler eBPF measurement.
193    #[must_use]
194    pub const fn kepler(energy_per_op_kwh: f64) -> Self {
195        Self {
196            energy_per_op_kwh,
197            model_tag: CO2_MODEL_KEPLER,
198        }
199    }
200
201    /// Build an entry from a Redfish BMC wall-plug measurement.
202    #[must_use]
203    pub const fn redfish(energy_per_op_kwh: f64) -> Self {
204        Self {
205            energy_per_op_kwh,
206            model_tag: CO2_MODEL_REDFISH,
207        }
208    }
209
210    /// Build an entry from a cloud `SPECpower` interpolation.
211    #[must_use]
212    pub const fn cloud(energy_per_op_kwh: f64) -> Self {
213        Self {
214            energy_per_op_kwh,
215            model_tag: CO2_MODEL_CLOUD_SPECPOWER,
216        }
217    }
218}
219
220/// CO₂ point estimate with 2x multiplicative uncertainty interval.
221///
222/// `model` and `methodology` are `String` (not `&'static str`) so the
223/// struct can be round-tripped through serde. In-process construction
224/// still uses static string constants; the one-time `.to_string()` at
225/// build time is negligible next to the numeric work around it.
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227pub struct CarbonEstimate {
228    pub low: f64,
229    pub mid: f64,
230    pub high: f64,
231    pub model: String,
232    pub methodology: String,
233}
234
235impl CarbonEstimate {
236    /// Derive `low`/`high` from midpoint using multiplicative factors.
237    pub(crate) fn new_with_model(mid: f64, model: &'static str, methodology: &'static str) -> Self {
238        Self {
239            low: mid * CO2_LOW_FACTOR,
240            mid,
241            high: mid * CO2_HIGH_FACTOR,
242            model: model.to_string(),
243            methodology: methodology.to_string(),
244        }
245    }
246
247    /// SCI v1.0 numerator estimate with default proxy v1 model.
248    #[must_use]
249    pub fn sci_numerator(mid: f64) -> Self {
250        Self::new_with_model(mid, CO2_MODEL, METHODOLOGY_SCI_NUMERATOR)
251    }
252
253    /// Avoidable CO₂ estimate with default proxy v1 model.
254    #[must_use]
255    pub fn operational_ratio(mid: f64) -> Self {
256        Self::new_with_model(mid, CO2_MODEL, METHODOLOGY_OPERATIONAL_RATIO)
257    }
258
259    /// SCI v1.0 numerator estimate with explicit model tag.
260    #[must_use]
261    pub fn sci_numerator_with_model(mid: f64, model: &'static str) -> Self {
262        Self::new_with_model(mid, model, METHODOLOGY_SCI_NUMERATOR)
263    }
264
265    /// Avoidable CO₂ estimate with explicit model tag.
266    #[must_use]
267    pub fn operational_ratio_with_model(mid: f64, model: &'static str) -> Self {
268        Self::new_with_model(mid, model, METHODOLOGY_OPERATIONAL_RATIO)
269    }
270}
271
272/// Structured carbon report aligned with the SCI v1.0 model.
273///
274/// Carries the per-run carbon estimate with two SCI-aligned views:
275/// `total` is the SCI numerator `(E × I) + M` summed over analyzed traces,
276/// `avoidable` is the region-blind operational ratio approximation.
277/// Each estimate carries a 2× multiplicative uncertainty bracket.
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct CarbonReport {
280    /// Total estimated CO₂ (operational + embodied) with confidence interval.
281    pub total: CarbonEstimate,
282    /// Estimated CO₂ that could be saved by eliminating I/O waste.
283    /// Excludes the embodied term (you can't optimize away manufactured
284    /// silicon by fixing N+1 queries).
285    pub avoidable: CarbonEstimate,
286    /// SCI `O = E × I` term: operational emissions from running the workload.
287    pub operational_gco2: f64,
288    /// SCI `M` term: embodied hardware emissions amortized per request.
289    /// Region-independent.
290    pub embodied_gco2: f64,
291    /// Network transport CO₂ for cross-region HTTP calls (gCO₂eq).
292    /// Only present when `[green] include_network_transport = true`
293    /// and at least one cross-region HTTP call had response size data.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub transport_gco2: Option<f64>,
296    /// SCI v1.0 per-functional-unit intensity: `total / R`, R = 1 trace.
297    /// The SCI score proper (an intensity), distinct from `total` (the
298    /// numerator footprint). Methodology tag `sci_v1_intensity`. Optional
299    /// only for backward-compatible deserialization of pre-0.8.13 baselines.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub sci_per_trace: Option<CarbonEstimate>,
302    /// SCI functional unit `R`. "trace" maps to the SCI spec's Transaction /
303    /// database read-or-write functional unit. Empty only on old baselines.
304    #[serde(default, skip_serializing_if = "String::is_empty")]
305    pub functional_unit: String,
306}
307
308/// Whether a region row used the flat annual, 24-hour, monthly x hourly profile,
309/// or real-time data from the Electricity Maps API.
310/// Variants are ordered by fidelity: `Annual` < `Hourly` < `MonthlyHourly` < `RealTime`.
311#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
312#[serde(rename_all = "snake_case")]
313pub enum IntensitySource {
314    #[default]
315    Annual,
316    Hourly,
317    MonthlyHourly,
318    /// Real-time data from Electricity Maps API (highest fidelity).
319    RealTime,
320}
321
322/// Per-region operational CO₂ breakdown row in `green_summary.regions[]`.
323///
324/// `status` is `String` (not `&'static str`) so the struct can be
325/// round-tripped through serde. Construction sites use the
326/// `REGION_STATUS_*` constants and pay a one-time `.to_string()` cost.
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
328pub struct RegionBreakdown {
329    /// `"known"` / `"out_of_table"` / `"unresolved"`.
330    pub status: String,
331    pub region: String,
332    /// Ops-weighted mean grid intensity (gCO₂eq/kWh). `0.0` if out-of-table.
333    pub grid_intensity_gco2_kwh: f64,
334    pub pue: f64,
335    pub io_ops: usize,
336    pub co2_gco2: f64,
337    #[serde(default)]
338    pub intensity_source: IntensitySource,
339    /// Whether the real-time intensity was estimated by `Electricity Maps`
340    /// rather than measured directly. Only present when
341    /// `intensity_source == RealTime`. `Some(true)` means estimated,
342    /// `Some(false)` means measured, `None` means unknown (the API
343    /// did not surface the field).
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub intensity_estimated: Option<bool>,
346    /// Estimation algorithm tag returned by `Electricity Maps`
347    /// alongside an estimated value, e.g. `"TIME_SLICER_AVERAGE"`.
348    /// Only present when `intensity_estimated == Some(true)`.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub intensity_estimation_method: Option<String>,
351}
352
353/// Carbon scoring configuration. Built via [`Config::carbon_context()`].
354/// `Default` is for tests only (`embodied = 0.0`, not the config default).
355#[derive(Debug, Clone)]
356pub struct CarbonContext {
357    pub default_region: Option<String>,
358    /// Keys lowercased at config load.
359    pub service_regions: HashMap<String, String>,
360    pub embodied_per_request_gco2: f64,
361    pub use_hourly_profiles: bool,
362    /// Measured energy from Scaphandre/cloud scrapers (daemon only).
363    pub energy_snapshot: Option<HashMap<String, EnergyEntry>>,
364    /// SQL verb / HTTP size tier weighting (proxy model only).
365    pub per_operation_coefficients: bool,
366    pub include_network_transport: bool,
367    pub network_energy_per_byte_kwh: f64,
368    /// User-supplied hourly profiles from `[green] hourly_profiles_file`.
369    /// Keys are pre-lowercased region identifiers.
370    /// Takes precedence over embedded profiles. Wrapped in `Arc` so the
371    /// daemon can clone the context per tick without deep-copying profiles.
372    pub custom_hourly_profiles: Option<Arc<HashMap<String, HourlyProfile>>>,
373    /// Per-service calibration factors from `[green] calibration_file`.
374    /// Multiplied with the proxy model `ENERGY_PER_IO_OP_KWH` per service.
375    pub calibration: Option<crate::calibrate::CalibrationData>,
376    /// Real-time grid intensity from Electricity Maps (daemon only).
377    /// Keys are lowercased cloud region names, values carry gCO2/kWh
378    /// plus the optional `isEstimated` / `estimationMethod` metadata
379    /// surfaced by the API.
380    pub real_time_intensity: Option<HashMap<String, RealTimeIntensityEntry>>,
381    /// Active Electricity Maps scoring configuration (API version,
382    /// emission factor type, temporal granularity). Surfaced on
383    /// [`crate::report::GreenSummary::scoring_config`] so auditors can
384    /// verify which carbon model produced the numbers without reading
385    /// the operator's TOML. `None` when Electricity Maps is not
386    /// configured.
387    pub scoring_config: Option<ScoringConfig>,
388    /// Declared database measured by Alumet (`[green.alumet.database]`).
389    /// `Some` with `window_kwh = 0.0` in the base context; the daemon
390    /// patches `window_kwh` per tick with the energy accumulated since
391    /// the previous scored batch. Batch mode builds the same `Some`
392    /// when configured, but `window_kwh` stays `0.0` there (no
393    /// scraper), so no figure is ever emitted.
394    pub db_energy: Option<DbEnergyContext>,
395}
396
397/// Window energy of the declared database cgroup, feeding
398/// [`crate::report::GreenSummary::database_waste`].
399#[derive(Debug, Clone, Default, PartialEq)]
400pub struct DbEnergyContext {
401    /// kWh since the previous scored batch, `0.0` = no reading.
402    pub window_kwh: f64,
403    /// Operator-declared region, `None` skips the carbon conversion.
404    pub region: Option<String>,
405}
406
407/// Active Electricity Maps scoring configuration. Three dimensions
408/// surfaced together because they all influence the carbon numbers:
409/// API version (v3 deprecated, v4 default), emission factor model
410/// (lifecycle default, direct opt-in), temporal granularity (hourly
411/// default, sub-hour opt-in). Built via
412/// [`ScoringConfig::from_electricity_maps`] at config load time.
413#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
414pub struct ScoringConfig {
415    pub api_version: ApiVersion,
416    pub emission_factor_type: EmissionFactorType,
417    pub temporal_granularity: TemporalGranularity,
418}
419
420impl ScoringConfig {
421    /// Build from the live Electricity Maps config. Used by
422    /// [`crate::config::Config::carbon_context`] when the daemon (or
423    /// the analyze pipeline) has the `[green.electricity_maps]` block
424    /// loaded.
425    #[must_use]
426    pub fn from_electricity_maps(cfg: &ElectricityMapsConfig) -> Self {
427        Self {
428            api_version: ApiVersion::from_endpoint(&cfg.api_endpoint),
429            emission_factor_type: cfg.emission_factor_type,
430            temporal_granularity: cfg.temporal_granularity,
431        }
432    }
433}
434
435/// One real-time intensity value from `Electricity Maps`, carrying the
436/// optional `isEstimated` and `estimationMethod` metadata fields the
437/// API surfaces alongside `carbonIntensity`. Plumbed through
438/// [`CarbonContext::real_time_intensity`] so the per-region breakdown
439/// can flag when the value was estimated rather than measured.
440#[derive(Debug, Clone)]
441#[must_use]
442pub struct RealTimeIntensityEntry {
443    /// Grid intensity in gCO₂eq/kWh.
444    pub gco2_per_kwh: f64,
445    /// `Some(true)` if the API marked this value as estimated,
446    /// `Some(false)` if explicitly measured, `None` if the field was
447    /// absent from the response (forward-compatibility with API
448    /// versions that may stop emitting it).
449    pub is_estimated: Option<bool>,
450    /// Method tag returned alongside an estimated value, e.g.
451    /// `"TIME_SLICER_AVERAGE"` or `"GENERAL_PURPOSE_ZONE_DEVELOPMENT"`.
452    /// Typically `Some` only when `is_estimated == Some(true)`.
453    pub estimation_method: Option<String>,
454}
455
456impl RealTimeIntensityEntry {
457    /// Build a measured entry with no estimation metadata. Convenience
458    /// constructor for tests and callers that only have a raw `f64`.
459    pub fn measured(gco2_per_kwh: f64) -> Self {
460        Self {
461            gco2_per_kwh,
462            is_estimated: None,
463            estimation_method: None,
464        }
465    }
466}
467
468impl Default for CarbonContext {
469    fn default() -> Self {
470        Self {
471            default_region: None,
472            service_regions: HashMap::new(),
473            embodied_per_request_gco2: 0.0,
474            use_hourly_profiles: true,
475            energy_snapshot: None,
476            per_operation_coefficients: true,
477            include_network_transport: false,
478            network_energy_per_byte_kwh: DEFAULT_NETWORK_ENERGY_PER_BYTE_KWH,
479            custom_hourly_profiles: None,
480            calibration: None,
481            real_time_intensity: None,
482            scoring_config: None,
483            db_energy: None,
484        }
485    }
486}
487
488/// Database waste kWh → gCO₂: real-time intensity when available,
489/// embedded annual otherwise, times provider PUE. A region unknown to
490/// the embedded table still converts with [`GENERIC_PUE`] when a
491/// real-time entry covers it (custom on-prem region ids), matching the
492/// per-span fallback. `None` only when no intensity exists at all.
493#[must_use]
494pub(crate) fn db_waste_gco2(waste_kwh: f64, region: &str, ctx: &CarbonContext) -> Option<f64> {
495    let region_lower = region.to_ascii_lowercase();
496    let real_time = ctx
497        .real_time_intensity
498        .as_ref()
499        .and_then(|m| m.get(&region_lower))
500        .map(|e| e.gco2_per_kwh);
501    let (intensity, pue) = match (lookup_region_lower(&region_lower), real_time) {
502        (Some((_, pue)), Some(rt)) => (rt, pue),
503        (Some((annual, pue)), None) => (annual, pue),
504        (None, Some(rt)) => (rt, GENERIC_PUE),
505        (None, None) => return None,
506    };
507    Some(per_op_gco2(waste_kwh, intensity, pue))
508}
509
510/// Resolve region: `cloud_region` > `service_regions` > `default_region` > `None`.
511#[must_use]
512pub fn resolve_region<'a>(event: &'a SpanEvent, ctx: &'a CarbonContext) -> Option<&'a str> {
513    if let Some(region) = event.cloud_region.as_deref() {
514        return Some(region);
515    }
516    // Probe-before-allocate: skip lowercase when service is already lowercase.
517    if !ctx.service_regions.is_empty() {
518        let lookup = if event.service.bytes().any(|b| b.is_ascii_uppercase()) {
519            ctx.service_regions.get(&event.service.to_ascii_lowercase())
520        } else {
521            ctx.service_regions.get(event.service.as_ref())
522        };
523        if let Some(region) = lookup {
524            return Some(region.as_str());
525        }
526    }
527    ctx.default_region.as_deref()
528}
529
530/// Validate a region identifier (`OTel` `cloud.region` attribute value or
531/// a config-provided region key).
532///
533/// Acceptance rule: **ASCII alphanumeric + `-` + `_`, length 1-64**.
534/// Covers all cloud-provider region naming conventions (`eu-west-3`,
535/// `us-east-1`, `europe-west9`, `francecentral`) and ISO country codes
536/// (`fr`, `de`, `us`) while rejecting control characters (log-forging
537/// protection), spaces, and oversized inputs (memory-exhaustion
538/// protection).
539///
540/// Used at the OTLP ingestion boundary (fail-silent: invalid values are
541/// replaced with `None`) and at config load time (fail-loud: invalid
542/// values cause a config error).
543#[must_use]
544pub(crate) fn is_valid_region_id(s: &str) -> bool {
545    !s.is_empty()
546        && s.len() <= 64
547        && s.bytes()
548            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
549}
550
551/// Cloud provider identifier for PUE lookup.
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553pub(super) enum Provider {
554    Aws,
555    Gcp,
556    Azure,
557    Generic,
558}
559
560impl Provider {
561    /// Power Usage Effectiveness for this provider.
562    const fn pue(self) -> f64 {
563        match self {
564            Self::Aws => 1.15,
565            Self::Gcp => 1.09,
566            Self::Azure => 1.17,
567            Self::Generic => GENERIC_PUE,
568        }
569    }
570}
571
572/// Hand-refreshed rows: regions on subnational grids. The refresh
573/// source (Ember) is national-only and these grids diverge beyond the
574/// 2x uncertainty bracket (us-west-2 = 89, hydro Oregon, vs ~370 US
575/// national). Values: CCF and Electricity Maps 2023-2024
576/// consumption-based averages. The `ca` rows carry a hydro-dominant
577/// zone value and the `br` rows the BR-CS (Central-South) zone value
578/// containing Sao Paulo; both hourly profiles are normalized to those
579/// levels, not to the national average. Nationally-gridded rows live
580/// in `carbon_data.rs`.
581///
582/// PUE values come from each provider's latest sustainability report
583/// (AWS 2024 global, GCP 2024 fleet, Azure FY25), 2026 refresh cycle.
584static MANUAL_CARBON_ROWS: &[(&str, f64, Provider)] = &[
585    // AWS regions
586    ("us-east-1", 379.0, Provider::Aws),
587    ("us-east-2", 410.0, Provider::Aws),
588    ("us-west-1", 200.0, Provider::Aws),
589    ("us-west-2", 89.0, Provider::Aws),
590    ("ca-central-1", 13.0, Provider::Aws), // Canada (hydro-dominant zone)
591    ("sa-east-1", 96.0, Provider::Aws),    // Sao Paulo (BR-CS zone)
592    // GCP regions
593    ("us-central1", 426.0, Provider::Gcp),
594    ("us-east1", 379.0, Provider::Gcp),
595    ("us-west1", 89.0, Provider::Gcp),
596    // Azure regions
597    ("eastus", 379.0, Provider::Azure),
598    ("westus2", 89.0, Provider::Azure),
599    // Country / ISO codes (generic PUE)
600    ("ca", 13.0, Provider::Generic),
601    ("br", 96.0, Provider::Generic), // BR-CS zone, matches sa-east-1
602];
603
604/// Pre-built map for O(1) region lookup (keys are lowercase).
605/// Chains the generated rows (`carbon_data.rs`) with the manual rows
606/// above; keys are disjoint by construction.
607static REGION_MAP: std::sync::LazyLock<HashMap<&'static str, (f64, Provider)>> =
608    std::sync::LazyLock::new(|| {
609        super::carbon_data::GENERATED_CARBON_ROWS
610            .iter()
611            .chain(MANUAL_CARBON_ROWS)
612            .map(|&(key, intensity, provider)| (key, (intensity, provider)))
613            .collect()
614    });
615
616/// Pre-built map for O(1) hourly profile lookup (keys are lowercase).
617/// Merges flat-year profiles, monthly profiles, and aliases.
618static HOURLY_REGION_MAP: std::sync::LazyLock<HashMap<&'static str, HourlyProfileRef<'static>>> =
619    std::sync::LazyLock::new(|| {
620        use super::carbon_profiles::{FLAT_YEAR_PROFILES, MONTHLY_PROFILES, PROFILE_ALIASES};
621
622        let cap = FLAT_YEAR_PROFILES.len() + MONTHLY_PROFILES.len() + PROFILE_ALIASES.len();
623        let mut map = HashMap::with_capacity(cap);
624        for (key, profile) in FLAT_YEAR_PROFILES {
625            map.insert(*key, HourlyProfileRef::FlatYear(profile));
626        }
627        for (key, profile) in MONTHLY_PROFILES {
628            map.insert(*key, HourlyProfileRef::Monthly(profile));
629        }
630        // Aliases: look up the canonical key and insert a copy of the
631        // reference under the alias key (same static data, zero-copy).
632        for &(alias, canonical) in PROFILE_ALIASES {
633            if let Some(&profile_ref) = map.get(canonical) {
634                map.insert(alias, profile_ref);
635            }
636        }
637        map
638    });
639
640/// Hourly intensity for a pre-lowercased region at UTC hour and optional
641/// month (0-indexed, 0 = January). Returns `None` for unknown regions
642/// or invalid hour/month values.
643#[cfg(test)]
644#[must_use]
645pub(crate) fn lookup_hourly_intensity_lower(
646    region: &str,
647    hour: u8,
648    month: Option<u8>,
649) -> Option<f64> {
650    if hour >= 24 {
651        return None;
652    }
653    if let Some(m) = month
654        && m >= 12
655    {
656        return None;
657    }
658    HOURLY_REGION_MAP
659        .get(region)
660        .map(|profile_ref: &HourlyProfileRef<'_>| profile_ref.intensity_at(hour, month))
661}
662
663/// Look up the profile reference for a pre-lowercased region.
664/// Returns `None` if no hourly profile exists for this region.
665#[must_use]
666pub(crate) fn hourly_profile_for_region_lower(region: &str) -> Option<HourlyProfileRef<'static>> {
667    HOURLY_REGION_MAP.get(region).copied()
668}
669
670/// Resolve hourly intensity with custom profile priority.
671/// Lookup chain: custom > embedded > None.
672///
673/// Returns `(intensity, source)` where `source` indicates
674/// whether a monthly or flat-year profile was used.
675#[cfg(test)]
676#[must_use]
677pub(crate) fn resolve_hourly_intensity(
678    region: &str,
679    hour: u8,
680    month: Option<u8>,
681    custom: Option<&HashMap<String, HourlyProfile>>,
682) -> Option<(f64, IntensitySource)> {
683    if hour >= 24 {
684        return None;
685    }
686    if let Some(m) = month
687        && m >= 12
688    {
689        return None;
690    }
691    // 1. Check custom profiles.
692    if let Some(custom_map) = custom
693        && let Some(profile) = custom_map.get(region)
694    {
695        let val = profile.intensity_at(hour, month);
696        let src = if profile.is_monthly() {
697            IntensitySource::MonthlyHourly
698        } else {
699            IntensitySource::Hourly
700        };
701        return Some((val, src));
702    }
703    // 2. Check embedded profiles.
704    HOURLY_REGION_MAP
705        .get(region)
706        .map(|profile_ref: &HourlyProfileRef<'_>| {
707            let val = profile_ref.intensity_at(hour, month);
708            let src = if profile_ref.is_monthly() {
709                IntensitySource::MonthlyHourly
710            } else {
711                IntensitySource::Hourly
712            };
713            (val, src)
714        })
715}
716
717/// Maximum file size for custom profiles (2 MiB). A 30-region monthly
718/// file with formatting is well under 100 KB; 2 MiB is generous.
719const MAX_PROFILE_FILE_BYTES: u64 = 2 * 1024 * 1024;
720
721/// Maximum plausible grid intensity (gCO2/kWh). No national grid
722/// exceeds ~950 (South Africa, Mongolia). Values above 1000 likely
723/// indicate a unit confusion (mg vs g or kgCO2 vs gCO2).
724const MAX_PLAUSIBLE_INTENSITY: f64 = 1000.0;
725
726/// Maximum number of custom profile entries (same cap as `MAX_REGIONS`).
727const MAX_CUSTOM_PROFILES: usize = 256;
728
729/// Load user-supplied hourly profiles from a JSON file.
730///
731/// Expected format:
732/// ```json
733/// {
734///   "profiles": {
735///     "my-region": { "type": "flat_year", "hours": [24 values] },
736///     "other":     { "type": "monthly", "months": [[24 values] x 12] }
737///   }
738/// }
739/// ```
740///
741/// Validation: dimension checks (24 or 12x24), finite, non-negative.
742/// Warns (does not reject) when mean diverges >5% from embedded annual.
743///
744/// # Errors
745///
746/// Returns `Err` when the file cannot be read, contains invalid JSON,
747/// has wrong dimensions, negative or non-finite values or invalid
748/// region keys.
749pub fn load_custom_profiles(
750    path: &std::path::Path,
751) -> Result<HashMap<String, HourlyProfile>, String> {
752    let content = read_custom_profiles_file(path)?;
753    let raw: serde_json::Value = serde_json::from_str(&content)
754        .map_err(|e| format!("invalid JSON in '{}': {e}", path.display()))?;
755    let profiles_obj = raw
756        .get("profiles")
757        .and_then(|v| v.as_object())
758        .ok_or_else(|| format!("'{}' missing 'profiles' object", path.display()))?;
759    if profiles_obj.len() > MAX_CUSTOM_PROFILES {
760        return Err(format!(
761            "'{}' contains {} profiles, exceeding the {} limit",
762            path.display(),
763            profiles_obj.len(),
764            MAX_CUSTOM_PROFILES
765        ));
766    }
767
768    let mut result = HashMap::with_capacity(profiles_obj.len());
769    for (region, value) in profiles_obj {
770        let region_lower = region.to_ascii_lowercase();
771        if !is_valid_region_id(&region_lower) {
772            return Err(
773                "invalid region key (expected ASCII alphanumeric + '-'/'_', length 1-64)"
774                    .to_string(),
775            );
776        }
777        let profile = parse_single_custom_profile(region, value)?;
778        warn_on_profile_anomalies(&region_lower, &profile);
779        result.insert(region_lower, profile);
780    }
781    Ok(result)
782}
783
784/// Stat-then-read the file, enforcing [`MAX_PROFILE_FILE_BYTES`] before
785/// loading any bytes into memory.
786fn read_custom_profiles_file(path: &std::path::Path) -> Result<String, String> {
787    let metadata =
788        std::fs::metadata(path).map_err(|e| format!("failed to stat '{}': {e}", path.display()))?;
789    if metadata.len() > MAX_PROFILE_FILE_BYTES {
790        return Err(format!(
791            "'{}' is {} bytes, exceeding the {} byte limit",
792            path.display(),
793            metadata.len(),
794            MAX_PROFILE_FILE_BYTES
795        ));
796    }
797    std::fs::read_to_string(path).map_err(|e| format!("failed to read '{}': {e}", path.display()))
798}
799
800/// Dispatch a single `(region, value)` JSON entry to the flat-year or
801/// monthly parser based on the `"type"` field.
802fn parse_single_custom_profile(
803    region: &str,
804    value: &serde_json::Value,
805) -> Result<HourlyProfile, String> {
806    let profile_type = value
807        .get("type")
808        .and_then(|t| t.as_str())
809        .ok_or_else(|| format!("region '{region}': missing 'type' field"))?;
810    match profile_type {
811        "flat_year" => parse_flat_year_profile(region, value),
812        "monthly" => parse_monthly_profile(region, value),
813        _ => Err(format!(
814            "region '{region}': unknown profile type (expected 'flat_year' or 'monthly')"
815        )),
816    }
817}
818
819/// Parse the `hours` array of a `flat_year` profile into a `[f64; 24]`.
820fn parse_flat_year_profile(
821    region: &str,
822    value: &serde_json::Value,
823) -> Result<HourlyProfile, String> {
824    let hours = value
825        .get("hours")
826        .and_then(|h| h.as_array())
827        .ok_or_else(|| format!("region '{region}': missing 'hours' array"))?;
828    if hours.len() != 24 {
829        return Err(format!(
830            "region '{region}': flat_year profile must have exactly 24 values, got {}",
831            hours.len()
832        ));
833    }
834    let mut arr = [0.0_f64; 24];
835    for (i, v) in hours.iter().enumerate() {
836        arr[i] = parse_profile_f64(v, &format!("region '{region}' hour {i}"))?;
837    }
838    Ok(HourlyProfile::FlatYear(arr))
839}
840
841/// Parse the `months` nested array of a `monthly` profile into a
842/// `[[f64; 24]; 12]`. Validates both dimensions strictly.
843fn parse_monthly_profile(region: &str, value: &serde_json::Value) -> Result<HourlyProfile, String> {
844    let months = value
845        .get("months")
846        .and_then(|m| m.as_array())
847        .ok_or_else(|| format!("region '{region}': missing 'months' array"))?;
848    if months.len() != 12 {
849        return Err(format!(
850            "region '{region}': monthly profile must have exactly 12 months, got {}",
851            months.len()
852        ));
853    }
854    let mut arr = [[0.0_f64; 24]; 12];
855    for (m, month_val) in months.iter().enumerate() {
856        let month_arr = month_val
857            .as_array()
858            .ok_or_else(|| format!("region '{region}' month {m}: expected an array"))?;
859        if month_arr.len() != 24 {
860            return Err(format!(
861                "region '{region}' month {m}: must have exactly 24 values, got {}",
862                month_arr.len()
863            ));
864        }
865        for (h, v) in month_arr.iter().enumerate() {
866            arr[m][h] = parse_profile_f64(v, &format!("region '{region}' month {m} hour {h}"))?;
867        }
868    }
869    Ok(HourlyProfile::Monthly(Box::new(arr)))
870}
871
872/// Convert a [`serde_json::Value`] to a finite non-negative `f64` or
873/// return an error prefixed with `context`. Errors are built eagerly
874/// because this is a one-shot config load, not a hot path.
875fn parse_profile_f64(v: &serde_json::Value, context: &str) -> Result<f64, String> {
876    let val = v
877        .as_f64()
878        .ok_or_else(|| format!("{context}: expected a number"))?;
879    if !val.is_finite() || val < 0.0 {
880        return Err(format!(
881            "{context}: value must be finite and non-negative, got {val}"
882        ));
883    }
884    Ok(val)
885}
886
887/// Emit soft warnings on a freshly parsed custom profile:
888/// - Mean divergence > 5% vs the embedded annual value for a known region
889/// - Mean above [`MAX_PLAUSIBLE_INTENSITY`] (likely unit confusion)
890///
891/// Never fails: these are hints to the operator, not validation errors.
892fn warn_on_profile_anomalies(region_lower: &str, profile: &HourlyProfile) {
893    let mean = profile.mean();
894    if let Some(&(annual, _)) = REGION_MAP.get(region_lower)
895        && annual > 0.0
896    {
897        let deviation = (mean - annual).abs() / annual;
898        if deviation > 0.05 {
899            tracing::warn!(
900                region = %region_lower,
901                profile_mean = mean,
902                annual_value = annual,
903                deviation_pct = deviation * 100.0,
904                "Custom hourly profile mean deviates from embedded annual value. \
905                 The profile will be used as-is.",
906            );
907        }
908    }
909    if mean > MAX_PLAUSIBLE_INTENSITY {
910        tracing::warn!(
911            region = %region_lower,
912            profile_mean = mean,
913            "Custom hourly profile has an unusually high mean intensity. \
914             Verify the values are in gCO2/kWh, not mg or another unit.",
915        );
916    }
917}
918
919/// Look up `(intensity, pue)` for a region (case-insensitive).
920#[must_use]
921pub fn lookup_region(region: &str) -> Option<(f64, f64)> {
922    if region.bytes().any(|b| b.is_ascii_uppercase()) {
923        lookup_region_lower(&region.to_ascii_lowercase())
924    } else {
925        lookup_region_lower(region)
926    }
927}
928
929/// Look up `(intensity, pue)` for a pre-lowercased region.
930#[must_use]
931pub(crate) fn lookup_region_lower(region: &str) -> Option<(f64, f64)> {
932    REGION_MAP
933        .get(region)
934        .map(|(intensity, provider)| (*intensity, provider.pue()))
935}
936
937/// `energy × intensity × pue`. Single source of truth for the CO₂ formula.
938#[inline]
939#[must_use]
940pub(crate) fn per_op_gco2(energy_kwh: f64, intensity: f64, pue: f64) -> f64 {
941    energy_kwh * intensity * pue
942}
943
944/// Return the energy multiplier for a span based on its operation type.
945///
946/// For SQL spans: extract the verb from the first word of `target` (the raw
947/// SQL statement). OTLP-ingested spans store `db.system` in `operation`,
948/// not the SQL verb, so we parse `target` instead.
949///
950/// For HTTP spans: classify by `response_size_bytes` into small/medium/large
951/// tiers. Falls back to `1.0` (base) when size is unknown.
952#[inline]
953#[must_use]
954pub(crate) fn energy_coefficient(event: &SpanEvent) -> f64 {
955    match event.event_type {
956        crate::event::EventType::Sql => {
957            let verb = event.target.split_ascii_whitespace().next().unwrap_or("");
958            if verb.eq_ignore_ascii_case("SELECT") {
959                SQL_SELECT_COEFF
960            } else if verb.eq_ignore_ascii_case("INSERT") {
961                SQL_INSERT_COEFF
962            } else if verb.eq_ignore_ascii_case("UPDATE") {
963                SQL_UPDATE_COEFF
964            } else if verb.eq_ignore_ascii_case("DELETE") {
965                SQL_DELETE_COEFF
966            } else {
967                SQL_OTHER_COEFF
968            }
969        }
970        crate::event::EventType::HttpOut => match event.response_size_bytes {
971            Some(size) if size > HTTP_LARGE_THRESHOLD => HTTP_LARGE_COEFF,
972            Some(size) if size >= HTTP_SMALL_THRESHOLD => HTTP_MEDIUM_COEFF,
973            Some(_) => HTTP_SMALL_COEFF,
974            None => 1.0,
975        },
976    }
977}
978
979/// Extract the hostname from an HTTP URL.
980///
981/// Handles `http://host:port/path`, `https://host:port/path`, and
982/// `http://user:pass@host:port/path` (RFC 3986 userinfo) patterns.
983/// Returns `None` if the URL is malformed or not an HTTP URL.
984#[must_use]
985pub(crate) fn extract_hostname(url: &str) -> Option<&str> {
986    let after_scheme = url
987        .strip_prefix("http://")
988        .or_else(|| url.strip_prefix("https://"))?;
989    let host_port = after_scheme.split('/').next()?;
990    // Strip userinfo (RFC 3986): "user:pass@host:port" -> "host:port"
991    let authority = host_port.rsplit('@').next().unwrap_or(host_port);
992    let host = authority.split(':').next()?;
993    if host.is_empty() { None } else { Some(host) }
994}
995
996/// Compute operational CO₂ in gCO₂eq from raw I/O operation count, grid
997/// carbon intensity, and provider PUE.
998///
999/// Single source of truth for the formula
1000/// `gCO₂eq = io_ops × ENERGY_PER_IO_OP_KWH × carbon_intensity × PUE`,
1001/// used by both [`io_ops_to_co2_grams`] (public convenience) and the
1002/// multi-region scoring stage in `score::compute_carbon_report`.
1003///
1004/// implemented as `io_ops × per_op_gco2(...)` to share the
1005/// formula with the hourly and Scaphandre paths.
1006#[must_use]
1007pub(crate) fn compute_operational_gco2(io_ops: usize, intensity: f64, pue: f64) -> f64 {
1008    io_ops as f64 * per_op_gco2(ENERGY_PER_IO_OP_KWH, intensity, pue)
1009}
1010
1011/// Convert I/O operations to estimated gCO₂eq for a **pre-lowercased** region.
1012///
1013/// Formula: `gCO₂eq = io_ops × ENERGY_PER_IO_OP_KWH × carbon_intensity × PUE`
1014/// (see [`compute_operational_gco2`]).
1015///
1016/// Returns `None` if the region is not recognized.
1017#[must_use]
1018pub(crate) fn io_ops_to_co2_grams(io_ops: usize, region: &str) -> Option<f64> {
1019    let (intensity, pue) = lookup_region_lower(region)?;
1020    Some(compute_operational_gco2(io_ops, intensity, pue))
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    use super::*;
1026
1027    // --- hourly profile tests ---
1028
1029    #[test]
1030    fn hourly_profile_present_for_key_regions() {
1031        // The original 4 regions (now Monthly) plus new FlatYear regions.
1032        assert!(hourly_profile_for_region_lower("eu-west-3").is_some());
1033        assert!(hourly_profile_for_region_lower("eu-central-1").is_some());
1034        assert!(hourly_profile_for_region_lower("eu-west-2").is_some());
1035        assert!(hourly_profile_for_region_lower("us-east-1").is_some());
1036        // New FlatYear regions.
1037        assert!(hourly_profile_for_region_lower("eu-west-1").is_some());
1038        assert!(hourly_profile_for_region_lower("eu-west-4").is_some());
1039        assert!(hourly_profile_for_region_lower("eu-north-1").is_some());
1040        assert!(hourly_profile_for_region_lower("europe-west1").is_some());
1041        assert!(hourly_profile_for_region_lower("europe-north1").is_some());
1042        assert!(hourly_profile_for_region_lower("us-east-2").is_some());
1043        assert!(hourly_profile_for_region_lower("us-west-1").is_some());
1044        assert!(hourly_profile_for_region_lower("us-west-2").is_some());
1045        assert!(hourly_profile_for_region_lower("ca-central-1").is_some());
1046        assert!(hourly_profile_for_region_lower("ap-southeast-2").is_some());
1047        assert!(hourly_profile_for_region_lower("ap-northeast-1").is_some());
1048        assert!(hourly_profile_for_region_lower("ap-southeast-1").is_some());
1049        assert!(hourly_profile_for_region_lower("ap-south-1").is_some());
1050        assert!(hourly_profile_for_region_lower("sa-east-1").is_some());
1051    }
1052
1053    #[test]
1054    fn hourly_profile_absent_for_unknown_region() {
1055        assert!(hourly_profile_for_region_lower("mars-1").is_none());
1056        assert!(hourly_profile_for_region_lower("unknown-region").is_none());
1057    }
1058
1059    #[test]
1060    fn hourly_profile_aliases_resolve() {
1061        // Country-code aliases should point to the same profile.
1062        assert!(hourly_profile_for_region_lower("fr").is_some());
1063        assert!(hourly_profile_for_region_lower("de").is_some());
1064        assert!(hourly_profile_for_region_lower("gb").is_some());
1065        assert!(hourly_profile_for_region_lower("ie").is_some());
1066        assert!(hourly_profile_for_region_lower("nl").is_some());
1067        assert!(hourly_profile_for_region_lower("se").is_some());
1068        assert!(hourly_profile_for_region_lower("no").is_some());
1069        assert!(hourly_profile_for_region_lower("jp").is_some());
1070        assert!(hourly_profile_for_region_lower("br").is_some());
1071        // Cloud-provider aliases.
1072        assert!(hourly_profile_for_region_lower("westeurope").is_some());
1073        assert!(hourly_profile_for_region_lower("northeurope").is_some());
1074        assert!(hourly_profile_for_region_lower("uksouth").is_some());
1075        assert!(hourly_profile_for_region_lower("francecentral").is_some());
1076    }
1077
1078    #[test]
1079    fn hourly_profile_original_4_are_monthly() {
1080        // The original 4 regions upgraded to Monthly profiles.
1081        assert!(
1082            hourly_profile_for_region_lower("eu-west-3")
1083                .unwrap()
1084                .is_monthly()
1085        );
1086        assert!(
1087            hourly_profile_for_region_lower("eu-central-1")
1088                .unwrap()
1089                .is_monthly()
1090        );
1091        assert!(
1092            hourly_profile_for_region_lower("eu-west-2")
1093                .unwrap()
1094                .is_monthly()
1095        );
1096        assert!(
1097            hourly_profile_for_region_lower("us-east-1")
1098                .unwrap()
1099                .is_monthly()
1100        );
1101    }
1102
1103    #[test]
1104    fn hourly_profile_new_regions_are_flat_year() {
1105        assert!(
1106            !hourly_profile_for_region_lower("eu-west-1")
1107                .unwrap()
1108                .is_monthly()
1109        );
1110        assert!(
1111            !hourly_profile_for_region_lower("us-east-2")
1112                .unwrap()
1113                .is_monthly()
1114        );
1115        assert!(
1116            !hourly_profile_for_region_lower("ca-central-1")
1117                .unwrap()
1118                .is_monthly()
1119        );
1120    }
1121
1122    #[test]
1123    fn hourly_intensity_lookup_returns_hour_value() {
1124        // France at July (month 6): night should be less than evening peak.
1125        let night_fr = lookup_hourly_intensity_lower("eu-west-3", 3, Some(6)).unwrap();
1126        let evening_fr = lookup_hourly_intensity_lower("eu-west-3", 18, Some(6)).unwrap();
1127        assert!(
1128            night_fr < evening_fr,
1129            "expected night ({night_fr}) < evening peak ({evening_fr}) in eu-west-3 (July)"
1130        );
1131    }
1132
1133    #[test]
1134    fn hourly_intensity_unknown_region_returns_none() {
1135        assert!(lookup_hourly_intensity_lower("mars-1", 10, None).is_none());
1136    }
1137
1138    #[test]
1139    fn hourly_intensity_invalid_hour_returns_none() {
1140        assert!(lookup_hourly_intensity_lower("eu-west-3", 24, None).is_none());
1141        assert!(lookup_hourly_intensity_lower("eu-west-3", 99, None).is_none());
1142    }
1143
1144    #[test]
1145    fn hourly_intensity_invalid_month_returns_none() {
1146        assert!(lookup_hourly_intensity_lower("eu-west-3", 12, Some(12)).is_none());
1147        assert!(lookup_hourly_intensity_lower("eu-west-3", 12, Some(99)).is_none());
1148    }
1149
1150    /// Helper: compute the grand mean of a profile (monthly or flat year).
1151    fn profile_grand_mean(pr: HourlyProfileRef<'_>) -> f64 {
1152        match pr {
1153            HourlyProfileRef::FlatYear(profile) => profile.iter().sum::<f64>() / 24.0,
1154            HourlyProfileRef::Monthly(profiles) => {
1155                let total: f64 = profiles.iter().flat_map(|m| m.iter()).sum();
1156                total / (12.0 * 24.0)
1157            }
1158        }
1159    }
1160
1161    #[test]
1162    fn hourly_profile_mean_close_to_annual_for_fr() {
1163        let pr = hourly_profile_for_region_lower("eu-west-3").unwrap();
1164        let mean = profile_grand_mean(pr);
1165        let annual = lookup_region_lower("eu-west-3").unwrap().0;
1166        let deviation = (mean - annual).abs() / annual;
1167        assert!(
1168            deviation < 0.05,
1169            "fr grand mean {mean:.1} deviates {deviation:.3} from annual {annual}"
1170        );
1171    }
1172
1173    #[test]
1174    fn hourly_profile_mean_close_to_annual_for_us_east() {
1175        let pr = hourly_profile_for_region_lower("us-east-1").unwrap();
1176        let mean = profile_grand_mean(pr);
1177        let annual = lookup_region_lower("us-east-1").unwrap().0;
1178        let deviation = (mean - annual).abs() / annual;
1179        assert!(
1180            deviation < 0.05,
1181            "us-east-1 grand mean {mean:.1} deviates {deviation:.3} from annual {annual}"
1182        );
1183    }
1184
1185    #[test]
1186    fn hourly_profile_mean_close_to_annual_for_gb() {
1187        let pr = hourly_profile_for_region_lower("eu-west-2").unwrap();
1188        let mean = profile_grand_mean(pr);
1189        let annual = lookup_region_lower("eu-west-2").unwrap().0;
1190        let deviation = (mean - annual).abs() / annual;
1191        assert!(
1192            deviation < 0.05,
1193            "gb grand mean {mean:.1} deviates {deviation:.3} from annual {annual}"
1194        );
1195    }
1196
1197    #[test]
1198    fn hourly_profile_de_mean_close_to_annual() {
1199        // The 2022-vintage profile level (grand mean ~431) was rescaled
1200        // to the Electricity Maps 2024 level, so the historical ~31%
1201        // divergence from the annual table is resolved.
1202        let pr = hourly_profile_for_region_lower("eu-central-1").unwrap();
1203        let mean = profile_grand_mean(pr);
1204        let annual = lookup_region_lower("eu-central-1").unwrap().0;
1205        let deviation = (mean - annual).abs() / annual;
1206        assert!(
1207            deviation < 0.05,
1208            "eu-central-1 grand mean {mean:.1} deviates {deviation:.3} from annual {annual}"
1209        );
1210    }
1211
1212    // Mean invariant for all new FlatYear regions.
1213    #[test]
1214    fn hourly_profile_mean_close_to_annual_for_all_flat_year_regions() {
1215        for &(key, ref profile) in crate::score::carbon_profiles::FLAT_YEAR_PROFILES {
1216            let vals: &[f64; 24] = profile;
1217            let mean: f64 = vals.iter().sum::<f64>() / 24.0;
1218            let (annual, _) = lookup_region_lower(key).unwrap_or_else(|| {
1219                panic!("{key} is a canonical profile key but is missing from the carbon intensity table")
1220            });
1221            let deviation = (mean - annual).abs() / annual;
1222            assert!(
1223                deviation < 0.05,
1224                "{key} hourly mean {mean:.1} deviates {deviation:.3} from annual {annual}"
1225            );
1226        }
1227    }
1228
1229    #[test]
1230    fn hourly_profile_mean_close_to_annual_for_all_monthly_regions() {
1231        for &(key, ref months) in crate::score::carbon_profiles::MONTHLY_PROFILES {
1232            let total: f64 = months.iter().flat_map(|m| m.iter()).sum();
1233            let mean = total / (12.0 * 24.0);
1234            let (annual, _) = lookup_region_lower(key).unwrap_or_else(|| {
1235                panic!(
1236                    "{key} is a canonical monthly profile key but is missing from the carbon intensity table"
1237                )
1238            });
1239            let deviation = (mean - annual).abs() / annual;
1240            assert!(
1241                deviation < 0.05,
1242                "{key} monthly grand mean {mean:.1} deviates {deviation:.3} from annual {annual}"
1243            );
1244        }
1245    }
1246
1247    #[test]
1248    fn monthly_profile_seasonal_variation_fr() {
1249        // France: winter months should have higher mean than summer months.
1250        let pr = hourly_profile_for_region_lower("eu-west-3").unwrap();
1251        let jan_mean = (0..24).map(|h| pr.intensity_at(h, Some(0))).sum::<f64>() / 24.0;
1252        let jul_mean = (0..24).map(|h| pr.intensity_at(h, Some(6))).sum::<f64>() / 24.0;
1253        assert!(
1254            jan_mean > jul_mean,
1255            "FR January mean ({jan_mean:.1}) should be higher than July ({jul_mean:.1})"
1256        );
1257    }
1258
1259    #[test]
1260    fn monthly_profile_seasonal_variation_de() {
1261        let pr = hourly_profile_for_region_lower("eu-central-1").unwrap();
1262        let jan_mean = (0..24).map(|h| pr.intensity_at(h, Some(0))).sum::<f64>() / 24.0;
1263        let jun_mean = (0..24).map(|h| pr.intensity_at(h, Some(5))).sum::<f64>() / 24.0;
1264        assert!(
1265            jan_mean > jun_mean,
1266            "DE January mean ({jan_mean:.1}) should be higher than June ({jun_mean:.1})"
1267        );
1268    }
1269
1270    // --- profile shape tests for solar-heavy grids ---
1271
1272    #[test]
1273    fn caiso_profile_has_midday_solar_dip() {
1274        // CAISO duck curve: intensity at peak solar (UTC 18-21, local 10am-1pm)
1275        // must be well below the evening gas ramp (UTC 2-4, local 6-8pm).
1276        let pr = hourly_profile_for_region_lower("us-west-1").unwrap();
1277        let solar_min = (18..=21)
1278            .map(|h| pr.intensity_at(h, None))
1279            .fold(f64::INFINITY, f64::min);
1280        let evening_max = (2..=4)
1281            .map(|h| pr.intensity_at(h, None))
1282            .fold(f64::NEG_INFINITY, f64::max);
1283        assert!(
1284            solar_min < evening_max * 0.80,
1285            "CAISO solar dip ({solar_min:.0}) should be well below evening peak ({evening_max:.0})"
1286        );
1287    }
1288
1289    #[test]
1290    fn spain_profile_has_midday_solar_dip() {
1291        // Spain: solar peak at local noon-2pm (UTC 10-12, CET=UTC+1).
1292        let pr = hourly_profile_for_region_lower("europe-southwest1").unwrap();
1293        let solar_min = (10..=13)
1294            .map(|h| pr.intensity_at(h, None))
1295            .fold(f64::INFINITY, f64::min);
1296        let evening_max = (17..=19)
1297            .map(|h| pr.intensity_at(h, None))
1298            .fold(f64::NEG_INFINITY, f64::max);
1299        assert!(
1300            solar_min < evening_max * 0.85,
1301            "Spain solar dip ({solar_min:.0}) should be below evening peak ({evening_max:.0})"
1302        );
1303    }
1304
1305    #[test]
1306    fn hydro_profiles_are_nearly_flat() {
1307        // Hydro-dominated grids (SE, NO, CA) should have very low variation.
1308        for region in ["eu-north-1", "europe-north2", "ca-central-1"] {
1309            let pr = hourly_profile_for_region_lower(region).unwrap();
1310            let min = (0..24)
1311                .map(|h| pr.intensity_at(h, None))
1312                .fold(f64::INFINITY, f64::min);
1313            let max = (0..24)
1314                .map(|h| pr.intensity_at(h, None))
1315                .fold(f64::NEG_INFINITY, f64::max);
1316            assert!(
1317                max <= min * 2.5,
1318                "{region} hydro profile should be nearly flat (min={min:.0}, max={max:.0})"
1319            );
1320        }
1321    }
1322
1323    // --- resolve_hourly_intensity tests ---
1324
1325    #[test]
1326    fn resolve_hourly_intensity_custom_takes_precedence() {
1327        let mut custom = HashMap::new();
1328        custom.insert(
1329            "eu-west-3".to_string(),
1330            HourlyProfile::FlatYear([999.0; 24]),
1331        );
1332        let (val, src) = resolve_hourly_intensity("eu-west-3", 12, None, Some(&custom)).unwrap();
1333        assert!((val - 999.0).abs() < f64::EPSILON);
1334        assert_eq!(src, IntensitySource::Hourly);
1335    }
1336
1337    #[test]
1338    fn resolve_hourly_intensity_falls_through_to_embedded() {
1339        let (val, src) = resolve_hourly_intensity("eu-west-1", 12, None, None).unwrap();
1340        assert!(val > 0.0);
1341        assert_eq!(src, IntensitySource::Hourly); // eu-west-1 is FlatYear
1342    }
1343
1344    #[test]
1345    fn resolve_hourly_intensity_monthly_embedded() {
1346        let (val, src) = resolve_hourly_intensity("eu-west-3", 12, Some(6), None).unwrap();
1347        assert!(val > 0.0);
1348        assert_eq!(src, IntensitySource::MonthlyHourly);
1349    }
1350
1351    #[test]
1352    fn resolve_hourly_intensity_unknown_region_returns_none() {
1353        assert!(resolve_hourly_intensity("mars-1", 12, None, None).is_none());
1354    }
1355
1356    #[test]
1357    fn resolve_hourly_intensity_rejects_invalid_month() {
1358        assert!(resolve_hourly_intensity("eu-west-3", 12, Some(12), None).is_none());
1359        assert!(resolve_hourly_intensity("eu-west-3", 12, Some(99), None).is_none());
1360    }
1361
1362    #[test]
1363    fn resolve_hourly_intensity_rejects_invalid_hour() {
1364        assert!(resolve_hourly_intensity("eu-west-3", 24, None, None).is_none());
1365        assert!(resolve_hourly_intensity("eu-west-3", 99, None, None).is_none());
1366    }
1367
1368    // --- load_custom_profiles tests ---
1369
1370    #[test]
1371    fn load_custom_profiles_flat_year() {
1372        let dir = std::env::temp_dir().join("perf_sentinel_test_profiles");
1373        let _ = std::fs::create_dir_all(&dir);
1374        let path = dir.join("test_flat.json");
1375        let hours: Vec<f64> = (0..24).map(|h| 50.0 + f64::from(h)).collect();
1376        let json =
1377            format!(r#"{{"profiles": {{"my-dc": {{"type": "flat_year", "hours": {hours:?}}}}}}}"#);
1378        std::fs::write(&path, &json).unwrap();
1379        let result = load_custom_profiles(&path).unwrap();
1380        assert!(result.contains_key("my-dc"));
1381        assert!(!result["my-dc"].is_monthly());
1382        let _ = std::fs::remove_file(&path);
1383    }
1384
1385    #[test]
1386    fn load_custom_profiles_monthly() {
1387        let dir = std::env::temp_dir().join("perf_sentinel_test_profiles");
1388        let _ = std::fs::create_dir_all(&dir);
1389        let path = dir.join("test_monthly.json");
1390        let month: Vec<f64> = vec![100.0; 24];
1391        let months: Vec<Vec<f64>> = vec![month; 12];
1392        let json =
1393            format!(r#"{{"profiles": {{"my-dc": {{"type": "monthly", "months": {months:?}}}}}}}"#);
1394        std::fs::write(&path, &json).unwrap();
1395        let result = load_custom_profiles(&path).unwrap();
1396        assert!(result["my-dc"].is_monthly());
1397        let _ = std::fs::remove_file(&path);
1398    }
1399
1400    #[test]
1401    fn load_custom_profiles_rejects_wrong_dimensions() {
1402        let dir = std::env::temp_dir().join("perf_sentinel_test_profiles");
1403        let _ = std::fs::create_dir_all(&dir);
1404        let path = dir.join("test_bad_dim.json");
1405        let json = r#"{"profiles": {"my-dc": {"type": "flat_year", "hours": [1.0, 2.0]}}}"#;
1406        std::fs::write(&path, json).unwrap();
1407        assert!(load_custom_profiles(&path).is_err());
1408        let _ = std::fs::remove_file(&path);
1409    }
1410
1411    #[test]
1412    fn load_custom_profiles_rejects_negative() {
1413        let dir = std::env::temp_dir().join("perf_sentinel_test_profiles");
1414        let _ = std::fs::create_dir_all(&dir);
1415        let path = dir.join("test_neg.json");
1416        let mut hours = vec![50.0; 24];
1417        hours[5] = -1.0;
1418        let json =
1419            format!(r#"{{"profiles": {{"my-dc": {{"type": "flat_year", "hours": {hours:?}}}}}}}"#);
1420        std::fs::write(&path, &json).unwrap();
1421        assert!(load_custom_profiles(&path).is_err());
1422        let _ = std::fs::remove_file(&path);
1423    }
1424
1425    #[test]
1426    fn load_custom_profiles_rejects_nan() {
1427        let dir = std::env::temp_dir().join("perf_sentinel_test_profiles");
1428        let _ = std::fs::create_dir_all(&dir);
1429        let path = dir.join("test_nan.json");
1430        // NaN is not valid JSON, so we use null which will fail to parse as f64.
1431        let json = r#"{"profiles": {"my-dc": {"type": "flat_year", "hours": [null, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0]}}}"#;
1432        std::fs::write(&path, json).unwrap();
1433        assert!(load_custom_profiles(&path).is_err());
1434        let _ = std::fs::remove_file(&path);
1435    }
1436
1437    #[test]
1438    fn per_op_gco2_single_source() {
1439        // verify the per_op helper matches the compute_operational
1440        // formula so the two paths stay in sync.
1441        let per_op = per_op_gco2(ENERGY_PER_IO_OP_KWH, 100.0, 1.2);
1442        let bulk = compute_operational_gco2(1, 100.0, 1.2);
1443        assert!((per_op - bulk).abs() < 1e-18);
1444        let bulk10 = compute_operational_gco2(10, 100.0, 1.2);
1445        assert!((per_op * 10.0 - bulk10).abs() < 1e-18);
1446    }
1447
1448    #[test]
1449    fn carbon_estimate_with_model_tags() {
1450        // new `_with_model` constructors must carry the
1451        // supplied model tag all the way through.
1452        let e = CarbonEstimate::sci_numerator_with_model(0.001, CO2_MODEL_V2);
1453        assert_eq!(e.model, "io_proxy_v2");
1454        assert_eq!(e.methodology, "sci_v1_numerator");
1455        let e = CarbonEstimate::operational_ratio_with_model(0.001, CO2_MODEL_SCAPHANDRE);
1456        assert_eq!(e.model, "scaphandre_rapl");
1457        assert_eq!(e.methodology, "sci_v1_operational_ratio");
1458    }
1459
1460    // PUE values are hand-maintained in this file, so pinning them is
1461    // fine. Intensities come from the generated table: assert the
1462    // same-country relation instead of a value a refresh will move.
1463    #[test]
1464    fn lookup_known_aws_region() {
1465        let (intensity, pue) = lookup_region("eu-west-3").expect("eu-west-3");
1466        let (fr_intensity, _) = lookup_region("fr").expect("fr");
1467        assert!((intensity - fr_intensity).abs() < f64::EPSILON);
1468        assert!((pue - 1.15).abs() < f64::EPSILON);
1469    }
1470
1471    #[test]
1472    fn lookup_known_gcp_region() {
1473        let (intensity, pue) = lookup_region("europe-west9").expect("europe-west9");
1474        let (fr_intensity, _) = lookup_region("fr").expect("fr");
1475        assert!((intensity - fr_intensity).abs() < f64::EPSILON);
1476        assert!((pue - 1.09).abs() < f64::EPSILON);
1477    }
1478
1479    #[test]
1480    fn lookup_country_code() {
1481        let (intensity, pue) = lookup_region("FR").expect("FR");
1482        assert!(intensity > 0.0);
1483        assert!((pue - 1.5).abs() < f64::EPSILON);
1484    }
1485
1486    #[test]
1487    fn lookup_case_insensitive() {
1488        assert!(lookup_region("EU-WEST-3").is_some());
1489        assert!(lookup_region("Us-East-1").is_some());
1490        assert!(lookup_region("fr").is_some());
1491        assert!(lookup_region("FR").is_some());
1492    }
1493
1494    #[test]
1495    fn lookup_unknown_region_returns_none() {
1496        assert!(lookup_region("unknown-region").is_none());
1497        assert!(lookup_region("").is_none());
1498    }
1499
1500    #[test]
1501    fn io_ops_to_co2_known_region() {
1502        let val = io_ops_to_co2_grams(1000, "eu-west-3").expect("eu-west-3");
1503        let (intensity, pue) = lookup_region("eu-west-3").expect("eu-west-3");
1504        let expected = 1000.0 * ENERGY_PER_IO_OP_KWH * intensity * pue;
1505        assert!((val - expected).abs() < 1e-9);
1506    }
1507
1508    #[test]
1509    fn io_ops_to_co2_unknown_region() {
1510        assert!(io_ops_to_co2_grams(1000, "mars-1").is_none());
1511    }
1512
1513    #[test]
1514    fn io_ops_to_co2_zero_ops() {
1515        let co2 = io_ops_to_co2_grams(0, "eu-west-3");
1516        assert!(co2.is_some());
1517        assert!((co2.unwrap() - 0.0).abs() < f64::EPSILON);
1518    }
1519
1520    #[test]
1521    fn high_carbon_region_vs_low() {
1522        let high = io_ops_to_co2_grams(1000, "ap-south-1").unwrap(); // India (coal-heavy)
1523        let low = io_ops_to_co2_grams(1000, "eu-north-1").unwrap(); // Stockholm (hydro/nuclear)
1524        assert!(high > low * 5.0, "India should be much higher than Sweden");
1525    }
1526
1527    // The generated/manual split relies on disjoint keys: a HashMap
1528    // collision would silently shadow a generated row with a stale
1529    // manual one.
1530    #[test]
1531    fn generated_and_manual_carbon_keys_are_disjoint() {
1532        assert_eq!(
1533            REGION_MAP.len(),
1534            super::super::carbon_data::GENERATED_CARBON_ROWS.len() + MANUAL_CARBON_ROWS.len(),
1535            "a manual carbon row shadows a generated one"
1536        );
1537    }
1538
1539    // Upstream data can publish a corrupt value (0, negative, or
1540    // mis-scaled). No real grid sits outside (0, 2000] gCO2eq/kWh.
1541    #[test]
1542    fn all_carbon_rows_are_plausible() {
1543        for &(key, intensity, _) in super::super::carbon_data::GENERATED_CARBON_ROWS
1544            .iter()
1545            .chain(MANUAL_CARBON_ROWS)
1546        {
1547            assert!(
1548                intensity > 0.0 && intensity <= 2000.0,
1549                "{key}: implausible carbon intensity {intensity}"
1550            );
1551        }
1552    }
1553
1554    #[test]
1555    fn lookup_azure_region() {
1556        let result = lookup_region("eastus");
1557        assert!(result.is_some());
1558        let (_, pue) = result.unwrap();
1559        assert!(
1560            (pue - 1.17).abs() < f64::EPSILON,
1561            "Azure PUE should be 1.17"
1562        );
1563    }
1564
1565    // ----- CarbonEstimate / CarbonReport / resolve_region tests -----
1566
1567    use std::sync::Arc;
1568
1569    use crate::event::{EventSource, EventType, SpanEvent};
1570
1571    fn make_event(service: &str, cloud_region: Option<&str>) -> SpanEvent {
1572        SpanEvent {
1573            timestamp: "2025-07-10T14:32:01.000Z".to_string(),
1574            trace_id: "trace-1".to_string(),
1575            span_id: "span-1".to_string(),
1576            parent_span_id: None,
1577            service: Arc::from(service),
1578            cloud_region: cloud_region.map(Arc::from),
1579            event_type: EventType::Sql,
1580            operation: "SELECT".to_string(),
1581            target: "SELECT 1".to_string(),
1582            duration_us: 1000,
1583            source: EventSource {
1584                endpoint: "GET /test".to_string(),
1585                method: "Test::method".to_string(),
1586            },
1587            status_code: None,
1588            response_size_bytes: None,
1589            code_function: None,
1590            code_filepath: None,
1591            code_lineno: None,
1592            code_namespace: None,
1593            instrumentation_scopes: Vec::new(),
1594        }
1595    }
1596
1597    #[test]
1598    fn carbon_estimate_sci_numerator_labels() {
1599        let est = CarbonEstimate::sci_numerator(0.000_100);
1600        assert!((est.mid - 0.000_100).abs() < f64::EPSILON);
1601        assert!((est.low - 0.000_050).abs() < f64::EPSILON);
1602        assert!((est.high - 0.000_200).abs() < f64::EPSILON);
1603        assert_eq!(est.model, "io_proxy_v1");
1604        assert_eq!(est.methodology, "sci_v1_numerator");
1605    }
1606
1607    #[test]
1608    fn carbon_estimate_operational_ratio_labels() {
1609        let est = CarbonEstimate::operational_ratio(0.000_050);
1610        assert!((est.mid - 0.000_050).abs() < f64::EPSILON);
1611        assert!((est.low - 0.000_025).abs() < f64::EPSILON);
1612        assert!((est.high - 0.000_100).abs() < f64::EPSILON);
1613        assert_eq!(est.model, "io_proxy_v1");
1614        assert_eq!(est.methodology, "sci_v1_operational_ratio");
1615    }
1616
1617    #[test]
1618    fn carbon_estimate_methodology_constants_are_distinct() {
1619        assert_ne!(METHODOLOGY_SCI_NUMERATOR, METHODOLOGY_OPERATIONAL_RATIO);
1620        assert_eq!(METHODOLOGY_SCI_NUMERATOR, "sci_v1_numerator");
1621        assert_eq!(METHODOLOGY_OPERATIONAL_RATIO, "sci_v1_operational_ratio");
1622    }
1623
1624    #[test]
1625    fn intensity_source_ordering_by_fidelity() {
1626        // Pin the derived Ord so reordering variants is caught.
1627        assert!(IntensitySource::Annual < IntensitySource::Hourly);
1628        assert!(IntensitySource::Hourly < IntensitySource::MonthlyHourly);
1629    }
1630
1631    #[test]
1632    fn carbon_estimate_from_zero_midpoint() {
1633        let est = CarbonEstimate::sci_numerator(0.0);
1634        assert!((est.low - 0.0).abs() < f64::EPSILON);
1635        assert!((est.mid - 0.0).abs() < f64::EPSILON);
1636        assert!((est.high - 0.0).abs() < f64::EPSILON);
1637    }
1638
1639    #[test]
1640    fn confidence_interval_factors_are_2x_multiplicative() {
1641        // The constants encode a 2× multiplicative uncertainty bracket
1642        // (not a symmetric ±50% window): low = mid/2, high = mid×2.
1643        // The geometric mean of low and high equals mid, making the
1644        // interval log-symmetric around the midpoint.
1645        let mid = 12.34_f64;
1646        let est = CarbonEstimate::sci_numerator(mid);
1647        assert!((est.low - mid * CO2_LOW_FACTOR).abs() < f64::EPSILON);
1648        assert!((est.high - mid * CO2_HIGH_FACTOR).abs() < f64::EPSILON);
1649        assert!((CO2_LOW_FACTOR - 0.5).abs() < f64::EPSILON);
1650        assert!((CO2_HIGH_FACTOR - 2.0).abs() < f64::EPSILON);
1651        // Geometric mean of low and high ≈ mid (log-symmetric).
1652        let geo_mean = (est.low * est.high).sqrt();
1653        assert!((geo_mean - mid).abs() < 1e-9);
1654    }
1655
1656    #[test]
1657    fn compute_operational_gco2_matches_expected() {
1658        // Hand-computed: 1000 ops × 1e-7 kWh × 56 gCO₂/kWh × 1.15 PUE = 6.440e-3 g
1659        let result = compute_operational_gco2(1000, 56.0, 1.15);
1660        assert!((result - 0.006_440).abs() < 1e-9);
1661    }
1662
1663    #[test]
1664    fn compute_operational_gco2_zero_ops() {
1665        assert!((compute_operational_gco2(0, 56.0, 1.15) - 0.0).abs() < f64::EPSILON);
1666    }
1667
1668    #[test]
1669    fn io_ops_to_co2_grams_delegates_to_helper() {
1670        // Cross-check: the public scalar API and the internal helper
1671        // must produce the same result for the same inputs.
1672        let scalar = io_ops_to_co2_grams(1000, "eu-west-3").unwrap();
1673        let (intensity, pue) = lookup_region_lower("eu-west-3").unwrap();
1674        let helper = compute_operational_gco2(1000, intensity, pue);
1675        assert!((scalar - helper).abs() < f64::EPSILON);
1676    }
1677
1678    #[test]
1679    fn is_valid_region_id_accepts_valid() {
1680        assert!(is_valid_region_id("eu-west-3"));
1681        assert!(is_valid_region_id("us-east-1"));
1682        assert!(is_valid_region_id("europe-west9"));
1683        assert!(is_valid_region_id("francecentral"));
1684        assert!(is_valid_region_id("fr"));
1685        assert!(is_valid_region_id("unknown"));
1686        assert!(is_valid_region_id("mars-1"));
1687        assert!(is_valid_region_id("my_region_42"));
1688    }
1689
1690    #[test]
1691    fn is_valid_region_id_rejects_invalid() {
1692        assert!(!is_valid_region_id(""), "empty string");
1693        assert!(!is_valid_region_id(&"a".repeat(65)), "too long");
1694        assert!(!is_valid_region_id("eu west 3"), "space");
1695        assert!(!is_valid_region_id("eu.west.3"), "dot");
1696        assert!(!is_valid_region_id("eu/west/3"), "slash");
1697        assert!(!is_valid_region_id("eu-west-3\n"), "newline");
1698        assert!(!is_valid_region_id("eu-west-3\0"), "null byte");
1699        assert!(!is_valid_region_id("région"), "non-ASCII");
1700    }
1701
1702    #[test]
1703    fn is_valid_region_id_accepts_exact_64_chars() {
1704        let max_len = "a".repeat(64);
1705        assert!(is_valid_region_id(&max_len));
1706    }
1707
1708    #[test]
1709    fn resolve_region_prefers_event_attribute() {
1710        let mut service_regions = HashMap::new();
1711        service_regions.insert("order-svc".to_string(), "us-east-1".to_string());
1712        let ctx = CarbonContext {
1713            default_region: Some("eu-west-3".to_string()),
1714            service_regions,
1715            embodied_per_request_gco2: DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2,
1716            use_hourly_profiles: true,
1717            energy_snapshot: None,
1718            ..CarbonContext::default()
1719        };
1720        let event = make_event("order-svc", Some("ap-south-1"));
1721        assert_eq!(resolve_region(&event, &ctx), Some("ap-south-1"));
1722    }
1723
1724    #[test]
1725    fn resolve_region_falls_back_to_service_map() {
1726        let mut service_regions = HashMap::new();
1727        service_regions.insert("order-svc".to_string(), "us-east-1".to_string());
1728        let ctx = CarbonContext {
1729            default_region: Some("eu-west-3".to_string()),
1730            service_regions,
1731            embodied_per_request_gco2: DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2,
1732            use_hourly_profiles: true,
1733            energy_snapshot: None,
1734            ..CarbonContext::default()
1735        };
1736        let event = make_event("order-svc", None);
1737        assert_eq!(resolve_region(&event, &ctx), Some("us-east-1"));
1738    }
1739
1740    #[test]
1741    fn resolve_region_falls_back_to_default() {
1742        let ctx = CarbonContext {
1743            default_region: Some("eu-west-3".to_string()),
1744            service_regions: HashMap::new(),
1745            embodied_per_request_gco2: DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2,
1746            use_hourly_profiles: true,
1747            energy_snapshot: None,
1748            ..CarbonContext::default()
1749        };
1750        let event = make_event("unknown-svc", None);
1751        assert_eq!(resolve_region(&event, &ctx), Some("eu-west-3"));
1752    }
1753
1754    #[test]
1755    fn resolve_region_returns_none_when_all_unset() {
1756        let ctx = CarbonContext::default();
1757        let event = make_event("any-svc", None);
1758        assert_eq!(resolve_region(&event, &ctx), None);
1759    }
1760
1761    #[test]
1762    fn resolve_region_service_map_does_not_shadow_event_attribute() {
1763        // Even if the service has a config override, the span's own
1764        // cloud.region should win (most authoritative source).
1765        let mut service_regions = HashMap::new();
1766        service_regions.insert("order-svc".to_string(), "us-east-1".to_string());
1767        let ctx = CarbonContext {
1768            default_region: None,
1769            service_regions,
1770            embodied_per_request_gco2: DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2,
1771            use_hourly_profiles: true,
1772            energy_snapshot: None,
1773            ..CarbonContext::default()
1774        };
1775        let event = make_event("order-svc", Some("eu-north-1"));
1776        assert_eq!(resolve_region(&event, &ctx), Some("eu-north-1"));
1777    }
1778
1779    #[test]
1780    fn resolve_region_service_map_is_case_insensitive() {
1781        // Config loader lowercases service_regions keys. Incoming span
1782        // events may carry mixed-case service names (e.g. "Order-Svc" from
1783        // an older .NET SDK). resolve_region lowercases event.service
1784        // before lookup so they still match.
1785        let mut service_regions = HashMap::new();
1786        service_regions.insert("order-svc".to_string(), "us-east-1".to_string());
1787        let ctx = CarbonContext {
1788            default_region: None,
1789            service_regions,
1790            embodied_per_request_gco2: 0.0,
1791            use_hourly_profiles: true,
1792            energy_snapshot: None,
1793            ..CarbonContext::default()
1794        };
1795        // Mixed-case service name on the event, should still match.
1796        let event = make_event("Order-Svc", None);
1797        assert_eq!(resolve_region(&event, &ctx), Some("us-east-1"));
1798        // Upper-case service name.
1799        let event_upper = make_event("ORDER-SVC", None);
1800        assert_eq!(resolve_region(&event_upper, &ctx), Some("us-east-1"));
1801    }
1802
1803    // ── energy_coefficient tests ───────────────────────────────────
1804
1805    fn make_sql_target_event(target: &str) -> SpanEvent {
1806        SpanEvent {
1807            timestamp: "2025-07-10T14:32:01.000Z".to_string(),
1808            trace_id: "trace-1".to_string(),
1809            span_id: "span-1".to_string(),
1810            parent_span_id: None,
1811            service: Arc::from("test"),
1812            cloud_region: None,
1813            event_type: EventType::Sql,
1814            operation: "postgresql".to_string(),
1815            target: target.to_string(),
1816            duration_us: 1000,
1817            source: EventSource {
1818                endpoint: "GET /test".to_string(),
1819                method: "Test::method".to_string(),
1820            },
1821            status_code: None,
1822            response_size_bytes: None,
1823            code_function: None,
1824            code_filepath: None,
1825            code_lineno: None,
1826            code_namespace: None,
1827            instrumentation_scopes: Vec::new(),
1828        }
1829    }
1830
1831    fn make_http_size_event(response_size_bytes: Option<u64>) -> SpanEvent {
1832        SpanEvent {
1833            timestamp: "2025-07-10T14:32:01.000Z".to_string(),
1834            trace_id: "trace-1".to_string(),
1835            span_id: "span-1".to_string(),
1836            parent_span_id: None,
1837            service: Arc::from("test"),
1838            cloud_region: None,
1839            event_type: EventType::HttpOut,
1840            operation: "GET".to_string(),
1841            target: "http://user-svc:5000/api/users/123".to_string(),
1842            duration_us: 1000,
1843            source: EventSource {
1844                endpoint: "GET /test".to_string(),
1845                method: "Test::method".to_string(),
1846            },
1847            status_code: Some(200),
1848            response_size_bytes,
1849            code_function: None,
1850            code_filepath: None,
1851            code_lineno: None,
1852            code_namespace: None,
1853            instrumentation_scopes: Vec::new(),
1854        }
1855    }
1856
1857    #[test]
1858    fn energy_coefficient_sql_select() {
1859        let event = make_sql_target_event("SELECT * FROM users WHERE id = 1");
1860        assert!((energy_coefficient(&event) - SQL_SELECT_COEFF).abs() < f64::EPSILON);
1861    }
1862
1863    #[test]
1864    fn energy_coefficient_sql_insert() {
1865        let event = make_sql_target_event("INSERT INTO users (name) VALUES ('Alice')");
1866        assert!((energy_coefficient(&event) - SQL_INSERT_COEFF).abs() < f64::EPSILON);
1867    }
1868
1869    #[test]
1870    fn energy_coefficient_sql_update() {
1871        let event = make_sql_target_event("UPDATE users SET name = 'Bob' WHERE id = 1");
1872        assert!((energy_coefficient(&event) - SQL_UPDATE_COEFF).abs() < f64::EPSILON);
1873    }
1874
1875    #[test]
1876    fn energy_coefficient_sql_delete() {
1877        let event = make_sql_target_event("DELETE FROM users WHERE id = 1");
1878        assert!((energy_coefficient(&event) - SQL_DELETE_COEFF).abs() < f64::EPSILON);
1879    }
1880
1881    #[test]
1882    fn energy_coefficient_sql_other() {
1883        let event = make_sql_target_event("CREATE TABLE users (id INT)");
1884        assert!((energy_coefficient(&event) - SQL_OTHER_COEFF).abs() < f64::EPSILON);
1885    }
1886
1887    #[test]
1888    fn energy_coefficient_sql_case_insensitive() {
1889        let event = make_sql_target_event("select * from users");
1890        assert!((energy_coefficient(&event) - SQL_SELECT_COEFF).abs() < f64::EPSILON);
1891    }
1892
1893    #[test]
1894    fn energy_coefficient_http_small() {
1895        let event = make_http_size_event(Some(1024)); // 1 KB
1896        assert!((energy_coefficient(&event) - HTTP_SMALL_COEFF).abs() < f64::EPSILON);
1897    }
1898
1899    #[test]
1900    fn energy_coefficient_http_medium() {
1901        let event = make_http_size_event(Some(100 * 1024)); // 100 KB
1902        assert!((energy_coefficient(&event) - HTTP_MEDIUM_COEFF).abs() < f64::EPSILON);
1903    }
1904
1905    #[test]
1906    fn energy_coefficient_http_large() {
1907        let event = make_http_size_event(Some(2 * 1024 * 1024)); // 2 MB
1908        assert!((energy_coefficient(&event) - HTTP_LARGE_COEFF).abs() < f64::EPSILON);
1909    }
1910
1911    #[test]
1912    fn energy_coefficient_http_no_size() {
1913        let event = make_http_size_event(None);
1914        assert!((energy_coefficient(&event) - 1.0).abs() < f64::EPSILON);
1915    }
1916
1917    #[test]
1918    fn energy_coefficient_http_boundary_small_threshold() {
1919        // Exactly at the small/medium boundary (10 KB) should be medium.
1920        let event = make_http_size_event(Some(HTTP_SMALL_THRESHOLD));
1921        assert!((energy_coefficient(&event) - HTTP_MEDIUM_COEFF).abs() < f64::EPSILON);
1922    }
1923
1924    #[test]
1925    fn energy_coefficient_http_boundary_large_threshold() {
1926        // Exactly at the large boundary (1 MB) is still medium; >1 MB is large.
1927        let event = make_http_size_event(Some(HTTP_LARGE_THRESHOLD));
1928        assert!((energy_coefficient(&event) - HTTP_MEDIUM_COEFF).abs() < f64::EPSILON);
1929        let event_over = make_http_size_event(Some(HTTP_LARGE_THRESHOLD + 1));
1930        assert!((energy_coefficient(&event_over) - HTTP_LARGE_COEFF).abs() < f64::EPSILON);
1931    }
1932
1933    // ── extract_hostname tests ─────────────────────────────────────
1934
1935    #[test]
1936    fn extract_hostname_http_with_port() {
1937        assert_eq!(
1938            extract_hostname("http://user-svc:5000/api/users"),
1939            Some("user-svc")
1940        );
1941    }
1942
1943    #[test]
1944    fn extract_hostname_http_no_port() {
1945        assert_eq!(
1946            extract_hostname("http://user-svc/api/users"),
1947            Some("user-svc")
1948        );
1949    }
1950
1951    #[test]
1952    fn extract_hostname_https() {
1953        assert_eq!(
1954            extract_hostname("https://api.example.com/path"),
1955            Some("api.example.com")
1956        );
1957    }
1958
1959    #[test]
1960    fn extract_hostname_empty() {
1961        assert_eq!(extract_hostname(""), None);
1962    }
1963
1964    #[test]
1965    fn extract_hostname_no_scheme() {
1966        assert_eq!(extract_hostname("/api/users"), None);
1967    }
1968
1969    #[test]
1970    fn extract_hostname_empty_host() {
1971        assert_eq!(extract_hostname("http:///path"), None);
1972    }
1973
1974    #[test]
1975    fn extract_hostname_with_userinfo() {
1976        // RFC 3986 userinfo: "user:pass@host:port" should extract "host"
1977        assert_eq!(
1978            extract_hostname("http://user:pass@order-api:8080/api/orders"),
1979            Some("order-api")
1980        );
1981    }
1982
1983    #[test]
1984    fn extract_hostname_with_user_only() {
1985        assert_eq!(
1986            extract_hostname("http://admin@order-api/api"),
1987            Some("order-api")
1988        );
1989    }
1990
1991    #[test]
1992    fn energy_coefficient_http_zero_bytes() {
1993        let event = make_http_size_event(Some(0));
1994        assert!((energy_coefficient(&event) - HTTP_SMALL_COEFF).abs() < f64::EPSILON);
1995    }
1996
1997    #[test]
1998    fn energy_coefficient_sql_empty_target() {
1999        let event = make_sql_target_event("");
2000        assert!((energy_coefficient(&event) - SQL_OTHER_COEFF).abs() < f64::EPSILON);
2001    }
2002
2003    // --- ScoringConfig (0.5.12 audit-trail surface) ---
2004
2005    #[test]
2006    fn scoring_config_default_is_v4_lifecycle_hourly() {
2007        let cfg = ScoringConfig::default();
2008        assert_eq!(cfg.api_version, ApiVersion::V4);
2009        assert_eq!(cfg.emission_factor_type, EmissionFactorType::Lifecycle);
2010        assert_eq!(cfg.temporal_granularity, TemporalGranularity::Hourly);
2011    }
2012
2013    #[test]
2014    fn scoring_config_round_trip_json_all_defaults() {
2015        let cfg = ScoringConfig::default();
2016        let json = serde_json::to_string(&cfg).unwrap();
2017        let back: ScoringConfig = serde_json::from_str(&json).unwrap();
2018        assert_eq!(cfg, back);
2019        assert!(json.contains("\"v4\""));
2020        assert!(json.contains("\"lifecycle\""));
2021        assert!(json.contains("\"hourly\""));
2022    }
2023
2024    #[test]
2025    fn scoring_config_round_trip_json_all_optins() {
2026        let cfg = ScoringConfig {
2027            api_version: ApiVersion::V3,
2028            emission_factor_type: EmissionFactorType::Direct,
2029            temporal_granularity: TemporalGranularity::FiveMinutes,
2030        };
2031        let json = serde_json::to_string(&cfg).unwrap();
2032        let back: ScoringConfig = serde_json::from_str(&json).unwrap();
2033        assert_eq!(cfg, back);
2034        assert!(json.contains("\"v3\""));
2035        assert!(json.contains("\"direct\""));
2036        assert!(json.contains("\"5_minutes\""));
2037    }
2038
2039    #[test]
2040    fn scoring_config_from_electricity_maps_derives_api_version_from_endpoint() {
2041        // ElectricityMapsConfig has no Default impl (auth_token is
2042        // mandatory), build manually. The test asserts that the
2043        // api_version field is derived from the endpoint URL and the
2044        // two knobs are copied through verbatim.
2045        let cfg = ElectricityMapsConfig {
2046            api_endpoint: "https://api.electricitymaps.com/v3".to_string(),
2047            auth_token: "test-token".to_string(),
2048            poll_interval: std::time::Duration::from_mins(5),
2049            region_map: HashMap::new(),
2050            emission_factor_type: EmissionFactorType::Direct,
2051            temporal_granularity: TemporalGranularity::FifteenMinutes,
2052        };
2053        let scoring = ScoringConfig::from_electricity_maps(&cfg);
2054        assert_eq!(scoring.api_version, ApiVersion::V3);
2055        assert_eq!(scoring.emission_factor_type, EmissionFactorType::Direct);
2056        assert_eq!(
2057            scoring.temporal_granularity,
2058            TemporalGranularity::FifteenMinutes
2059        );
2060    }
2061
2062    #[test]
2063    fn scoring_config_from_electricity_maps_v4_default_endpoint() {
2064        // Lock the v4 path so a future short-circuit on V3 in
2065        // `from_electricity_maps` cannot regress the default detection.
2066        let cfg = ElectricityMapsConfig {
2067            api_endpoint: "https://api.electricitymaps.com/v4".to_string(),
2068            auth_token: "test-token".to_string(),
2069            poll_interval: std::time::Duration::from_mins(5),
2070            region_map: HashMap::new(),
2071            emission_factor_type: EmissionFactorType::Lifecycle,
2072            temporal_granularity: TemporalGranularity::Hourly,
2073        };
2074        let scoring = ScoringConfig::from_electricity_maps(&cfg);
2075        assert_eq!(scoring.api_version, ApiVersion::V4);
2076        assert_eq!(scoring.emission_factor_type, EmissionFactorType::Lifecycle);
2077        assert_eq!(scoring.temporal_granularity, TemporalGranularity::Hourly);
2078    }
2079
2080    #[test]
2081    fn scoring_config_from_electricity_maps_custom_endpoint() {
2082        // Lock the Custom path so an enterprise proxy or mock URL
2083        // without a `/vN` suffix surfaces correctly on the
2084        // `green_summary.scoring_config.api_version` chip.
2085        let cfg = ElectricityMapsConfig {
2086            api_endpoint: "https://corp-proxy.acme.internal/electricity-maps".to_string(),
2087            auth_token: "test-token".to_string(),
2088            poll_interval: std::time::Duration::from_mins(5),
2089            region_map: HashMap::new(),
2090            emission_factor_type: EmissionFactorType::Lifecycle,
2091            temporal_granularity: TemporalGranularity::Hourly,
2092        };
2093        let scoring = ScoringConfig::from_electricity_maps(&cfg);
2094        assert_eq!(scoring.api_version, ApiVersion::Custom);
2095    }
2096}