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/// Output format for context serialization.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum OutputFormat {
12    Compact,
13    Structured,
14    Delta,
15}
16
17/// Configuration for context assembly.
18#[derive(Debug, Clone)]
19pub struct AssemblyConfig {
20    pub token_budget: usize,
21    pub format: OutputFormat,
22    pub include_edges: bool,
23    pub include_metadata: bool,
24}
25
26impl Default for AssemblyConfig {
27    fn default() -> Self {
28        Self {
29            token_budget: 4096,
30            format: OutputFormat::Structured,
31            include_edges: false,
32            include_metadata: true,
33        }
34    }
35}
36
37/// Metadata about the assembly result.
38#[derive(Debug, Clone)]
39pub struct AssemblyMetadata {
40    pub total_candidates: usize,
41    pub included_count: usize,
42    pub excluded_count: usize,
43    pub edges_included: usize,
44    pub zones_used: usize,
45}
46
47/// The assembled context window ready for LLM consumption.
48#[derive(Debug, Clone)]
49pub struct ContextWindow {
50    pub blocks: Vec<ContextBlock>,
51    pub total_tokens: usize,
52    pub format: String,
53    pub metadata: AssemblyMetadata,
54}
55
56/// Main entry point for context assembly.
57#[derive(Debug)]
58pub struct ContextAssembler;
59
60impl ContextAssembler {
61    /// Assemble memories and edges into a context window.
62    pub fn assemble(
63        memories: Vec<ScoredMemory>,
64        edges: Vec<MemoryEdge>,
65        config: &AssemblyConfig,
66    ) -> ContextWindow {
67        let total_candidates = memories.len();
68
69        // 1. Sort by score descending
70        let mut sorted = memories;
71        sorted.sort_by(|a, b| {
72            b.score
73                .partial_cmp(&a.score)
74                .unwrap_or(std::cmp::Ordering::Equal)
75        });
76
77        // 2. Apply token budget — greedily include memories that fit
78        let mut budget = TokenBudget::new(config.token_budget);
79        let mut included = Vec::new();
80
81        for sm in sorted {
82            if budget.can_fit(&sm.memory.content) {
83                budget.consume(&sm.memory.content);
84                included.push(sm);
85            }
86        }
87
88        let included_count = included.len();
89        let excluded_count = total_candidates - included_count;
90
91        // 3. Arrange into attention zones
92        let layout = ContextLayout::default();
93        let blocks = layout.arrange(included);
94
95        // 4. Optionally append edge info to format
96        let edge_section = if config.include_edges && !edges.is_empty() {
97            let mut lines = vec!["\n## 🔗 Relationships".to_string()];
98            for edge in &edges {
99                lines.push(format!(
100                    "- {} --[{:?} w={:.2}]--> {}",
101                    &edge.source.to_string()[..8],
102                    edge.edge_type,
103                    edge.weight,
104                    &edge.target.to_string()[..8],
105                ));
106            }
107            lines.join("\n")
108        } else {
109            String::new()
110        };
111
112        // 5. Serialize
113        let serialized = Self::serialize_blocks(&blocks, config);
114        let total_tokens = budget.used_tokens;
115
116        let format_output = if edge_section.is_empty() {
117            serialized
118        } else {
119            format!("{serialized}\n{edge_section}")
120        };
121
122        let zones_used = blocks.iter().filter(|b| !b.memories.is_empty()).count();
123
124        ContextWindow {
125            blocks,
126            total_tokens,
127            format: format_output,
128            metadata: AssemblyMetadata {
129                total_candidates,
130                included_count,
131                excluded_count,
132                edges_included: if config.include_edges { edges.len() } else { 0 },
133                zones_used,
134            },
135        }
136    }
137
138    /// Assemble with delta tracking: only sends changes from the previous turn.
139    pub fn assemble_delta(
140        current_memories: Vec<ScoredMemory>,
141        edges: Vec<MemoryEdge>,
142        delta_tracker: &mut DeltaTracker,
143        config: &AssemblyConfig,
144    ) -> ContextWindow {
145        let current_ids: Vec<_> = current_memories.iter().map(|sm| sm.memory.id).collect();
146        let delta = delta_tracker.compute_delta(&current_ids, &delta_tracker.last_served.clone());
147
148        // Build lookup for added memories
149        let added_memories: Vec<ScoredMemory> = current_memories
150            .into_iter()
151            .filter(|sm| delta.added.contains(&sm.memory.id))
152            .collect();
153
154        let removed_summaries: Vec<String> = delta
155            .removed
156            .iter()
157            .map(|id| format!("memory {}", &id.to_string()[..8]))
158            .collect();
159
160        let delta_header = DeltaTracker::format_delta_context(
161            &added_memories
162                .iter()
163                .map(|sm| &sm.memory)
164                .collect::<Vec<_>>(),
165            &removed_summaries,
166            delta.unchanged.len(),
167        );
168
169        // Assemble only the new memories
170        let total_candidates = added_memories.len() + delta.unchanged.len();
171        let mut budget = TokenBudget::new(config.token_budget);
172
173        // Reserve tokens for delta header
174        budget.consume(&delta_header);
175
176        let mut sorted = added_memories;
177        sorted.sort_by(|a, b| {
178            b.score
179                .partial_cmp(&a.score)
180                .unwrap_or(std::cmp::Ordering::Equal)
181        });
182
183        let mut included = Vec::new();
184        for sm in sorted {
185            if budget.can_fit(&sm.memory.content) {
186                budget.consume(&sm.memory.content);
187                included.push(sm);
188            }
189        }
190
191        let included_count = included.len();
192        let layout = ContextLayout::default();
193        let blocks = layout.arrange(included);
194        let total_tokens = budget.used_tokens;
195
196        let fmt = DeltaFormat::new(delta_header);
197        let format_output = fmt.serialize(&blocks);
198
199        // Update tracker
200        delta_tracker.update(&current_ids);
201
202        let zones_used = blocks.iter().filter(|b| !b.memories.is_empty()).count();
203
204        ContextWindow {
205            blocks,
206            total_tokens,
207            format: format_output,
208            metadata: AssemblyMetadata {
209                total_candidates,
210                included_count,
211                excluded_count: total_candidates.saturating_sub(included_count),
212                edges_included: if config.include_edges { edges.len() } else { 0 },
213                zones_used,
214            },
215        }
216    }
217
218    fn serialize_blocks(blocks: &[ContextBlock], config: &AssemblyConfig) -> String {
219        match config.format {
220            OutputFormat::Compact => CompactFormat.serialize(blocks),
221            OutputFormat::Structured => StructuredFormat.serialize(blocks),
222            OutputFormat::Delta => {
223                // Delta without tracker context — fall back to structured
224                StructuredFormat.serialize(blocks)
225            }
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::layout::ScoredMemory;
234    use mentedb_core::MemoryNode;
235    use mentedb_core::memory::MemoryType;
236    use mentedb_core::types::AgentId;
237
238    fn make_scored(content: &str, score: f32, salience: f32, mem_type: MemoryType) -> ScoredMemory {
239        let mut m = MemoryNode::new(AgentId::new(), 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}