Skip to main content

lmn_core/response_template/
stats.rs

1use std::collections::HashMap;
2
3use crate::histogram::{
4    CategoricalHistogram, CategoricalHistogramParams, NumericHistogram, NumericHistogramParams,
5};
6use crate::response_template::extractor::{ExtractedValue, ExtractionResult};
7
8const DEFAULT_CATEGORICAL_MAX_BUCKETS: usize = 256;
9const DEFAULT_NUMERIC_MAX_SAMPLES: usize = 10_000;
10
11// ── ResponseStats ─────────────────────────────────────────────────────────────
12
13pub struct ResponseStats {
14    pub string_fields: HashMap<String, CategoricalHistogram>,
15    pub float_fields: HashMap<String, NumericHistogram>,
16    pub mismatch_counts: HashMap<String, u64>,
17    pub total_responses: u64,
18}
19
20impl Default for ResponseStats {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl ResponseStats {
27    pub fn new() -> Self {
28        Self {
29            string_fields: HashMap::new(),
30            float_fields: HashMap::new(),
31            mismatch_counts: HashMap::new(),
32            total_responses: 0,
33        }
34    }
35
36    pub fn record(&mut self, result: ExtractionResult) {
37        self.total_responses += 1;
38
39        for (path, value) in result.values {
40            match value {
41                ExtractedValue::String(s) => {
42                    self.string_fields
43                        .entry(path)
44                        .or_insert_with(|| {
45                            CategoricalHistogram::new(CategoricalHistogramParams {
46                                max_buckets: DEFAULT_CATEGORICAL_MAX_BUCKETS,
47                            })
48                        })
49                        .record(&s);
50                }
51                ExtractedValue::Float(f) => {
52                    self.float_fields
53                        .entry(path)
54                        .or_insert_with(|| {
55                            NumericHistogram::new(NumericHistogramParams {
56                                max_samples: DEFAULT_NUMERIC_MAX_SAMPLES,
57                            })
58                        })
59                        .record(f);
60                }
61            }
62        }
63
64        for path in result.mismatches {
65            *self.mismatch_counts.entry(path).or_insert(0) += 1;
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use crate::response_template::extractor::{ExtractedValue, ExtractionResult};
74
75    fn empty_result() -> ExtractionResult {
76        ExtractionResult {
77            values: vec![],
78            mismatches: vec![],
79        }
80    }
81
82    fn mixed_result() -> ExtractionResult {
83        ExtractionResult {
84            values: vec![
85                (
86                    "status".to_string(),
87                    ExtractedValue::String("ok".to_string()),
88                ),
89                ("score".to_string(), ExtractedValue::Float(9.5)),
90            ],
91            mismatches: vec!["missing".to_string()],
92        }
93    }
94
95    #[test]
96    fn empty_result_still_increments_total() {
97        let mut stats = ResponseStats::new();
98        stats.record(empty_result());
99        assert_eq!(stats.total_responses, 1);
100        assert!(stats.string_fields.is_empty());
101        assert!(stats.float_fields.is_empty());
102        assert!(stats.mismatch_counts.is_empty());
103    }
104
105    #[test]
106    fn mixed_result_records_all_field_types() {
107        let mut stats = ResponseStats::new();
108        stats.record(mixed_result());
109        assert!(stats.string_fields.contains_key("status"));
110        assert!(stats.float_fields.contains_key("score"));
111        assert_eq!(stats.mismatch_counts["missing"], 1);
112    }
113
114    #[test]
115    fn float_values_accumulate_across_records() {
116        let mut stats = ResponseStats::new();
117        stats.record(mixed_result());
118        stats.record(mixed_result());
119        // Two records of score=9.5 should be tracked in NumericHistogram
120        let hist = stats.float_fields.get("score").expect("score field");
121        assert_eq!(hist.total_seen(), 2);
122    }
123}