Skip to main content

sentinel_core/report/
mod.rs

1//! Report stage: outputs analysis results.
2//!
3//! # Deserialization invariant (baseline round-trip)
4//!
5//! The full [`Report`] tree derives `Deserialize` so `perf-sentinel
6//! report --before <baseline.json>` can feed a stored baseline back in.
7//! Every saved baseline from a past release must keep parsing after a
8//! minor version bump, so the following rule is load-bearing:
9//!
10//! **New fields added to `Report`, `Analysis`, `GreenSummary`,
11//! `QualityGate`, `Finding`, `Pattern`, `TopOffender`, `CarbonReport`,
12//! `CarbonEstimate`, `RegionBreakdown` or any nested type must be
13//! either `Option<T>` or carry `#[serde(default)]` with a sensible
14//! `Default` impl.** A required field added to any of these types
15//! breaks every stored baseline and every downstream consumer that
16//! deserializes via the same JSON.
17//!
18//! Removed fields should stay in the struct for at least one minor
19//! version with `#[serde(default)]` so incoming JSON from the previous
20//! version does not fail on unknown-field attempts to re-read them.
21//!
22//! We deliberately do NOT add `#[serde(deny_unknown_fields)]`. The
23//! trade-off is that a typo like `findigs:` silently deserializes as
24//! the default (empty vec), so production pipelines should validate
25//! baseline shapes upstream when they care.
26
27pub mod html;
28pub mod interpret;
29pub mod json;
30pub mod metrics;
31pub mod periodic;
32pub mod sarif;
33pub mod warnings;
34
35pub use self::warnings::Warning;
36
37use crate::correlate::Trace;
38use crate::detect::Finding;
39use crate::detect::correlate_cross::CrossTraceCorrelation;
40use crate::report::interpret::InterpretationLevel;
41use crate::score::carbon::{CarbonReport, RegionBreakdown, ScoringConfig};
42use serde::{Deserialize, Serialize};
43use std::collections::BTreeMap;
44
45/// A complete analysis report.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Report {
48    pub analysis: Analysis,
49    pub findings: Vec<Finding>,
50    pub green_summary: GreenSummary,
51    pub quality_gate: QualityGate,
52    /// Raw I/O operation count per `(service, endpoint)`. Populated by
53    /// the pipeline regardless of `[green] enabled`, so the `diff`
54    /// subcommand works even with green scoring off. Sorted by `service`
55    /// then `endpoint` for deterministic JSON output. Empty when no
56    /// traces were analyzed.
57    ///
58    /// Lives on `Report` rather than on `GreenSummary` because it is a
59    /// raw telemetry counter, not a green metric, and is filled in
60    /// regardless of the green configuration.
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub per_endpoint_io_ops: Vec<PerEndpointIoOps>,
63    /// Cross-trace temporal correlations produced by the daemon's
64    /// correlator. Always empty in the batch pipeline (the correlator
65    /// runs over a rolling window that batch mode does not maintain).
66    /// The HTML dashboard's Correlations tab lights up when this field
67    /// is non-empty, i.e. when a daemon-produced Report is fed into
68    /// `perf-sentinel report --input <daemon.json>`.
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub correlations: Vec<CrossTraceCorrelation>,
71    /// Snapshot- or analysis-level warnings surfaced to consumers. The
72    /// daemon's `/api/export/report` cold-start path populates this with
73    /// `"daemon has not yet processed any events"` so consumers can
74    /// distinguish "daemon is empty" from "daemon emitted zero findings"
75    /// without resorting to a 5xx HTTP status. Empty in CLI batch
76    /// output. Additive on pre-0.5.16 baselines via `skip_serializing_if`.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub warnings: Vec<String>,
79    /// Structured snapshot warnings (0.5.19+). Coexists with the legacy
80    /// `warnings: Vec<String>` field. Each entry carries a stable
81    /// `kind` (suitable for alerting / aggregation) and a
82    /// human-readable `message`. Renderers prefer this field when
83    /// non-empty, fall back to `warnings` otherwise. Additive on
84    /// pre-0.5.19 baselines via `skip_serializing_if`.
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub warning_details: Vec<Warning>,
87    /// Findings filtered out by the user's acknowledgments file
88    /// (`.perf-sentinel-acknowledgments.toml`), paired with the matching
89    /// ack metadata. Cleared from the wire payload by default; the CLI
90    /// only retains it when `--show-acknowledged` is set so audit output
91    /// stays opt-in. Additive on pre-0.5.17 baselines via `serde(default)`.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub acknowledged_findings: Vec<AcknowledgedFinding>,
94    /// `CARGO_PKG_VERSION` of the binary that wrote this report. Empty
95    /// on reports written by binaries that predate this field.
96    #[serde(default, skip_serializing_if = "String::is_empty")]
97    pub binary_version: String,
98    /// Avoidable energy/carbon tiers (operator + canonical threshold), set
99    /// only by the daemon archive path (the periodic aggregator reads them).
100    /// `None` in batch and live outputs. Additive via `serde(default)`.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub disclosure_waste: Option<DisclosureWaste>,
103}
104
105/// A finding paired with the acknowledgment that suppressed it.
106///
107/// Surfaced under [`Report::acknowledged_findings`] when the operator
108/// asks for `--show-acknowledged`. The CLI clears this vector from the
109/// emitted payload otherwise so the default audit trail is opt-in.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct AcknowledgedFinding {
112    pub finding: Finding,
113    pub acknowledgment: crate::acknowledgments::Acknowledgment,
114}
115
116/// Avoidable energy/carbon at one N+1 threshold, archived per window.
117/// `avoidable_kwh`/`avoidable_gco2` are the energy/carbon shares of the
118/// avoidable I/O ops. The aggregator sums these and derives ratio/efficiency
119/// into the period-aggregate `periodic::schema::WasteTier` (gCO₂ → kg there).
120#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
121pub struct AvoidableTier {
122    pub n_plus_one_threshold: u32,
123    pub avoidable_io_ops: usize,
124    pub avoidable_kwh: f64,
125    pub avoidable_gco2: f64,
126}
127
128/// The two avoidable tiers archived with a daemon window: `canonical` at the
129/// binary-pinned threshold (non-manipulable), `operational` at the operator's.
130#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
131pub struct DisclosureWaste {
132    pub canonical: AvoidableTier,
133    pub operational: AvoidableTier,
134}
135
136/// Analysis metadata.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct Analysis {
139    pub duration_ms: u64,
140    pub events_processed: usize,
141    pub traces_analyzed: usize,
142}
143
144/// `GreenOps` summary of I/O waste.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct GreenSummary {
147    pub total_io_ops: usize,
148    pub avoidable_io_ops: usize,
149    /// SQL share of `total_io_ops`. Together with `avoidable_sql_io_ops`
150    /// this lets operators apply the SQL-only waste ratio to a measured
151    /// database energy reading (e.g. Alumet on the database cgroup).
152    /// `0` on baselines from versions before this field existed.
153    #[serde(default)]
154    pub total_sql_io_ops: usize,
155    /// SQL share of `avoidable_io_ops`, same dedup semantics restricted
156    /// to the SQL finding types (`n_plus_one_sql`, `redundant_sql`).
157    /// `0` on baselines from versions before this field existed.
158    #[serde(default)]
159    pub avoidable_sql_io_ops: usize,
160    /// Region-resolved I/O ops (`total_io_ops` minus the unknown bucket): the
161    /// denominator behind `co2.avoidable`. In-process only (`serde(skip)`),
162    /// read by the daemon to rescale avoidable at the canonical threshold.
163    #[serde(skip)]
164    pub accounted_io_ops: usize,
165    pub io_waste_ratio: f64,
166    /// Classification band for `io_waste_ratio`
167    /// (`healthy` / `moderate` / `high` / `critical`).
168    ///
169    /// Computed by [`InterpretationLevel::for_waste_ratio`]. The enum
170    /// values are stable across versions, the thresholds behind them
171    /// are versioned with the binary. See the [`interpret`] module for
172    /// the stability contract.
173    pub io_waste_ratio_band: InterpretationLevel,
174    pub top_offenders: Vec<TopOffender>,
175    /// Structured CO₂ report. Includes 2× multiplicative uncertainty
176    /// bracket, SCI v1.0 methodology tags, and operational + embodied terms.
177    /// `None` when green scoring is disabled or when no events were analyzed.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub co2: Option<CarbonReport>,
180    /// Per-region operational CO₂ breakdown sorted by `co2_gco2` descending.
181    /// Empty when green scoring is disabled or no events were analyzed.
182    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183    pub regions: Vec<RegionBreakdown>,
184    /// Network transport CO₂ (gCO₂eq). Only present when
185    /// `[green] include_network_transport = true` and at least one
186    /// cross-region HTTP call had response size data.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub transport_gco2: Option<f64>,
189    /// Active Electricity Maps scoring configuration (API version,
190    /// emission factor type, temporal granularity). Surfaced for
191    /// Scope 2 audit trails so reporters can verify which carbon
192    /// model produced the numbers without reading the operator's
193    /// TOML config. `None` when Electricity Maps is not configured.
194    /// Additive on pre-0.5.12 baselines via `skip_serializing_if`.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub scoring_config: Option<ScoringConfig>,
197    /// Total energy consumed by the workload during the scoring window
198    /// in kWh, runtime-calibrated. Sum of per-service energy when
199    /// service-level measurement is available, falls back to the
200    /// operational proxy (`total_io_ops × ENERGY_PER_IO_OP_KWH`) when
201    /// not. `0.0` on pre-carbon-attribution baselines via `serde(default)`.
202    #[serde(default)]
203    pub energy_kwh: f64,
204    /// Energy model used to compute `energy_kwh`. One of
205    /// `"alumet_rapl"`, `"scaphandre_rapl"`, `"kepler_ebpf"`,
206    /// `"redfish_bmc"`, `"cloud_specpower"`, `"io_proxy_v3"`,
207    /// `"io_proxy_v2"`, `"io_proxy_v1"`, with optional `+cal` suffix
208    /// when per-service calibration factors are active. Reflects the
209    /// highest-fidelity model observed in the window (not weighted by
210    /// energy consumption). Empty string on pre-carbon-attribution
211    /// baselines.
212    #[serde(default)]
213    pub energy_model: String,
214    /// Operational carbon per service in kgCO2eq. Excludes the embodied
215    /// term (which stays in `co2.total` only) and the transport term.
216    /// Built at scoring time using the runtime-resolved
217    /// `service → region` mapping and the per-region grid intensity
218    /// (Electricity Maps real-time when available). Sum is
219    /// approximately `co2.operational_gco2 / 1000.0` up to
220    /// floating-point rounding. Empty on pre-carbon-attribution baselines.
221    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
222    pub per_service_carbon_kgco2eq: BTreeMap<String, f64>,
223    /// Operational energy per service in kWh. Built at scoring time
224    /// using the runtime-resolved energy entries (Scaphandre per-process
225    /// RAPL when available, cloud `SPECpower` interpolation otherwise,
226    /// proxy fallback). Sum is approximately `energy_kwh` up to
227    /// floating-point rounding. Empty on pre-carbon-attribution
228    /// baselines.
229    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
230    pub per_service_energy_kwh: BTreeMap<String, f64>,
231    /// Per-service region attribution snapshot at scoring time. Surfaces
232    /// the `service → region` mapping that produced the per-service
233    /// carbon, using `"unknown"` for services that could not be resolved
234    /// to a region. Empty on pre-carbon-attribution baselines.
235    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
236    pub per_service_region: BTreeMap<String, String>,
237    /// Per-service energy model tag. Same value set as `energy_model`
238    /// (window-level), per-service this time so auditors can verify which
239    /// services benefited from Alumet, Scaphandre, Kepler, Redfish, or
240    /// cloud `SPECpower` during this window. Presence of any measured tag
241    /// (`"alumet_rapl"`, `"scaphandre_rapl"`, `"kepler_ebpf"`,
242    /// `"redfish_bmc"`, `"cloud_specpower"`) indicates that at least one span of the
243    /// service hit a measured energy source, not that 100% of the
244    /// service's spans were measured.
245    /// Read together with `per_service_measured_ratio` for the share of
246    /// spans that benefited from the measured model. Services without any
247    /// measured span inherit the window-level proxy tag; the `+cal` suffix
248    /// on that inherited tag reflects window-wide calibration state, not
249    /// whether a calibration factor applied to this specific service.
250    /// Empty on pre-per-service-model baselines.
251    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
252    pub per_service_energy_model: BTreeMap<String, String>,
253    /// Fraction of spans whose energy was resolved by Scaphandre or
254    /// cloud `SPECpower` (versus proxy fallback) per service, in `[0.0,
255    /// 1.0]`. `1.0` means every span had measured energy, `0.0` means
256    /// the service fell back to proxy entirely. Pair with
257    /// `per_service_energy_model` to assess fidelity. The aggregator
258    /// surfaces a simple arithmetic mean of these per-window ratios
259    /// under `aggregate.per_service_measured_ratio`, not a span-weighted
260    /// average. Empty on pre-per-service-ratio baselines.
261    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
262    pub per_service_measured_ratio: BTreeMap<String, f64>,
263    /// Waste figure for the `[green.alumet.database]` cgroup, daemon
264    /// only. Excluded from every total and from the public disclosure:
265    /// CPU-only lower bound, count-based ratio (`docs/METHODOLOGY.md`).
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub database_waste: Option<DatabaseWaste>,
268}
269
270/// Measured database window energy × the SQL-only waste ratio.
271/// Informational, never summed into the report totals.
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
273pub struct DatabaseWaste {
274    /// Window energy of the database cgroup in kWh (CPU share only).
275    pub energy_kwh: f64,
276    /// `energy_kwh × sql_waste_ratio`.
277    pub waste_kwh: f64,
278    /// `waste_kwh` × declared region intensity × PUE. `None` without a
279    /// declared or known region.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub waste_gco2: Option<f64>,
282    /// Operator-declared region of the database host.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub region: Option<String>,
285    /// `avoidable_sql_io_ops / total_sql_io_ops`, in `[0, 1]`.
286    pub sql_waste_ratio: f64,
287}
288
289/// Raw I/O operation count for a single `(service, endpoint)` pair.
290///
291/// Stable JSON shape: field names will not be renamed or removed in a
292/// minor release. The `(service, endpoint)` pair is the
293/// primary key so the same endpoint path served by two different
294/// services produces two distinct entries (microservices commonly share
295/// generic paths like `/health`, `/metrics`, `/api/users`).
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297pub struct PerEndpointIoOps {
298    pub service: String,
299    pub endpoint: String,
300    pub io_ops: usize,
301}
302
303/// Single-pass per-endpoint I/O op counter. Returns the counts sorted by
304/// `(service, endpoint)` for deterministic output. O(N) over the total
305/// span count.
306///
307/// Used by the pipeline to populate `Report.per_endpoint_io_ops` when
308/// green scoring is **disabled**. When green scoring is enabled,
309/// [`crate::score::score_green`] returns the same data as part of its
310/// own single-pass span iteration, so this helper is not called and the
311/// hot path stays a single O(N) walk.
312#[must_use]
313pub fn compute_per_endpoint_io_ops(traces: &[Trace]) -> Vec<PerEndpointIoOps> {
314    // BTreeMap so the resulting Vec is naturally sorted by key without
315    // a separate sort pass. Key is `(service, endpoint)` so two traces
316    // for the same endpoint on different services stay distinct.
317    let mut counts: BTreeMap<(&str, &str), usize> = BTreeMap::new();
318    for trace in traces {
319        for span in &trace.spans {
320            let key = (
321                span.event.service.as_ref(),
322                span.event.source.endpoint.as_str(),
323            );
324            *counts.entry(key).or_insert(0) += 1;
325        }
326    }
327    counts
328        .into_iter()
329        .map(|((service, endpoint), io_ops)| PerEndpointIoOps {
330            service: service.to_string(),
331            endpoint: endpoint.to_string(),
332            io_ops,
333        })
334        .collect()
335}
336
337impl GreenSummary {
338    /// Create a `GreenSummary` with only `total_io_ops` set (green scoring disabled).
339    #[must_use]
340    pub fn disabled(total_io_ops: usize) -> Self {
341        Self {
342            total_io_ops,
343            avoidable_io_ops: 0,
344            total_sql_io_ops: 0,
345            avoidable_sql_io_ops: 0,
346            accounted_io_ops: total_io_ops,
347            io_waste_ratio: 0.0,
348            io_waste_ratio_band: InterpretationLevel::Healthy,
349            top_offenders: vec![],
350            co2: None,
351            regions: vec![],
352            transport_gco2: None,
353            scoring_config: None,
354            energy_kwh: 0.0,
355            energy_model: String::new(),
356            per_service_carbon_kgco2eq: BTreeMap::new(),
357            per_service_energy_kwh: BTreeMap::new(),
358            per_service_region: BTreeMap::new(),
359            per_service_energy_model: BTreeMap::new(),
360            per_service_measured_ratio: BTreeMap::new(),
361            database_waste: None,
362        }
363    }
364}
365
366/// A top offender endpoint ranked by I/O Intensity Score.
367#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
368pub struct TopOffender {
369    pub endpoint: String,
370    pub service: String,
371    pub io_intensity_score: f64,
372    /// Classification band for `io_intensity_score`. Stable enum values
373    /// across versions, thresholds versioned with the binary. See the
374    /// [`interpret`] module for the stability contract.
375    pub io_intensity_band: InterpretationLevel,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub co2_grams: Option<f64>,
378}
379
380/// Quality gate result.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct QualityGate {
383    pub passed: bool,
384    pub rules: Vec<QualityRule>,
385}
386
387/// A single quality gate rule check.
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct QualityRule {
390    pub rule: String,
391    pub threshold: f64,
392    pub actual: f64,
393    pub passed: bool,
394}
395
396/// Trait for report output sinks.
397pub trait ReportSink {
398    type Error: std::error::Error;
399
400    /// # Errors
401    ///
402    /// Returns an error if the report cannot be written to the output sink.
403    fn emit(&self, report: &Report) -> Result<(), Self::Error>;
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn green_summary_pre_0512_baseline_loads_without_scoring_config() {
412        // Hand-crafted JSON shaped like a pre-0.5.12 baseline (no
413        // scoring_config field). The Option must default to None,
414        // ensuring `report --before <old.json>` still works after the
415        // additive change.
416        let json = r#"{
417            "total_io_ops": 0,
418            "avoidable_io_ops": 0,
419            "io_waste_ratio": 0.0,
420            "io_waste_ratio_band": "healthy",
421            "top_offenders": []
422        }"#;
423        let summary: GreenSummary = serde_json::from_str(json).expect("backward-compat parse");
424        assert!(summary.scoring_config.is_none());
425    }
426
427    #[test]
428    fn green_summary_disabled_factory_has_no_scoring_config() {
429        let summary = GreenSummary::disabled(0);
430        assert!(summary.scoring_config.is_none());
431    }
432
433    #[test]
434    fn green_summary_skips_scoring_config_when_none() {
435        let summary = GreenSummary::disabled(42);
436        let json = serde_json::to_string(&summary).unwrap();
437        assert!(
438            !json.contains("scoring_config"),
439            "scoring_config should be skipped when None, got: {json}"
440        );
441    }
442
443    fn minimal_report_json_without_warning_details() -> String {
444        // Shaped like a 0.5.18 Report (no warning_details key). Used to
445        // verify that the new field defaults to empty when absent, so a
446        // pre-0.5.19 baseline replayed via `report --before <old.json>`
447        // still parses cleanly.
448        r#"{
449            "analysis": {"duration_ms": 0, "events_processed": 0, "traces_analyzed": 0},
450            "findings": [],
451            "green_summary": {
452                "total_io_ops": 0,
453                "avoidable_io_ops": 0,
454                "io_waste_ratio": 0.0,
455                "io_waste_ratio_band": "healthy",
456                "top_offenders": []
457            },
458            "quality_gate": {"passed": true, "rules": []},
459            "warnings": ["legacy warning text"]
460        }"#
461        .to_string()
462    }
463
464    #[test]
465    fn report_warning_details_default_empty_when_absent() {
466        let report: Report =
467            serde_json::from_str(&minimal_report_json_without_warning_details()).expect("parse");
468        assert!(report.warning_details.is_empty());
469    }
470
471    #[test]
472    fn report_legacy_warnings_field_still_parses() {
473        let report: Report =
474            serde_json::from_str(&minimal_report_json_without_warning_details()).expect("parse");
475        assert_eq!(report.warnings, vec!["legacy warning text".to_string()]);
476        assert!(report.warning_details.is_empty());
477    }
478
479    #[test]
480    fn report_warning_details_skipped_in_serialize_when_empty() {
481        let report = crate::test_helpers::empty_report();
482        let json = serde_json::to_string(&report).expect("serialize");
483        assert!(
484            !json.contains("warning_details"),
485            "warning_details should be skipped when empty, got: {json}"
486        );
487    }
488
489    #[test]
490    fn report_warning_details_serialized_when_present() {
491        let mut report = crate::test_helpers::empty_report();
492        report.warning_details = vec![
493            Warning::new("cold_start", "msg one"),
494            Warning::new("ingestion_drops", "msg two"),
495        ];
496        let json = serde_json::to_string(&report).expect("serialize");
497        let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
498        let array = parsed
499            .get("warning_details")
500            .and_then(|v| v.as_array())
501            .expect("warning_details array");
502        assert_eq!(array.len(), 2);
503        assert_eq!(array[0]["kind"], "cold_start");
504        assert_eq!(array[1]["kind"], "ingestion_drops");
505    }
506
507    #[test]
508    fn green_summary_roundtrip_with_new_carbon_attribution_fields() {
509        let mut per_service_carbon = BTreeMap::new();
510        per_service_carbon.insert("checkout".to_string(), 0.42);
511        per_service_carbon.insert("catalog".to_string(), 0.11);
512        let mut per_service_energy = BTreeMap::new();
513        per_service_energy.insert("checkout".to_string(), 0.0021);
514        per_service_energy.insert("catalog".to_string(), 0.0005);
515        let mut per_service_region = BTreeMap::new();
516        per_service_region.insert("checkout".to_string(), "eu-west-3".to_string());
517        per_service_region.insert("catalog".to_string(), "unknown".to_string());
518        let mut per_service_energy_model = BTreeMap::new();
519        per_service_energy_model.insert("checkout".to_string(), "scaphandre_rapl".to_string());
520        per_service_energy_model.insert("catalog".to_string(), "io_proxy_v3+cal".to_string());
521        let mut per_service_measured_ratio = BTreeMap::new();
522        per_service_measured_ratio.insert("checkout".to_string(), 0.75);
523        per_service_measured_ratio.insert("catalog".to_string(), 0.0);
524
525        let summary = GreenSummary {
526            energy_kwh: 0.0026,
527            energy_model: "scaphandre_rapl+cal".to_string(),
528            per_service_carbon_kgco2eq: per_service_carbon.clone(),
529            per_service_energy_kwh: per_service_energy.clone(),
530            per_service_region: per_service_region.clone(),
531            per_service_energy_model: per_service_energy_model.clone(),
532            per_service_measured_ratio: per_service_measured_ratio.clone(),
533            ..GreenSummary::disabled(0)
534        };
535        let json = serde_json::to_string(&summary).expect("serialize");
536        let parsed: GreenSummary = serde_json::from_str(&json).expect("deserialize");
537
538        assert!((parsed.energy_kwh - 0.0026).abs() < 1e-12);
539        assert_eq!(parsed.energy_model, "scaphandre_rapl+cal");
540        assert_eq!(parsed.per_service_carbon_kgco2eq, per_service_carbon);
541        assert_eq!(parsed.per_service_energy_kwh, per_service_energy);
542        assert_eq!(parsed.per_service_region, per_service_region);
543        assert_eq!(parsed.per_service_energy_model, per_service_energy_model);
544        assert_eq!(
545            parsed.per_service_measured_ratio,
546            per_service_measured_ratio
547        );
548    }
549
550    #[test]
551    fn green_summary_legacy_baseline_deserializes_with_default_carbon_attribution() {
552        // A pre-carbon-attribution archive line carries `GreenSummary`
553        // without `energy_kwh`, `energy_model`, or the per_service_*
554        // maps. Deserialization must fill them with the documented
555        // defaults so the aggregator can detect the absence and fall
556        // back to the proxy path.
557        let legacy = serde_json::json!({
558            "total_io_ops": 100,
559            "avoidable_io_ops": 5,
560            "io_waste_ratio": 0.05,
561            "io_waste_ratio_band": "healthy",
562            "top_offenders": []
563        });
564        let parsed: GreenSummary = serde_json::from_value(legacy).expect("deserialize legacy");
565        assert!(parsed.energy_kwh.abs() < f64::EPSILON);
566        assert!(parsed.energy_model.is_empty());
567        assert!(parsed.per_service_carbon_kgco2eq.is_empty());
568        assert!(parsed.per_service_energy_kwh.is_empty());
569        assert!(parsed.per_service_region.is_empty());
570        assert!(parsed.per_service_energy_model.is_empty());
571        assert!(parsed.per_service_measured_ratio.is_empty());
572    }
573}