Skip to main content

made_core/entities/
statistics.rs

1//! [`Statistics`] entity — operational counters for the service.
2//!
3//! Neutral translation of `OrchestratorStatistics` from the Python
4//! reference. All counters are specialty-indexed instead of role-indexed;
5//! no SWE-specific vocabulary remains.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::value_objects::{DurationMs, Specialty};
12
13/// Operational statistics tracked by MADE.
14///
15/// Mutable entity: methods advance the counters, they are never
16/// mutated from outside. Saturating arithmetic prevents silent
17/// overflow under very long uptimes.
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19pub struct Statistics {
20    total_deliberations: u64,
21    total_orchestrations: u64,
22    total_duration: DurationMs,
23    per_specialty: BTreeMap<Specialty, u64>,
24}
25
26impl Statistics {
27    #[must_use]
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Rehydrate a [`Statistics`] from already-aggregated counters.
33    ///
34    /// Used by persistent adapters whose storage shape keeps running
35    /// sums rather than the stream of individual records. The entity's
36    /// mutators stay the single source of truth for increments; this
37    /// is the matching read path.
38    #[must_use]
39    pub fn from_counters(
40        total_deliberations: u64,
41        total_orchestrations: u64,
42        total_duration: DurationMs,
43        per_specialty: BTreeMap<Specialty, u64>,
44    ) -> Self {
45        Self {
46            total_deliberations,
47            total_orchestrations,
48            total_duration,
49            per_specialty,
50        }
51    }
52
53    /// Record that a deliberation for `specialty` completed in `duration`.
54    pub fn record_deliberation(&mut self, specialty: &Specialty, duration: DurationMs) {
55        self.total_deliberations = self.total_deliberations.saturating_add(1);
56        self.total_duration = self.total_duration.saturating_add(duration);
57        let entry = self.per_specialty.entry(specialty.clone()).or_insert(0);
58        *entry = entry.saturating_add(1);
59    }
60
61    /// Record that an orchestration (deliberate + execute) completed.
62    pub fn record_orchestration(&mut self, duration: DurationMs) {
63        self.total_orchestrations = self.total_orchestrations.saturating_add(1);
64        self.total_duration = self.total_duration.saturating_add(duration);
65    }
66
67    /// Reset all counters. Used mostly in tests and admin endpoints.
68    pub fn reset(&mut self) {
69        *self = Self::default();
70    }
71
72    #[must_use]
73    pub fn total_deliberations(&self) -> u64 {
74        self.total_deliberations
75    }
76
77    #[must_use]
78    pub fn total_orchestrations(&self) -> u64 {
79        self.total_orchestrations
80    }
81
82    #[must_use]
83    pub fn total_duration(&self) -> DurationMs {
84        self.total_duration
85    }
86
87    #[must_use]
88    pub fn per_specialty(&self) -> &BTreeMap<Specialty, u64> {
89        &self.per_specialty
90    }
91
92    /// Average duration per operation across all deliberations and
93    /// orchestrations. Returns zero when no operation has been recorded.
94    #[must_use]
95    pub fn average_duration_ms(&self) -> f64 {
96        let ops = self.total_deliberations + self.total_orchestrations;
97        if ops == 0 {
98            0.0
99        } else {
100            self.total_duration.get() as f64 / ops as f64
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    fn sp(s: &str) -> Specialty {
110        Specialty::new(s).unwrap()
111    }
112
113    #[test]
114    fn fresh_stats_are_zero() {
115        let s = Statistics::new();
116        assert_eq!(s.total_deliberations(), 0);
117        assert_eq!(s.total_orchestrations(), 0);
118        assert_eq!(s.total_duration(), DurationMs::ZERO);
119        assert_eq!(s.average_duration_ms(), 0.0);
120        assert!(s.per_specialty().is_empty());
121    }
122
123    #[test]
124    fn record_deliberation_advances_counters() {
125        let mut s = Statistics::new();
126        s.record_deliberation(&sp("triage"), DurationMs::from_millis(100));
127        s.record_deliberation(&sp("triage"), DurationMs::from_millis(50));
128        s.record_deliberation(&sp("reviewer"), DurationMs::from_millis(200));
129
130        assert_eq!(s.total_deliberations(), 3);
131        assert_eq!(s.total_duration(), DurationMs::from_millis(350));
132        assert_eq!(s.per_specialty().get(&sp("triage")).copied(), Some(2));
133        assert_eq!(s.per_specialty().get(&sp("reviewer")).copied(), Some(1));
134    }
135
136    #[test]
137    fn record_orchestration_advances_counters_but_not_specialty_map() {
138        let mut s = Statistics::new();
139        s.record_orchestration(DurationMs::from_millis(300));
140        assert_eq!(s.total_orchestrations(), 1);
141        assert_eq!(s.total_duration(), DurationMs::from_millis(300));
142        assert!(s.per_specialty().is_empty());
143    }
144
145    #[test]
146    fn average_duration_divides_over_all_ops() {
147        let mut s = Statistics::new();
148        s.record_deliberation(&sp("a"), DurationMs::from_millis(100));
149        s.record_orchestration(DurationMs::from_millis(200));
150        // (100 + 200) / 2 = 150.0
151        assert_eq!(s.average_duration_ms(), 150.0);
152    }
153
154    #[test]
155    fn reset_clears_state() {
156        let mut s = Statistics::new();
157        s.record_deliberation(&sp("x"), DurationMs::from_millis(10));
158        s.reset();
159        assert_eq!(s, Statistics::default());
160    }
161
162    #[test]
163    fn saturating_counters_do_not_overflow() {
164        let mut s = Statistics::new();
165        s.total_deliberations = u64::MAX;
166        s.total_duration = DurationMs::from_millis(u64::MAX);
167        s.record_deliberation(&sp("x"), DurationMs::from_millis(42));
168        assert_eq!(s.total_deliberations(), u64::MAX);
169        assert_eq!(s.total_duration().get(), u64::MAX);
170    }
171}