Skip to main content

microscope_memory/
recall_ctx.rs

1//! Persistent recall context: the reader plus every mutable layer state.
2//!
3//! A single `recall()` used to `open_reader` plus `load_or_init` about twenty
4//! state objects on every call (~1.3 ms of file I/O inside a ~2.2 ms call).
5//! This module holds them once and reuses them across calls.
6//!
7//! # Safety contract
8//!
9//! The context is behaviour-preserving for a **single-writer process**: every
10//! call saves its mutations back (same durability as before), so each call
11//! starts from the previously saved state — exactly as if it had re-read it.
12//!
13//! Two staleness guards make long-lived use safe:
14//!
15//! 1. **Generation check.** `SharedContext` fingerprints `meta.bin`
16//!    (byte length + mtime) plus the block count at open. Every access
17//!    re-stats the file (~1 us); on mismatch the whole context is dropped
18//!    and reopened. This catches rebuilds, which reassign block ids and
19//!    would otherwise leave the mmap pointing at a replaced file.
20//! 2. **Layer guards.** The text index validates `block_count()` on every
21//!    use (full-scan fallback when stale); the append log validates its
22//!    own (len, mtime) stamp per query. See `reader.rs`.
23//!
24//! If another process rewrites the state files *without* changing `meta.bin`
25//! (in-place edits, which the build never does — it renames), the context
26//! keeps serving in-memory copies. The CLI opens a fresh context per
27//! invocation and is unaffected.
28
29use std::path::{Path, PathBuf};
30use std::sync::{Mutex, OnceLock};
31
32use crate::config::Config;
33use crate::reader::MicroscopeReader;
34
35/// All state one `recall()` call needs, loaded once.
36pub struct RecallContext {
37    pub reader: MicroscopeReader,
38    pub attention: crate::attention::AttentionState,
39    pub hebb: crate::hebbian::HebbianState,
40    pub thought_graph: crate::thought_graph::ThoughtGraphState,
41    pub pred_cache: crate::predictive_cache::PredictiveCache,
42    pub emotion_21d: crate::emotional_21d::EmotionalState21D,
43    pub mirror: crate::mirror::MirrorState,
44    pub resonance: crate::resonance::ResonanceState,
45    pub archetypes: crate::archetype::ArchetypeState,
46    pub temporal: crate::temporal_archetype::TemporalArchetypeState,
47    pub eureka_log: crate::eureka::EurekaLog,
48    pub spaced: crate::spaced_repetition::SpacedRepetition,
49    pub narrative: crate::narrative::NarrativeState,
50    pub esr: crate::emotional_state::EmotionalStateRing,
51    pub self_model: crate::self_model::SelfModel,
52    pub curiosity: crate::curiosity::CuriosityState,
53    pub narrative_memory: crate::narrative_memory::NarrativeMemory,
54    pub monologue: crate::inner_monologue::MonologueState,
55}
56
57impl RecallContext {
58    /// Open a fresh context: one reader plus one load of every layer state.
59    /// This is what a single `recall()` call used to pay on every invocation.
60    pub fn open(config: &Config) -> Result<Self, String> {
61        let reader = MicroscopeReader::open(config)?;
62        let output_dir = Path::new(&config.paths.output_dir);
63        let emotion_file = output_dir.join("emotion_21d.bin");
64        Ok(Self {
65            attention: crate::attention::AttentionState::load_or_init(output_dir),
66            hebb: crate::hebbian::HebbianState::load_or_init(output_dir, reader.block_count),
67            thought_graph: crate::thought_graph::ThoughtGraphState::load_or_init(output_dir),
68            pred_cache: crate::predictive_cache::PredictiveCache::load_or_init(output_dir),
69            emotion_21d: crate::emotional_21d::EmotionalState21D::load_or_init(&emotion_file),
70            mirror: crate::mirror::MirrorState::load_or_init(output_dir),
71            resonance: crate::resonance::ResonanceState::load_or_init(output_dir),
72            archetypes: crate::archetype::ArchetypeState::load_or_init(output_dir),
73            temporal: crate::temporal_archetype::TemporalArchetypeState::load_or_init(output_dir),
74            eureka_log: crate::eureka::EurekaLog::load_or_init(output_dir),
75            spaced: crate::spaced_repetition::SpacedRepetition::load_or_init(output_dir),
76            narrative: crate::narrative::NarrativeState::load_or_init(output_dir),
77            esr: crate::emotional_state::EmotionalStateRing::load_or_init(output_dir),
78            self_model: crate::self_model::SelfModel::load_or_init(output_dir),
79            curiosity: crate::curiosity::CuriosityState::load_or_init(output_dir),
80            narrative_memory: crate::narrative_memory::NarrativeMemory::load_or_init(output_dir),
81            monologue: crate::inner_monologue::MonologueState::load_or_init(output_dir),
82            reader,
83        })
84    }
85}
86
87/// Fingerprint of the index layout a context was built over.
88/// Any rebuild changes `meta.bin` (new mtime at minimum), so comparing this
89/// per access catches a replaced layout before a single stale block is served.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91struct Generation {
92    meta_len: u64,
93    meta_mtime_secs: u64,
94    meta_mtime_nanos: u32,
95    block_count: usize,
96}
97
98impl Generation {
99    fn capture(output_dir: &Path, block_count: usize) -> Option<Self> {
100        let md = std::fs::metadata(output_dir.join("meta.bin")).ok()?;
101        let mtime = md.modified().ok()?;
102        let dur = mtime
103            .duration_since(std::time::UNIX_EPOCH)
104            .unwrap_or_default();
105        Some(Self {
106            meta_len: md.len(),
107            meta_mtime_secs: dur.as_secs(),
108            meta_mtime_nanos: dur.subsec_nanos(),
109            block_count,
110        })
111    }
112
113    fn is_current(&self, output_dir: &Path, block_count: usize) -> bool {
114        if self.block_count != block_count {
115            return false;
116        }
117        match Self::capture(output_dir, block_count) {
118            Some(now) => now == *self,
119            // meta.bin vanished: the index is gone, so is the context.
120            None => false,
121        }
122    }
123}
124
125/// A `RecallContext` plus its layout fingerprint, with reopen-on-stale.
126pub struct SharedContext {
127    ctx: Option<RecallContext>,
128    generation: Option<Generation>,
129    output_dir: PathBuf,
130}
131
132impl SharedContext {
133    pub fn new(output_dir: PathBuf) -> Self {
134        Self {
135            ctx: None,
136            generation: None,
137            output_dir,
138        }
139    }
140
141    /// Get the context, opening or reopening as needed.
142    ///
143    /// Reopen triggers: first use, `meta.bin` changed (rebuild), `meta.bin`
144    /// vanished, or the block count moved. One `metadata()` syscall per call
145    /// when warm (~1 us); a full reopen only when the layout actually changed.
146    pub fn get(&mut self, config: &Config) -> Result<&mut RecallContext, String> {
147        let output_dir = Path::new(&config.paths.output_dir);
148        let need_reopen = match (&self.ctx, &self.generation) {
149            (Some(ctx), Some(gen)) => !gen.is_current(output_dir, ctx.reader.block_count),
150            _ => true,
151        };
152        if need_reopen {
153            let ctx = RecallContext::open(config)?;
154            let gen = Generation::capture(output_dir, ctx.reader.block_count);
155            self.ctx = Some(ctx);
156            self.generation = gen;
157            self.output_dir = output_dir.to_path_buf();
158        }
159        self.ctx
160            .as_mut()
161            .ok_or_else(|| "shared recall context failed to open".to_string())
162    }
163
164    /// Drop the held context, forcing a reopen on next use.
165    /// Call after operations known to replace the layout outside the
166    /// generation check's view (there are currently none — `build` and
167    /// `rebuild_pending` both rewrite `meta.bin` — but the hook exists
168    /// so callers do not need to know that).
169    pub fn invalidate(&mut self) {
170        self.ctx = None;
171        self.generation = None;
172    }
173}
174
175/// Process-global shared context for stdio-server use (MCP).
176///
177/// MCP stdio keeps one process alive across many tool calls; without this,
178/// every call reopens the reader and reloads every state file. The `Mutex`
179/// serialises concurrent tool calls; the generation check inside `get`
180/// keeps a rebuild from ever serving stale blocks.
181static GLOBAL: OnceLock<Mutex<SharedContext>> = OnceLock::new();
182
183/// Run `f` with the process-global shared recall context for `config`.
184///
185/// The context is opened on first use and transparently reopened whenever
186/// the index layout changes underneath it. Returns the closure's result,
187/// or the open error as a string.
188pub fn with_shared<R>(
189    config: &Config,
190    f: impl FnOnce(&mut RecallContext) -> R,
191) -> Result<R, String> {
192    let cell = GLOBAL
193        .get_or_init(|| Mutex::new(SharedContext::new(PathBuf::from(&config.paths.output_dir))));
194    let mut shared = cell.lock().map_err(|e| format!("shared ctx lock: {e}"))?;
195    // A different output dir than the global was opened for: treat as a
196    // generation change (simplest correct behaviour for multi-tenant use).
197    if shared.output_dir != Path::new(&config.paths.output_dir) {
198        shared.invalidate();
199    }
200    let ctx = shared.get(config)?;
201    Ok(f(ctx))
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn generation_roundtrip() {
210        let dir = tempfile::tempdir().unwrap();
211        let meta = dir.path().join("meta.bin");
212        std::fs::write(&meta, vec![0u8; 64]).unwrap();
213        let g = Generation::capture(dir.path(), 10).unwrap();
214        assert!(g.is_current(dir.path(), 10));
215        assert!(!g.is_current(dir.path(), 11));
216        // Touch the file: mtime moves, generation must go stale.
217        std::thread::sleep(std::time::Duration::from_millis(5));
218        std::fs::write(&meta, vec![0u8; 64]).unwrap();
219        // mtime granularity is filesystem-dependent; length change always works.
220        std::fs::write(&meta, vec![0u8; 65]).unwrap();
221        assert!(!g.is_current(dir.path(), 10));
222    }
223
224    #[test]
225    fn shared_context_opens_and_reopens() {
226        // Empty dir: open fails (no index), get() propagates the error
227        // instead of panicking, and a later valid open still works.
228        let dir = tempfile::tempdir().unwrap();
229        let mut cfg = crate::config::Config::default();
230        cfg.paths.output_dir = dir.path().to_string_lossy().to_string();
231        cfg.paths.layers_dir = dir.path().to_string_lossy().to_string();
232        cfg.paths.temp_dir = dir.path().to_string_lossy().to_string();
233
234        let mut shared = SharedContext::new(dir.path().to_path_buf());
235        assert!(shared.get(&cfg).is_err());
236        // Still unusable, still an error — never a stale success.
237        assert!(shared.get(&cfg).is_err());
238        shared.invalidate();
239        assert!(shared.get(&cfg).is_err());
240    }
241}