1use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet, VecDeque};
9use ucm_core::{BlockId, Content, Document};
10
11#[cfg(test)]
12use ucm_core::Block;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub enum InclusionReason {
17 DirectReference,
19 NavigationPath,
21 StructuralContext,
23 SemanticRelevance,
25 ExternalDecision,
27 RequiredContext,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ContextBlock {
34 pub block_id: BlockId,
35 pub inclusion_reason: InclusionReason,
36 pub relevance_score: f32,
37 pub token_estimate: usize,
38 pub access_count: usize,
39 pub last_accessed: chrono::DateTime<chrono::Utc>,
40 pub compressed: bool,
41 pub original_content: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ContextRelation {
47 pub source: BlockId,
48 pub target: BlockId,
49 pub relation_type: String,
50}
51
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54pub struct ContextMetadata {
55 pub focus_area: Option<BlockId>,
56 pub task_description: Option<String>,
57 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
58 pub last_modified: Option<chrono::DateTime<chrono::Utc>>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ContextConstraints {
64 pub max_tokens: usize,
65 pub max_blocks: usize,
66 pub max_depth: usize,
67 pub min_relevance: f32,
68 pub required_roles: Vec<String>,
69 pub excluded_tags: Vec<String>,
70 pub preserve_structure: bool,
71 pub allow_compression: bool,
72}
73
74impl Default for ContextConstraints {
75 fn default() -> Self {
76 Self {
77 max_tokens: 4000,
78 max_blocks: 100,
79 max_depth: 10,
80 min_relevance: 0.0,
81 required_roles: Vec::new(),
82 excluded_tags: Vec::new(),
83 preserve_structure: true,
84 allow_compression: true,
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91pub enum ExpandDirection {
92 Up,
93 Down,
94 Both,
95 Semantic,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
100pub enum ExpansionPolicy {
101 Conservative,
103 #[default]
105 Balanced,
106 Aggressive,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
112pub enum PruningPolicy {
113 #[default]
115 RelevanceFirst,
116 RecencyFirst,
118 RedundancyFirst,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
124pub enum CompressionMethod {
125 #[default]
127 Truncate,
128 Summarize,
130 StructureOnly,
132}
133
134#[derive(Debug, Clone, Default, Serialize, Deserialize)]
136pub struct ContextUpdateResult {
137 pub blocks_added: Vec<BlockId>,
138 pub blocks_removed: Vec<BlockId>,
139 pub blocks_compressed: Vec<BlockId>,
140 pub total_tokens: usize,
141 pub total_blocks: usize,
142 pub warnings: Vec<String>,
143}
144
145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
147pub struct ContextStatistics {
148 pub total_tokens: usize,
149 pub total_blocks: usize,
150 pub blocks_by_reason: HashMap<String, usize>,
151 pub average_relevance: f32,
152 pub depth_distribution: HashMap<usize, usize>,
153 pub compressed_count: usize,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct ContextWindow {
159 pub id: String,
160 pub blocks: HashMap<BlockId, ContextBlock>,
161 pub relationships: Vec<ContextRelation>,
162 pub metadata: ContextMetadata,
163 pub constraints: ContextConstraints,
164}
165
166impl ContextWindow {
167 pub fn new(id: impl Into<String>, constraints: ContextConstraints) -> Self {
169 Self {
170 id: id.into(),
171 blocks: HashMap::new(),
172 relationships: Vec::new(),
173 metadata: ContextMetadata {
174 created_at: Some(chrono::Utc::now()),
175 ..Default::default()
176 },
177 constraints,
178 }
179 }
180
181 pub fn block_count(&self) -> usize {
183 self.blocks.len()
184 }
185
186 pub fn total_tokens(&self) -> usize {
188 self.blocks.values().map(|b| b.token_estimate).sum()
189 }
190
191 pub fn has_capacity(&self) -> bool {
193 self.blocks.len() < self.constraints.max_blocks
194 && self.total_tokens() < self.constraints.max_tokens
195 }
196
197 pub fn contains(&self, block_id: &BlockId) -> bool {
199 self.blocks.contains_key(block_id)
200 }
201
202 pub fn get(&self, block_id: &BlockId) -> Option<&ContextBlock> {
204 self.blocks.get(block_id)
205 }
206
207 pub fn block_ids(&self) -> Vec<BlockId> {
209 self.blocks.keys().copied().collect()
210 }
211}
212
213pub struct ContextManager {
217 window: ContextWindow,
218 expansion_policy: ExpansionPolicy,
219 pruning_policy: PruningPolicy,
220}
221
222impl ContextManager {
223 pub fn new(id: impl Into<String>) -> Self {
225 Self {
226 window: ContextWindow::new(id, ContextConstraints::default()),
227 expansion_policy: ExpansionPolicy::default(),
228 pruning_policy: PruningPolicy::default(),
229 }
230 }
231
232 pub fn with_constraints(id: impl Into<String>, constraints: ContextConstraints) -> Self {
234 Self {
235 window: ContextWindow::new(id, constraints),
236 expansion_policy: ExpansionPolicy::default(),
237 pruning_policy: PruningPolicy::default(),
238 }
239 }
240
241 pub fn with_expansion_policy(mut self, policy: ExpansionPolicy) -> Self {
243 self.expansion_policy = policy;
244 self
245 }
246
247 pub fn with_pruning_policy(mut self, policy: PruningPolicy) -> Self {
249 self.pruning_policy = policy;
250 self
251 }
252
253 pub fn window(&self) -> &ContextWindow {
255 &self.window
256 }
257
258 pub fn initialize_focus(
260 &mut self,
261 doc: &Document,
262 focus_id: BlockId,
263 task_description: &str,
264 ) -> ContextUpdateResult {
265 self.window.metadata.focus_area = Some(focus_id);
266 self.window.metadata.task_description = Some(task_description.to_string());
267 self.window.metadata.last_modified = Some(chrono::Utc::now());
268
269 let mut result = ContextUpdateResult::default();
270
271 if let Some(_block) = doc.get_block(&focus_id) {
273 self.add_block_internal(doc, focus_id, InclusionReason::DirectReference, 1.0);
274 result.blocks_added.push(focus_id);
275 }
276
277 let mut current = focus_id;
279 let mut depth = 0;
280 while let Some(parent) = doc.parent(¤t) {
281 if *parent == doc.root || depth >= 3 {
282 break;
283 }
284 self.add_block_internal(
285 doc,
286 *parent,
287 InclusionReason::StructuralContext,
288 0.8 - depth as f32 * 0.1,
289 );
290 result.blocks_added.push(*parent);
291 current = *parent;
292 depth += 1;
293 }
294
295 result.total_tokens = self.window.total_tokens();
296 result.total_blocks = self.window.block_count();
297 result
298 }
299
300 pub fn navigate_to(
302 &mut self,
303 doc: &Document,
304 target_id: BlockId,
305 task_description: &str,
306 ) -> ContextUpdateResult {
307 self.window.metadata.focus_area = Some(target_id);
308 self.window.metadata.task_description = Some(task_description.to_string());
309 self.window.metadata.last_modified = Some(chrono::Utc::now());
310
311 let mut result = ContextUpdateResult::default();
312
313 if doc.get_block(&target_id).is_some() {
315 self.add_block_internal(doc, target_id, InclusionReason::NavigationPath, 1.0);
316 result.blocks_added.push(target_id);
317 }
318
319 let pruned = self.prune_if_needed();
321 result.blocks_removed = pruned;
322
323 result.total_tokens = self.window.total_tokens();
324 result.total_blocks = self.window.block_count();
325 result
326 }
327
328 pub fn add_block(
330 &mut self,
331 doc: &Document,
332 block_id: BlockId,
333 reason: InclusionReason,
334 ) -> ContextUpdateResult {
335 let mut result = ContextUpdateResult::default();
336
337 if doc.get_block(&block_id).is_some() {
338 self.add_block_internal(doc, block_id, reason, 0.7);
339 result.blocks_added.push(block_id);
340 }
341
342 let pruned = self.prune_if_needed();
344 result.blocks_removed = pruned;
345
346 result.total_tokens = self.window.total_tokens();
347 result.total_blocks = self.window.block_count();
348 result
349 }
350
351 pub fn remove_block(&mut self, block_id: BlockId) -> ContextUpdateResult {
353 let mut result = ContextUpdateResult::default();
354
355 if self.window.blocks.remove(&block_id).is_some() {
356 result.blocks_removed.push(block_id);
357 }
358
359 self.window.metadata.last_modified = Some(chrono::Utc::now());
360 result.total_tokens = self.window.total_tokens();
361 result.total_blocks = self.window.block_count();
362 result
363 }
364
365 pub fn expand_context(
367 &mut self,
368 doc: &Document,
369 direction: ExpandDirection,
370 depth: usize,
371 ) -> ContextUpdateResult {
372 let mut result = ContextUpdateResult::default();
373
374 let focus_id = match self.window.metadata.focus_area {
375 Some(id) => id,
376 None => return result,
377 };
378
379 match direction {
380 ExpandDirection::Down => {
381 let added = self.expand_downward(doc, focus_id, depth);
382 result.blocks_added = added;
383 }
384 ExpandDirection::Up => {
385 let added = self.expand_upward(doc, focus_id, depth);
386 result.blocks_added = added;
387 }
388 ExpandDirection::Both => {
389 let added_down = self.expand_downward(doc, focus_id, depth);
390 let added_up = self.expand_upward(doc, focus_id, depth);
391 result.blocks_added = added_down.into_iter().chain(added_up).collect();
392 }
393 ExpandDirection::Semantic => {
394 let added = self.expand_semantic(doc, focus_id, depth);
396 result.blocks_added = added;
397 }
398 }
399
400 let pruned = self.prune_if_needed();
402 result.blocks_removed = pruned;
403
404 self.window.metadata.last_modified = Some(chrono::Utc::now());
405 result.total_tokens = self.window.total_tokens();
406 result.total_blocks = self.window.block_count();
407 result
408 }
409
410 pub fn compress(&mut self, doc: &Document, method: CompressionMethod) -> ContextUpdateResult {
412 let mut result = ContextUpdateResult::default();
413
414 if !self.window.constraints.allow_compression {
415 result
416 .warnings
417 .push("Compression not allowed by constraints".to_string());
418 return result;
419 }
420
421 let mut blocks_to_compress: Vec<(BlockId, f32)> = self
423 .window
424 .blocks
425 .iter()
426 .filter(|(_, cb)| !cb.compressed)
427 .map(|(id, cb)| (*id, cb.relevance_score))
428 .collect();
429
430 blocks_to_compress
431 .sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
432
433 for (block_id, _) in blocks_to_compress.iter().take(10) {
434 let original = doc
436 .get_block(block_id)
437 .map(|block| self.extract_content_text(&block.content));
438
439 if let Some(context_block) = self.window.blocks.get_mut(block_id) {
440 if let Some(original_text) = original {
441 context_block.original_content = Some(original_text);
442
443 match method {
444 CompressionMethod::Truncate => {
445 context_block.token_estimate /= 2;
447 }
448 CompressionMethod::StructureOnly => {
449 context_block.token_estimate = 10;
451 }
452 CompressionMethod::Summarize => {
453 context_block.token_estimate /= 3;
455 }
456 }
457
458 context_block.compressed = true;
459 result.blocks_compressed.push(*block_id);
460 }
461 }
462
463 if self.window.total_tokens() <= self.window.constraints.max_tokens {
465 break;
466 }
467 }
468
469 result.total_tokens = self.window.total_tokens();
470 result.total_blocks = self.window.block_count();
471 result
472 }
473
474 pub fn get_statistics(&self) -> ContextStatistics {
476 let mut blocks_by_reason: HashMap<String, usize> = HashMap::new();
477 let mut total_relevance = 0.0;
478 let mut compressed_count = 0;
479
480 for cb in self.window.blocks.values() {
481 let reason = format!("{:?}", cb.inclusion_reason);
482 *blocks_by_reason.entry(reason).or_insert(0) += 1;
483 total_relevance += cb.relevance_score;
484 if cb.compressed {
485 compressed_count += 1;
486 }
487 }
488
489 let average_relevance = if self.window.blocks.is_empty() {
490 0.0
491 } else {
492 total_relevance / self.window.blocks.len() as f32
493 };
494
495 ContextStatistics {
496 total_tokens: self.window.total_tokens(),
497 total_blocks: self.window.block_count(),
498 blocks_by_reason,
499 average_relevance,
500 depth_distribution: HashMap::new(), compressed_count,
502 }
503 }
504
505 pub fn render_for_prompt(&self, doc: &Document) -> String {
507 let mut output = String::new();
508
509 let mut blocks: Vec<(&BlockId, &ContextBlock)> = self.window.blocks.iter().collect();
511 blocks.sort_by(|a, b| {
512 b.1.relevance_score
513 .partial_cmp(&a.1.relevance_score)
514 .unwrap_or(std::cmp::Ordering::Equal)
515 });
516
517 for (block_id, context_block) in blocks {
518 if let Some(block) = doc.get_block(block_id) {
519 let content = if context_block.compressed {
520 if let Some(ref original) = context_block.original_content {
521 format!("[compressed] {}...", &original[..original.len().min(50)])
522 } else {
523 "[compressed]".to_string()
524 }
525 } else {
526 self.extract_content_text(&block.content)
527 };
528
529 let role = block
530 .metadata
531 .semantic_role
532 .as_ref()
533 .map(|r| r.category.as_str())
534 .unwrap_or("block");
535
536 output.push_str(&format!("[{}] {}: {}\n", block_id, role, content));
537 }
538 }
539
540 output
541 }
542
543 fn add_block_internal(
546 &mut self,
547 doc: &Document,
548 block_id: BlockId,
549 reason: InclusionReason,
550 relevance: f32,
551 ) {
552 if self.window.blocks.contains_key(&block_id) {
553 if let Some(cb) = self.window.blocks.get_mut(&block_id) {
555 cb.access_count += 1;
556 cb.last_accessed = chrono::Utc::now();
557 }
558 return;
559 }
560
561 if let Some(block) = doc.get_block(&block_id) {
562 let token_estimate = self.estimate_tokens(&block.content);
563
564 let context_block = ContextBlock {
565 block_id,
566 inclusion_reason: reason,
567 relevance_score: relevance,
568 token_estimate,
569 access_count: 1,
570 last_accessed: chrono::Utc::now(),
571 compressed: false,
572 original_content: None,
573 };
574
575 self.window.blocks.insert(block_id, context_block);
576 }
577 }
578
579 fn expand_downward(
580 &mut self,
581 doc: &Document,
582 start: BlockId,
583 max_depth: usize,
584 ) -> Vec<BlockId> {
585 let mut added = Vec::new();
586 let mut queue = VecDeque::new();
587 queue.push_back((start, 0usize));
588
589 while let Some((node_id, depth)) = queue.pop_front() {
590 if depth > max_depth || !self.window.has_capacity() {
591 break;
592 }
593
594 for child in doc.children(&node_id) {
595 if !self.window.contains(child) {
596 let relevance = 0.6 - depth as f32 * 0.1;
597 self.add_block_internal(
598 doc,
599 *child,
600 InclusionReason::StructuralContext,
601 relevance.max(0.1),
602 );
603 added.push(*child);
604 queue.push_back((*child, depth + 1));
605 }
606 }
607 }
608
609 added
610 }
611
612 fn expand_upward(&mut self, doc: &Document, start: BlockId, max_depth: usize) -> Vec<BlockId> {
613 let mut added = Vec::new();
614 let mut current = start;
615 let mut depth = 0;
616
617 while let Some(parent) = doc.parent(¤t) {
618 if *parent == doc.root || depth >= max_depth || !self.window.has_capacity() {
619 break;
620 }
621
622 if !self.window.contains(parent) {
623 let relevance = 0.7 - depth as f32 * 0.1;
624 self.add_block_internal(
625 doc,
626 *parent,
627 InclusionReason::StructuralContext,
628 relevance.max(0.1),
629 );
630 added.push(*parent);
631 }
632
633 current = *parent;
634 depth += 1;
635 }
636
637 added
638 }
639
640 fn expand_semantic(
641 &mut self,
642 doc: &Document,
643 start: BlockId,
644 max_depth: usize,
645 ) -> Vec<BlockId> {
646 let mut added = Vec::new();
647 let mut visited = HashSet::new();
648 let mut queue = VecDeque::new();
649 queue.push_back((start, 0usize));
650
651 while let Some((node_id, depth)) = queue.pop_front() {
652 if depth > max_depth || !self.window.has_capacity() {
653 break;
654 }
655
656 if visited.contains(&node_id) {
657 continue;
658 }
659 visited.insert(node_id);
660
661 if let Some(block) = doc.get_block(&node_id) {
662 for edge in &block.edges {
663 if !self.window.contains(&edge.target) && !visited.contains(&edge.target) {
664 let relevance = 0.5 - depth as f32 * 0.1;
665 self.add_block_internal(
666 doc,
667 edge.target,
668 InclusionReason::SemanticRelevance,
669 relevance.max(0.1),
670 );
671 added.push(edge.target);
672 queue.push_back((edge.target, depth + 1));
673 }
674 }
675 }
676 }
677
678 added
679 }
680
681 fn prune_if_needed(&mut self) -> Vec<BlockId> {
682 let mut removed = Vec::new();
683
684 while self.window.block_count() > self.window.constraints.max_blocks
685 || self.window.total_tokens() > self.window.constraints.max_tokens
686 {
687 let to_remove = match self.pruning_policy {
689 PruningPolicy::RelevanceFirst => self.find_lowest_relevance(),
690 PruningPolicy::RecencyFirst => self.find_least_recent(),
691 PruningPolicy::RedundancyFirst => self.find_lowest_relevance(), };
693
694 if let Some(block_id) = to_remove {
695 self.window.blocks.remove(&block_id);
696 removed.push(block_id);
697 } else {
698 break;
699 }
700 }
701
702 removed
703 }
704
705 fn find_lowest_relevance(&self) -> Option<BlockId> {
706 self.window
707 .blocks
708 .iter()
709 .filter(|(id, _)| Some(**id) != self.window.metadata.focus_area)
710 .min_by(|a, b| {
711 a.1.relevance_score
712 .partial_cmp(&b.1.relevance_score)
713 .unwrap_or(std::cmp::Ordering::Equal)
714 })
715 .map(|(id, _)| *id)
716 }
717
718 fn find_least_recent(&self) -> Option<BlockId> {
719 self.window
720 .blocks
721 .iter()
722 .filter(|(id, _)| Some(**id) != self.window.metadata.focus_area)
723 .min_by(|a, b| a.1.last_accessed.cmp(&b.1.last_accessed))
724 .map(|(id, _)| *id)
725 }
726
727 fn estimate_tokens(&self, content: &Content) -> usize {
728 let text = self.extract_content_text(content);
729 (text.len() / 4).max(1)
731 }
732
733 fn extract_content_text(&self, content: &Content) -> String {
734 match content {
735 Content::Text(t) => t.text.clone(),
736 Content::Code(c) => c.source.clone(),
737 Content::Table(t) => format!("Table: {} rows", t.rows.len()),
738 Content::Math(m) => m.expression.clone(),
739 Content::Media(m) => m.alt_text.clone().unwrap_or_else(|| "Media".to_string()),
740 Content::Json { .. } => "JSON data".to_string(),
741 Content::Binary { .. } => "Binary data".to_string(),
742 Content::Composite { children, .. } => {
743 format!("Composite: {} children", children.len())
744 }
745 }
746 }
747}
748
749#[cfg(test)]
750mod tests {
751 use super::*;
752 use ucm_core::DocumentId;
753
754 fn create_test_document() -> Document {
755 let mut doc = Document::new(DocumentId::new("test"));
756 let root = doc.root;
757
758 let h1 = Block::new(Content::text("Chapter 1"), Some("heading1"));
759 let h1_id = doc.add_block(h1, &root).unwrap();
760
761 let p1 = Block::new(
762 Content::text("Introduction paragraph with some content"),
763 Some("paragraph"),
764 );
765 doc.add_block(p1, &h1_id).unwrap();
766
767 let h2 = Block::new(Content::text("Section 1.1"), Some("heading2"));
768 let h2_id = doc.add_block(h2, &h1_id).unwrap();
769
770 let p2 = Block::new(Content::text("Section content here"), Some("paragraph"));
771 doc.add_block(p2, &h2_id).unwrap();
772
773 doc
774 }
775
776 #[test]
777 fn test_context_manager_creation() {
778 let manager = ContextManager::new("test-context");
779 assert_eq!(manager.window().block_count(), 0);
780 assert!(manager.window().has_capacity());
781 }
782
783 #[test]
784 fn test_initialize_focus() {
785 let doc = create_test_document();
786 let mut manager = ContextManager::new("test-context");
787
788 let root_children = doc.children(&doc.root);
789 let h1_id = root_children[0];
790
791 let result = manager.initialize_focus(&doc, h1_id, "Test task");
792
793 assert!(!result.blocks_added.is_empty());
794 assert!(manager.window().contains(&h1_id));
795 }
796
797 #[test]
798 fn test_expand_context() {
799 let doc = create_test_document();
800 let mut manager = ContextManager::new("test-context");
801
802 let root_children = doc.children(&doc.root);
803 let h1_id = root_children[0];
804
805 manager.initialize_focus(&doc, h1_id, "Test task");
806
807 let result = manager.expand_context(&doc, ExpandDirection::Down, 2);
808
809 assert!(result.total_blocks > 1);
810 }
811
812 #[test]
813 fn test_add_and_remove_block() {
814 let doc = create_test_document();
815 let mut manager = ContextManager::new("test-context");
816
817 let root_children = doc.children(&doc.root);
818 let h1_id = root_children[0];
819
820 let result = manager.add_block(&doc, h1_id, InclusionReason::DirectReference);
821 assert!(result.blocks_added.contains(&h1_id));
822 assert!(manager.window().contains(&h1_id));
823
824 let result = manager.remove_block(h1_id);
825 assert!(result.blocks_removed.contains(&h1_id));
826 assert!(!manager.window().contains(&h1_id));
827 }
828
829 #[test]
830 fn test_constraints() {
831 let constraints = ContextConstraints {
832 max_blocks: 5,
833 max_tokens: 100,
834 ..Default::default()
835 };
836
837 let doc = create_test_document();
838 let mut manager = ContextManager::with_constraints("test-context", constraints);
839
840 let root_children = doc.children(&doc.root);
841 let h1_id = root_children[0];
842
843 manager.initialize_focus(&doc, h1_id, "Test task");
844 manager.expand_context(&doc, ExpandDirection::Down, 10);
845
846 assert!(manager.window().block_count() <= 5);
848 }
849
850 #[test]
851 fn test_statistics() {
852 let doc = create_test_document();
853 let mut manager = ContextManager::new("test-context");
854
855 let root_children = doc.children(&doc.root);
856 let h1_id = root_children[0];
857
858 manager.initialize_focus(&doc, h1_id, "Test task");
859
860 let stats = manager.get_statistics();
861 assert!(stats.total_blocks > 0);
862 assert!(stats.total_tokens > 0);
863 }
864
865 #[test]
866 fn test_render_for_prompt() {
867 let doc = create_test_document();
868 let mut manager = ContextManager::new("test-context");
869
870 let root_children = doc.children(&doc.root);
871 let h1_id = root_children[0];
872
873 manager.initialize_focus(&doc, h1_id, "Test task");
874
875 let prompt = manager.render_for_prompt(&doc);
876 assert!(!prompt.is_empty());
877 assert!(prompt.contains("Chapter 1"));
878 }
879}