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    /// Region-resolved I/O ops (`total_io_ops` minus the unknown bucket): the
150    /// denominator behind `co2.avoidable`. In-process only (`serde(skip)`),
151    /// read by the daemon to rescale avoidable at the canonical threshold.
152    #[serde(skip)]
153    pub accounted_io_ops: usize,
154    pub io_waste_ratio: f64,
155    /// Classification band for `io_waste_ratio`
156    /// (`healthy` / `moderate` / `high` / `critical`).
157    ///
158    /// Computed by [`InterpretationLevel::for_waste_ratio`]. The enum
159    /// values are stable across versions, the thresholds behind them
160    /// are versioned with the binary. See the [`interpret`] module for
161    /// the stability contract.
162    pub io_waste_ratio_band: InterpretationLevel,
163    pub top_offenders: Vec<TopOffender>,
164    /// Structured CO₂ report. Includes 2× multiplicative uncertainty
165    /// bracket, SCI v1.0 methodology tags, and operational + embodied terms.
166    /// `None` when green scoring is disabled or when no events were analyzed.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub co2: Option<CarbonReport>,
169    /// Per-region operational CO₂ breakdown sorted by `co2_gco2` descending.
170    /// Empty when green scoring is disabled or no events were analyzed.
171    #[serde(default, skip_serializing_if = "Vec::is_empty")]
172    pub regions: Vec<RegionBreakdown>,
173    /// Network transport CO₂ (gCO₂eq). Only present when
174    /// `[green] include_network_transport = true` and at least one
175    /// cross-region HTTP call had response size data.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub transport_gco2: Option<f64>,
178    /// Active Electricity Maps scoring configuration (API version,
179    /// emission factor type, temporal granularity). Surfaced for
180    /// Scope 2 audit trails so reporters can verify which carbon
181    /// model produced the numbers without reading the operator's
182    /// TOML config. `None` when Electricity Maps is not configured.
183    /// Additive on pre-0.5.12 baselines via `skip_serializing_if`.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub scoring_config: Option<ScoringConfig>,
186    /// Total energy consumed by the workload during the scoring window
187    /// in kWh, runtime-calibrated. Sum of per-service energy when
188    /// service-level measurement is available, falls back to the
189    /// operational proxy (`total_io_ops × ENERGY_PER_IO_OP_KWH`) when
190    /// not. `0.0` on pre-carbon-attribution baselines via `serde(default)`.
191    #[serde(default)]
192    pub energy_kwh: f64,
193    /// Energy model used to compute `energy_kwh`. One of
194    /// `"scaphandre_rapl"`, `"kepler_ebpf"`, `"redfish_bmc"`,
195    /// `"cloud_specpower"`, `"io_proxy_v3"`, `"io_proxy_v2"`,
196    /// `"io_proxy_v1"`, with optional `+cal` suffix
197    /// when per-service calibration factors are active. Reflects the
198    /// highest-fidelity model observed in the window (not weighted by
199    /// energy consumption). Empty string on pre-carbon-attribution
200    /// baselines.
201    #[serde(default)]
202    pub energy_model: String,
203    /// Operational carbon per service in kgCO2eq. Excludes the embodied
204    /// term (which stays in `co2.total` only) and the transport term.
205    /// Built at scoring time using the runtime-resolved
206    /// `service → region` mapping and the per-region grid intensity
207    /// (Electricity Maps real-time when available). Sum is
208    /// approximately `co2.operational_gco2 / 1000.0` up to
209    /// floating-point rounding. Empty on pre-carbon-attribution baselines.
210    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
211    pub per_service_carbon_kgco2eq: BTreeMap<String, f64>,
212    /// Operational energy per service in kWh. Built at scoring time
213    /// using the runtime-resolved energy entries (Scaphandre per-process
214    /// RAPL when available, cloud `SPECpower` interpolation otherwise,
215    /// proxy fallback). Sum is approximately `energy_kwh` up to
216    /// floating-point rounding. Empty on pre-carbon-attribution
217    /// baselines.
218    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
219    pub per_service_energy_kwh: BTreeMap<String, f64>,
220    /// Per-service region attribution snapshot at scoring time. Surfaces
221    /// the `service → region` mapping that produced the per-service
222    /// carbon, using `"unknown"` for services that could not be resolved
223    /// to a region. Empty on pre-carbon-attribution baselines.
224    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
225    pub per_service_region: BTreeMap<String, String>,
226    /// Per-service energy model tag. Same value set as `energy_model`
227    /// (window-level), per-service this time so auditors can verify which
228    /// services benefited from Scaphandre, Kepler, Redfish, or cloud
229    /// `SPECpower` during this window. Presence of any measured tag
230    /// (`"scaphandre_rapl"`, `"kepler_ebpf"`, `"redfish_bmc"`,
231    /// `"cloud_specpower"`) indicates that at least one span of the
232    /// service hit a measured energy source, not that 100% of the
233    /// service's spans were measured.
234    /// Read together with `per_service_measured_ratio` for the share of
235    /// spans that benefited from the measured model. Services without any
236    /// measured span inherit the window-level proxy tag; the `+cal` suffix
237    /// on that inherited tag reflects window-wide calibration state, not
238    /// whether a calibration factor applied to this specific service.
239    /// Empty on pre-per-service-model baselines.
240    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
241    pub per_service_energy_model: BTreeMap<String, String>,
242    /// Fraction of spans whose energy was resolved by Scaphandre or
243    /// cloud `SPECpower` (versus proxy fallback) per service, in `[0.0,
244    /// 1.0]`. `1.0` means every span had measured energy, `0.0` means
245    /// the service fell back to proxy entirely. Pair with
246    /// `per_service_energy_model` to assess fidelity. The aggregator
247    /// surfaces a simple arithmetic mean of these per-window ratios
248    /// under `aggregate.per_service_measured_ratio`, not a span-weighted
249    /// average. Empty on pre-per-service-ratio baselines.
250    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
251    pub per_service_measured_ratio: BTreeMap<String, f64>,
252}
253
254/// Raw I/O operation count for a single `(service, endpoint)` pair.
255///
256/// Stable JSON shape: field names will not be renamed or removed in a
257/// minor release. The `(service, endpoint)` pair is the
258/// primary key so the same endpoint path served by two different
259/// services produces two distinct entries (microservices commonly share
260/// generic paths like `/health`, `/metrics`, `/api/users`).
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct PerEndpointIoOps {
263    pub service: String,
264    pub endpoint: String,
265    pub io_ops: usize,
266}
267
268/// Single-pass per-endpoint I/O op counter. Returns the counts sorted by
269/// `(service, endpoint)` for deterministic output. O(N) over the total
270/// span count.
271///
272/// Used by the pipeline to populate `Report.per_endpoint_io_ops` when
273/// green scoring is **disabled**. When green scoring is enabled,
274/// [`crate::score::score_green`] returns the same data as part of its
275/// own single-pass span iteration, so this helper is not called and the
276/// hot path stays a single O(N) walk.
277#[must_use]
278pub fn compute_per_endpoint_io_ops(traces: &[Trace]) -> Vec<PerEndpointIoOps> {
279    // BTreeMap so the resulting Vec is naturally sorted by key without
280    // a separate sort pass. Key is `(service, endpoint)` so two traces
281    // for the same endpoint on different services stay distinct.
282    let mut counts: BTreeMap<(&str, &str), usize> = BTreeMap::new();
283    for trace in traces {
284        for span in &trace.spans {
285            let key = (
286                span.event.service.as_ref(),
287                span.event.source.endpoint.as_str(),
288            );
289            *counts.entry(key).or_insert(0) += 1;
290        }
291    }
292    counts
293        .into_iter()
294        .map(|((service, endpoint), io_ops)| PerEndpointIoOps {
295            service: service.to_string(),
296            endpoint: endpoint.to_string(),
297            io_ops,
298        })
299        .collect()
300}
301
302impl GreenSummary {
303    /// Create a `GreenSummary` with only `total_io_ops` set (green scoring disabled).
304    #[must_use]
305    pub fn disabled(total_io_ops: usize) -> Self {
306        Self {
307            total_io_ops,
308            avoidable_io_ops: 0,
309            accounted_io_ops: total_io_ops,
310            io_waste_ratio: 0.0,
311            io_waste_ratio_band: InterpretationLevel::Healthy,
312            top_offenders: vec![],
313            co2: None,
314            regions: vec![],
315            transport_gco2: None,
316            scoring_config: None,
317            energy_kwh: 0.0,
318            energy_model: String::new(),
319            per_service_carbon_kgco2eq: BTreeMap::new(),
320            per_service_energy_kwh: BTreeMap::new(),
321            per_service_region: BTreeMap::new(),
322            per_service_energy_model: BTreeMap::new(),
323            per_service_measured_ratio: BTreeMap::new(),
324        }
325    }
326}
327
328/// A top offender endpoint ranked by I/O Intensity Score.
329#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330pub struct TopOffender {
331    pub endpoint: String,
332    pub service: String,
333    pub io_intensity_score: f64,
334    /// Classification band for `io_intensity_score`. Stable enum values
335    /// across versions, thresholds versioned with the binary. See the
336    /// [`interpret`] module for the stability contract.
337    pub io_intensity_band: InterpretationLevel,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub co2_grams: Option<f64>,
340}
341
342/// Quality gate result.
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct QualityGate {
345    pub passed: bool,
346    pub rules: Vec<QualityRule>,
347}
348
349/// A single quality gate rule check.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct QualityRule {
352    pub rule: String,
353    pub threshold: f64,
354    pub actual: f64,
355    pub passed: bool,
356}
357
358/// Trait for report output sinks.
359pub trait ReportSink {
360    type Error: std::error::Error;
361
362    /// # Errors
363    ///
364    /// Returns an error if the report cannot be written to the output sink.
365    fn emit(&self, report: &Report) -> Result<(), Self::Error>;
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn green_summary_pre_0512_baseline_loads_without_scoring_config() {
374        // Hand-crafted JSON shaped like a pre-0.5.12 baseline (no
375        // scoring_config field). The Option must default to None,
376        // ensuring `report --before <old.json>` still works after the
377        // additive change.
378        let json = r#"{
379            "total_io_ops": 0,
380            "avoidable_io_ops": 0,
381            "io_waste_ratio": 0.0,
382            "io_waste_ratio_band": "healthy",
383            "top_offenders": []
384        }"#;
385        let summary: GreenSummary = serde_json::from_str(json).expect("backward-compat parse");
386        assert!(summary.scoring_config.is_none());
387    }
388
389    #[test]
390    fn green_summary_disabled_factory_has_no_scoring_config() {
391        let summary = GreenSummary::disabled(0);
392        assert!(summary.scoring_config.is_none());
393    }
394
395    #[test]
396    fn green_summary_skips_scoring_config_when_none() {
397        let summary = GreenSummary::disabled(42);
398        let json = serde_json::to_string(&summary).unwrap();
399        assert!(
400            !json.contains("scoring_config"),
401            "scoring_config should be skipped when None, got: {json}"
402        );
403    }
404
405    fn minimal_report_json_without_warning_details() -> String {
406        // Shaped like a 0.5.18 Report (no warning_details key). Used to
407        // verify that the new field defaults to empty when absent, so a
408        // pre-0.5.19 baseline replayed via `report --before <old.json>`
409        // still parses cleanly.
410        r#"{
411            "analysis": {"duration_ms": 0, "events_processed": 0, "traces_analyzed": 0},
412            "findings": [],
413            "green_summary": {
414                "total_io_ops": 0,
415                "avoidable_io_ops": 0,
416                "io_waste_ratio": 0.0,
417                "io_waste_ratio_band": "healthy",
418                "top_offenders": []
419            },
420            "quality_gate": {"passed": true, "rules": []},
421            "warnings": ["legacy warning text"]
422        }"#
423        .to_string()
424    }
425
426    #[test]
427    fn report_warning_details_default_empty_when_absent() {
428        let report: Report =
429            serde_json::from_str(&minimal_report_json_without_warning_details()).expect("parse");
430        assert!(report.warning_details.is_empty());
431    }
432
433    #[test]
434    fn report_legacy_warnings_field_still_parses() {
435        let report: Report =
436            serde_json::from_str(&minimal_report_json_without_warning_details()).expect("parse");
437        assert_eq!(report.warnings, vec!["legacy warning text".to_string()]);
438        assert!(report.warning_details.is_empty());
439    }
440
441    #[test]
442    fn report_warning_details_skipped_in_serialize_when_empty() {
443        let report = crate::test_helpers::empty_report();
444        let json = serde_json::to_string(&report).expect("serialize");
445        assert!(
446            !json.contains("warning_details"),
447            "warning_details should be skipped when empty, got: {json}"
448        );
449    }
450
451    #[test]
452    fn report_warning_details_serialized_when_present() {
453        let mut report = crate::test_helpers::empty_report();
454        report.warning_details = vec![
455            Warning::new("cold_start", "msg one"),
456            Warning::new("ingestion_drops", "msg two"),
457        ];
458        let json = serde_json::to_string(&report).expect("serialize");
459        let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
460        let array = parsed
461            .get("warning_details")
462            .and_then(|v| v.as_array())
463            .expect("warning_details array");
464        assert_eq!(array.len(), 2);
465        assert_eq!(array[0]["kind"], "cold_start");
466        assert_eq!(array[1]["kind"], "ingestion_drops");
467    }
468
469    #[test]
470    fn green_summary_roundtrip_with_new_carbon_attribution_fields() {
471        let mut per_service_carbon = BTreeMap::new();
472        per_service_carbon.insert("checkout".to_string(), 0.42);
473        per_service_carbon.insert("catalog".to_string(), 0.11);
474        let mut per_service_energy = BTreeMap::new();
475        per_service_energy.insert("checkout".to_string(), 0.0021);
476        per_service_energy.insert("catalog".to_string(), 0.0005);
477        let mut per_service_region = BTreeMap::new();
478        per_service_region.insert("checkout".to_string(), "eu-west-3".to_string());
479        per_service_region.insert("catalog".to_string(), "unknown".to_string());
480        let mut per_service_energy_model = BTreeMap::new();
481        per_service_energy_model.insert("checkout".to_string(), "scaphandre_rapl".to_string());
482        per_service_energy_model.insert("catalog".to_string(), "io_proxy_v3+cal".to_string());
483        let mut per_service_measured_ratio = BTreeMap::new();
484        per_service_measured_ratio.insert("checkout".to_string(), 0.75);
485        per_service_measured_ratio.insert("catalog".to_string(), 0.0);
486
487        let summary = GreenSummary {
488            energy_kwh: 0.0026,
489            energy_model: "scaphandre_rapl+cal".to_string(),
490            per_service_carbon_kgco2eq: per_service_carbon.clone(),
491            per_service_energy_kwh: per_service_energy.clone(),
492            per_service_region: per_service_region.clone(),
493            per_service_energy_model: per_service_energy_model.clone(),
494            per_service_measured_ratio: per_service_measured_ratio.clone(),
495            ..GreenSummary::disabled(0)
496        };
497        let json = serde_json::to_string(&summary).expect("serialize");
498        let parsed: GreenSummary = serde_json::from_str(&json).expect("deserialize");
499
500        assert!((parsed.energy_kwh - 0.0026).abs() < 1e-12);
501        assert_eq!(parsed.energy_model, "scaphandre_rapl+cal");
502        assert_eq!(parsed.per_service_carbon_kgco2eq, per_service_carbon);
503        assert_eq!(parsed.per_service_energy_kwh, per_service_energy);
504        assert_eq!(parsed.per_service_region, per_service_region);
505        assert_eq!(parsed.per_service_energy_model, per_service_energy_model);
506        assert_eq!(
507            parsed.per_service_measured_ratio,
508            per_service_measured_ratio
509        );
510    }
511
512    #[test]
513    fn green_summary_legacy_baseline_deserializes_with_default_carbon_attribution() {
514        // A pre-carbon-attribution archive line carries `GreenSummary`
515        // without `energy_kwh`, `energy_model`, or the per_service_*
516        // maps. Deserialization must fill them with the documented
517        // defaults so the aggregator can detect the absence and fall
518        // back to the proxy path.
519        let legacy = serde_json::json!({
520            "total_io_ops": 100,
521            "avoidable_io_ops": 5,
522            "io_waste_ratio": 0.05,
523            "io_waste_ratio_band": "healthy",
524            "top_offenders": []
525        });
526        let parsed: GreenSummary = serde_json::from_value(legacy).expect("deserialize legacy");
527        assert!(parsed.energy_kwh.abs() < f64::EPSILON);
528        assert!(parsed.energy_model.is_empty());
529        assert!(parsed.per_service_carbon_kgco2eq.is_empty());
530        assert!(parsed.per_service_energy_kwh.is_empty());
531        assert!(parsed.per_service_region.is_empty());
532        assert!(parsed.per_service_energy_model.is_empty());
533        assert!(parsed.per_service_measured_ratio.is_empty());
534    }
535}