1use 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: "hebbian_cache",
45 label: "Hebbian eviction",
46 science: "co-activation (cells that fire together)",
47 },
48 Subsystem {
49 key: "memory_consolidation",
50 label: "Memory consolidation",
51 science: "complementary learning systems",
52 },
53 Subsystem {
54 key: "integration_phi",
55 label: "Integration-aware Phi",
56 science: "IIT non-redundancy (MMR)",
57 },
58 Subsystem {
59 key: "gwt_ignition",
60 label: "Global-workspace ignition",
61 science: "global workspace theory",
62 },
63 Subsystem {
64 key: "field_weights_bandit",
65 label: "Learned field weights",
66 science: "reinforcement learning (bandit)",
67 },
68 Subsystem {
69 key: "replay_consolidation",
70 label: "Idle replay",
71 science: "sharp-wave-ripple replay",
72 },
73 Subsystem {
74 key: "fep_prefetch",
75 label: "FEP prefetch",
76 science: "active inference / free energy",
77 },
78 Subsystem {
79 key: "immune_detector",
80 label: "Immune detector",
81 science: "artificial immune system",
82 },
83 Subsystem {
84 key: "observation_synthesis",
85 label: "Observation synthesis",
86 science: "entity-summary memory (Hindsight)",
87 },
88 Subsystem {
89 key: "qubo_select",
90 label: "QUBO selection (spike)",
91 science: "quantum-inspired optimization",
92 },
93];
94
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97pub struct Activity {
98 pub count: u64,
99 pub last_unix: i64,
100}
101
102#[derive(Default)]
103struct Entry {
104 count: u64,
105 flushed: u64,
108 last_unix: i64,
109}
110
111#[derive(Default)]
112struct Registry {
113 entries: HashMap<&'static str, Entry>,
114 last_flush: Option<Instant>,
115}
116
117static REGISTRY: LazyLock<Mutex<Registry>> = LazyLock::new(|| Mutex::new(Registry::default()));
118
119const FLUSH_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(2);
121
122fn now_unix() -> i64 {
123 std::time::SystemTime::now()
124 .duration_since(std::time::UNIX_EPOCH)
125 .map_or(0, |d| d.as_secs() as i64)
126}
127
128pub fn tick(key: &'static str) {
131 if let Ok(mut reg) = REGISTRY.lock() {
132 let e = reg.entries.entry(key).or_default();
133 e.count += 1;
134 e.last_unix = now_unix();
135 }
136}
137
138fn activity_path() -> Option<std::path::PathBuf> {
139 crate::core::data_dir::lean_ctx_data_dir()
140 .ok()
141 .map(|d| d.join("cognition_activity.json"))
142}
143
144fn read_disk() -> HashMap<String, Activity> {
145 activity_path()
146 .filter(|p| p.exists())
147 .and_then(|p| std::fs::read_to_string(p).ok())
148 .and_then(|s| serde_json::from_str(&s).ok())
149 .unwrap_or_default()
150}
151
152pub fn flush() {
156 let Ok(mut reg) = REGISTRY.lock() else {
157 return;
158 };
159 if reg.entries.values().all(|e| e.count == e.flushed) {
160 reg.last_flush = Some(Instant::now());
161 return;
162 }
163 let mut disk = read_disk();
164 for (key, e) in &mut reg.entries {
165 let delta = e.count - e.flushed;
166 if delta == 0 {
167 continue;
168 }
169 let rec = disk.entry((*key).to_string()).or_default();
170 rec.count += delta;
171 rec.last_unix = rec.last_unix.max(e.last_unix);
172 e.flushed = e.count;
173 }
174 if let Some(path) = activity_path() {
175 if let Some(parent) = path.parent() {
176 let _ = std::fs::create_dir_all(parent);
177 }
178 if let Ok(json) = serde_json::to_string_pretty(&disk) {
179 let _ = crate::config_io::write_atomic(&path, &json);
180 }
181 }
182 reg.last_flush = Some(Instant::now());
183}
184
185pub fn flush_if_due() {
188 let due = {
189 match REGISTRY.lock() {
190 Ok(reg) => reg.last_flush.is_none_or(|t| t.elapsed() >= FLUSH_DEBOUNCE),
191 Err(_) => false,
192 }
193 };
194 if due {
195 flush();
196 }
197}
198
199pub fn snapshot() -> Vec<(&'static Subsystem, Activity)> {
203 let mut disk = read_disk();
204 if let Ok(reg) = REGISTRY.lock() {
205 for (key, e) in ®.entries {
206 let rec = disk.entry((*key).to_string()).or_default();
207 rec.count += e.count - e.flushed;
208 rec.last_unix = rec.last_unix.max(e.last_unix);
209 }
210 }
211 SUBSYSTEMS
212 .iter()
213 .map(|s| {
214 let act = disk.get(s.key).cloned().unwrap_or_default();
215 (s, act)
216 })
217 .collect()
218}
219
220pub fn format_report() -> String {
222 let snap = snapshot();
223 let active = snap.iter().filter(|(_, a)| a.count > 0).count();
224 let total = snap.len();
225 let mut out = String::new();
226 out.push_str(&format!(
227 "Cognition subsystems: {active}/{total} active ({total} wired)\n\n"
228 ));
229 for (sys, act) in snap {
230 let status = if act.count > 0 { "active" } else { "idle " };
231 let last = if act.last_unix > 0 {
232 format_age(now_unix() - act.last_unix)
233 } else {
234 "never".to_string()
235 };
236 out.push_str(&format!(
237 " [{status}] {label:<26} count={count:<7} last={last:<10} {science}\n",
238 label = sys.label,
239 count = act.count,
240 science = sys.science,
241 ));
242 }
243 out
244}
245
246pub fn snapshot_json() -> String {
248 let map: HashMap<&str, Activity> = snapshot().into_iter().map(|(s, a)| (s.key, a)).collect();
249 serde_json::to_string_pretty(&map).unwrap_or_else(|_| "{}".to_string())
250}
251
252fn format_age(secs: i64) -> String {
253 if secs < 0 {
254 return "just now".to_string();
255 }
256 if secs < 60 {
257 format!("{secs}s ago")
258 } else if secs < 3600 {
259 format!("{}m ago", secs / 60)
260 } else if secs < 86400 {
261 format!("{}h ago", secs / 3600)
262 } else {
263 format!("{}d ago", secs / 86400)
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn tick_increments_in_memory_snapshot() {
273 let _env = crate::core::data_dir::test_env_lock();
275 let dir = tempfile::tempdir().unwrap();
276 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
277
278 tick("phi_recompute");
279 tick("phi_recompute");
280 let snap = snapshot();
281 let phi = snap
282 .iter()
283 .find(|(s, _)| s.key == "phi_recompute")
284 .map(|(_, a)| a.count)
285 .unwrap();
286 assert!(phi >= 2, "tick should be reflected in snapshot, got {phi}");
287 }
288
289 #[test]
290 fn flush_persists_and_accumulates() {
291 let _env = crate::core::data_dir::test_env_lock();
292 let dir = tempfile::tempdir().unwrap();
293 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
294
295 tick("hebbian_cache");
296 flush();
297 let disk = read_disk();
298 assert!(disk.get("hebbian_cache").is_some_and(|a| a.count >= 1));
299
300 tick("hebbian_cache");
302 flush();
303 let disk2 = read_disk();
304 assert!(
305 disk2.get("hebbian_cache").unwrap().count >= 2,
306 "deltas should accumulate across flushes"
307 );
308 }
309
310 #[test]
311 fn report_lists_all_subsystems() {
312 let _env = crate::core::data_dir::test_env_lock();
313 let dir = tempfile::tempdir().unwrap();
314 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
315 let report = format_report();
316 for sys in SUBSYSTEMS {
317 assert!(
318 report.contains(sys.label),
319 "report must mention {}",
320 sys.label
321 );
322 }
323 }
324}