lmn_core/response_template/
stats.rs1use 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
11pub 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 let hist = stats.float_fields.get("score").expect("score field");
121 assert_eq!(hist.total_seen(), 2);
122 }
123}