1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum OutputFormat {
13 Compact,
14 Structured,
15 Delta,
16}
17
18#[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#[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#[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#[derive(Debug)]
59pub struct ContextAssembler;
60
61impl ContextAssembler {
62 pub fn assemble(
64 memories: Vec<ScoredMemory>,
65 edges: Vec<MemoryEdge>,
66 config: &AssemblyConfig,
67 ) -> ContextWindow {
68 let total_candidates = memories.len();
69
70 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 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 let layout = ContextLayout::default();
94 let blocks = layout.arrange(included);
95
96 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 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 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(¤t_ids, &delta_tracker.last_served.clone());
148
149 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 let total_candidates = added_memories.len() + delta.unchanged.len();
172 let mut budget = TokenBudget::new(config.token_budget);
173
174 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 delta_tracker.update(¤t_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 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 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 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 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 let window2 = ContextAssembler::assemble_delta(vec![m1, m2], vec![], &mut tracker, &config);
315 assert!(window2.format.contains("[UNCHANGED]"));
316 }
317}