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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum OutputFormat {
12 Compact,
13 Structured,
14 Delta,
15}
16
17#[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#[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#[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#[derive(Debug)]
58pub struct ContextAssembler;
59
60impl ContextAssembler {
61 pub fn assemble(
63 memories: Vec<ScoredMemory>,
64 edges: Vec<MemoryEdge>,
65 config: &AssemblyConfig,
66 ) -> ContextWindow {
67 let total_candidates = memories.len();
68
69 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 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 let layout = ContextLayout::default();
93 let blocks = layout.arrange(included);
94
95 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 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 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(¤t_ids, &delta_tracker.last_served.clone());
147
148 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 let total_candidates = added_memories.len() + delta.unchanged.len();
171 let mut budget = TokenBudget::new(config.token_budget);
172
173 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 delta_tracker.update(¤t_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 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 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}