Skip to main content

writerslogic_warm/core/cognition/
loop.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Background cognition loop.
5//!
6//! Runs pattern scanning, abstraction, gap detection, hypothesis generation,
7//! and analogy detection on a configurable interval. Uses READ-ONLY locks on
8//! meaning memory stores. Discovered insights are collected in a separate Vec.
9
10use crate::core::sync::RwLock;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::Arc;
13use std::time::Duration;
14
15use crate::core::atom_memory::AtomMemory;
16use crate::core::cognition::abstraction::{Abstraction, AbstractionEngine};
17use crate::core::cognition::analogy::{Analogy, AnalogyDetector};
18use crate::core::cognition::gaps::{GapDetector, KnowledgeGap};
19use crate::core::cognition::hypothesis::{Hypothesis, HypothesisEngine};
20use crate::core::cognition::patterns::{PatternScanner, RelationPattern};
21use crate::core::triple_store::TripleStore;
22
23/// A single insight discovered by the cognition loop.
24#[derive(Clone, Debug)]
25pub enum Insight {
26    Pattern(RelationPattern),
27    Abstraction(Abstraction),
28    Gap(KnowledgeGap),
29    Hypothesis(Hypothesis),
30    Analogy(Analogy),
31}
32
33/// Configuration for the cognition loop.
34#[derive(Clone, Debug)]
35pub struct CognitionConfig {
36    /// Interval between cognition cycles.
37    pub interval: Duration,
38    /// Minimum frequency for a relation pattern to be reported.
39    pub min_pattern_freq: usize,
40    /// Minimum members to form an abstraction.
41    pub min_abstraction_members: usize,
42    /// Minimum shared relations to consider entities as peers (gap detection).
43    pub min_shared_relations: usize,
44    /// Minimum fraction of peers that must have a relation for it to be a gap.
45    pub min_peer_coverage: f64,
46    /// Hopfield beta for hypothesis cleanup.
47    pub hypothesis_beta: f64,
48    /// Minimum confidence for a hypothesis to be reported.
49    pub min_hypothesis_confidence: f64,
50    /// Minimum shared relations for analogy detection.
51    pub min_analogy_relations: usize,
52}
53
54impl Default for CognitionConfig {
55    fn default() -> Self {
56        Self {
57            interval: Duration::from_secs(60),
58            min_pattern_freq: 3,
59            min_abstraction_members: 3,
60            min_shared_relations: 2,
61            min_peer_coverage: 0.5,
62            hypothesis_beta: 24.0,
63            min_hypothesis_confidence: 0.3,
64            min_analogy_relations: 2,
65        }
66    }
67}
68
69/// Shared state for the cognition loop: insights and run flag.
70pub struct CognitionState {
71    insights: RwLock<Vec<Insight>>,
72    running: AtomicBool,
73    stop: AtomicBool,
74    cycle_count: std::sync::atomic::AtomicU64,
75}
76
77impl CognitionState {
78    fn new() -> Self {
79        Self {
80            insights: RwLock::new(Vec::new()),
81            running: AtomicBool::new(false),
82            stop: AtomicBool::new(false),
83            cycle_count: std::sync::atomic::AtomicU64::new(0),
84        }
85    }
86
87    pub fn is_running(&self) -> bool {
88        self.running.load(Ordering::SeqCst)
89    }
90
91    pub fn cycle_count(&self) -> u64 {
92        self.cycle_count.load(Ordering::SeqCst)
93    }
94
95    pub fn take_insights(&self) -> Vec<Insight> {
96        std::mem::take(&mut *self.insights.write())
97    }
98
99    pub fn insight_count(&self) -> usize {
100        self.insights.read().len()
101    }
102}
103
104/// The cognition loop. Spawns a background thread that periodically
105/// scans the triple store for patterns, gaps, and analogies.
106pub struct CognitionLoop {
107    state: Arc<CognitionState>,
108    handle: Option<std::thread::JoinHandle<()>>,
109}
110
111impl CognitionLoop {
112    /// Start the cognition loop in a background thread.
113    ///
114    /// The loop acquires read-only references to the atom memory and triple store.
115    /// It does NOT hold locks across cycles; it snapshots and releases.
116    pub fn start(
117        atom_memory: Arc<AtomMemory>,
118        triple_store: Arc<TripleStore>,
119        config: CognitionConfig,
120    ) -> Self {
121        let state = Arc::new(CognitionState::new());
122        let thread_state = Arc::clone(&state);
123
124        state.running.store(true, Ordering::SeqCst);
125
126        let handle = std::thread::Builder::new()
127            .name("warm-cognition".to_string())
128            .spawn(move || {
129                cognition_thread(&thread_state, &atom_memory, &triple_store, &config);
130            })
131            .expect("failed to spawn cognition thread");
132
133        Self {
134            state,
135            handle: Some(handle),
136        }
137    }
138
139    /// Request the loop to stop and wait for the thread to finish.
140    pub fn stop(&mut self) {
141        self.state.stop.store(true, Ordering::SeqCst);
142        if let Some(handle) = self.handle.take() {
143            let _ = handle.join();
144        }
145        self.state.running.store(false, Ordering::SeqCst);
146    }
147
148    /// Access the shared state (insights, cycle count, etc.).
149    pub fn state(&self) -> &Arc<CognitionState> {
150        &self.state
151    }
152
153    /// Run a single cognition cycle synchronously (for testing).
154    pub fn run_once(
155        atom_memory: &AtomMemory,
156        triple_store: &TripleStore,
157        config: &CognitionConfig,
158    ) -> Vec<Insight> {
159        run_cycle(atom_memory, triple_store, config)
160    }
161}
162
163impl Drop for CognitionLoop {
164    fn drop(&mut self) {
165        self.stop();
166    }
167}
168
169fn cognition_thread(
170    state: &CognitionState,
171    atom_memory: &AtomMemory,
172    triple_store: &TripleStore,
173    config: &CognitionConfig,
174) {
175    while !state.stop.load(Ordering::SeqCst) {
176        // Each cycle derives every insight from the whole store again, so the latest cycle's
177        // set supersedes the pending one; appending would repeat each insight every cycle.
178        *state.insights.write() = run_cycle(atom_memory, triple_store, config);
179
180        state.cycle_count.fetch_add(1, Ordering::SeqCst);
181
182        // Sleep in small increments so we can respond to stop quickly
183        let mut remaining = config.interval;
184        let tick = Duration::from_millis(100);
185        while remaining > Duration::ZERO && !state.stop.load(Ordering::SeqCst) {
186            let sleep_time = remaining.min(tick);
187            std::thread::sleep(sleep_time);
188            remaining = remaining.saturating_sub(sleep_time);
189        }
190    }
191
192    state.running.store(false, Ordering::SeqCst);
193}
194
195fn run_cycle(
196    atom_memory: &AtomMemory,
197    triple_store: &TripleStore,
198    config: &CognitionConfig,
199) -> Vec<Insight> {
200    let mut insights = Vec::new();
201
202    // 1. Pattern scanning
203    let patterns = PatternScanner::scan_relation_patterns(triple_store, config.min_pattern_freq);
204    for p in patterns {
205        insights.push(Insight::Pattern(p));
206    }
207
208    // 2. Abstraction
209    let abstractions =
210        AbstractionEngine::discover(triple_store, atom_memory, config.min_abstraction_members);
211    for a in abstractions {
212        insights.push(Insight::Abstraction(a));
213    }
214
215    // 3. Gap detection
216    let gaps = GapDetector::detect(
217        triple_store,
218        config.min_shared_relations,
219        config.min_peer_coverage,
220    );
221
222    // 4. Hypothesis generation (from gaps)
223    let hypotheses = HypothesisEngine::propose(
224        &gaps,
225        triple_store,
226        atom_memory,
227        config.hypothesis_beta,
228        config.min_hypothesis_confidence,
229    );
230    for h in hypotheses {
231        insights.push(Insight::Hypothesis(h));
232    }
233
234    // Push gaps after hypotheses so hypotheses reference valid gaps
235    for g in gaps {
236        insights.push(Insight::Gap(g));
237    }
238
239    // 5. Analogy detection
240    let analogies = AnalogyDetector::detect(triple_store, config.min_analogy_relations);
241    for a in analogies {
242        insights.push(Insight::Analogy(a));
243    }
244
245    insights
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn make_test_data() -> (AtomMemory, TripleStore) {
253        let dim = 16384;
254        let atom_mem = AtomMemory::new(dim, 3.0);
255        let triple_store = TripleStore::new();
256
257        for city in &["paris", "berlin", "tokyo", "london", "madrid"] {
258            atom_mem.get_or_insert(city);
259        }
260        for country in &["france", "germany", "japan", "uk", "spain", "europe"] {
261            atom_mem.get_or_insert(country);
262        }
263
264        triple_store.add("paris", "capital_of", "france", "c1");
265        triple_store.add("berlin", "capital_of", "germany", "c2");
266        triple_store.add("tokyo", "capital_of", "japan", "c3");
267        triple_store.add("london", "capital_of", "uk", "c4");
268        triple_store.add("madrid", "capital_of", "spain", "c5");
269        triple_store.add("paris", "located_in", "europe", "c6");
270        triple_store.add("berlin", "located_in", "europe", "c7");
271        triple_store.add("london", "located_in", "europe", "c8");
272        triple_store.add("madrid", "located_in", "europe", "c9");
273        // tokyo missing located_in
274
275        (atom_mem, triple_store)
276    }
277
278    #[test]
279    fn test_run_once() {
280        let (atom_mem, triple_store) = make_test_data();
281        let config = CognitionConfig::default();
282        let insights = CognitionLoop::run_once(&atom_mem, &triple_store, &config);
283        assert!(!insights.is_empty());
284
285        // Should find patterns
286        assert!(insights.iter().any(|i| matches!(i, Insight::Pattern(_))));
287    }
288
289    #[test]
290    fn test_run_once_finds_gaps() {
291        let (atom_mem, triple_store) = make_test_data();
292        let config = CognitionConfig {
293            min_shared_relations: 1,
294            min_peer_coverage: 0.5,
295            ..Default::default()
296        };
297        let insights = CognitionLoop::run_once(&atom_mem, &triple_store, &config);
298
299        let has_gap = insights.iter().any(|i| {
300            if let Insight::Gap(g) = i {
301                g.entity == "tokyo" && g.missing_relation == "located_in"
302            } else {
303                false
304            }
305        });
306        assert!(has_gap, "Should detect tokyo missing located_in");
307    }
308
309    #[test]
310    fn test_background_loop_start_stop() {
311        let (atom_mem, triple_store) = make_test_data();
312        let config = CognitionConfig {
313            interval: Duration::from_millis(50),
314            ..Default::default()
315        };
316
317        let mut cognition =
318            CognitionLoop::start(Arc::new(atom_mem), Arc::new(triple_store), config);
319
320        assert!(cognition.state().is_running());
321
322        // Let it run a couple cycles
323        std::thread::sleep(Duration::from_millis(200));
324
325        let cycles = cognition.state().cycle_count();
326        assert!(
327            cycles >= 1,
328            "Should have completed at least 1 cycle, got {}",
329            cycles
330        );
331
332        cognition.stop();
333        assert!(!cognition.state().is_running());
334    }
335
336    #[test]
337    fn test_take_insights() {
338        let (atom_mem, triple_store) = make_test_data();
339        let config = CognitionConfig {
340            interval: Duration::from_millis(50),
341            ..Default::default()
342        };
343
344        let mut cognition =
345            CognitionLoop::start(Arc::new(atom_mem), Arc::new(triple_store), config);
346
347        std::thread::sleep(Duration::from_millis(200));
348
349        let insights = cognition.state().take_insights();
350        assert!(!insights.is_empty());
351
352        // After take, count should be 0 (or only new ones from ongoing cycle)
353        // Stop first to prevent race
354        cognition.stop();
355        let remaining = cognition.state().take_insights();
356        // This is fine - just checking take works
357        let _ = remaining;
358    }
359
360    #[test]
361    fn pending_insights_do_not_grow_across_cycles_of_an_unchanged_store() {
362        let (atom_mem, triple_store) = make_test_data();
363        let config = CognitionConfig {
364            interval: Duration::from_millis(1),
365            ..Default::default()
366        };
367        let per_cycle = CognitionLoop::run_once(&atom_mem, &triple_store, &config).len();
368        assert!(per_cycle > 0);
369        let mut cognition =
370            CognitionLoop::start(Arc::new(atom_mem), Arc::new(triple_store), config);
371        let deadline = std::time::Instant::now() + Duration::from_secs(10);
372        while cognition.state().cycle_count() < 3 && std::time::Instant::now() < deadline {
373            std::thread::sleep(Duration::from_millis(5));
374        }
375        cognition.stop();
376        assert!(cognition.state().cycle_count() >= 3);
377        assert_eq!(cognition.state().insight_count(), per_cycle);
378    }
379
380    #[test]
381    fn test_empty_store_no_panic() {
382        let atom_mem = AtomMemory::new(16384, 3.0);
383        let triple_store = TripleStore::new();
384        let config = CognitionConfig::default();
385        let insights = CognitionLoop::run_once(&atom_mem, &triple_store, &config);
386        assert!(insights.is_empty());
387    }
388}