Skip to main content

lean_ctx/core/ocla/
compose_cache.rs

1//! Section-aware cache for `ctx_compose` results.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, OnceLock};
6
7use crate::core::ocla::cache_types::{CacheKeyBuilder, ComposedContextKey};
8
9#[derive(Clone, Debug)]
10struct ComposeRecord {
11    corpus_revision: String,
12    source_paths: Vec<PathBuf>,
13    source_digests: Vec<String>,
14    text: String,
15}
16
17/// In-process composition cache. A record is valid only when every source file
18/// still has the digest used to build its `ComposedContextKey`.
19#[derive(Default)]
20pub struct ComposeSectionCache {
21    records: Mutex<BTreeMap<(String, String), ComposeRecord>>,
22}
23
24impl ComposeSectionCache {
25    pub fn check(&self, task: &str, path: &str) -> Option<String> {
26        // Legacy records do not bind their complete source set to a policy.
27        if crate::core::policy::runtime::is_active() {
28            return None;
29        }
30        // A record does not carry the Workspace revision it was composed
31        // under; under a Workspace every compose is fresh (#2120).
32        if crate::core::workspace_authority::is_configured() {
33            return None;
34        }
35        let key = (task.trim().to_string(), path.to_string());
36        let record = self.records.lock().ok()?.get(&key)?.clone();
37        if source_revision(path)? != record.corpus_revision {
38            return None;
39        }
40        let source_digests = source_digests(path, &record.source_paths)?;
41        let builder = ComposedContextKey {
42            task: key.0,
43            path: key.1,
44            source_digests,
45        };
46        (builder.source_digests == record.source_digests).then_some(record.text)
47    }
48
49    pub fn record(&self, task: &str, path: &str, text: String, corpus_revision: &str) -> bool {
50        if source_revision(path).as_deref() != Some(corpus_revision) {
51            return false;
52        }
53        let source_paths = source_paths(path, &text);
54        let Some(source_digests) = source_digests(path, &source_paths) else {
55            return false;
56        };
57        if source_revision(path).as_deref() != Some(corpus_revision) {
58            return false;
59        }
60        let builder = ComposedContextKey {
61            task: task.trim().to_string(),
62            path: path.to_string(),
63            source_digests: source_digests.clone(),
64        };
65        let _cache_key = builder.cache_key();
66        let key = (builder.task, builder.path);
67        if let Ok(mut records) = self.records.lock() {
68            records.insert(
69                key,
70                ComposeRecord {
71                    corpus_revision: corpus_revision.to_string(),
72                    source_paths,
73                    source_digests,
74                    text,
75                },
76            );
77            return true;
78        }
79        false
80    }
81}
82
83/// A legacy Community cache is reusable only while the bounded source corpus
84/// and secret-path visibility are unchanged, including after policy removal.
85pub fn source_revision(path: &str) -> Option<String> {
86    if crate::core::policy::runtime::is_active() {
87        return None;
88    }
89    let role = crate::core::roles::active_role();
90    let allow_secret = role.io.allow_secret_paths;
91    let role_fingerprint = crate::core::policy::runtime::role_fingerprint(&role).ok()?;
92    crate::core::search_index::corpus_signature(path, true, allow_secret).map(|revision| {
93        format!(
94            "{revision}:role={role_fingerprint}:dense={}",
95            dense_stamp(Path::new(path))
96        )
97    })
98}
99
100/// Compose ranks with the dense index too, so its result depends on more than
101/// the corpus: switching the embedding model or rebuilding the vectors must not
102/// keep serving results ranked by the old ones (#2060). Model name plus the
103/// size and mtime of `embeddings.bin` — a metadata read, no index load.
104fn dense_stamp(root: &Path) -> String {
105    #[cfg(feature = "embeddings")]
106    {
107        let model = crate::core::embeddings::model_registry::resolve_model();
108        let vectors = crate::core::index_namespace::vectors_dir(root).join("embeddings.bin");
109        let file = std::fs::metadata(vectors).ok().map_or_else(
110            || "none".to_string(),
111            |meta| {
112                let mtime = meta
113                    .modified()
114                    .ok()
115                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
116                    .map_or(0, |d| d.as_nanos());
117                format!("{}@{mtime}", meta.len())
118            },
119        );
120        format!("{model:?}/{file}")
121    }
122    #[cfg(not(feature = "embeddings"))]
123    {
124        let _ = root;
125        "off".to_string()
126    }
127}
128
129pub fn global() -> &'static ComposeSectionCache {
130    static CACHE: OnceLock<ComposeSectionCache> = OnceLock::new();
131    CACHE.get_or_init(ComposeSectionCache::default)
132}
133
134fn source_paths(project_root: &str, text: &str) -> Vec<PathBuf> {
135    let root = Path::new(project_root);
136    let mut paths = text
137        .lines()
138        .filter_map(|line| line.trim().strip_prefix("File: "))
139        .filter_map(|raw| raw.split_whitespace().next())
140        .map(|raw| {
141            let path = PathBuf::from(raw);
142            if path.is_absolute() {
143                path
144            } else {
145                root.join(path)
146            }
147        })
148        .filter(|path| path.is_file())
149        .collect::<Vec<_>>();
150    paths.sort();
151    paths.dedup();
152    paths
153}
154
155fn source_digests(root: &str, paths: &[PathBuf]) -> Option<Vec<String>> {
156    let mut digests = paths
157        .iter()
158        .map(|path| {
159            crate::core::rooted_read::read_file_for_tool_rooted(
160                &path.to_string_lossy(),
161                root,
162                "ctx_compose",
163            )
164            .ok()
165            .map(|content| blake3::hash(content.as_bytes()).to_hex().to_string())
166        })
167        .collect::<Option<Vec<_>>>()?;
168    digests.sort();
169    Some(digests)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::ComposeSectionCache;
175
176    #[test]
177    fn section_cache_hits_only_while_all_sources_match() {
178        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(None);
179        let dir = tempfile::tempdir().unwrap();
180        let first = dir.path().join("first.rs");
181        let second = dir.path().join("second.rs");
182        std::fs::write(&first, "one").unwrap();
183        std::fs::write(&second, "two").unwrap();
184        let root = dir.path().to_string_lossy();
185        let text = "File: first.rs\nbody one\nFile: second.rs\nbody two".to_string();
186        let cache = ComposeSectionCache::default();
187        let revision = super::source_revision(&root).unwrap();
188        assert!(cache.record("task", &root, text.clone(), &revision));
189        assert_eq!(cache.check("task", &root), Some(text));
190        std::fs::write(&second, "changed").unwrap();
191        assert_eq!(cache.check("task", &root), None);
192    }
193
194    #[test]
195    fn section_cache_rejects_corpus_changes_without_file_headers() {
196        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(None);
197        let dir = tempfile::tempdir().unwrap();
198        let source = dir.path().join("source.rs");
199        std::fs::write(&source, "fn before() {}\n").unwrap();
200        let root = dir.path().to_string_lossy();
201        let cache = ComposeSectionCache::default();
202        let revision = super::source_revision(&root).unwrap();
203        assert!(cache.record("task", &root, "old derived result".to_string(), &revision));
204        assert_eq!(
205            cache.check("task", &root).as_deref(),
206            Some("old derived result")
207        );
208        std::fs::write(&source, "fn after_policy_removal() {}\n").unwrap();
209        assert_eq!(cache.check("task", &root), None);
210        assert!(!cache.record("task", &root, "old derived result".to_string(), &revision));
211    }
212
213    /// #2060: a rebuilt (or newly built) dense index invalidates cached
214    /// results — they were ranked without it or with other vectors.
215    #[cfg(feature = "embeddings")]
216    #[test]
217    fn section_cache_rejects_a_rebuilt_dense_index() {
218        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(None);
219        let _lock = crate::core::data_dir::test_env_lock();
220        let data = tempfile::tempdir().unwrap();
221        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
222        let root = tempfile::tempdir().unwrap();
223        std::fs::write(root.path().join("source.rs"), "fn sample() {}\n").unwrap();
224        let path = root.path().to_str().unwrap();
225        let cache = ComposeSectionCache::default();
226        let revision = super::source_revision(path).unwrap();
227        assert!(cache.record("task", path, "derived text".into(), &revision));
228        assert_eq!(cache.check("task", path).as_deref(), Some("derived text"));
229
230        let vectors = crate::core::index_namespace::vectors_dir(root.path());
231        std::fs::create_dir_all(&vectors).unwrap();
232        std::fs::write(vectors.join("embeddings.bin"), b"vectors").unwrap();
233        let after = cache.check("task", path);
234        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
235        assert_eq!(after, None);
236    }
237
238    #[test]
239    fn section_cache_rejects_a_changed_role_even_with_same_secret_visibility() {
240        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(None);
241        let root = tempfile::tempdir().unwrap();
242        std::fs::write(root.path().join("source.rs"), "fn sample() {}\n").unwrap();
243        let path = root.path().to_str().unwrap();
244        let role = crate::core::roles::load_role("coder").unwrap();
245        crate::core::roles::with_test_active_role(role.clone(), || {
246            let cache = ComposeSectionCache::default();
247            let revision = super::source_revision(path).unwrap();
248            assert!(cache.record("task", path, "derived text".into(), &revision));
249            assert_eq!(cache.check("task", path).as_deref(), Some("derived text"));
250            let mut changed = role;
251            changed.tools.denied.push("ctx_symbol".into());
252            crate::core::roles::set_test_active_role(changed);
253            assert_eq!(cache.check("task", path), None);
254            assert!(!cache.record("task", path, "derived text".into(), &revision));
255        });
256    }
257}