Skip to main content

lean_ctx/core/
introspect.rs

1//! Cognition introspection — proves that each scientific subsystem is wired
2//! *and* actually active at runtime.
3//!
4//! The Cognition v2 stack (power-law decay, Hebbian eviction, global-workspace
5//! ignition, replay consolidation, FEP prefetch, immune detection, …) is only
6//! useful if it is genuinely reached on the hot path — not dead code. Every
7//! subsystem calls [`tick`] at its real call site; [`flush`] persists the
8//! counters to a small JSON file so a *separate* process (`lean-ctx introspect
9//! cognition`) can report, per subsystem: wired (present in this build), active
10//! (call count > 0), last-run, and total count.
11//!
12//! Determinism note (#498): this is diagnostic CLI output, never an MCP tool
13//! body, so wall-clock timestamps here do not affect provider prompt caching.
14
15use std::collections::HashMap;
16use std::sync::{LazyLock, Mutex};
17use std::time::Instant;
18
19use serde::{Deserialize, Serialize};
20
21/// One cognition subsystem in the activation registry. `key` is the stable
22/// identifier passed to [`tick`]; `label`/`science` are for human-readable
23/// reports only.
24pub struct Subsystem {
25    pub key: &'static str,
26    pub label: &'static str,
27    pub science: &'static str,
28}
29
30/// The full set of Cognition v2 subsystems that must be wired and active.
31/// Order is the natural reading order for the report (foundation → experiment).
32pub 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/// Persisted activity record for a single subsystem.
96#[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    /// Count already merged to disk — lets [`flush`] write only the delta so
106    /// concurrent processes accumulate instead of clobbering each other.
107    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
119/// Minimum gap between debounced flushes, keeping `tick` cheap on the hot path.
120const 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
128/// Record one activation of `key`. Cheap (one mutex + map update); call this at
129/// the *real* call site of each subsystem so the registry reflects reality.
130pub 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
152/// Persist in-memory deltas to the shared JSON file (delta-merge so multiple
153/// processes accumulate). Best-effort: failures are swallowed — diagnostics
154/// must never break the hot path.
155pub 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
185/// Debounced [`flush`]: persists at most once per `FLUSH_DEBOUNCE`. Safe to
186/// call from `post_dispatch` on every tool call.
187pub 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
199/// Snapshot of all known subsystems merged with persisted activity, for the
200/// in-process and CLI reports. Reads disk and overlays unflushed in-memory
201/// deltas so a freshly-active subsystem shows up even before the next flush.
202pub fn snapshot() -> Vec<(&'static Subsystem, Activity)> {
203    let mut disk = read_disk();
204    if let Ok(reg) = REGISTRY.lock() {
205        for (key, e) in &reg.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
220/// Render a human-readable cognition activity report.
221pub 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
246/// Machine-readable variant for `--json`.
247pub 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        // Use a dedicated data dir so flush/snapshot stay isolated.
274        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        // A second flush of new ticks must add, not clobber.
301        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}