Skip to main content

lean_ctx/core/
negative_knowledge.rs

1//! Negative knowledge tracking (#1314).
2//!
3//! Remembers what the agent already explored and found irrelevant, so
4//! lean-ctx can suppress re-exploration of dead-end paths. Based on
5//! SARA (ACL 2026): "what should be retrieved next hinges on what has
6//! already been inferred from previously retrieved evidence."
7
8use std::collections::{HashMap, HashSet};
9use std::sync::{Mutex, PoisonError};
10
11static GLOBAL: Mutex<Option<NegativeKnowledge>> = Mutex::new(None);
12
13/// Access the global negative-knowledge tracker.
14pub fn global() -> std::sync::MutexGuard<'static, Option<NegativeKnowledge>> {
15    GLOBAL.lock().unwrap_or_else(PoisonError::into_inner)
16}
17
18/// Session-scoped negative knowledge store.
19#[derive(Debug, Clone, Default)]
20pub struct NegativeKnowledge {
21    /// Files confirmed irrelevant to the current task.
22    irrelevant_files: HashSet<String>,
23    /// Search queries that returned no useful results.
24    dead_end_queries: HashSet<String>,
25    /// File → set of specific aspects confirmed not present.
26    absent_features: HashMap<String, HashSet<String>>,
27}
28
29impl NegativeKnowledge {
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    /// Mark a file as irrelevant to the current task.
35    pub fn mark_irrelevant(&mut self, path: &str) {
36        self.irrelevant_files.insert(path.to_string());
37    }
38
39    /// Check if a file has been marked as irrelevant.
40    pub fn is_irrelevant(&self, path: &str) -> bool {
41        self.irrelevant_files.contains(path)
42    }
43
44    /// Record a dead-end search query.
45    pub fn record_dead_end_query(&mut self, query: &str) {
46        self.dead_end_queries.insert(query.to_lowercase());
47    }
48
49    /// Check if a similar query was already a dead end.
50    pub fn is_dead_end_query(&self, query: &str) -> bool {
51        self.dead_end_queries.contains(&query.to_lowercase())
52    }
53
54    /// Record that a specific feature/symbol is absent from a file.
55    pub fn record_absent(&mut self, path: &str, feature: &str) {
56        self.absent_features
57            .entry(path.to_string())
58            .or_default()
59            .insert(feature.to_string());
60    }
61
62    /// Check if a feature was already confirmed absent from a file.
63    pub fn is_absent(&self, path: &str, feature: &str) -> bool {
64        self.absent_features
65            .get(path)
66            .is_some_and(|features| features.contains(feature))
67    }
68
69    /// Invalidate negative knowledge for a file (e.g., after edit).
70    pub fn invalidate(&mut self, path: &str) {
71        self.irrelevant_files.remove(path);
72        self.absent_features.remove(path);
73    }
74
75    /// Summary for diagnostics.
76    pub fn stats(&self) -> (usize, usize, usize) {
77        (
78            self.irrelevant_files.len(),
79            self.dead_end_queries.len(),
80            self.absent_features.values().map(HashSet::len).sum(),
81        )
82    }
83
84    pub fn reset(&mut self) {
85        self.irrelevant_files.clear();
86        self.dead_end_queries.clear();
87        self.absent_features.clear();
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn mark_and_check_irrelevant() {
97        let mut nk = NegativeKnowledge::new();
98        assert!(!nk.is_irrelevant("src/utils.rs"));
99        nk.mark_irrelevant("src/utils.rs");
100        assert!(nk.is_irrelevant("src/utils.rs"));
101    }
102
103    #[test]
104    fn dead_end_queries_case_insensitive() {
105        let mut nk = NegativeKnowledge::new();
106        nk.record_dead_end_query("DatabasePool");
107        assert!(nk.is_dead_end_query("databasepool"));
108        assert!(nk.is_dead_end_query("DATABASEPOOL"));
109    }
110
111    #[test]
112    fn absent_features_per_file() {
113        let mut nk = NegativeKnowledge::new();
114        nk.record_absent("src/db.rs", "connection_pool");
115        assert!(nk.is_absent("src/db.rs", "connection_pool"));
116        assert!(!nk.is_absent("src/db.rs", "query_builder"));
117        assert!(!nk.is_absent("src/other.rs", "connection_pool"));
118    }
119
120    #[test]
121    fn invalidate_clears_file_knowledge() {
122        let mut nk = NegativeKnowledge::new();
123        nk.mark_irrelevant("src/lib.rs");
124        nk.record_absent("src/lib.rs", "foo");
125        nk.invalidate("src/lib.rs");
126        assert!(!nk.is_irrelevant("src/lib.rs"));
127        assert!(!nk.is_absent("src/lib.rs", "foo"));
128    }
129
130    #[test]
131    fn stats_counts() {
132        let mut nk = NegativeKnowledge::new();
133        nk.mark_irrelevant("a.rs");
134        nk.mark_irrelevant("b.rs");
135        nk.record_dead_end_query("q1");
136        nk.record_absent("c.rs", "x");
137        nk.record_absent("c.rs", "y");
138        assert_eq!(nk.stats(), (2, 1, 2));
139    }
140}