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}