Skip to main content

lean_ctx/core/
consolidation.rs

1//! Consolidation engine for provider data — hippocampal sleep replay.
2//!
3//! Converts provider results into long-term context artifacts:
4//!   1. BM25/embedding index chunks (for future searches)
5//!   2. Cross-source graph edges (for related-file discovery)
6//!   3. Knowledge facts (for semantic memory)
7//!   4. Session cache entries (for fast re-reads at ~13 tokens)
8//!
9//! This is the "sleep replay" mechanism: raw episodic data (provider API
10//! responses) is consolidated into durable semantic representations.
11//!
12//! Scientific basis: Hippocampal memory consolidation (Kitamura, Science 2017).
13//! Fast hippocampal (session cache) traces are replayed to build slow
14//! neocortical (knowledge + graph + index) representations.
15
16use crate::core::content_chunk::ContentChunk;
17use crate::core::cross_source_edges;
18use crate::core::graph_index::IndexEdge;
19use crate::core::knowledge_provider_extract::{self, ExtractedFact};
20
21/// Result of a consolidation run — tells the caller what was created.
22#[derive(Debug, Clone, Default)]
23pub struct ConsolidationResult {
24    pub chunks_indexed: usize,
25    pub edges_created: usize,
26    pub facts_extracted: usize,
27    pub cache_entries_stored: usize,
28}
29
30/// Consolidate a batch of ContentChunks into all long-term stores.
31///
32/// This is the main entry point. It does NOT perform I/O itself — it returns
33/// the artifacts that the caller should persist. This keeps the consolidation
34/// logic pure and testable.
35pub fn consolidate(chunks: &[ContentChunk]) -> ConsolidationArtifacts {
36    let external_chunks: Vec<&ContentChunk> = chunks.iter().filter(|c| c.is_external()).collect();
37
38    if external_chunks.is_empty() {
39        return ConsolidationArtifacts::default();
40    }
41
42    let edges = cross_source_edges::extract_cross_source_edges(chunks);
43
44    let facts = knowledge_provider_extract::extract_facts(chunks);
45
46    let cache_entries: Vec<CacheableProviderResult> = external_chunks
47        .iter()
48        .map(|c| CacheableProviderResult {
49            uri: c.file_path.clone(),
50            content: c.content.clone(),
51            token_count: c.token_count,
52        })
53        .collect();
54
55    ConsolidationArtifacts {
56        bm25_chunks: chunks.to_vec(),
57        edges,
58        facts,
59        cache_entries,
60    }
61}
62
63/// Pure artifacts produced by consolidation — no side effects yet.
64#[derive(Debug, Clone, Default)]
65pub struct ConsolidationArtifacts {
66    pub bm25_chunks: Vec<ContentChunk>,
67    pub edges: Vec<IndexEdge>,
68    pub facts: Vec<ExtractedFact>,
69    pub cache_entries: Vec<CacheableProviderResult>,
70}
71
72impl ConsolidationArtifacts {
73    pub fn is_empty(&self) -> bool {
74        self.bm25_chunks.is_empty()
75            && self.edges.is_empty()
76            && self.facts.is_empty()
77            && self.cache_entries.is_empty()
78    }
79
80    pub fn summary(&self) -> ConsolidationResult {
81        ConsolidationResult {
82            chunks_indexed: self.bm25_chunks.iter().filter(|c| c.is_external()).count(),
83            edges_created: self.edges.len(),
84            facts_extracted: self.facts.len(),
85            cache_entries_stored: self.cache_entries.len(),
86        }
87    }
88}
89
90/// A provider result ready to be stored in the session cache.
91#[derive(Debug, Clone)]
92pub struct CacheableProviderResult {
93    pub uri: String,
94    pub content: String,
95    pub token_count: usize,
96}
97
98/// Apply consolidation artifacts to the live systems.
99///
100/// This function performs the actual side effects: writing to BM25, graph,
101/// knowledge, and session cache. Designed to be called from a background
102/// thread or after a provider query returns.
103pub fn apply_artifacts(
104    artifacts: &ConsolidationArtifacts,
105    bm25: Option<&mut crate::core::bm25_index::BM25Index>,
106    graph_edges: Option<&mut Vec<IndexEdge>>,
107    session_cache: Option<&mut crate::core::cache::SessionCache>,
108) -> ConsolidationResult {
109    apply_artifacts_with_pg(artifacts, bm25, graph_edges, session_cache, None)
110}
111
112pub fn apply_artifacts_with_pg(
113    artifacts: &ConsolidationArtifacts,
114    bm25: Option<&mut crate::core::bm25_index::BM25Index>,
115    graph_edges: Option<&mut Vec<IndexEdge>>,
116    session_cache: Option<&mut crate::core::cache::SessionCache>,
117    property_graph: Option<&crate::core::property_graph::CodeGraph>,
118) -> ConsolidationResult {
119    let mut result = ConsolidationResult::default();
120
121    if let Some(index) = bm25 {
122        result.chunks_indexed = index.ingest_content_chunks(artifacts.bm25_chunks.clone());
123    }
124
125    if let Some(edges) = graph_edges {
126        result.edges_created = cross_source_edges::merge_edges(edges, artifacts.edges.clone());
127    }
128
129    if let Some(pg) = property_graph {
130        write_edges_to_property_graph(pg, &artifacts.edges);
131    }
132
133    result.facts_extracted = artifacts.facts.len();
134
135    if let Some(cache) = session_cache {
136        for entry in &artifacts.cache_entries {
137            cache.store(&entry.uri, &entry.content);
138            result.cache_entries_stored += 1;
139        }
140    }
141
142    result
143}
144
145fn write_edges_to_property_graph(pg: &crate::core::property_graph::CodeGraph, edges: &[IndexEdge]) {
146    // Cross-source edges live in their own table (#682) so external URIs never
147    // pollute the File-node catalog and the exact relation kind + weight survive.
148    for edge in edges {
149        let _ = pg.upsert_cross_source_edge(&edge.from, &edge.to, &edge.kind, edge.weight);
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::core::bm25_index::{BM25Index, ChunkKind};
157    use crate::core::cache::SessionCache;
158    use crate::core::content_chunk::ContentChunk;
159
160    fn sample_chunks() -> Vec<ContentChunk> {
161        vec![
162            ContentChunk::from_provider(
163                "github",
164                "issues",
165                "42",
166                "Auth token bug",
167                ChunkKind::Issue,
168                "Token expires too early in src/auth.rs".into(),
169                vec!["src/auth.rs".into()],
170                Some(serde_json::json!({"state": "open", "labels": ["bug"]})),
171            ),
172            ContentChunk::from_provider(
173                "github",
174                "pull_requests",
175                "100",
176                "Fix auth expiry",
177                ChunkKind::PullRequest,
178                "Fixes token lifetime calculation in src/auth.rs".into(),
179                vec!["src/auth.rs".into()],
180                Some(serde_json::json!({"state": "open"})),
181            ),
182        ]
183    }
184
185    #[test]
186    fn consolidate_produces_all_artifact_types() {
187        let chunks = sample_chunks();
188        let artifacts = consolidate(&chunks);
189
190        assert!(!artifacts.is_empty());
191        assert_eq!(artifacts.bm25_chunks.len(), 2);
192        assert!(!artifacts.edges.is_empty());
193        assert!(!artifacts.facts.is_empty());
194        assert_eq!(artifacts.cache_entries.len(), 2);
195    }
196
197    #[test]
198    fn consolidate_empty_input_produces_empty_artifacts() {
199        let artifacts = consolidate(&[]);
200        assert!(artifacts.is_empty());
201    }
202
203    #[test]
204    fn consolidate_code_only_produces_empty_external_artifacts() {
205        let code = ContentChunk::from(crate::core::bm25_index::CodeChunk {
206            file_path: "src/main.rs".into(),
207            symbol_name: "main".into(),
208            kind: ChunkKind::Function,
209            start_line: 1,
210            end_line: 5,
211            content: "fn main() {}".into(),
212            tokens: vec![],
213            token_count: 0,
214        });
215        let artifacts = consolidate(&[code]);
216        assert!(artifacts.edges.is_empty());
217        assert!(artifacts.facts.is_empty());
218        assert!(artifacts.cache_entries.is_empty());
219    }
220
221    #[test]
222    fn consolidation_summary_counts_correctly() {
223        let chunks = sample_chunks();
224        let artifacts = consolidate(&chunks);
225        let summary = artifacts.summary();
226
227        assert_eq!(summary.chunks_indexed, 2);
228        assert!(summary.edges_created > 0);
229        assert!(summary.facts_extracted > 0);
230        assert_eq!(summary.cache_entries_stored, 2);
231    }
232
233    #[test]
234    fn apply_artifacts_to_bm25() {
235        let chunks = sample_chunks();
236        let artifacts = consolidate(&chunks);
237
238        let mut index = BM25Index::new();
239
240        let result = apply_artifacts(&artifacts, Some(&mut index), None, None);
241        assert_eq!(result.chunks_indexed, 2);
242        assert_eq!(index.doc_count, 2);
243        assert_eq!(index.external_chunk_count(), 2);
244    }
245
246    #[test]
247    fn apply_artifacts_to_graph() {
248        let chunks = sample_chunks();
249        let artifacts = consolidate(&chunks);
250
251        let mut edges: Vec<IndexEdge> = Vec::new();
252        let result = apply_artifacts(&artifacts, None, Some(&mut edges), None);
253
254        assert!(result.edges_created > 0);
255        assert!(!edges.is_empty());
256        assert!(edges.iter().any(|e| e.to == "src/auth.rs"));
257    }
258
259    #[test]
260    fn apply_artifacts_to_session_cache() {
261        let chunks = sample_chunks();
262        let artifacts = consolidate(&chunks);
263
264        let mut cache = SessionCache::new();
265        let result = apply_artifacts(&artifacts, None, None, Some(&mut cache));
266
267        assert_eq!(result.cache_entries_stored, 2);
268        assert!(cache.get("github://issues/42").is_some());
269        assert!(cache.get("github://pull_requests/100").is_some());
270    }
271
272    #[test]
273    fn apply_artifacts_to_all_systems() {
274        let chunks = sample_chunks();
275        let artifacts = consolidate(&chunks);
276
277        let mut index = BM25Index::new();
278        let mut edges: Vec<IndexEdge> = Vec::new();
279        let mut cache = SessionCache::new();
280
281        let result = apply_artifacts(
282            &artifacts,
283            Some(&mut index),
284            Some(&mut edges),
285            Some(&mut cache),
286        );
287
288        assert!(result.chunks_indexed > 0);
289        assert!(result.edges_created > 0);
290        assert!(result.facts_extracted > 0);
291        assert!(result.cache_entries_stored > 0);
292    }
293
294    #[test]
295    fn apply_artifacts_persists_cross_source_to_property_graph_for_hints() {
296        // End-to-end (#682): provider chunks → consolidate → PropertyGraph, then
297        // the cross_source_hints consumer resolves a hint for the referenced file.
298        let chunks = sample_chunks(); // github issue + PR, both reference src/auth.rs
299        let artifacts = consolidate(&chunks);
300
301        let pg = crate::core::property_graph::CodeGraph::open_in_memory().unwrap();
302        apply_artifacts_with_pg(&artifacts, None, None, None, Some(&pg));
303
304        let edges = pg.all_cross_source_edges();
305        assert!(
306            !edges.is_empty(),
307            "cross-source edges land in the property graph"
308        );
309
310        let hints = crate::core::cross_source_hints::hints_for_file("src/auth.rs", &edges, "/proj");
311        assert!(
312            hints.iter().any(|h| h.source_uri.contains("github://")),
313            "issue/PR hint resolves from PG-backed edges, got {hints:?}"
314        );
315    }
316}