lean_ctx/core/
introspect.rs1use std::collections::HashMap;
16use std::sync::{LazyLock, Mutex};
17use std::time::Instant;
18
19use serde::{Deserialize, Serialize};
20
21pub struct Subsystem {
25 pub key: &'static str,
26 pub label: &'static str,
27 pub science: &'static str,
28}
29
30pub const SUBSYSTEMS: &[Subsystem] = &[
33 Subsystem {
34 key: "phi_recompute",
35 label: "Sticky-Phi fix",
36 science: "time-variant salience (attention)",
37 },
38 Subsystem {
39 key: "power_law_decay",
40 label: "Power-law decay",
41 science: "Ebbinghaus forgetting + spacing effect",
42 },
43 Subsystem {
44 key: "integration_phi",
45 label: "Integration-aware Phi",
46 science: "IIT non-redundancy (MMR)",
47 },
48 Subsystem {
49 key: "gwt_ignition",
50 label: "Global-workspace ignition",
51 science: "global workspace theory",
52 },
53 Subsystem {
54 key: "immune_detector",
55 label: "Context-poisoning screen (Control)",
56 science: "self/non-self screening of provider data",
57 },
58];
59
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62pub struct Activity {
63 pub count: u64,
64 pub last_unix: i64,
65}
66
67#[derive(Default)]
68struct Entry {
69 count: u64,
70 flushed: u64,
73 last_unix: i64,
74}
75
76#[derive(Default)]
77struct Registry {
78 entries: HashMap<&'static str, Entry>,
79 last_flush: Option<Instant>,
80}
81
82static REGISTRY: LazyLock<Mutex<Registry>> = LazyLock::new(|| Mutex::new(Registry::default()));
83
84const FLUSH_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(2);
86
87fn now_unix() -> i64 {
88 std::time::SystemTime::now()
89 .duration_since(std::time::UNIX_EPOCH)
90 .map_or(0, |d| d.as_secs() as i64)
91}
92
93pub fn tick(key: &'static str) {
96 if let Ok(mut reg) = REGISTRY.lock() {
97 let e = reg.entries.entry(key).or_default();
98 e.count += 1;
99 e.last_unix = now_unix();
100 }
101}
102
103fn activity_path() -> Option<std::path::PathBuf> {
104 crate::core::data_dir::lean_ctx_data_dir()
105 .ok()
106 .map(|d| d.join("cognition_activity.json"))
107}
108
109fn read_disk() -> HashMap<String, Activity> {
110 activity_path()
111 .filter(|p| p.exists())
112 .and_then(|p| std::fs::read_to_string(p).ok())
113 .and_then(|s| serde_json::from_str(&s).ok())
114 .unwrap_or_default()
115}
116
117pub fn flush() {
121 let Ok(mut reg) = REGISTRY.lock() else {
122 return;
123 };
124 if reg.entries.values().all(|e| e.count == e.flushed) {
125 reg.last_flush = Some(Instant::now());
126 return;
127 }
128 let mut disk = read_disk();
129 for (key, e) in &mut reg.entries {
130 let delta = e.count - e.flushed;
131 if delta == 0 {
132 continue;
133 }
134 let rec = disk.entry((*key).to_string()).or_default();
135 rec.count += delta;
136 rec.last_unix = rec.last_unix.max(e.last_unix);
137 e.flushed = e.count;
138 }
139 if let Some(path) = activity_path() {
140 if let Some(parent) = path.parent() {
141 let _ = std::fs::create_dir_all(parent);
142 }
143 if let Ok(json) = serde_json::to_string_pretty(&disk) {
144 let _ = crate::config_io::write_atomic(&path, &json);
145 }
146 }
147 reg.last_flush = Some(Instant::now());
148}
149
150pub fn flush_if_due() {
153 let due = {
154 match REGISTRY.lock() {
155 Ok(reg) => reg.last_flush.is_none_or(|t| t.elapsed() >= FLUSH_DEBOUNCE),
156 Err(_) => false,
157 }
158 };
159 if due {
160 flush();
161 }
162}
163
164pub fn snapshot() -> Vec<(&'static Subsystem, Activity)> {
168 let mut disk = read_disk();
169 if let Ok(reg) = REGISTRY.lock() {
170 for (key, e) in ®.entries {
171 let rec = disk.entry((*key).to_string()).or_default();
172 rec.count += e.count - e.flushed;
173 rec.last_unix = rec.last_unix.max(e.last_unix);
174 }
175 }
176 SUBSYSTEMS
177 .iter()
178 .map(|s| {
179 let act = disk.get(s.key).cloned().unwrap_or_default();
180 (s, act)
181 })
182 .collect()
183}
184
185pub fn format_report() -> String {
187 let snap = snapshot();
188 let active = snap.iter().filter(|(_, a)| a.count > 0).count();
189 let total = snap.len();
190 let mut out = String::new();
191 out.push_str(&format!(
192 "Cognition subsystems: {active}/{total} active ({total} wired)\n\n"
193 ));
194 for (sys, act) in snap {
195 let status = if act.count > 0 { "active" } else { "idle " };
196 let last = if act.last_unix > 0 {
197 format_age(now_unix() - act.last_unix)
198 } else {
199 "never".to_string()
200 };
201 out.push_str(&format!(
202 " [{status}] {label:<26} count={count:<7} last={last:<10} {science}\n",
203 label = sys.label,
204 count = act.count,
205 science = sys.science,
206 ));
207 }
208 out
209}
210
211pub fn snapshot_json() -> String {
213 let map: HashMap<&str, Activity> = snapshot().into_iter().map(|(s, a)| (s.key, a)).collect();
214 serde_json::to_string_pretty(&map).unwrap_or_else(|_| "{}".to_string())
215}
216
217fn format_age(secs: i64) -> String {
218 if secs < 0 {
219 return "just now".to_string();
220 }
221 if secs < 60 {
222 format!("{secs}s ago")
223 } else if secs < 3600 {
224 format!("{}m ago", secs / 60)
225 } else if secs < 86400 {
226 format!("{}h ago", secs / 3600)
227 } else {
228 format!("{}d ago", secs / 86400)
229 }
230}
231
232#[cfg(test)]
233pub mod tests {
234 use super::*;
235
236 #[test]
237 fn tick_increments_in_memory_snapshot() {
238 let _env = crate::core::data_dir::test_env_lock();
240 let dir = tempfile::tempdir().unwrap();
241 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
242
243 tick("phi_recompute");
244 tick("phi_recompute");
245 let snap = snapshot();
246 let phi = snap
247 .iter()
248 .find(|(s, _)| s.key == "phi_recompute")
249 .map(|(_, a)| a.count)
250 .unwrap();
251 assert!(phi >= 2, "tick should be reflected in snapshot, got {phi}");
252 }
253
254 #[test]
255 fn flush_persists_and_accumulates() {
256 let _env = crate::core::data_dir::test_env_lock();
257 let dir = tempfile::tempdir().unwrap();
258 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
259
260 tick("immune_detector");
261 flush();
262 let disk = read_disk();
263 assert!(disk.get("immune_detector").is_some_and(|a| a.count >= 1));
264
265 tick("immune_detector");
267 flush();
268 let disk2 = read_disk();
269 assert!(
270 disk2.get("immune_detector").unwrap().count >= 2,
271 "deltas should accumulate across flushes"
272 );
273 }
274
275 #[test]
276 fn report_lists_all_subsystems() {
277 let _env = crate::core::data_dir::test_env_lock();
278 let dir = tempfile::tempdir().unwrap();
279 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
280 let report = format_report();
281 for sys in SUBSYSTEMS {
282 assert!(
283 report.contains(sys.label),
284 "report must mention {}",
285 sys.label
286 );
287 }
288 }
289}