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}