Skip to main content

termivar_scanner/
monitoring.rs

1//! In-memory scan measurement and comparison models.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** opt-in via `monitoring`.
6//! - **Execution:** no repository runtime caller (not on any default path).
7//! - **Default `termivar scan`:** no.
8//! - **Support:** experimental data models.
9//!
10//! Calculations are derived from caller-supplied measurements. This module does
11//! not observe resources, select a winner when measurements tie, diagnose a
12//! cause, or prescribe optimization actions.
13
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16
17/// Caller-supplied measurements for one phase.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct PhaseProfile {
20    pub phase_number: u8,
21    pub phase_name: String,
22    pub start_time: u64,
23    pub end_time: u64,
24    pub duration_ms: u64,
25    pub requests_sent: u64,
26    pub responses_received: u64,
27    pub findings_discovered: u64,
28    pub error_count: u64,
29    pub response_time_samples_ms: Vec<u64>,
30}
31
32impl PhaseProfile {
33    pub fn response_request_ratio_percent(&self) -> Option<f64> {
34        ratio_percent(self.responses_received, self.requests_sent)
35    }
36
37    pub fn findings_per_100_responses(&self) -> Option<f64> {
38        ratio_percent(self.findings_discovered, self.responses_received)
39    }
40
41    pub fn mean_response_time_ms(&self) -> Option<f64> {
42        mean(&self.response_time_samples_ms)
43    }
44}
45
46/// Caller-supplied resource measurements. No sampler is implemented here.
47#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
48pub struct ResourceMetrics {
49    #[serde(with = "nonnegative_f32")]
50    pub memory_used_mb: f32,
51    #[serde(with = "nonnegative_f32")]
52    pub memory_peak_mb: f32,
53    #[serde(with = "percent_f32")]
54    pub cpu_usage_percent: f32,
55    #[serde(with = "percent_f32")]
56    pub cpu_peak_percent: f32,
57    #[serde(with = "nonnegative_f32")]
58    pub disk_read_mb: f32,
59    #[serde(with = "nonnegative_f32")]
60    pub disk_write_mb: f32,
61    #[serde(with = "nonnegative_f32")]
62    pub network_in_mb: f32,
63    #[serde(with = "nonnegative_f32")]
64    pub network_out_mb: f32,
65}
66
67impl ResourceMetrics {
68    /// Returns whether every measurement is finite and within its documented range.
69    pub fn is_valid(&self) -> bool {
70        [
71            self.memory_used_mb,
72            self.memory_peak_mb,
73            self.disk_read_mb,
74            self.disk_write_mb,
75            self.network_in_mb,
76            self.network_out_mb,
77        ]
78        .into_iter()
79        .all(|value| value.is_finite() && value >= 0.0)
80            && [self.cpu_usage_percent, self.cpu_peak_percent]
81                .into_iter()
82                .all(|value| value.is_finite() && (0.0..=100.0).contains(&value))
83    }
84}
85
86mod nonnegative_f32 {
87    use serde::{Deserialize, Deserializer, Serializer};
88
89    pub fn serialize<S>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
90    where
91        S: Serializer,
92    {
93        if value.is_finite() && *value >= 0.0 {
94            serializer.serialize_f32(*value)
95        } else {
96            Err(serde::ser::Error::custom(
97                "resource measurement must be finite and non-negative",
98            ))
99        }
100    }
101
102    pub fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
103    where
104        D: Deserializer<'de>,
105    {
106        let value = f32::deserialize(deserializer)?;
107        if value.is_finite() && value >= 0.0 {
108            Ok(value)
109        } else {
110            Err(serde::de::Error::custom(
111                "resource measurement must be finite and non-negative",
112            ))
113        }
114    }
115}
116
117mod percent_f32 {
118    use serde::{Deserialize, Deserializer, Serializer};
119
120    pub fn serialize<S>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
121    where
122        S: Serializer,
123    {
124        if value.is_finite() && (0.0..=100.0).contains(value) {
125            serializer.serialize_f32(*value)
126        } else {
127            Err(serde::ser::Error::custom(
128                "percentage must be finite and between 0 and 100",
129            ))
130        }
131    }
132
133    pub fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
134    where
135        D: Deserializer<'de>,
136    {
137        let value = f32::deserialize(deserializer)?;
138        if value.is_finite() && (0.0..=100.0).contains(&value) {
139            Ok(value)
140        } else {
141            Err(serde::de::Error::custom(
142                "percentage must be finite and between 0 and 100",
143            ))
144        }
145    }
146}
147
148/// In-memory profile composed of caller-supplied phase measurements.
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150pub struct ScanProfile {
151    pub scan_id: String,
152    pub total_duration_ms: u64,
153    pub phases: Vec<PhaseProfile>,
154    pub resources: ResourceMetrics,
155}
156
157impl ScanProfile {
158    pub fn new(scan_id: String) -> Self {
159        Self {
160            scan_id,
161            total_duration_ms: 0,
162            phases: Vec::new(),
163            resources: ResourceMetrics::default(),
164        }
165    }
166
167    pub fn add_phase(&mut self, phase: PhaseProfile) {
168        self.phases.push(phase);
169    }
170
171    /// Returns whether every retained resource measurement is wire-safe.
172    pub fn is_valid(&self) -> bool {
173        self.resources.is_valid()
174    }
175
176    pub fn total_requests(&self) -> u128 {
177        self.phases
178            .iter()
179            .map(|phase| u128::from(phase.requests_sent))
180            .sum()
181    }
182
183    pub fn total_responses(&self) -> u128 {
184        self.phases
185            .iter()
186            .map(|phase| u128::from(phase.responses_received))
187            .sum()
188    }
189
190    pub fn total_findings(&self) -> u128 {
191        self.phases
192            .iter()
193            .map(|phase| u128::from(phase.findings_discovered))
194            .sum()
195    }
196
197    pub fn total_errors(&self) -> u128 {
198        self.phases
199            .iter()
200            .map(|phase| u128::from(phase.error_count))
201            .sum()
202    }
203
204    pub fn response_request_ratio_percent(&self) -> Option<f64> {
205        ratio_percent_u128(self.total_responses(), self.total_requests())
206    }
207
208    /// Returns every phase tied for the greatest recorded duration.
209    pub fn slowest_phases(&self) -> Vec<&PhaseProfile> {
210        let Some(maximum) = self.phases.iter().map(|phase| phase.duration_ms).max() else {
211            return Vec::new();
212        };
213        self.phases
214            .iter()
215            .filter(|phase| phase.duration_ms == maximum)
216            .collect()
217    }
218
219    /// Returns every phase tied for the greatest recorded finding count.
220    pub fn most_productive_phases(&self) -> Vec<&PhaseProfile> {
221        let Some(maximum) = self
222            .phases
223            .iter()
224            .map(|phase| phase.findings_discovered)
225            .max()
226        else {
227            return Vec::new();
228        };
229        self.phases
230            .iter()
231            .filter(|phase| phase.findings_discovered == maximum)
232            .collect()
233    }
234}
235
236/// In-memory profile catalog with pure comparison helpers.
237#[derive(Debug, Clone, Default)]
238pub struct PerformanceAnalyzer {
239    profiles: BTreeMap<String, ScanProfile>,
240}
241
242impl PerformanceAnalyzer {
243    pub fn new() -> Self {
244        Self::default()
245    }
246
247    /// Records a validated profile, returning the rejected record on failure.
248    pub fn record_profile(
249        &mut self,
250        profile: ScanProfile,
251    ) -> Result<Option<ScanProfile>, ScanProfile> {
252        if !profile.is_valid() {
253            return Err(profile);
254        }
255        Ok(self.profiles.insert(profile.scan_id.clone(), profile))
256    }
257
258    pub fn get_profile(&self, scan_id: &str) -> Option<&ScanProfile> {
259        self.profiles.get(scan_id)
260    }
261
262    pub fn get_profiles(&self) -> Vec<&ScanProfile> {
263        self.profiles.values().collect()
264    }
265
266    pub fn compare(&self, first_id: &str, second_id: &str) -> Option<ScanComparison> {
267        let first = self.profiles.get(first_id)?;
268        let second = self.profiles.get(second_id)?;
269        Some(ScanComparison {
270            first_scan_id: first_id.to_string(),
271            second_scan_id: second_id.to_string(),
272            duration: DurationComparison::between(
273                first.total_duration_ms,
274                second.total_duration_ms,
275            ),
276            findings: CountComparison::between(first.total_findings(), second.total_findings()),
277            response_request_ratio_difference_percentage_points: match (
278                first.response_request_ratio_percent(),
279                second.response_request_ratio_percent(),
280            ) {
281                (Some(first_ratio), Some(second_ratio)) => Some(second_ratio - first_ratio),
282                _ => None,
283            },
284        })
285    }
286
287    pub fn profile_count(&self) -> usize {
288        self.profiles.len()
289    }
290}
291
292/// Explicit duration comparison, including ties.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294pub enum DurationComparison {
295    FirstFasterBy(u64),
296    Equal,
297    SecondFasterBy(u64),
298}
299
300impl DurationComparison {
301    fn between(first_ms: u64, second_ms: u64) -> Self {
302        match first_ms.cmp(&second_ms) {
303            std::cmp::Ordering::Less => Self::FirstFasterBy(second_ms - first_ms),
304            std::cmp::Ordering::Equal => Self::Equal,
305            std::cmp::Ordering::Greater => Self::SecondFasterBy(first_ms - second_ms),
306        }
307    }
308}
309
310/// Explicit count comparison, including ties.
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
312pub enum CountComparison {
313    FirstHigherBy(u128),
314    Equal,
315    SecondHigherBy(u128),
316}
317
318impl CountComparison {
319    fn between(first: u128, second: u128) -> Self {
320        match first.cmp(&second) {
321            std::cmp::Ordering::Less => Self::SecondHigherBy(second - first),
322            std::cmp::Ordering::Equal => Self::Equal,
323            std::cmp::Ordering::Greater => Self::FirstHigherBy(first - second),
324        }
325    }
326}
327
328/// Direct comparison between two recorded profiles.
329#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330pub struct ScanComparison {
331    pub first_scan_id: String,
332    pub second_scan_id: String,
333    pub duration: DurationComparison,
334    pub findings: CountComparison,
335    #[serde(with = "optional_finite_f64")]
336    pub response_request_ratio_difference_percentage_points: Option<f64>,
337}
338
339mod optional_finite_f64 {
340    use serde::{Deserialize, Deserializer, Serializer};
341
342    pub fn serialize<S>(value: &Option<f64>, serializer: S) -> Result<S::Ok, S::Error>
343    where
344        S: Serializer,
345    {
346        match value {
347            Some(value) if value.is_finite() => serializer.serialize_some(value),
348            Some(_) => Err(serde::ser::Error::custom("value must be finite")),
349            None => serializer.serialize_none(),
350        }
351    }
352
353    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
354    where
355        D: Deserializer<'de>,
356    {
357        let value = Option::<f64>::deserialize(deserializer)?;
358        match value {
359            Some(value) if value.is_finite() => Ok(Some(value)),
360            Some(_) => Err(serde::de::Error::custom("value must be finite")),
361            None => Ok(None),
362        }
363    }
364}
365
366/// Raw benchmark duration samples supplied by a caller.
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368pub struct BenchmarkResult {
369    pub benchmark_id: String,
370    pub benchmark_name: String,
371    pub duration_samples_micros: Vec<u64>,
372}
373
374impl BenchmarkResult {
375    pub fn mean_duration_micros(&self) -> Option<f64> {
376        mean(&self.duration_samples_micros)
377    }
378
379    /// Nearest-rank percentile derived from the raw samples.
380    pub fn percentile_duration_micros(&self, percentile: f64) -> Option<u64> {
381        if self.duration_samples_micros.is_empty()
382            || !percentile.is_finite()
383            || !(0.0..=100.0).contains(&percentile)
384        {
385            return None;
386        }
387        let mut samples = self.duration_samples_micros.clone();
388        samples.sort_unstable();
389        let rank = ((percentile / 100.0) * samples.len() as f64).ceil() as usize;
390        Some(samples[rank.saturating_sub(1).min(samples.len() - 1)])
391    }
392}
393
394/// In-memory benchmark result catalog.
395#[derive(Debug, Clone, Default)]
396pub struct BenchmarkSuite {
397    results: Vec<BenchmarkResult>,
398}
399
400impl BenchmarkSuite {
401    pub fn new() -> Self {
402        Self::default()
403    }
404
405    pub fn record_result(&mut self, result: BenchmarkResult) {
406        self.results.push(result);
407    }
408
409    pub fn get_result(&self, benchmark_id: &str) -> Option<&BenchmarkResult> {
410        self.results
411            .iter()
412            .find(|result| result.benchmark_id == benchmark_id)
413    }
414
415    pub fn get_results(&self) -> &[BenchmarkResult] {
416        &self.results
417    }
418
419    /// Selects records whose calculated mean exceeds the caller's threshold.
420    pub fn results_with_mean_above(&self, threshold_micros: f64) -> Vec<&BenchmarkResult> {
421        self.results
422            .iter()
423            .filter(|result| {
424                result
425                    .mean_duration_micros()
426                    .is_some_and(|mean| mean > threshold_micros)
427            })
428            .collect()
429    }
430
431    pub fn result_count(&self) -> usize {
432        self.results.len()
433    }
434}
435
436fn ratio_percent(numerator: u64, denominator: u64) -> Option<f64> {
437    ratio_percent_u128(u128::from(numerator), u128::from(denominator))
438}
439
440fn ratio_percent_u128(numerator: u128, denominator: u128) -> Option<f64> {
441    (denominator != 0).then_some((numerator as f64 / denominator as f64) * 100.0)
442}
443
444fn mean(samples: &[u64]) -> Option<f64> {
445    if samples.is_empty() {
446        return None;
447    }
448    let sum: u128 = samples.iter().map(|&sample| u128::from(sample)).sum();
449    Some(sum as f64 / samples.len() as f64)
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    fn phase(number: u8, duration_ms: u64, findings: u64) -> PhaseProfile {
457        PhaseProfile {
458            phase_number: number,
459            phase_name: format!("phase-{number}"),
460            start_time: 0,
461            end_time: duration_ms,
462            duration_ms,
463            requests_sent: 2,
464            responses_received: 1,
465            findings_discovered: findings,
466            error_count: 1,
467            response_time_samples_ms: vec![10, 30],
468        }
469    }
470
471    #[test]
472    fn ratios_and_means_are_derived_from_raw_measurements() {
473        let phase = phase(1, 40, 1);
474        assert_eq!(phase.response_request_ratio_percent(), Some(50.0));
475        assert_eq!(phase.findings_per_100_responses(), Some(100.0));
476        assert_eq!(phase.mean_response_time_ms(), Some(20.0));
477    }
478
479    #[test]
480    fn ties_return_every_tied_phase() {
481        let mut profile = ScanProfile::new("scan".into());
482        profile.add_phase(phase(1, 50, 2));
483        profile.add_phase(phase(2, 50, 2));
484
485        assert_eq!(profile.slowest_phases().len(), 2);
486        assert_eq!(profile.most_productive_phases().len(), 2);
487    }
488
489    #[test]
490    fn equal_profiles_are_reported_as_equal() {
491        let mut catalog = PerformanceAnalyzer::new();
492        let mut first = ScanProfile::new("first".into());
493        first.total_duration_ms = 100;
494        first.add_phase(phase(1, 100, 1));
495        let mut second = first.clone();
496        second.scan_id = "second".into();
497        catalog.record_profile(first).expect("valid profile");
498        catalog.record_profile(second).expect("valid profile");
499
500        let comparison = catalog.compare("first", "second").unwrap();
501        assert_eq!(comparison.duration, DurationComparison::Equal);
502        assert_eq!(comparison.findings, CountComparison::Equal);
503        assert_eq!(
504            comparison.response_request_ratio_difference_percentage_points,
505            Some(0.0)
506        );
507    }
508
509    #[test]
510    fn benchmark_statistics_require_raw_samples() {
511        let empty = BenchmarkResult {
512            benchmark_id: "empty".into(),
513            benchmark_name: "empty".into(),
514            duration_samples_micros: Vec::new(),
515        };
516        assert_eq!(empty.mean_duration_micros(), None);
517        assert_eq!(empty.percentile_duration_micros(95.0), None);
518
519        let result = BenchmarkResult {
520            benchmark_id: "fixture".into(),
521            benchmark_name: "fixture".into(),
522            duration_samples_micros: vec![40, 10, 30, 20],
523        };
524        assert_eq!(result.mean_duration_micros(), Some(25.0));
525        assert_eq!(result.percentile_duration_micros(50.0), Some(20));
526        assert_eq!(result.percentile_duration_micros(f64::NAN), None);
527    }
528
529    #[test]
530    fn resource_measurements_reject_nonfinite_and_out_of_range_wire_values() {
531        let metrics = ResourceMetrics {
532            memory_used_mb: f32::NAN,
533            ..ResourceMetrics::default()
534        };
535        assert!(!metrics.is_valid());
536        assert!(serde_json::to_string(&metrics).is_err());
537
538        let invalid_cpu = r#"{
539            "memory_used_mb": 1.0,
540            "memory_peak_mb": 1.0,
541            "cpu_usage_percent": 101.0,
542            "cpu_peak_percent": 1.0,
543            "disk_read_mb": 0.0,
544            "disk_write_mb": 0.0,
545            "network_in_mb": 0.0,
546            "network_out_mb": 0.0
547        }"#;
548        assert!(serde_json::from_str::<ResourceMetrics>(invalid_cpu).is_err());
549    }
550
551    #[test]
552    fn profile_catalog_rejects_invalid_resources_and_orders_by_id() {
553        let mut catalog = PerformanceAnalyzer::new();
554        let mut invalid = ScanProfile::new("invalid".into());
555        invalid.resources.cpu_peak_percent = f32::INFINITY;
556        assert!(catalog.record_profile(invalid).is_err());
557        assert_eq!(catalog.profile_count(), 0);
558
559        for id in ["zeta", "alpha"] {
560            catalog
561                .record_profile(ScanProfile::new(id.into()))
562                .expect("valid profile");
563        }
564        assert_eq!(
565            catalog
566                .get_profiles()
567                .into_iter()
568                .map(|profile| profile.scan_id.as_str())
569                .collect::<Vec<_>>(),
570            vec!["alpha", "zeta"]
571        );
572    }
573
574    #[test]
575    fn comparison_wire_rejects_nonfinite_values() {
576        let comparison = ScanComparison {
577            first_scan_id: "first".into(),
578            second_scan_id: "second".into(),
579            duration: DurationComparison::Equal,
580            findings: CountComparison::Equal,
581            response_request_ratio_difference_percentage_points: Some(f64::NAN),
582        };
583        assert!(serde_json::to_string(&comparison).is_err());
584    }
585}