Skip to main content

rusty_stack/telemetry/
benchmark.rs

1//! Stability benchmark for GPU hardware metrics.
2//!
3//! Runs a minimum 180-second benchmark collecting:
4//! - GPU utilization (sampled every 10s, float in [0.0, 100.0])
5//! - VRAM usage (used and total in MB)
6//! - Thermal behavior (temperature in °C, range 0–120)
7//! - Throttling indicators (boolean)
8//! - Clock speeds (positive MHz)
9//!
10//! # Minimum Duration (VAL-TELE-001)
11//!
12//! The benchmark must run for at least 180 seconds. Shorter runs are flagged
13//! as incomplete and excluded from submission.
14//!
15//! # Sampling (VAL-TELE-002)
16//!
17//! GPU utilization is sampled at least every 10 seconds. For a 180-second run,
18//! this produces ≥18 samples.
19//!
20//! # Hardware Metrics (VAL-TELE-003, VAL-TELE-004)
21//!
22//! VRAM metrics enforce `vram_used_mb ≤ vram_total_mb`. Thermal values are
23//! clamped to [0, 120]. Clock speeds are positive. Throttling is a boolean.
24
25use crate::core::telemetry_types::{BenchmarkResult, GpuMetrics};
26use anyhow::{Context, Result};
27use std::process::Command;
28use std::time::{Duration, Instant};
29
30/// Minimum benchmark duration in seconds.
31pub const MIN_BENCHMARK_DURATION_SECS: u64 = 180;
32
33/// Sampling interval in seconds.
34pub const SAMPLE_INTERVAL_SECS: u64 = 10;
35
36/// Minimum number of samples required for a valid 180s benchmark.
37/// At 10s intervals over 180s, we expect ≥18 samples (one at t=0 + 17 more).
38pub const MIN_SAMPLE_COUNT: u32 = 18;
39
40/// Maximum thermal value in Celsius.
41pub const MAX_TEMP_C: f32 = 120.0;
42
43/// Maximum payload size in bytes (64 KiB).
44pub const MAX_PAYLOAD_SIZE_BYTES: usize = 65536;
45
46/// A single GPU metric sample collected during a benchmark run.
47#[derive(Debug, Clone)]
48pub struct GpuSample {
49    /// GPU utilization percentage (0.0–100.0).
50    pub utilization_pct: f32,
51    /// VRAM used in megabytes.
52    pub vram_used_mb: f64,
53    /// Total VRAM in megabytes.
54    pub vram_total_mb: f64,
55    /// GPU temperature in Celsius.
56    pub temp_c: f32,
57    /// Whether GPU thermal throttling is active.
58    pub throttling: bool,
59    /// GPU clock speed in MHz.
60    pub clock_mhz: f32,
61    /// Timestamp relative to benchmark start in seconds.
62    pub timestamp_secs: f64,
63}
64
65/// Result of a benchmark run, including whether it meets minimum requirements.
66#[derive(Debug, Clone)]
67pub struct BenchmarkOutcome {
68    /// All collected samples.
69    pub samples: Vec<GpuSample>,
70    /// Actual duration of the benchmark in seconds.
71    pub duration_secs: u64,
72    /// Whether the benchmark meets the minimum duration requirement.
73    pub is_complete: bool,
74    /// Aggregated GPU metrics (average of all samples).
75    pub aggregated_metrics: Option<GpuMetrics>,
76    /// Benchmark result for telemetry payload.
77    pub benchmark_result: BenchmarkResult,
78}
79
80impl BenchmarkOutcome {
81    /// Create a new benchmark outcome from collected samples.
82    ///
83    /// Validates that:
84    /// - Duration ≥ 180 seconds
85    /// - Sample count ≥ 18
86    /// - All metrics are within valid ranges
87    pub fn from_samples(samples: Vec<GpuSample>, duration_secs: u64) -> Self {
88        let is_complete = duration_secs >= MIN_BENCHMARK_DURATION_SECS
89            && samples.len() >= MIN_SAMPLE_COUNT as usize;
90
91        let aggregated_metrics = if !samples.is_empty() {
92            Some(aggregate_samples(&samples))
93        } else {
94            None
95        };
96
97        let benchmark_result = BenchmarkResult {
98            duration_seconds: duration_secs,
99            completed: is_complete,
100            sample_count: samples.len() as u32,
101        };
102
103        Self {
104            samples,
105            duration_secs,
106            is_complete,
107            aggregated_metrics,
108            benchmark_result,
109        }
110    }
111}
112
113/// Aggregate multiple GPU samples into a single GpuMetrics summary.
114fn aggregate_samples(samples: &[GpuSample]) -> GpuMetrics {
115    if samples.is_empty() {
116        return GpuMetrics::default();
117    }
118
119    let count = samples.len() as f32;
120    let count_f64 = samples.len() as f64;
121
122    let avg_util = samples.iter().map(|s| s.utilization_pct).sum::<f32>() / count;
123    let avg_vram_used = samples.iter().map(|s| s.vram_used_mb).sum::<f64>() / count_f64;
124    let max_vram_total = samples
125        .iter()
126        .map(|s| s.vram_total_mb)
127        .fold(0.0_f64, f64::max);
128    let avg_temp = samples.iter().map(|s| s.temp_c).sum::<f32>() / count;
129    let any_throttling = samples.iter().any(|s| s.throttling);
130    let avg_clock = samples.iter().map(|s| s.clock_mhz).sum::<f32>() / count;
131
132    GpuMetrics {
133        gpu_utilization_pct: clamp_f32(avg_util, 0.0, 100.0),
134        vram_used_mb: avg_vram_used,
135        vram_total_mb: max_vram_total,
136        gpu_temp_c: clamp_f32(avg_temp, 0.0, MAX_TEMP_C),
137        gpu_throttling: any_throttling,
138        gpu_clock_mhz: avg_clock.max(0.0),
139    }
140}
141
142/// Clamp a float value to [min, max].
143fn clamp_f32(value: f32, min: f32, max: f32) -> f32 {
144    value.max(min).min(max)
145}
146
147/// Parse GPU utilization from rocm-smi output.
148///
149/// Expects output like: `GPU[0]\t\t: GPU use (%): 42`
150fn parse_gpu_utilization(output: &str) -> f32 {
151    for line in output.lines() {
152        if line.contains("GPU use (%)") {
153            if let Some(val) = line.split(':').next_back() {
154                if let Ok(pct) = val.trim().parse::<f32>() {
155                    return clamp_f32(pct, 0.0, 100.0);
156                }
157            }
158        }
159    }
160    0.0
161}
162
163/// Parse VRAM usage from rocm-smi output.
164///
165/// Expects output like:
166/// `GPU[0]\t\t: VRAM Total Memory (B): 25753026560`
167/// `GPU[0]\t\t: VRAM Total Used Memory (B): 63635456`
168fn parse_vram(output: &str) -> (f64, f64) {
169    let mut total_bytes: f64 = 0.0;
170    let mut used_bytes: f64 = 0.0;
171
172    for line in output.lines() {
173        let trimmed = line.trim();
174        if trimmed.contains("VRAM Total Memory (B)") {
175            if let Some(val) = trimmed.split(':').next_back() {
176                total_bytes = val.trim().parse::<f64>().unwrap_or(0.0);
177            }
178        } else if trimmed.contains("VRAM Total Used Memory (B)") {
179            if let Some(val) = trimmed.split(':').next_back() {
180                used_bytes = val.trim().parse::<f64>().unwrap_or(0.0);
181            }
182        }
183    }
184
185    let total_mb = total_bytes / (1024.0 * 1024.0);
186    let used_mb = used_bytes / (1024.0 * 1024.0);
187    (used_mb, total_mb)
188}
189
190/// Parse GPU temperature from rocm-smi output.
191///
192/// Expects output like: `GPU[0]\t\t: Temperature (Sensor edge) (C): 33.0`
193fn parse_temperature(output: &str) -> f32 {
194    for line in output.lines() {
195        if line.contains("Temperature") && line.contains("(C)") {
196            if let Some(val) = line.split(':').next_back() {
197                if let Ok(temp) = val.trim().parse::<f32>() {
198                    return clamp_f32(temp, 0.0, MAX_TEMP_C);
199                }
200            }
201        }
202    }
203    0.0
204}
205
206/// Parse clock speed from rocm-smi output.
207///
208/// Expects output like: `GPU[0]\t\t: sclk clock level: S: (2500Mhz)`
209fn parse_clock_mhz(output: &str) -> f32 {
210    for line in output.lines() {
211        if line.contains("sclk clock level") {
212            // Extract value from parentheses like (2500Mhz)
213            if let Some(start) = line.find('(') {
214                if let Some(end) = line.find("Mhz)") {
215                    let num_str = &line[start + 1..end];
216                    if let Ok(mhz) = num_str.trim().parse::<f32>() {
217                        return mhz.max(0.0);
218                    }
219                }
220            }
221        }
222    }
223    0.0
224}
225
226/// Detect throttling from rocm-smi output.
227///
228/// Throttling is inferred from performance level or explicit throttling indicators.
229fn parse_throttling(output: &str) -> bool {
230    let lower = output.to_lowercase();
231    lower.contains("throttl") || lower.contains("performance level: manual")
232}
233
234/// Sample GPU metrics once using rocm-smi.
235///
236/// Queries rocm-smi for utilization, VRAM, temperature, clock, and throttling.
237/// Returns a GpuSample with the collected data.
238pub fn sample_gpu_metrics() -> Result<GpuSample> {
239    let rocm_smi = find_rocm_smi()?;
240
241    // Query all metrics in one call for efficiency
242    let output = Command::new(&rocm_smi)
243        .arg("--showuse")
244        .arg("--showmeminfo")
245        .arg("vram")
246        .arg("--showtemp")
247        .arg("--showclock")
248        .output()
249        .context("failed to execute rocm-smi")?;
250
251    let stdout = String::from_utf8_lossy(&output.stdout);
252
253    let utilization_pct = parse_gpu_utilization(&stdout);
254    let (vram_used_mb, vram_total_mb) = parse_vram(&stdout);
255    let temp_c = parse_temperature(&stdout);
256    let clock_mhz = parse_clock_mhz(&stdout);
257    let throttling = parse_throttling(&stdout);
258
259    Ok(GpuSample {
260        utilization_pct,
261        vram_used_mb,
262        vram_total_mb,
263        temp_c,
264        throttling,
265        clock_mhz,
266        timestamp_secs: 0.0,
267    })
268}
269
270/// Find the rocm-smi binary path.
271fn find_rocm_smi() -> Result<String> {
272    // Check standard locations
273    let candidates = ["/opt/rocm/bin/rocm-smi", "/usr/bin/rocm-smi", "rocm-smi"];
274
275    for candidate in &candidates {
276        if Command::new(candidate).arg("--version").output().is_ok() {
277            return Ok(candidate.to_string());
278        }
279    }
280
281    anyhow::bail!("rocm-smi not found in standard locations")
282}
283
284/// Run the stability benchmark for the specified duration.
285///
286/// This is the main entry point for benchmark execution. It collects GPU
287/// metrics every `SAMPLE_INTERVAL_SECS` seconds for the given duration.
288///
289/// For a valid benchmark, `duration` must be ≥ `MIN_BENCHMARK_DURATION_SECS` (180s).
290/// Shorter durations will produce an incomplete result.
291///
292/// # Arguments
293///
294/// * `duration` - How long to run the benchmark (minimum 180s for validity)
295///
296/// # Returns
297///
298/// A `BenchmarkOutcome` containing all samples and whether the benchmark is complete.
299pub fn run_benchmark(duration: Duration) -> Result<BenchmarkOutcome> {
300    let start = Instant::now();
301    let mut samples: Vec<GpuSample> = Vec::new();
302
303    // Take initial sample at t=0
304    match sample_gpu_metrics() {
305        Ok(mut sample) => {
306            sample.timestamp_secs = 0.0;
307            samples.push(sample);
308        }
309        Err(e) => {
310            // Log but don't fail — we might recover on subsequent samples
311            eprintln!("Warning: initial GPU sample failed: {}", e);
312        }
313    }
314
315    // Sample every SAMPLE_INTERVAL_SECS
316    let interval = Duration::from_secs(SAMPLE_INTERVAL_SECS);
317    let mut next_sample = start + interval;
318
319    while start.elapsed() < duration {
320        let now = Instant::now();
321        if now >= next_sample {
322            match sample_gpu_metrics() {
323                Ok(mut sample) => {
324                    sample.timestamp_secs = now.duration_since(start).as_secs_f64();
325                    samples.push(sample);
326                }
327                Err(e) => {
328                    eprintln!("Warning: GPU sample failed at {:?}: {}", now - start, e);
329                }
330            }
331            next_sample = now + interval;
332        }
333
334        // Sleep briefly to avoid busy-waiting
335        std::thread::sleep(Duration::from_millis(100));
336    }
337
338    let elapsed = start.elapsed().as_secs();
339    Ok(BenchmarkOutcome::from_samples(samples, elapsed))
340}
341
342/// Validate that a benchmark outcome meets all quality criteria.
343///
344/// Checks:
345/// - Duration ≥ 180 seconds
346/// - Sample count ≥ 18
347/// - All utilization values in [0.0, 100.0]
348/// - All VRAM used ≤ VRAM total
349/// - All temperatures in [0, 120]
350/// - All clock speeds positive
351pub fn validate_benchmark(outcome: &BenchmarkOutcome) -> Result<(), Vec<String>> {
352    let mut errors = Vec::new();
353
354    // VAL-TELE-001: Minimum duration
355    if outcome.duration_secs < MIN_BENCHMARK_DURATION_SECS {
356        errors.push(format!(
357            "Benchmark duration {}s is below minimum {}s",
358            outcome.duration_secs, MIN_BENCHMARK_DURATION_SECS
359        ));
360    }
361
362    // VAL-TELE-002: Minimum sample count
363    if (outcome.samples.len() as u32) < MIN_SAMPLE_COUNT {
364        errors.push(format!(
365            "Sample count {} is below minimum {} for a valid benchmark",
366            outcome.samples.len(),
367            MIN_SAMPLE_COUNT
368        ));
369    }
370
371    // Validate individual samples
372    for (i, sample) in outcome.samples.iter().enumerate() {
373        // Utilization bounds
374        if !(0.0..=100.0).contains(&sample.utilization_pct) {
375            errors.push(format!(
376                "Sample {}: utilization {} out of range [0.0, 100.0]",
377                i, sample.utilization_pct
378            ));
379        }
380
381        // VAL-TELE-003: VRAM invariant
382        if sample.vram_used_mb > sample.vram_total_mb {
383            errors.push(format!(
384                "Sample {}: vram_used_mb ({}) > vram_total_mb ({})",
385                i, sample.vram_used_mb, sample.vram_total_mb
386            ));
387        }
388
389        // VAL-TELE-004: Temperature range
390        if !(0.0..=MAX_TEMP_C).contains(&sample.temp_c) {
391            errors.push(format!(
392                "Sample {}: temperature {} out of range [0, {}]",
393                i, sample.temp_c, MAX_TEMP_C
394            ));
395        }
396
397        // VAL-TELE-004: Clock speed positive
398        if sample.clock_mhz < 0.0 {
399            errors.push(format!(
400                "Sample {}: clock speed {} is negative",
401                i, sample.clock_mhz
402            ));
403        }
404    }
405
406    if errors.is_empty() {
407        Ok(())
408    } else {
409        Err(errors)
410    }
411}
412
413// ===========================================================================
414// Tests
415// ===========================================================================
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    // =======================================================================
422    // VAL-TELE-001: Stability Benchmark — Minimum Duration
423    // =======================================================================
424
425    #[test]
426    fn test_benchmark_rejects_short_duration() {
427        // Create a synthetic outcome with only 90 seconds
428        let samples = create_synthetic_samples(10, 90.0);
429        let outcome = BenchmarkOutcome::from_samples(samples, 90);
430        assert!(
431            !outcome.is_complete,
432            "90-second benchmark should be incomplete"
433        );
434        assert!(
435            validate_benchmark(&outcome).is_err(),
436            "90-second benchmark should fail validation"
437        );
438    }
439
440    #[test]
441    fn test_benchmark_accepts_180_seconds() {
442        // Create a synthetic outcome with exactly 180 seconds and 19 samples
443        let samples = create_synthetic_samples(19, 180.0);
444        let outcome = BenchmarkOutcome::from_samples(samples, 180);
445        assert!(
446            outcome.is_complete,
447            "180-second benchmark with 19 samples should be complete"
448        );
449        assert!(
450            validate_benchmark(&outcome).is_ok(),
451            "180-second benchmark should pass validation"
452        );
453    }
454
455    #[test]
456    fn test_benchmark_rejects_insufficient_samples() {
457        // 180 seconds but only 10 samples (below 18 minimum)
458        let samples = create_synthetic_samples(10, 180.0);
459        let outcome = BenchmarkOutcome::from_samples(samples, 180);
460        assert!(
461            !outcome.is_complete,
462            "180s benchmark with only 10 samples should be incomplete"
463        );
464        assert!(
465            validate_benchmark(&outcome).is_err(),
466            "Insufficient samples should fail validation"
467        );
468    }
469
470    // =======================================================================
471    // VAL-TELE-002: GPU Utilization Metric
472    // =======================================================================
473
474    #[test]
475    fn test_gpu_utilization_in_valid_range() {
476        let samples = create_synthetic_samples(19, 180.0);
477        for sample in &samples {
478            assert!(
479                (0.0..=100.0).contains(&sample.utilization_pct),
480                "Utilization {} out of range",
481                sample.utilization_pct
482            );
483        }
484    }
485
486    #[test]
487    fn test_sample_count_minimum_18_for_180s() {
488        // At 10s intervals over 180s, we get 19 samples (t=0, 10, 20, ..., 180)
489        let samples = create_synthetic_samples(19, 180.0);
490        assert!(
491            samples.len() >= MIN_SAMPLE_COUNT as usize,
492            "Expected at least {} samples, got {}",
493            MIN_SAMPLE_COUNT,
494            samples.len()
495        );
496    }
497
498    // =======================================================================
499    // VAL-TELE-003: VRAM Metric
500    // =======================================================================
501
502    #[test]
503    fn test_vram_used_le_total() {
504        let samples = create_synthetic_samples(19, 180.0);
505        for (i, sample) in samples.iter().enumerate() {
506            assert!(
507                sample.vram_used_mb <= sample.vram_total_mb,
508                "Sample {}: vram_used_mb ({}) > vram_total_mb ({})",
509                i,
510                sample.vram_used_mb,
511                sample.vram_total_mb
512            );
513        }
514    }
515
516    #[test]
517    fn test_vram_validation_catches_used_exceeds_total() {
518        let mut sample = create_synthetic_sample(0.0);
519        sample.vram_used_mb = 30000.0;
520        sample.vram_total_mb = 24576.0;
521        let samples = vec![sample; 19];
522        let outcome = BenchmarkOutcome::from_samples(samples, 180);
523        let validation = validate_benchmark(&outcome);
524        assert!(validation.is_err());
525        let errors = validation.unwrap_err();
526        assert!(
527            errors.iter().any(|e| e.contains("vram_used_mb")),
528            "Should catch vram_used_mb > vram_total_mb"
529        );
530    }
531
532    // =======================================================================
533    // VAL-TELE-004: Thermal and Throttling Metrics
534    // =======================================================================
535
536    #[test]
537    fn test_temperature_in_valid_range() {
538        let samples = create_synthetic_samples(19, 180.0);
539        for (i, sample) in samples.iter().enumerate() {
540            assert!(
541                (0.0..=MAX_TEMP_C).contains(&sample.temp_c),
542                "Sample {}: temperature {} out of range [0, {}]",
543                i,
544                sample.temp_c,
545                MAX_TEMP_C
546            );
547        }
548    }
549
550    #[test]
551    fn test_throttling_is_bool() {
552        let sample = create_synthetic_sample(0.0);
553        // Just verify it compiles as bool
554        let _: bool = sample.throttling;
555    }
556
557    #[test]
558    fn test_clock_speed_positive() {
559        let samples = create_synthetic_samples(19, 180.0);
560        for (i, sample) in samples.iter().enumerate() {
561            assert!(
562                sample.clock_mhz >= 0.0,
563                "Sample {}: clock speed {} is negative",
564                i,
565                sample.clock_mhz
566            );
567        }
568    }
569
570    // =======================================================================
571    // Aggregation tests
572    // =======================================================================
573
574    #[test]
575    fn test_aggregate_samples_averages_correctly() {
576        let mut samples = Vec::new();
577        for i in 0..3 {
578            let s = GpuSample {
579                utilization_pct: 50.0 + i as f32 * 10.0, // 50, 60, 70
580                vram_used_mb: 1000.0,
581                vram_total_mb: 24000.0,
582                temp_c: 60.0,
583                throttling: i == 2,
584                clock_mhz: 2000.0,
585                timestamp_secs: i as f64 * 10.0,
586            };
587            samples.push(s);
588        }
589
590        let metrics = aggregate_samples(&samples);
591        // Average of 50, 60, 70 = 60
592        assert!(
593            (metrics.gpu_utilization_pct - 60.0).abs() < 0.1,
594            "Expected avg utilization ~60, got {}",
595            metrics.gpu_utilization_pct
596        );
597        assert!(
598            metrics.gpu_throttling,
599            "Any throttling should set throttling=true"
600        );
601        assert!(
602            (metrics.vram_used_mb - 1000.0).abs() < 0.1,
603            "Expected avg vram_used ~1000, got {}",
604            metrics.vram_used_mb
605        );
606        assert!(
607            (metrics.vram_total_mb - 24000.0).abs() < 0.1,
608            "Expected max vram_total ~24000, got {}",
609            metrics.vram_total_mb
610        );
611    }
612
613    #[test]
614    fn test_aggregate_empty_samples() {
615        let metrics = aggregate_samples(&[]);
616        assert_eq!(metrics, GpuMetrics::default());
617    }
618
619    // =======================================================================
620    // Parsing tests
621    // =======================================================================
622
623    #[test]
624    fn test_parse_gpu_utilization_valid() {
625        let output = "GPU[0]\t\t: GPU use (%): 42";
626        assert!((parse_gpu_utilization(output) - 42.0).abs() < 0.1);
627    }
628
629    #[test]
630    fn test_parse_gpu_utilization_zero() {
631        let output = "GPU[0]\t\t: GPU use (%): 0";
632        assert!((parse_gpu_utilization(output) - 0.0).abs() < 0.1);
633    }
634
635    #[test]
636    fn test_parse_gpu_utilization_empty() {
637        assert!((parse_gpu_utilization("") - 0.0).abs() < 0.1);
638    }
639
640    #[test]
641    fn test_parse_vram_valid() {
642        let output = "\
643GPU[0]\t\t: VRAM Total Memory (B): 25753026560
644GPU[0]\t\t: VRAM Total Used Memory (B): 63635456";
645        let (used, total) = parse_vram(output);
646        assert!(
647            total > 24000.0,
648            "Total VRAM should be ~24574 MB, got {}",
649            total
650        );
651        assert!(used < total, "Used VRAM should be less than total");
652    }
653
654    #[test]
655    fn test_parse_temperature_valid() {
656        let output = "GPU[0]\t\t: Temperature (Sensor edge) (C): 33.0";
657        assert!((parse_temperature(output) - 33.0).abs() < 0.1);
658    }
659
660    #[test]
661    fn test_parse_temperature_out_of_range_clamped() {
662        let output = "GPU[0]\t\t: Temperature (Sensor edge) (C): 150.0";
663        let temp = parse_temperature(output);
664        assert!(
665            temp <= MAX_TEMP_C,
666            "Temperature should be clamped to {}, got {}",
667            MAX_TEMP_C,
668            temp
669        );
670    }
671
672    #[test]
673    fn test_parse_clock_mhz_valid() {
674        let output = "GPU[0]\t\t: sclk clock level: S: (2500Mhz)";
675        assert!((parse_clock_mhz(output) - 2500.0).abs() < 0.1);
676    }
677
678    #[test]
679    fn test_parse_throttling_detected() {
680        let output = "GPU[0]\t\t: Throttling: active";
681        assert!(parse_throttling(output));
682    }
683
684    #[test]
685    fn test_parse_throttling_not_detected() {
686        let output = "GPU[0]\t\t: Performance Level: auto";
687        assert!(!parse_throttling(output));
688    }
689
690    // =======================================================================
691    // Hardware test (requires real AMD GPU)
692    // =======================================================================
693
694    #[test]
695    #[ignore]
696    fn test_sample_gpu_metrics_real_hardware() {
697        let sample = sample_gpu_metrics().expect("Should sample GPU metrics on real hardware");
698        assert!(
699            (0.0..=100.0).contains(&sample.utilization_pct),
700            "Utilization out of range: {}",
701            sample.utilization_pct
702        );
703        assert!(
704            sample.vram_total_mb > 0.0,
705            "VRAM total should be positive: {}",
706            sample.vram_total_mb
707        );
708        assert!(
709            sample.vram_used_mb <= sample.vram_total_mb,
710            "VRAM used ({}) exceeds total ({})",
711            sample.vram_used_mb,
712            sample.vram_total_mb
713        );
714        assert!(
715            (0.0..=MAX_TEMP_C).contains(&sample.temp_c),
716            "Temperature out of range: {}",
717            sample.temp_c
718        );
719    }
720
721    // =======================================================================
722    // Helpers
723    // =======================================================================
724
725    /// Create synthetic samples for testing.
726    fn create_synthetic_samples(count: usize, _total_duration: f64) -> Vec<GpuSample> {
727        (0..count)
728            .map(|i| GpuSample {
729                utilization_pct: 50.0,
730                vram_used_mb: 8192.0,
731                vram_total_mb: 24576.0,
732                temp_c: 65.0,
733                throttling: false,
734                clock_mhz: 2000.0,
735                timestamp_secs: i as f64 * SAMPLE_INTERVAL_SECS as f64,
736            })
737            .collect()
738    }
739
740    /// Create a single synthetic sample.
741    fn create_synthetic_sample(timestamp: f64) -> GpuSample {
742        GpuSample {
743            utilization_pct: 50.0,
744            vram_used_mb: 8192.0,
745            vram_total_mb: 24576.0,
746            temp_c: 65.0,
747            throttling: false,
748            clock_mhz: 2000.0,
749            timestamp_secs: timestamp,
750        }
751    }
752}