Skip to main content

lean_ctx/core/anti_interrupt/
tracker.rs

1//! Session-scoped tracking of cognitive interruption events.
2//!
3//! Records echo repetition, redundant reads, context switches, and related
4//! anti-interruption outcomes for metrics and adaptive compression.
5
6use std::sync::Mutex;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11/// Types of cognitive interruption events that lean-ctx prevents.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub enum InterruptionEvent {
14    /// Agent repeated context already delivered in this session.
15    EchoRepetition {
16        /// Repeated token count, always ≥ 0.
17        tokens: u64,
18    },
19    /// File re-read without any changes since last read.
20    RedundantRead {
21        /// Path of the redundantly read file.
22        path: String,
23    },
24    /// Agent jumped between unrelated code areas unnecessarily.
25    ContextSwitch {
26        /// Previous code area or module label.
27        from: String,
28        /// New code area or module label.
29        to: String,
30    },
31    /// Tokens wasted on bounce (G7 pattern).
32    BounceWaste {
33        /// Wasted token count, always ≥ 0.
34        tokens: u64,
35    },
36    /// Knowledge fact injected that the agent already had in context.
37    StaleContext {
38        /// Stable knowledge-fact key that was already in context.
39        fact_key: String,
40    },
41}
42
43/// Internal event record with timestamp.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45struct TimestampedEvent {
46    event: InterruptionEvent,
47    timestamp: DateTime<Utc>,
48    prevented: bool,
49}
50
51static SESSION_EVENTS: Mutex<Vec<TimestampedEvent>> = Mutex::new(Vec::new());
52
53const MAX_SESSION_EVENTS: usize = 10_000;
54
55fn session_events() -> std::sync::MutexGuard<'static, Vec<TimestampedEvent>> {
56    SESSION_EVENTS
57        .lock()
58        .unwrap_or_else(std::sync::PoisonError::into_inner)
59}
60
61/// Record an interruption event (either occurred or was prevented).
62pub(crate) fn record_interruption(event: InterruptionEvent, prevented: bool) {
63    let mut events = session_events();
64    if events.len() >= MAX_SESSION_EVENTS {
65        let remove_count = MAX_SESSION_EVENTS / 10;
66        events.drain(0..remove_count);
67    }
68    events.push(TimestampedEvent {
69        event,
70        timestamp: Utc::now(),
71        prevented,
72    });
73}
74
75/// Get all recorded interruption events for the current session.
76pub(crate) fn session_interruptions() -> Vec<(InterruptionEvent, bool)> {
77    session_events()
78        .iter()
79        .map(|event| (event.event.clone(), event.prevented))
80        .collect()
81}
82
83/// Reset session tracking (called at session start).
84pub(crate) fn reset_session() {
85    session_events().clear();
86}
87
88/// Count prevented interruptions by type.
89pub(crate) fn prevented_counts() -> PreventedCounts {
90    let events = session_events();
91    let mut counts = PreventedCounts::default();
92    for event in events.iter().filter(|event| event.prevented) {
93        match &event.event {
94            InterruptionEvent::EchoRepetition { tokens } => {
95                counts.echo_prevented = counts.echo_prevented.saturating_add(*tokens);
96            }
97            InterruptionEvent::RedundantRead { .. } => counts.redundant_reads_prevented += 1,
98            InterruptionEvent::ContextSwitch { .. } => counts.context_switches_prevented += 1,
99            InterruptionEvent::BounceWaste { tokens } => {
100                counts.bounce_waste_prevented =
101                    counts.bounce_waste_prevented.saturating_add(*tokens);
102            }
103            InterruptionEvent::StaleContext { .. } => counts.stale_context_prevented += 1,
104        }
105    }
106    counts
107}
108
109/// Prevented interruption totals, with token-based totals for token-bearing events.
110#[derive(Debug, Default)]
111pub(crate) struct PreventedCounts {
112    /// Echo tokens prevented from being repeated.
113    pub echo_prevented: u64,
114    /// Redundant file reads prevented.
115    pub redundant_reads_prevented: u64,
116    /// Unnecessary context switches prevented.
117    pub context_switches_prevented: u64,
118    /// Bounce-waste tokens prevented.
119    pub bounce_waste_prevented: u64,
120    /// Stale context injections prevented.
121    pub stale_context_prevented: u64,
122}
123
124#[cfg(test)]
125/// Serializes tests that mutate the global session event store.
126pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(());
127
128#[cfg(test)]
129mod tests {
130    use std::thread;
131
132    use super::{
133        InterruptionEvent, TEST_LOCK, prevented_counts, record_interruption, reset_session,
134        session_interruptions,
135    };
136
137    #[test]
138    fn record_and_retrieve_events() {
139        let _guard = TEST_LOCK.lock().expect("test lock should be available");
140        reset_session();
141        record_interruption(
142            InterruptionEvent::RedundantRead {
143                path: "src/lib.rs".to_string(),
144            },
145            true,
146        );
147
148        let events = session_interruptions();
149        assert_eq!(events.len(), 1);
150        assert!(events[0].1);
151        assert!(matches!(
152            events[0].0,
153            InterruptionEvent::RedundantRead { .. }
154        ));
155    }
156
157    #[test]
158    fn reset_clears_events() {
159        let _guard = TEST_LOCK.lock().expect("test lock should be available");
160        reset_session();
161        record_interruption(InterruptionEvent::EchoRepetition { tokens: 20 }, false);
162        reset_session();
163
164        assert!(session_interruptions().is_empty());
165    }
166
167    #[test]
168    fn prevented_counts_are_correct() {
169        let _guard = TEST_LOCK.lock().expect("test lock should be available");
170        reset_session();
171        record_interruption(InterruptionEvent::EchoRepetition { tokens: 30 }, true);
172        record_interruption(InterruptionEvent::EchoRepetition { tokens: 12 }, false);
173        record_interruption(
174            InterruptionEvent::ContextSwitch {
175                from: "core".to_string(),
176                to: "cli".to_string(),
177            },
178            true,
179        );
180        record_interruption(InterruptionEvent::BounceWaste { tokens: 8 }, true);
181        record_interruption(
182            InterruptionEvent::StaleContext {
183                fact_key: "decision:format".to_string(),
184            },
185            true,
186        );
187
188        let counts = prevented_counts();
189        assert_eq!(counts.echo_prevented, 30);
190        assert_eq!(counts.redundant_reads_prevented, 0);
191        assert_eq!(counts.context_switches_prevented, 1);
192        assert_eq!(counts.bounce_waste_prevented, 8);
193        assert_eq!(counts.stale_context_prevented, 1);
194    }
195
196    #[test]
197    fn concurrent_recording_does_not_panic() {
198        let _guard = TEST_LOCK.lock().expect("test lock should be available");
199        reset_session();
200        let threads: Vec<_> = (0..10)
201            .map(|_| {
202                thread::spawn(|| {
203                    for _ in 0..100 {
204                        record_interruption(InterruptionEvent::EchoRepetition { tokens: 1 }, true);
205                    }
206                })
207            })
208            .collect();
209
210        for handle in threads {
211            handle.join().expect("recording thread should not panic");
212        }
213        assert_eq!(session_interruptions().len(), 1_000);
214    }
215}