Skip to main content

sentinel_core/
calibrate.rs

1//! Calibration mode: tune I/O-to-energy coefficients from real measurements.
2//!
3//! The `calibrate` subcommand correlates trace I/O ops with measured power or
4//! energy readings (e.g. from Scaphandre RAPL exports or cloud monitoring) and
5//! produces adjusted energy-per-op coefficients. These are written to a TOML
6//! file that can be loaded via `[green] calibration_file`.
7
8use std::collections::HashMap;
9use std::fmt::Write as _;
10use std::sync::Arc;
11
12use serde::{Deserialize, Serialize};
13
14use crate::event::SpanEvent;
15use crate::score::carbon::ENERGY_PER_IO_OP_KWH;
16
17// ---------------------------------------------------------------
18// Error type
19// ---------------------------------------------------------------
20
21/// Errors that can occur during calibration.
22///
23/// `#[non_exhaustive]` for SemVer-minor variant additions.
24#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum CalibrationError {
27    /// A CSV row had the wrong number of columns, unparseable numeric
28    /// values or an unknown header layout. `line` is 1-indexed.
29    #[error("CSV parse error at line {line}: {reason}")]
30    CsvParse { line: usize, reason: String },
31
32    /// An ISO 8601 timestamp column could not be parsed. Covers missing
33    /// `Z` suffix, non-UTC offsets and out-of-range date/time fields.
34    #[error("failed to parse timestamp '{value}' at line {line}: {reason}")]
35    TimestampParse {
36        line: usize,
37        value: String,
38        reason: String,
39    },
40
41    /// The CSV parsed successfully but contained zero data rows. At
42    /// least one measurement is required to compute a calibration.
43    #[error("empty energy CSV: no data rows found")]
44    EmptyData,
45
46    /// Underlying filesystem I/O error when reading the CSV or writing
47    /// the calibration TOML output.
48    #[error("I/O error: {0}")]
49    Io(#[from] std::io::Error),
50
51    /// The calibration TOML file loaded via `[green] calibration_file`
52    /// failed TOML deserialization (malformed syntax, wrong types).
53    #[error("TOML parse error: {0}")]
54    TomlParse(#[from] toml::de::Error),
55
56    /// A semantic validation check failed on an otherwise well-formed
57    /// calibration TOML: negative or non-finite factor, missing base
58    /// energy or a service factor outside the accepted range.
59    #[error("validation error: {0}")]
60    Validation(String),
61}
62
63// ---------------------------------------------------------------
64// Energy CSV parsing
65// ---------------------------------------------------------------
66
67/// Whether the CSV uses power (watts) or direct energy (kWh).
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69enum CsvFormat {
70    PowerWatts,
71    EnergyKwh,
72}
73
74/// A single energy measurement reading.
75#[derive(Debug, Clone)]
76pub struct EnergyReading {
77    pub timestamp_ms: u64,
78    pub service: String,
79    pub energy_kwh: f64,
80}
81
82/// Parse an ISO 8601 UTC timestamp into milliseconds since epoch.
83///
84/// Thin wrapper around [`crate::time::parse_iso8601_utc_to_ms`]. Kept
85/// as a module-local function for a clearer stack trace on CSV errors
86/// ("failed to parse timestamp at row 42") and because earlier versions
87/// of this module had a hand-rolled implementation that has since been
88/// centralized in `time.rs`.
89fn parse_timestamp_ms(s: &str) -> Result<u64, String> {
90    crate::time::parse_iso8601_utc_to_ms(s)
91}
92
93/// Parse an energy measurement CSV file.
94///
95/// Two column formats are supported, auto-detected from the header:
96/// - `timestamp,service,power_watts`: power measurements converted to energy
97///   using intervals between consecutive readings per service.
98/// - `timestamp,service,energy_kwh`: direct energy readings.
99///
100/// Lines starting with `#` are treated as comments and skipped.
101///
102/// # Errors
103///
104/// Returns `CalibrationError::EmptyData` if no data rows are found, or
105/// `CalibrationError::CsvParse` / `CalibrationError::TimestampParse` for
106/// malformed rows.
107pub fn parse_energy_csv(content: &str) -> Result<Vec<EnergyReading>, CalibrationError> {
108    let mut lines = content.lines().enumerate();
109    let format = detect_csv_format(&mut lines)?;
110    let raw_rows = collect_csv_data_rows(lines)?;
111    match format {
112        CsvFormat::EnergyKwh => Ok(raw_rows
113            .into_iter()
114            .map(|(ts, service, energy_kwh)| EnergyReading {
115                timestamp_ms: ts,
116                service,
117                energy_kwh,
118            })
119            .collect()),
120        CsvFormat::PowerWatts => convert_power_to_energy(raw_rows),
121    }
122}
123
124/// Advance `lines` until a non-comment, non-blank header row is found
125/// and return the detected [`CsvFormat`]. Leaves the iterator positioned
126/// just after the header so [`collect_csv_data_rows`] can resume.
127fn detect_csv_format(
128    lines: &mut std::iter::Enumerate<std::str::Lines<'_>>,
129) -> Result<CsvFormat, CalibrationError> {
130    loop {
131        let (line_num, line) = lines.next().ok_or(CalibrationError::EmptyData)?;
132        let line = line.trim();
133        if line.is_empty() || line.starts_with('#') {
134            continue;
135        }
136        let lower = line.to_ascii_lowercase();
137        if lower.contains("power_watts") {
138            return Ok(CsvFormat::PowerWatts);
139        }
140        if lower.contains("energy_kwh") {
141            return Ok(CsvFormat::EnergyKwh);
142        }
143        return Err(CalibrationError::CsvParse {
144            line: line_num + 1,
145            reason: "header must contain 'power_watts' or 'energy_kwh'".to_string(),
146        });
147    }
148}
149
150/// Consume the remaining `lines` and parse each non-comment row into
151/// a `(timestamp_ms, service, value)` tuple. Returns [`CalibrationError::EmptyData`]
152/// if no data rows were found.
153fn collect_csv_data_rows(
154    lines: std::iter::Enumerate<std::str::Lines<'_>>,
155) -> Result<Vec<(u64, String, f64)>, CalibrationError> {
156    let mut raw_rows: Vec<(u64, String, f64)> = Vec::new();
157    for (line_num, line) in lines {
158        let line = line.trim();
159        if line.is_empty() || line.starts_with('#') {
160            continue;
161        }
162        raw_rows.push(parse_csv_data_row(line_num, line)?);
163    }
164    if raw_rows.is_empty() {
165        return Err(CalibrationError::EmptyData);
166    }
167    Ok(raw_rows)
168}
169
170/// Parse a single non-comment data row: `timestamp,service,value`.
171/// Validates the 3-column shape, the timestamp, and the numeric value
172/// (must be finite and non-negative). `line_num` is 0-indexed on
173/// entry; all emitted errors use 1-indexed line numbers.
174fn parse_csv_data_row(line_num: usize, line: &str) -> Result<(u64, String, f64), CalibrationError> {
175    let parts: Vec<&str> = line.splitn(3, ',').collect();
176    if parts.len() != 3 {
177        return Err(CalibrationError::CsvParse {
178            line: line_num + 1,
179            reason: "expected 3 comma-separated columns".to_string(),
180        });
181    }
182    let ts =
183        parse_timestamp_ms(parts[0].trim()).map_err(|reason| CalibrationError::TimestampParse {
184            line: line_num + 1,
185            value: parts[0].trim().to_string(),
186            reason,
187        })?;
188    let service = parts[1].trim().to_string();
189    let value: f64 = parts[2]
190        .trim()
191        .parse()
192        .map_err(|_| CalibrationError::CsvParse {
193            line: line_num + 1,
194            reason: format!("invalid numeric value '{}'", parts[2].trim()),
195        })?;
196    if !value.is_finite() || value < 0.0 {
197        return Err(CalibrationError::CsvParse {
198            line: line_num + 1,
199            reason: format!("invalid value: {value} (must be finite and non-negative)"),
200        });
201    }
202    Ok((ts, service, value))
203}
204
205/// Convert power (watts) readings to energy (kWh) by computing intervals
206/// between consecutive readings per service.
207fn convert_power_to_energy(
208    mut rows: Vec<(u64, String, f64)>,
209) -> Result<Vec<EnergyReading>, CalibrationError> {
210    // Sort by service then timestamp for sequential processing
211    rows.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
212
213    let mut readings = Vec::new();
214    let mut prev: Option<(u64, &str, f64)> = None;
215
216    for (ts, service, watts) in &rows {
217        if let Some((prev_ts, prev_svc, prev_watts)) = prev
218            && prev_svc == service.as_str()
219        {
220            let interval_secs = ts.saturating_sub(prev_ts) as f64 / 1000.0;
221            if interval_secs > 0.0 {
222                // Average power over the interval, converted to kWh
223                let avg_watts = (prev_watts + watts) / 2.0;
224                let energy_kwh = avg_watts * interval_secs / 3_600_000.0;
225                readings.push(EnergyReading {
226                    timestamp_ms: *ts,
227                    service: service.clone(),
228                    energy_kwh,
229                });
230            }
231        }
232        prev = Some((*ts, service, *watts));
233    }
234
235    if readings.is_empty() {
236        return Err(CalibrationError::EmptyData);
237    }
238
239    Ok(readings)
240}
241
242// ---------------------------------------------------------------
243// Calibration computation
244// ---------------------------------------------------------------
245
246/// Result of calibrating a single service.
247#[derive(Debug, Clone)]
248pub struct CalibrationResult {
249    pub service: String,
250    pub total_ops: u64,
251    pub total_energy_kwh: f64,
252    pub energy_per_op_kwh: f64,
253    pub default_energy_per_op_kwh: f64,
254    pub factor: f64,
255}
256
257/// Run calibration: correlate trace I/O ops with energy measurements.
258///
259/// For each service that appears in both traces and energy readings, compute:
260/// - total I/O ops from the traces
261/// - total energy from the readings
262/// - energy per op = total energy / total ops
263/// - calibration factor = energy per op / default proxy energy
264///
265/// Services with zero ops in the observation window are skipped.
266///
267/// # Errors
268///
269/// Returns `CalibrationError::EmptyData` if the readings slice is empty.
270pub fn calibrate(
271    events: &[SpanEvent],
272    readings: &[EnergyReading],
273) -> Result<Vec<CalibrationResult>, CalibrationError> {
274    if readings.is_empty() {
275        return Err(CalibrationError::EmptyData);
276    }
277
278    // Determine the observation window from the energy readings
279    let window_start = readings.iter().map(|r| r.timestamp_ms).min().unwrap_or(0);
280    let window_end = readings.iter().map(|r| r.timestamp_ms).max().unwrap_or(0);
281
282    // Count I/O ops per service within the observation window. Keying on
283    // Arc<str> lets us amortize the per-event clone via Arc::clone (O(1))
284    // instead of allocating a String per event.
285    let mut ops_per_service: HashMap<Arc<str>, u64> = HashMap::new();
286    let mut skipped = 0usize;
287    for event in events {
288        let Ok(ts) = parse_timestamp_ms(&event.timestamp) else {
289            skipped += 1;
290            continue;
291        };
292        if ts >= window_start && ts <= window_end {
293            *ops_per_service
294                .entry(Arc::clone(&event.service))
295                .or_default() += 1;
296        }
297    }
298    if skipped > 0 {
299        tracing::debug!(
300            skipped,
301            "skipped events with unparsable timestamps during calibration"
302        );
303    }
304
305    // Sum energy per service. Readings come from disk (CalibrationResult /
306    // EnergyReading store String), so keying on String matches the join.
307    let mut energy_per_service: HashMap<String, f64> = HashMap::new();
308    for reading in readings {
309        *energy_per_service
310            .entry(reading.service.clone())
311            .or_default() += reading.energy_kwh;
312    }
313
314    // Compute calibration results for services with both measurements
315    let mut results: Vec<CalibrationResult> = Vec::new();
316    for (service, total_energy_kwh) in &energy_per_service {
317        let total_ops = ops_per_service.get(service.as_str()).copied().unwrap_or(0);
318        if total_ops == 0 {
319            tracing::warn!(
320                service = %service,
321                "no I/O ops found for service in the observation window, skipping"
322            );
323            continue;
324        }
325        let energy_per_op_kwh = total_energy_kwh / total_ops as f64;
326        let factor = energy_per_op_kwh / ENERGY_PER_IO_OP_KWH;
327
328        results.push(CalibrationResult {
329            service: service.clone(),
330            total_ops,
331            total_energy_kwh: *total_energy_kwh,
332            energy_per_op_kwh,
333            default_energy_per_op_kwh: ENERGY_PER_IO_OP_KWH,
334            factor,
335        });
336    }
337
338    // Sort by service name for deterministic output
339    results.sort_by(|a, b| a.service.cmp(&b.service));
340
341    Ok(results)
342}
343
344// ---------------------------------------------------------------
345// Calibration output (TOML file)
346// ---------------------------------------------------------------
347
348/// Generate a calibration TOML file from calibration results.
349#[must_use]
350pub fn write_calibration_toml(
351    results: &[CalibrationResult],
352    traces_path: &str,
353    energy_path: &str,
354) -> String {
355    let now = chrono_like_now();
356    let mut out = String::new();
357    out.push_str("# Auto-generated by perf-sentinel calibrate\n");
358    let _ = writeln!(out, "# Based on: {traces_path} + {energy_path}");
359    let _ = writeln!(out, "# Date: {now}");
360    out.push('\n');
361    out.push_str("[calibration]\n");
362    let _ = writeln!(out, "base_energy_per_io_op_kwh = {ENERGY_PER_IO_OP_KWH}");
363    out.push('\n');
364    out.push_str("[calibration.services]\n");
365    for r in results {
366        // Escape service name for TOML double-quoted string to prevent
367        // injection via service names containing quotes or newlines.
368        let escaped = r
369            .service
370            .replace('\\', "\\\\")
371            .replace('"', "\\\"")
372            .replace('\n', "\\n")
373            .replace('\r', "\\r");
374        let _ = writeln!(
375            out,
376            "\"{escaped}\" = {{ factor = {:.2}, measured_energy_per_op_kwh = {:.10} }}",
377            r.factor, r.energy_per_op_kwh
378        );
379    }
380    out
381}
382
383/// Simple UTC timestamp for the calibration file header.
384fn chrono_like_now() -> String {
385    let now = std::time::SystemTime::now()
386        .duration_since(std::time::UNIX_EPOCH)
387        .unwrap_or_default();
388    crate::time::nanos_to_iso8601(now.as_nanos() as u64)
389}
390
391// ---------------------------------------------------------------
392// Calibration data loading (for config integration)
393// ---------------------------------------------------------------
394
395/// Per-service calibration factor loaded from a calibration TOML file.
396#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct ServiceCalibration {
398    pub factor: f64,
399    pub measured_energy_per_op_kwh: f64,
400}
401
402/// Calibration data loaded from a `.perf-sentinel-calibration.toml` file.
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct CalibrationData {
405    pub calibration: CalibrationSection,
406}
407
408/// The `[calibration]` section in the calibration TOML file.
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct CalibrationSection {
411    pub base_energy_per_io_op_kwh: f64,
412    #[serde(default)]
413    pub services: HashMap<String, ServiceCalibration>,
414}
415
416impl CalibrationData {
417    /// Look up the calibration factor for a service.
418    ///
419    /// Returns `None` if the service has no calibration entry.
420    #[must_use]
421    pub fn factor_for(&self, service: &str) -> Option<f64> {
422        self.calibration.services.get(service).map(|s| s.factor)
423    }
424}
425
426/// Load and validate a calibration TOML file.
427///
428/// # Errors
429///
430/// Returns `CalibrationError::Io` if the file cannot be read,
431/// `CalibrationError::TomlParse` if parsing fails, or
432/// `CalibrationError::Validation` for invalid factor values.
433pub fn load_calibration_file(path: &str) -> Result<CalibrationData, CalibrationError> {
434    let content = std::fs::read_to_string(path)?;
435    let data: CalibrationData = toml::from_str(&content)?;
436
437    // Validate factors
438    for (service, cal) in &data.calibration.services {
439        if !cal.factor.is_finite() || cal.factor < 0.0 {
440            return Err(CalibrationError::Validation(format!(
441                "service '{service}' has invalid calibration factor: {}",
442                cal.factor
443            )));
444        }
445        if !cal.measured_energy_per_op_kwh.is_finite() || cal.measured_energy_per_op_kwh < 0.0 {
446            return Err(CalibrationError::Validation(format!(
447                "service '{service}' has invalid measured_energy_per_op_kwh: {}",
448                cal.measured_energy_per_op_kwh
449            )));
450        }
451        if cal.factor == 0.0 {
452            tracing::warn!(
453                service = %service,
454                "calibration factor is 0.0, service will have zero carbon impact"
455            );
456        }
457        if cal.factor > 10.0 {
458            tracing::warn!(
459                service = %service,
460                factor = cal.factor,
461                "calibration factor > 10x default, possible measurement error"
462            );
463        }
464        if cal.factor > 0.0 && cal.factor < 0.1 {
465            tracing::warn!(
466                service = %service,
467                factor = cal.factor,
468                "calibration factor < 0.1x default, possible measurement error"
469            );
470        }
471    }
472
473    Ok(data)
474}
475
476/// Validate calibration results for sanity.
477#[must_use]
478pub fn validate_results(results: &[CalibrationResult]) -> Vec<String> {
479    let mut warnings = Vec::new();
480    for r in results {
481        if r.factor > 10.0 {
482            warnings.push(format!(
483                "{}: factor {:.1}x is > 10x default, possible measurement error",
484                r.service, r.factor
485            ));
486        }
487        if r.factor > 0.0 && r.factor < 0.1 {
488            warnings.push(format!(
489                "{}: factor {:.1}x is < 0.1x default, possible measurement error",
490                r.service, r.factor
491            ));
492        }
493    }
494    warnings
495}
496
497// ---------------------------------------------------------------
498// Tests
499// ---------------------------------------------------------------
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::event::{EventSource, EventType};
505    use core::assert_matches;
506
507    fn make_event(service: &str, timestamp: &str) -> SpanEvent {
508        SpanEvent {
509            timestamp: timestamp.to_string(),
510            trace_id: "trace-1".to_string(),
511            span_id: "span-1".to_string(),
512            parent_span_id: None,
513            service: Arc::from(service),
514            cloud_region: None,
515            event_type: EventType::Sql,
516            operation: "SELECT".to_string(),
517            target: "SELECT * FROM t".to_string(),
518            duration_us: 100,
519            status_code: None,
520            response_size_bytes: None,
521            source: EventSource {
522                endpoint: "GET /api/test".to_string(),
523                method: "test".to_string(),
524            },
525            code_function: None,
526            code_filepath: None,
527            code_lineno: None,
528            code_namespace: None,
529            instrumentation_scopes: Vec::new(),
530        }
531    }
532
533    // --- Timestamp parsing ---
534
535    #[test]
536    fn parse_timestamp_basic() {
537        let ms = parse_timestamp_ms("2025-07-10T14:32:01.123Z").unwrap();
538        assert!(ms > 0);
539    }
540
541    #[test]
542    fn parse_timestamp_no_fraction() {
543        let ms = parse_timestamp_ms("2025-07-10T14:32:01Z").unwrap();
544        assert!(ms > 0);
545    }
546
547    #[test]
548    fn parse_timestamp_rejects_non_utc() {
549        assert!(parse_timestamp_ms("2025-07-10T14:32:01+02:00").is_err());
550    }
551
552    #[test]
553    fn parse_timestamp_rejects_invalid() {
554        assert!(parse_timestamp_ms("not-a-timestamp").is_err());
555        assert!(parse_timestamp_ms("").is_err());
556    }
557
558    // --- CSV parsing: energy_kwh format ---
559
560    #[test]
561    fn parse_csv_energy_format() {
562        let csv = "timestamp,service,energy_kwh\n\
563                   2025-07-10T14:00:00Z,order-svc,0.0001\n\
564                   2025-07-10T14:05:00Z,order-svc,0.0002\n";
565        let readings = parse_energy_csv(csv).unwrap();
566        assert_eq!(readings.len(), 2);
567        assert_eq!(readings[0].service, "order-svc");
568        assert!((readings[0].energy_kwh - 0.0001).abs() < 1e-10);
569    }
570
571    #[test]
572    fn parse_csv_power_format() {
573        let csv = "timestamp,service,power_watts\n\
574                   2025-07-10T14:00:00Z,svc-a,10.0\n\
575                   2025-07-10T14:00:05Z,svc-a,12.0\n";
576        let readings = parse_energy_csv(csv).unwrap();
577        assert_eq!(readings.len(), 1);
578        assert_eq!(readings[0].service, "svc-a");
579        // Average power = (10+12)/2 = 11W, interval = 5s
580        // Energy = 11 * 5 / 3_600_000 kWh
581        let expected = 11.0 * 5.0 / 3_600_000.0;
582        assert!((readings[0].energy_kwh - expected).abs() < 1e-15);
583    }
584
585    #[test]
586    fn parse_csv_with_comments() {
587        let csv = "# Energy measurements\n\
588                   timestamp,service,energy_kwh\n\
589                   # First reading\n\
590                   2025-07-10T14:00:00Z,svc-a,0.001\n";
591        let readings = parse_energy_csv(csv).unwrap();
592        assert_eq!(readings.len(), 1);
593    }
594
595    #[test]
596    fn parse_csv_empty_data() {
597        let csv = "timestamp,service,energy_kwh\n";
598        assert_matches!(parse_energy_csv(csv), Err(CalibrationError::EmptyData));
599    }
600
601    #[test]
602    fn parse_csv_rejects_negative() {
603        let csv = "timestamp,service,energy_kwh\n\
604                   2025-07-10T14:00:00Z,svc-a,-0.001\n";
605        assert_matches!(
606            parse_energy_csv(csv),
607            Err(CalibrationError::CsvParse { .. })
608        );
609    }
610
611    #[test]
612    fn parse_csv_rejects_bad_header() {
613        let csv = "timestamp,service,something\n\
614                   2025-07-10T14:00:00Z,svc-a,0.001\n";
615        assert_matches!(
616            parse_energy_csv(csv),
617            Err(CalibrationError::CsvParse { .. })
618        );
619    }
620
621    #[test]
622    fn parse_csv_rejects_malformed_row() {
623        let csv = "timestamp,service,energy_kwh\n\
624                   2025-07-10T14:00:00Z,svc-a\n";
625        assert_matches!(
626            parse_energy_csv(csv),
627            Err(CalibrationError::CsvParse { .. })
628        );
629    }
630
631    // --- Calibration computation ---
632
633    #[test]
634    fn calibrate_basic() {
635        let events = vec![
636            make_event("svc-a", "2025-07-10T14:00:01Z"),
637            make_event("svc-a", "2025-07-10T14:00:02Z"),
638            make_event("svc-a", "2025-07-10T14:00:03Z"),
639            make_event("svc-a", "2025-07-10T14:00:04Z"),
640        ];
641        let readings = vec![
642            EnergyReading {
643                timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:00Z").unwrap(),
644                service: "svc-a".to_string(),
645                energy_kwh: 0.000_000_2, // 2e-7
646            },
647            EnergyReading {
648                timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:05Z").unwrap(),
649                service: "svc-a".to_string(),
650                energy_kwh: 0.000_000_2,
651            },
652        ];
653
654        let results = calibrate(&events, &readings).unwrap();
655        assert_eq!(results.len(), 1);
656        assert_eq!(results[0].service, "svc-a");
657        assert_eq!(results[0].total_ops, 4);
658        let expected_energy = 0.000_000_4; // sum of readings
659        assert!((results[0].total_energy_kwh - expected_energy).abs() < 1e-15);
660        let expected_per_op = expected_energy / 4.0;
661        assert!((results[0].energy_per_op_kwh - expected_per_op).abs() < 1e-15);
662        assert!((results[0].factor - (expected_per_op / ENERGY_PER_IO_OP_KWH)).abs() < 1e-10);
663    }
664
665    #[test]
666    fn calibrate_multiple_services() {
667        let events = vec![
668            make_event("svc-a", "2025-07-10T14:00:01Z"),
669            make_event("svc-a", "2025-07-10T14:00:02Z"),
670            make_event("svc-b", "2025-07-10T14:00:01Z"),
671        ];
672        let readings = vec![
673            EnergyReading {
674                timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:00Z").unwrap(),
675                service: "svc-a".to_string(),
676                energy_kwh: 0.000_001,
677            },
678            EnergyReading {
679                timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:05Z").unwrap(),
680                service: "svc-b".to_string(),
681                energy_kwh: 0.000_000_5,
682            },
683        ];
684
685        let results = calibrate(&events, &readings).unwrap();
686        assert_eq!(results.len(), 2);
687        assert_eq!(results[0].service, "svc-a");
688        assert_eq!(results[0].total_ops, 2);
689        assert_eq!(results[1].service, "svc-b");
690        assert_eq!(results[1].total_ops, 1);
691    }
692
693    #[test]
694    fn calibrate_skips_zero_ops_service() {
695        let events = vec![make_event("svc-a", "2025-07-10T14:00:01Z")];
696        let readings = vec![
697            EnergyReading {
698                timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:00Z").unwrap(),
699                service: "svc-a".to_string(),
700                energy_kwh: 0.0001,
701            },
702            EnergyReading {
703                timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:05Z").unwrap(),
704                service: "svc-b".to_string(), // no ops for svc-b
705                energy_kwh: 0.0001,
706            },
707        ];
708
709        let results = calibrate(&events, &readings).unwrap();
710        assert_eq!(results.len(), 1);
711        assert_eq!(results[0].service, "svc-a");
712    }
713
714    // --- TOML output ---
715
716    #[test]
717    fn write_toml_round_trip() {
718        let results = vec![CalibrationResult {
719            service: "svc-a".to_string(),
720            total_ops: 100,
721            total_energy_kwh: 0.00001,
722            energy_per_op_kwh: 0.000_000_1,
723            default_energy_per_op_kwh: ENERGY_PER_IO_OP_KWH,
724            factor: 1.0,
725        }];
726
727        let toml_str = write_calibration_toml(&results, "traces.json", "energy.csv");
728        assert!(toml_str.contains("[calibration]"));
729        assert!(toml_str.contains("[calibration.services]"));
730        assert!(toml_str.contains("svc-a"));
731
732        // Round-trip: parse the generated TOML back
733        let data: CalibrationData = toml::from_str(&toml_str).unwrap();
734        assert!(data.calibration.services.contains_key("svc-a"));
735        let cal = &data.calibration.services["svc-a"];
736        assert!((cal.factor - 1.0).abs() < 0.01);
737    }
738
739    // --- Calibration file loading ---
740
741    #[test]
742    fn load_calibration_validates_negative_factor() {
743        let toml_str = r#"
744[calibration]
745base_energy_per_io_op_kwh = 0.000_000_1
746
747[calibration.services]
748"svc-a" = { factor = -1.0, measured_energy_per_op_kwh = 0.000_000_1 }
749"#;
750        let tmp = std::env::temp_dir().join("test-cal-negative.toml");
751        std::fs::write(&tmp, toml_str).unwrap();
752        let result = load_calibration_file(tmp.to_str().unwrap());
753        assert_matches!(result, Err(CalibrationError::Validation(_)));
754        let _ = std::fs::remove_file(tmp);
755    }
756
757    // --- Validation ---
758
759    #[test]
760    fn validate_warns_extreme_factors() {
761        let results = vec![
762            CalibrationResult {
763                service: "normal".to_string(),
764                total_ops: 100,
765                total_energy_kwh: 0.00001,
766                energy_per_op_kwh: ENERGY_PER_IO_OP_KWH,
767                default_energy_per_op_kwh: ENERGY_PER_IO_OP_KWH,
768                factor: 1.0,
769            },
770            CalibrationResult {
771                service: "too-high".to_string(),
772                total_ops: 100,
773                total_energy_kwh: 0.001,
774                energy_per_op_kwh: ENERGY_PER_IO_OP_KWH * 15.0,
775                default_energy_per_op_kwh: ENERGY_PER_IO_OP_KWH,
776                factor: 15.0,
777            },
778            CalibrationResult {
779                service: "too-low".to_string(),
780                total_ops: 100,
781                total_energy_kwh: 0.000_000_1,
782                energy_per_op_kwh: ENERGY_PER_IO_OP_KWH * 0.05,
783                default_energy_per_op_kwh: ENERGY_PER_IO_OP_KWH,
784                factor: 0.05,
785            },
786        ];
787        let warnings = validate_results(&results);
788        assert_eq!(warnings.len(), 2);
789        assert!(warnings[0].contains("too-high"));
790        assert!(warnings[1].contains("too-low"));
791    }
792
793    // --- parse_timestamp_ms error branches ---
794
795    #[test]
796    fn parse_timestamp_accepts_space_between_date_and_time() {
797        // Exercises the space-separator branch of parse_timestamp_ms.
798        let ms = parse_timestamp_ms("2025-07-10 14:32:01Z").unwrap();
799        let t_form = parse_timestamp_ms("2025-07-10T14:32:01Z").unwrap();
800        assert_eq!(ms, t_form);
801    }
802
803    #[test]
804    fn parse_timestamp_rejects_missing_t_and_space() {
805        let err = parse_timestamp_ms("2025-07-1014:32:01Z").unwrap_err();
806        assert!(err.contains("'T' or space"));
807    }
808
809    #[test]
810    fn parse_timestamp_rejects_wrong_date_format() {
811        let err = parse_timestamp_ms("2025-07T14:32:01Z").unwrap_err();
812        assert!(err.contains("YYYY-MM-DD"));
813    }
814
815    #[test]
816    fn parse_timestamp_rejects_non_numeric_year_month_day() {
817        assert!(
818            parse_timestamp_ms("abcd-07-10T14:32:01Z")
819                .unwrap_err()
820                .contains("year")
821        );
822        assert!(
823            parse_timestamp_ms("2025-ab-10T14:32:01Z")
824                .unwrap_err()
825                .contains("month")
826        );
827        assert!(
828            parse_timestamp_ms("2025-07-abT14:32:01Z")
829                .unwrap_err()
830                .contains("day")
831        );
832    }
833
834    #[test]
835    fn parse_timestamp_rejects_pre_1970_year() {
836        let err = parse_timestamp_ms("1969-12-31T23:59:59Z").unwrap_err();
837        assert!(err.contains("1970"));
838    }
839
840    #[test]
841    fn parse_timestamp_rejects_month_day_out_of_range() {
842        assert!(
843            parse_timestamp_ms("2025-13-01T00:00:00Z")
844                .unwrap_err()
845                .contains("out of range")
846        );
847        assert!(
848            parse_timestamp_ms("2025-07-32T00:00:00Z")
849                .unwrap_err()
850                .contains("out of range")
851        );
852    }
853
854    #[test]
855    fn parse_timestamp_rejects_wrong_time_format() {
856        let err = parse_timestamp_ms("2025-07-10T14:32Z").unwrap_err();
857        assert!(err.contains("HH:MM:SS"));
858    }
859
860    #[test]
861    fn parse_timestamp_rejects_non_numeric_hours_minutes_seconds() {
862        assert!(
863            parse_timestamp_ms("2025-07-10Tab:32:01Z")
864                .unwrap_err()
865                .contains("hours")
866        );
867        assert!(
868            parse_timestamp_ms("2025-07-10T14:ab:01Z")
869                .unwrap_err()
870                .contains("minutes")
871        );
872        assert!(
873            parse_timestamp_ms("2025-07-10T14:32:abZ")
874                .unwrap_err()
875                .contains("seconds")
876        );
877    }
878
879    #[test]
880    fn parse_timestamp_rejects_time_out_of_range() {
881        let err = parse_timestamp_ms("2025-07-10T25:00:00Z").unwrap_err();
882        assert!(err.contains("out of range"));
883    }
884
885    #[test]
886    fn parse_timestamp_accepts_1_digit_fractional_seconds() {
887        // Exercises the `1 => ... * 100` branch of the fractional parser.
888        let ms_1 = parse_timestamp_ms("2025-07-10T14:32:01.5Z").unwrap();
889        let ms_base = parse_timestamp_ms("2025-07-10T14:32:01Z").unwrap();
890        assert_eq!(ms_1 - ms_base, 500);
891    }
892
893    #[test]
894    fn parse_timestamp_accepts_2_digit_fractional_seconds() {
895        // Exercises the `2 => ... * 10` branch.
896        let ms_2 = parse_timestamp_ms("2025-07-10T14:32:01.25Z").unwrap();
897        let ms_base = parse_timestamp_ms("2025-07-10T14:32:01Z").unwrap();
898        assert_eq!(ms_2 - ms_base, 250);
899    }
900
901    #[test]
902    fn parse_timestamp_truncates_sub_millisecond_fractional_seconds() {
903        // Exercises the `_ => frac[..3].parse()` branch for 4+ digits.
904        let ms = parse_timestamp_ms("2025-07-10T14:32:01.123456Z").unwrap();
905        let ms_base = parse_timestamp_ms("2025-07-10T14:32:01Z").unwrap();
906        assert_eq!(ms - ms_base, 123);
907    }
908
909    #[test]
910    fn parse_timestamp_rejects_non_numeric_fractional_seconds() {
911        let err = parse_timestamp_ms("2025-07-10T14:32:01.abZ").unwrap_err();
912        assert!(err.contains("fractional"));
913    }
914
915    // --- parse_energy_csv error branches ---
916
917    #[test]
918    fn parse_energy_csv_reports_invalid_timestamp_with_line_number() {
919        let csv = "timestamp,service,energy_kwh\n\
920                   not-a-timestamp,svc-a,0.001\n";
921        let err = parse_energy_csv(csv).unwrap_err();
922        match err {
923            CalibrationError::TimestampParse { line, value, .. } => {
924                assert_eq!(line, 2);
925                assert_eq!(value, "not-a-timestamp");
926            }
927            other => panic!("expected TimestampParse, got {other:?}"),
928        }
929    }
930
931    #[test]
932    fn parse_energy_csv_reports_invalid_numeric_value_with_line_number() {
933        let csv = "timestamp,service,energy_kwh\n\
934                   2025-07-10T14:00:00Z,svc-a,not-a-number\n";
935        let err = parse_energy_csv(csv).unwrap_err();
936        match err {
937            CalibrationError::CsvParse { line, reason } => {
938                assert_eq!(line, 2);
939                assert!(reason.contains("not-a-number"));
940            }
941            other => panic!("expected CsvParse, got {other:?}"),
942        }
943    }
944
945    #[test]
946    fn parse_energy_csv_power_watts_empty_after_conversion_returns_empty_data() {
947        // A single power reading cannot be converted (needs a pair).
948        // After conversion, the result is empty → EmptyData error.
949        let csv = "timestamp,service,power_watts\n\
950                   2025-07-10T14:00:00Z,svc-a,12.5\n";
951        let err = parse_energy_csv(csv).unwrap_err();
952        assert_matches!(err, CalibrationError::EmptyData);
953    }
954
955    // --- calibrate() error branches ---
956
957    #[test]
958    fn calibrate_rejects_empty_readings() {
959        let events = vec![make_event("svc-a", "2025-07-10T14:00:00Z")];
960        let err = calibrate(&events, &[]).unwrap_err();
961        assert_matches!(err, CalibrationError::EmptyData);
962    }
963
964    #[test]
965    fn calibrate_skips_events_with_unparsable_timestamp() {
966        // The trace event has a garbage timestamp, calibrate() must skip
967        // it silently via the `let Ok(ts) = ... else continue` path and
968        // still produce a valid result for the other events.
969        let mut bad_event = make_event("svc-a", "2025-07-10T14:00:05Z");
970        bad_event.timestamp = "not-a-timestamp".to_string();
971        let events = vec![bad_event, make_event("svc-a", "2025-07-10T14:00:05Z")];
972        let readings = vec![
973            EnergyReading {
974                timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:00Z").unwrap(),
975                service: "svc-a".to_string(),
976                energy_kwh: 0.000_000_2,
977            },
978            EnergyReading {
979                timestamp_ms: parse_timestamp_ms("2025-07-10T14:00:10Z").unwrap(),
980                service: "svc-a".to_string(),
981                energy_kwh: 0.000_000_2,
982            },
983        ];
984        let results = calibrate(&events, &readings).unwrap();
985        assert_eq!(results.len(), 1);
986        assert_eq!(results[0].total_ops, 1, "bad-timestamp event was skipped");
987    }
988
989    // --- load_calibration_file error branches ---
990
991    #[test]
992    fn load_calibration_file_rejects_missing_file() {
993        let err = load_calibration_file("/tmp/does-not-exist-abc123.toml").unwrap_err();
994        assert_matches!(err, CalibrationError::Io(_));
995    }
996
997    /// Write `contents` to a fresh file inside a `tempfile::TempDir` and
998    /// return the owned dir + path. The dir is auto-cleaned on drop, and
999    /// the path is unique per test invocation, avoiding symlink TOCTOU
1000    /// and parallel-run collisions from predictable `/tmp/...` names.
1001    fn write_temp_toml(filename: &str, contents: &str) -> (tempfile::TempDir, std::path::PathBuf) {
1002        let dir = tempfile::tempdir().expect("tempdir creation");
1003        let path = dir.path().join(filename);
1004        std::fs::write(&path, contents).unwrap();
1005        (dir, path)
1006    }
1007
1008    #[test]
1009    fn load_calibration_file_rejects_malformed_toml() {
1010        let (_dir, path) = write_temp_toml("malformed.toml", "not = valid [toml");
1011        let err = load_calibration_file(path.to_str().unwrap()).unwrap_err();
1012        assert_matches!(err, CalibrationError::TomlParse(_));
1013    }
1014
1015    #[test]
1016    fn load_calibration_file_rejects_negative_factor() {
1017        let (_dir, path) = write_temp_toml(
1018            "neg.toml",
1019            r#"
1020[calibration]
1021base_energy_per_io_op_kwh = 0.0000001
1022
1023[calibration.services]
1024"svc-a" = { factor = -1.0, measured_energy_per_op_kwh = 0.0000001 }
1025"#,
1026        );
1027        let err = load_calibration_file(path.to_str().unwrap()).unwrap_err();
1028        assert_matches!(err, CalibrationError::Validation(_));
1029    }
1030
1031    #[test]
1032    fn load_calibration_file_rejects_nonfinite_measured_energy() {
1033        let (_dir, path) = write_temp_toml(
1034            "inf.toml",
1035            r#"
1036[calibration]
1037base_energy_per_io_op_kwh = 0.0000001
1038
1039[calibration.services]
1040"svc-a" = { factor = 1.0, measured_energy_per_op_kwh = nan }
1041"#,
1042        );
1043        let err = load_calibration_file(path.to_str().unwrap()).unwrap_err();
1044        assert_matches!(err, CalibrationError::Validation(_));
1045    }
1046
1047    #[test]
1048    fn load_calibration_file_accepts_extreme_factors_with_warning() {
1049        // Factors 0.0, > 10, < 0.1 all emit warnings but load successfully.
1050        // This exercises the three `tracing::warn!` branches in load_calibration_file.
1051        let (_dir, path) = write_temp_toml(
1052            "warn.toml",
1053            r#"
1054[calibration]
1055base_energy_per_io_op_kwh = 0.0000001
1056
1057[calibration.services]
1058"zero" = { factor = 0.0, measured_energy_per_op_kwh = 0.0 }
1059"too-high" = { factor = 15.0, measured_energy_per_op_kwh = 0.0000015 }
1060"too-low" = { factor = 0.05, measured_energy_per_op_kwh = 0.000000005 }
1061"normal" = { factor = 1.0, measured_energy_per_op_kwh = 0.0000001 }
1062"#,
1063        );
1064        let data = load_calibration_file(path.to_str().unwrap()).unwrap();
1065        assert_eq!(data.calibration.services.len(), 4);
1066    }
1067}