Skip to main content

mentedb_context/
assembler.rs

1//! Context assembler: the main entry point for context assembly.
2
3use mentedb_core::MemoryEdge;
4
5use crate::budget::TokenBudget;
6use crate::delta::DeltaTracker;
7use crate::layout::{ContextBlock, ContextLayout, ScoredMemory};
8use crate::serializer::{CompactFormat, ContextSerializer, DeltaFormat, StructuredFormat};
9
10/// Output format for context serialization.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum OutputFormat {
13    Compact,
14    Structured,
15    Delta,
16}
17
18/// Configuration for context assembly.
19#[derive(Debug, Clone)]
20pub struct AssemblyConfig {
21    pub token_budget: usize,
22    pub format: OutputFormat,
23    pub include_edges: bool,
24    pub include_metadata: bool,
25}
26
27impl Default for AssemblyConfig {
28    fn default() -> Self {
29        Self {
30            token_budget: 4096,
31            format: OutputFormat::Structured,
32            include_edges: false,
33            include_metadata: true,
34        }
35    }
36}
37
38/// Metadata about the assembly result.
39#[derive(Debug, Clone)]
40pub struct AssemblyMetadata {
41    pub total_candidates: usize,
42    pub included_count: usize,
43    pub excluded_count: usize,
44    pub edges_included: usize,
45    pub zones_used: usize,
46}
47
48/// The assembled context window ready for LLM consumption.
49#[derive(Debug, Clone)]
50pub struct ContextWindow {
51    pub blocks: Vec<ContextBlock>,
52    pub total_tokens: usize,
53    pub format: String,
54    pub metadata: AssemblyMetadata,
55}
56
57/// Main entry point for context assembly.
58#[derive(Debug)]
59pub struct ContextAssembler;
60
61impl ContextAssembler {
62    /// Assemble memories and edges into a context window.
63    pub fn assemble(
64        memories: Vec<ScoredMemory>,
65        edges: Vec<MemoryEdge>,
66        config: &AssemblyConfig,
67    ) -> ContextWindow {
68        let total_candidates = memories.len();
69
70        // 1. Sort by score descending
71        let mut sorted = memories;
72        sorted.sort_by(|a, b| {
73            b.score
74                .partial_cmp(&a.score)
75                .unwrap_or(std::cmp::Ordering::Equal)
76        });
77
78        // 2. Apply token budget — greedily include memories that fit
79        let mut budget = TokenBudget::new(config.token_budget);
80        let mut included = Vec::new();
81
82        for sm in sorted {
83            if budget.can_fit(&sm.memory.content) {
84                budget.consume(&sm.memory.content);
85                included.push(sm);
86            }
87        }
88
89        let included_count = included.len();
90        let excluded_count = total_candidates - included_count;
91
92        // 3. Arrange into attention zones
93        let layout = ContextLayout::default();
94        let blocks = layout.arrange(included);
95
96        // 4. Optionally append edge info to format
97        let edge_section = if config.include_edges && !edges.is_empty() {
98            let mut lines = vec!["\n## 🔗 Relationships".to_string()];
99            for edge in &edges {
100                lines.push(format!(
101                    "- {} --[{:?} w={:.2}]--> {}",
102                    &edge.source.to_string()[..8],
103                    edge.edge_type,
104                    edge.weight,
105                    &edge.target.to_string()[..8],
106                ));
107            }
108            lines.join("\n")
109        } else {
110            String::new()
111        };
112
113        // 5. Serialize
114        let serialized = Self::serialize_blocks(&blocks, config);
115        let total_tokens = budget.used_tokens;
116
117        let format_output = if edge_section.is_empty() {
118            serialized
119        } else {
120            format!("{serialized}\n{edge_section}")
121        };
122
123        let zones_used = blocks.iter().filter(|b| !b.memories.is_empty()).count();
124
125        ContextWindow {
126            blocks,
127            total_tokens,
128            format: format_output,
129            metadata: AssemblyMetadata {
130                total_candidates,
131                included_count,
132                excluded_count,
133                edges_included: if config.include_edges { edges.len() } else { 0 },
134                zones_used,
135            },
136        }
137    }
138
139    /// Assemble with delta tracking: only sends changes from the previous turn.
140    pub fn assemble_delta(
141        current_memories: Vec<ScoredMemory>,
142        edges: Vec<MemoryEdge>,
143        delta_tracker: &mut DeltaTracker,
144        config: &AssemblyConfig,
145    ) -> ContextWindow {
146        let current_ids: Vec<_> = current_memories.iter().map(|sm| sm.memory.id).collect();
147        let delta = delta_tracker.compute_delta(&current_ids, &delta_tracker.last_served.clone());
148
149        // Build lookup for added memories
150        let added_memories: Vec<ScoredMemory> = current_memories
151            .into_iter()
152            .filter(|sm| delta.added.contains(&sm.memory.id))
153            .collect();
154
155        let removed_summaries: Vec<String> = delta
156            .removed
157            .iter()
158            .map(|id| format!("memory {}", &id.to_string()[..8]))
159            .collect();
160
161        let delta_header = DeltaTracker::format_delta_context(
162            &added_memories
163                .iter()
164                .map(|sm| &sm.memory)
165                .collect::<Vec<_>>(),
166            &removed_summaries,
167            delta.unchanged.len(),
168        );
169
170        // Assemble only the new memories
171        let total_candidates = added_memories.len() + delta.unchanged.len();
172        let mut budget = TokenBudget::new(config.token_budget);
173
174        // Reserve tokens for delta header
175        budget.consume(&delta_header);
176
177        let mut sorted = added_memories;
178        sorted.sort_by(|a, b| {
179            b.score
180                .partial_cmp(&a.score)
181                .unwrap_or(std::cmp::Ordering::Equal)
182        });
183
184        let mut included = Vec::new();
185        for sm in sorted {
186            if budget.can_fit(&sm.memory.content) {
187                budget.consume(&sm.memory.content);
188                included.push(sm);
189            }
190        }
191
192        let included_count = included.len();
193        let layout = ContextLayout::default();
194        let blocks = layout.arrange(included);
195        let total_tokens = budget.used_tokens;
196
197        let fmt = DeltaFormat::new(delta_header);
198        let format_output = fmt.serialize(&blocks);
199
200        // Update tracker
201        delta_tracker.update(&current_ids);
202
203        let zones_used = blocks.iter().filter(|b| !b.memories.is_empty()).count();
204
205        ContextWindow {
206            blocks,
207            total_tokens,
208            format: format_output,
209            metadata: AssemblyMetadata {
210                total_candidates,
211                included_count,
212                excluded_count: total_candidates.saturating_sub(included_count),
213                edges_included: if config.include_edges { edges.len() } else { 0 },
214                zones_used,
215            },
216        }
217    }
218
219    fn serialize_blocks(blocks: &[ContextBlock], config: &AssemblyConfig) -> String {
220        match config.format {
221            OutputFormat::Compact => CompactFormat.serialize(blocks),
222            OutputFormat::Structured => StructuredFormat.serialize(blocks),
223            OutputFormat::Delta => {
224                // Delta without tracker context — fall back to structured
225                StructuredFormat.serialize(blocks)
226            }
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::layout::ScoredMemory;
235    use mentedb_core::MemoryNode;
236    use mentedb_core::memory::MemoryType;
237
238    fn make_scored(content: &str, score: f32, salience: f32, mem_type: MemoryType) -> ScoredMemory {
239        let mut m = MemoryNode::new(uuid::Uuid::new_v4(), mem_type, content.to_string(), vec![]);
240        m.salience = salience;
241        ScoredMemory { memory: m, score }
242    }
243
244    #[test]
245    fn test_assemble_basic() {
246        let memories = vec![
247            make_scored("high priority fact", 0.95, 0.9, MemoryType::Semantic),
248            make_scored("low priority note", 0.3, 0.4, MemoryType::Episodic),
249        ];
250        let config = AssemblyConfig::default();
251        let window = ContextAssembler::assemble(memories, vec![], &config);
252
253        assert_eq!(window.metadata.total_candidates, 2);
254        assert_eq!(window.metadata.included_count, 2);
255        assert!(!window.format.is_empty());
256    }
257
258    #[test]
259    fn test_assemble_respects_budget() {
260        // Tiny budget to force exclusion
261        let memories = vec![
262            make_scored(
263                "a very important memory with lots of words",
264                0.9,
265                0.9,
266                MemoryType::Semantic,
267            ),
268            make_scored(
269                "another memory with many words in it",
270                0.8,
271                0.8,
272                MemoryType::Episodic,
273            ),
274        ];
275        let config = AssemblyConfig {
276            token_budget: 10,
277            ..Default::default()
278        };
279        let window = ContextAssembler::assemble(memories, vec![], &config);
280        // At least one should be included, possibly not both
281        assert!(window.metadata.included_count <= 2);
282        assert!(window.total_tokens <= 10);
283    }
284
285    #[test]
286    fn test_assemble_compact_format() {
287        let memories = vec![make_scored("compact test", 0.9, 0.9, MemoryType::Semantic)];
288        let config = AssemblyConfig {
289            format: OutputFormat::Compact,
290            ..Default::default()
291        };
292        let window = ContextAssembler::assemble(memories, vec![], &config);
293        assert!(window.format.contains("M|Semantic|"));
294    }
295
296    #[test]
297    fn test_assemble_delta() {
298        let mut tracker = DeltaTracker::new();
299        let m1 = make_scored("first fact", 0.9, 0.9, MemoryType::Semantic);
300        let m2 = make_scored("second fact", 0.8, 0.8, MemoryType::Episodic);
301
302        let config = AssemblyConfig::default();
303
304        // First turn — all new
305        let window = ContextAssembler::assemble_delta(
306            vec![m1.clone(), m2.clone()],
307            vec![],
308            &mut tracker,
309            &config,
310        );
311        assert!(window.format.contains("[NEW]"));
312
313        // Second turn — same memories, should see UNCHANGED
314        let window2 = ContextAssembler::assemble_delta(vec![m1, m2], vec![], &mut tracker, &config);
315        assert!(window2.format.contains("[UNCHANGED]"));
316    }
317}