Skip to main content

gate4agent_shell_native/
efficiency.rs

1//! Plain-data efficiency facts `NativeEffectShell` collects from real
2//! (non-gated) work inside `collect_terminal_frames` and
3//! `reclassify_foreground`.
4//!
5//! `gate4agent-shell-native` does not depend on `gate4agent-runtime-native`
6//! (see this crate's own `Cargo.toml`), so it cannot hold that crate's
7//! `RingStats`/`Distribution` types from `tick_profile`, and must not
8//! re-derive a second copy of that percentile machinery here either --
9//! that would be two independent sources of truth for the same math. The
10//! split this crate honours instead: THIS crate reports plain facts (`u64`
11//! counters, raw `Duration`/byte samples, no percentile computed anywhere
12//! in this module); `gate4agent-runtime-native`'s worker loop, which
13//! already calls both `collect_terminal_frames` and `reclassify_foreground`
14//! once per iteration, is the only place on this side of the dependency
15//! edge that can turn a fact into a distribution. Do not "fix" this by
16//! importing `tick_profile` here -- there is no dependency edge for that
17//! import to take, and adding one would make this crate depend on its own
18//! downstream consumer.
19use std::time::Duration;
20
21use gate4agent::pty::ForegroundProbeTiming;
22
23/// Upper bound on how many raw timing/byte samples one metric inside
24/// [`ShellEfficiencyFacts`] can hold between drains.
25///
26/// A `NativeEffectShell` lives inside exactly one `AgentInstanceId`'s
27/// worker task (see `gate4agent-runtime-native::run_effect_worker`), so its
28/// `pty_sessions` map only ever holds THAT instance's own generations --
29/// one in the steady state, briefly two during a resume race. The caller
30/// drains this struct (`take`) every time it calls
31/// `collect_terminal_frames`/`reclassify_foreground`, so the bound only
32/// needs to cover one such call's worth of samples, never a shell's whole
33/// lifetime -- it is sized generously above that realistic count for that
34/// reason. If it is ever exceeded, the excess sample is silently dropped
35/// from the array (the eventual distribution loses that one data point)
36/// while every `u64` counter below -- which does not read from the array
37/// -- stays exact regardless.
38const MAX_EFFICIENCY_SAMPLES: usize = 32;
39
40/// What [`crate::NativeEffectShell::collect_terminal_frames`] and
41/// `::reclassify_foreground` have done since the last [`Self::take`],
42/// handed to the caller and reset by it. See the module doc for why this
43/// carries plain facts rather than a computed distribution.
44#[derive(Debug)]
45pub struct ShellEfficiencyFacts {
46    terminal_state_samples: [Duration; MAX_EFFICIENCY_SAMPLES],
47    terminal_state_sample_count: usize,
48    /// Count of real `terminal_state()` calls since the last drain -- every
49    /// call that passed the cheap `terminal_sequence()` gate, regardless of
50    /// what its result turned out to be.
51    terminal_state_captures: u64,
52    /// Count of sessions the cheap `terminal_sequence()` gate skipped
53    /// before ever calling `terminal_state()`, since the last drain --
54    /// counts skip EVENTS (one per gated iteration of the session loop),
55    /// not distinct sessions, so a session skipped on every call for a
56    /// whole drain window is counted once per call, not once total.
57    terminal_state_skips: u64,
58    terminal_frame_byte_samples: [u64; MAX_EFFICIENCY_SAMPLES],
59    terminal_frame_byte_sample_count: usize,
60    terminal_frames_published: u64,
61    terminal_frame_bytes_total: u64,
62    /// The three components of one `observe_foreground_timed()` call --
63    /// see [`ForegroundProbeTiming`] for what each one means and why a
64    /// single combined duration is not interpretable. Kept as three
65    /// independent series, not folded into one, for the same reason: a
66    /// reader can add three numbers, but a fourth series that must equal
67    /// their sum is a thing that can silently disagree with them.
68    foreground_probe_queued_samples: [Duration; MAX_EFFICIENCY_SAMPLES],
69    foreground_probe_queued_sample_count: usize,
70    foreground_probe_lock_wait_samples: [Duration; MAX_EFFICIENCY_SAMPLES],
71    foreground_probe_lock_wait_sample_count: usize,
72    foreground_probe_walk_samples: [Duration; MAX_EFFICIENCY_SAMPLES],
73    foreground_probe_walk_sample_count: usize,
74    /// Count of probes, not components -- one `record_foreground_probe`
75    /// call increments this by one regardless of how many of the three
76    /// component arrays above it also wrote into.
77    foreground_probes: u64,
78}
79
80impl Default for ShellEfficiencyFacts {
81    fn default() -> Self {
82        Self {
83            terminal_state_samples: [Duration::ZERO; MAX_EFFICIENCY_SAMPLES],
84            terminal_state_sample_count: 0,
85            terminal_state_captures: 0,
86            terminal_state_skips: 0,
87            terminal_frame_byte_samples: [0; MAX_EFFICIENCY_SAMPLES],
88            terminal_frame_byte_sample_count: 0,
89            terminal_frames_published: 0,
90            terminal_frame_bytes_total: 0,
91            foreground_probe_queued_samples: [Duration::ZERO; MAX_EFFICIENCY_SAMPLES],
92            foreground_probe_queued_sample_count: 0,
93            foreground_probe_lock_wait_samples: [Duration::ZERO; MAX_EFFICIENCY_SAMPLES],
94            foreground_probe_lock_wait_sample_count: 0,
95            foreground_probe_walk_samples: [Duration::ZERO; MAX_EFFICIENCY_SAMPLES],
96            foreground_probe_walk_sample_count: 0,
97            foreground_probes: 0,
98        }
99    }
100}
101
102impl ShellEfficiencyFacts {
103    /// Record one real `terminal_state()` call -- the sequence gate already
104    /// let it through, so `elapsed` is the actual render-and-clone cost the
105    /// module doc on `collect_terminal_frames` describes.
106    pub fn record_terminal_state_capture(&mut self, elapsed: Duration) {
107        self.terminal_state_captures = self.terminal_state_captures.saturating_add(1);
108        if self.terminal_state_sample_count < MAX_EFFICIENCY_SAMPLES {
109            self.terminal_state_samples[self.terminal_state_sample_count] = elapsed;
110            self.terminal_state_sample_count += 1;
111        }
112    }
113
114    /// Record one session the cheap sequence gate skipped this call.
115    pub fn record_terminal_state_skip(&mut self) {
116        self.terminal_state_skips = self.terminal_state_skips.saturating_add(1);
117    }
118
119    /// Record one published `TerminalFrame`'s wire-relevant byte size.
120    pub fn record_terminal_frame_published(&mut self, bytes: u64) {
121        self.terminal_frames_published = self.terminal_frames_published.saturating_add(1);
122        self.terminal_frame_bytes_total = self.terminal_frame_bytes_total.saturating_add(bytes);
123        if self.terminal_frame_byte_sample_count < MAX_EFFICIENCY_SAMPLES {
124            self.terminal_frame_byte_samples[self.terminal_frame_byte_sample_count] = bytes;
125            self.terminal_frame_byte_sample_count += 1;
126        }
127    }
128
129    /// Record one `observe_foreground_timed()` OS process-tree probe,
130    /// successful or not -- the walk was paid for either way. Increments
131    /// `foreground_probes` by exactly one call, and records each of the
132    /// three timing components into its own series -- see the field docs
133    /// above for why they stay separate.
134    pub fn record_foreground_probe(&mut self, timing: ForegroundProbeTiming) {
135        self.foreground_probes = self.foreground_probes.saturating_add(1);
136        if self.foreground_probe_queued_sample_count < MAX_EFFICIENCY_SAMPLES {
137            self.foreground_probe_queued_samples[self.foreground_probe_queued_sample_count] =
138                timing.queued;
139            self.foreground_probe_queued_sample_count += 1;
140        }
141        if self.foreground_probe_lock_wait_sample_count < MAX_EFFICIENCY_SAMPLES {
142            self.foreground_probe_lock_wait_samples[self.foreground_probe_lock_wait_sample_count] =
143                timing.lock_wait;
144            self.foreground_probe_lock_wait_sample_count += 1;
145        }
146        if self.foreground_probe_walk_sample_count < MAX_EFFICIENCY_SAMPLES {
147            self.foreground_probe_walk_samples[self.foreground_probe_walk_sample_count] =
148                timing.walk;
149            self.foreground_probe_walk_sample_count += 1;
150        }
151    }
152
153    /// Hand the caller everything recorded since the last `take`, and reset
154    /// this struct back to empty -- see the module doc for why
155    /// `gate4agent-runtime-native` is the only place that can do anything
156    /// with what this returns.
157    pub fn take(&mut self) -> Self {
158        std::mem::take(self)
159    }
160
161    pub fn terminal_state_samples(&self) -> &[Duration] {
162        &self.terminal_state_samples[..self.terminal_state_sample_count]
163    }
164
165    pub fn terminal_state_captures(&self) -> u64 {
166        self.terminal_state_captures
167    }
168
169    pub fn terminal_state_skips(&self) -> u64 {
170        self.terminal_state_skips
171    }
172
173    pub fn terminal_frame_byte_samples(&self) -> &[u64] {
174        &self.terminal_frame_byte_samples[..self.terminal_frame_byte_sample_count]
175    }
176
177    pub fn terminal_frames_published(&self) -> u64 {
178        self.terminal_frames_published
179    }
180
181    pub fn terminal_frame_bytes_total(&self) -> u64 {
182        self.terminal_frame_bytes_total
183    }
184
185    pub fn foreground_probe_queued_samples(&self) -> &[Duration] {
186        &self.foreground_probe_queued_samples[..self.foreground_probe_queued_sample_count]
187    }
188
189    pub fn foreground_probe_lock_wait_samples(&self) -> &[Duration] {
190        &self.foreground_probe_lock_wait_samples[..self.foreground_probe_lock_wait_sample_count]
191    }
192
193    pub fn foreground_probe_walk_samples(&self) -> &[Duration] {
194        &self.foreground_probe_walk_samples[..self.foreground_probe_walk_sample_count]
195    }
196
197    pub fn foreground_probes(&self) -> u64 {
198        self.foreground_probes
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn two_captures_then_a_drain_reports_two_and_a_second_drain_reports_zero() {
208        let mut facts = ShellEfficiencyFacts::default();
209        facts.record_terminal_state_capture(Duration::from_micros(10));
210        facts.record_terminal_state_capture(Duration::from_micros(20));
211
212        let drained = facts.take();
213        assert_eq!(drained.terminal_state_captures(), 2);
214        assert_eq!(drained.terminal_state_samples(), [
215            Duration::from_micros(10),
216            Duration::from_micros(20),
217        ]);
218
219        let drained_again = facts.take();
220        assert_eq!(drained_again.terminal_state_captures(), 0);
221        assert!(drained_again.terminal_state_samples().is_empty());
222    }
223
224    #[test]
225    fn a_session_with_an_unchanged_sequence_increments_skips_and_not_captures() {
226        // Mirrors the gate in `NativeEffectShell::collect_terminal_frames`:
227        // an unchanged sequence takes the skip branch and must never call
228        // `record_terminal_state_capture`.
229        let unchanged_sequence = 5_u64;
230        let last_captured_sequence = 5_u64;
231        let mut facts = ShellEfficiencyFacts::default();
232        if unchanged_sequence <= last_captured_sequence {
233            facts.record_terminal_state_skip();
234        } else {
235            facts.record_terminal_state_capture(Duration::from_micros(1));
236        }
237
238        assert_eq!(facts.terminal_state_skips(), 1);
239        assert_eq!(facts.terminal_state_captures(), 0);
240    }
241
242    #[test]
243    fn saturating_totals_do_not_wrap_past_u64_max() {
244        let mut facts = ShellEfficiencyFacts::default();
245        facts.terminal_frame_bytes_total = u64::MAX - 1;
246        facts.record_terminal_frame_published(10);
247        assert_eq!(facts.terminal_frame_bytes_total(), u64::MAX);
248
249        facts.terminal_state_captures = u64::MAX;
250        facts.record_terminal_state_capture(Duration::from_micros(1));
251        assert_eq!(facts.terminal_state_captures(), u64::MAX);
252    }
253}