1use crate::{
7 identity::EntityIdType, AgentId, AgentWorkingSetEntry, Artifact, ArtifactId, CellstateConfig,
8 CellstateResult, EdgeId, EdgeType, EntityType, Note, ScopeId, Timestamp, Trajectory,
9 TrajectoryId, Turn,
10};
11use chrono::Utc;
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct ContextPackage {
23 pub trajectory_id: TrajectoryId,
25 pub scope_id: ScopeId,
27 pub user_input: Option<String>,
29 pub relevant_notes: Vec<Note>,
31 pub recent_artifacts: Vec<Artifact>,
33 pub working_set_entries: Vec<AgentWorkingSetEntry>,
35 pub graph_links: Vec<GraphLink>,
37 pub conversation_turns: Vec<Turn>,
39 pub scope_summaries: Vec<ScopeSummary>,
41 pub trajectory_hierarchy: Vec<Trajectory>,
43 pub session_markers: SessionMarkers,
45 pub kernel_config: Option<KernelConfig>,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
54pub struct ContextOutline {
55 pub trajectory_id: TrajectoryId,
57 pub scope_id: ScopeId,
59 pub note_count: usize,
61 pub note_titles: Vec<String>,
63 pub artifact_count: usize,
65 pub artifact_names: Vec<String>,
67 pub working_set_count: usize,
69 pub graph_link_count: usize,
71 pub turn_count: usize,
73 pub summary_count: usize,
75 pub hierarchy_depth: usize,
77 pub has_user_input: bool,
79 pub has_kernel_config: bool,
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct ScopeSummary {
86 pub scope_id: ScopeId,
88 pub summary: String,
90 pub token_count: i32,
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
96pub struct SessionMarkers {
97 pub active_trajectory_id: Option<TrajectoryId>,
99 pub active_scope_id: Option<ScopeId>,
101 pub recent_artifact_ids: Vec<ArtifactId>,
103 pub agent_id: Option<AgentId>,
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
109pub struct KernelConfig {
110 pub persona: Option<String>,
112 pub tone: Option<String>,
114 pub reasoning_style: Option<String>,
116 pub domain_focus: Option<String>,
118}
119
120impl ContextPackage {
121 pub fn new(trajectory_id: TrajectoryId, scope_id: ScopeId) -> Self {
123 Self {
124 trajectory_id,
125 scope_id,
126 user_input: None,
127 relevant_notes: Vec::new(),
128 recent_artifacts: Vec::new(),
129 working_set_entries: Vec::new(),
130 graph_links: Vec::new(),
131 conversation_turns: Vec::new(),
132 scope_summaries: Vec::new(),
133 trajectory_hierarchy: Vec::new(),
134 session_markers: SessionMarkers::default(),
135 kernel_config: None,
136 }
137 }
138
139 pub fn with_user_input(mut self, input: String) -> Self {
141 self.user_input = Some(input);
142 self
143 }
144
145 pub fn with_notes(mut self, notes: Vec<Note>) -> Self {
147 self.relevant_notes = notes;
148 self
149 }
150
151 pub fn with_artifacts(mut self, artifacts: Vec<Artifact>) -> Self {
153 self.recent_artifacts = artifacts;
154 self
155 }
156
157 pub fn with_working_set(mut self, entries: Vec<AgentWorkingSetEntry>) -> Self {
159 self.working_set_entries = entries;
160 self
161 }
162
163 pub fn with_graph_links(mut self, links: Vec<GraphLink>) -> Self {
165 self.graph_links = links;
166 self
167 }
168
169 pub fn with_turns(mut self, turns: Vec<Turn>) -> Self {
171 self.conversation_turns = turns;
172 self
173 }
174
175 pub fn with_hierarchy(mut self, hierarchy: Vec<Trajectory>) -> Self {
177 self.trajectory_hierarchy = hierarchy;
178 self
179 }
180
181 pub fn with_session_markers(mut self, markers: SessionMarkers) -> Self {
183 self.session_markers = markers;
184 self
185 }
186
187 pub fn with_kernel_config(mut self, config: KernelConfig) -> Self {
189 self.kernel_config = Some(config);
190 self
191 }
192
193 pub fn outline(&self) -> ContextOutline {
195 ContextOutline {
196 trajectory_id: self.trajectory_id,
197 scope_id: self.scope_id,
198 note_count: self.relevant_notes.len(),
199 note_titles: self
200 .relevant_notes
201 .iter()
202 .map(|note| note.title.clone())
203 .collect(),
204 artifact_count: self.recent_artifacts.len(),
205 artifact_names: self
206 .recent_artifacts
207 .iter()
208 .map(|artifact| artifact.name.clone())
209 .collect(),
210 working_set_count: self.working_set_entries.len(),
211 graph_link_count: self.graph_links.len(),
212 turn_count: self.conversation_turns.len(),
213 summary_count: self.scope_summaries.len(),
214 hierarchy_depth: self.trajectory_hierarchy.len(),
215 has_user_input: self.user_input.is_some(),
216 has_kernel_config: self.kernel_config.is_some(),
217 }
218 }
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
224pub struct GraphLink {
225 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
226 pub edge_id: EdgeId,
227 pub src_type: EntityType,
228 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
229 pub src_id: Uuid,
230 pub dst_type: EntityType,
231 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
232 pub dst_id: Uuid,
233 pub rel_type: EdgeType,
234 pub depth: i32,
235 pub weight: Option<f32>,
236 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = "uuid"))]
237 pub trajectory_id: Option<TrajectoryId>,
238 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
239 pub props: Option<serde_json::Value>,
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
248#[serde(rename_all = "snake_case")]
249#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
250pub enum SectionType {
251 SystemPrompt,
253 Instructions,
255 Evidence,
257 Memory,
259 ToolResult,
261 ConversationHistory,
263 System,
265 Persona,
267 Notes,
269 History,
271 Artifacts,
273 User,
275}
276
277impl SectionType {
278 pub fn as_str(self) -> &'static str {
280 match self {
281 Self::SystemPrompt => "system_prompt",
282 Self::Instructions => "instructions",
283 Self::Evidence => "evidence",
284 Self::Memory => "memory",
285 Self::ToolResult => "tool_result",
286 Self::ConversationHistory => "conversation_history",
287 Self::System => "system",
288 Self::Persona => "persona",
289 Self::Notes => "notes",
290 Self::History => "history",
291 Self::Artifacts => "artifacts",
292 Self::User => "user",
293 }
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
300pub struct SourceRef {
301 pub source_type: EntityType,
303 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = "uuid"))]
305 pub id: Option<Uuid>,
306 pub relevance_score: Option<f32>,
308}
309
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
313pub struct ContextSection {
314 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
316 pub section_id: Uuid,
317 pub section_type: SectionType,
319 pub content: String,
321 pub token_count: i32,
323 pub priority: i32,
325 pub compressible: bool,
327 pub sources: Vec<SourceRef>,
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
333#[serde(rename_all = "snake_case")]
334#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
335pub enum AssemblyAction {
336 Include,
338 Exclude,
340 Compress,
342 Truncate,
344}
345
346#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
348#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
349pub struct AssemblyDecision {
350 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
352 pub timestamp: Timestamp,
353 pub action: AssemblyAction,
355 pub target_type: String,
357 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = "uuid"))]
359 pub target_id: Option<Uuid>,
360 pub reason: String,
362 pub tokens_affected: i32,
364}
365
366#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
368#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
369pub struct ContextWindow {
370 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
372 pub window_id: Uuid,
373 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
375 pub assembled_at: Timestamp,
376 pub max_tokens: i32,
378 pub used_tokens: i32,
380 pub sections: Vec<ContextSection>,
382 pub truncated: bool,
384 pub included_sections: Vec<String>,
386 pub assembly_trace: Vec<AssemblyDecision>,
388 pub budget: Option<TokenBudget>,
390 pub usage: SegmentUsage,
392}
393
394#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
401pub struct ContextPageDiff {
402 pub evicted: Vec<Uuid>,
404 pub promoted: Vec<Uuid>,
406 pub retained: Vec<Uuid>,
408}
409
410impl ContextPageDiff {
411 pub fn has_changes(&self) -> bool {
413 !self.evicted.is_empty() || !self.promoted.is_empty()
414 }
415}
416
417impl ContextSection {
418 pub fn new(section_type: SectionType, content: String, priority: i32) -> Self {
420 let token_count = estimate_tokens(&content);
421 Self {
422 section_id: Uuid::now_v7(),
423 section_type,
424 content,
425 token_count,
426 priority,
427 compressible: true,
428 sources: Vec::new(),
429 }
430 }
431
432 pub fn with_compressible(mut self, compressible: bool) -> Self {
434 self.compressible = compressible;
435 self
436 }
437
438 pub fn with_sources(mut self, sources: Vec<SourceRef>) -> Self {
440 self.sources = sources;
441 self
442 }
443}
444
445impl ContextWindow {
446 pub fn new(max_tokens: i32) -> Self {
448 Self {
449 window_id: Uuid::now_v7(),
450 assembled_at: Utc::now(),
451 max_tokens,
452 used_tokens: 0,
453 sections: Vec::new(),
454 truncated: false,
455 included_sections: Vec::new(),
456 assembly_trace: Vec::new(),
457 budget: None,
458 usage: SegmentUsage::default(),
459 }
460 }
461
462 pub fn with_budget(budget: TokenBudget) -> Self {
464 let max_tokens = budget.total();
465 Self {
466 window_id: Uuid::now_v7(),
467 assembled_at: Utc::now(),
468 max_tokens,
469 used_tokens: 0,
470 sections: Vec::new(),
471 truncated: false,
472 included_sections: Vec::new(),
473 assembly_trace: Vec::new(),
474 budget: Some(budget),
475 usage: SegmentUsage::default(),
476 }
477 }
478
479 pub fn add_to_segment(
483 &mut self,
484 segment: ContextSegment,
485 content: String,
486 priority: i32,
487 ) -> Result<(), SegmentBudgetError> {
488 let section_type = match segment {
489 ContextSegment::System => SectionType::SystemPrompt,
490 ContextSegment::Instructions => SectionType::Instructions,
491 ContextSegment::Evidence => SectionType::Evidence,
492 ContextSegment::Memory => SectionType::Memory,
493 ContextSegment::ToolResults => SectionType::ToolResult,
494 ContextSegment::History => SectionType::ConversationHistory,
495 };
496
497 let section = ContextSection::new(section_type, content, priority);
498 self.add_section_to_segment(segment, section)
499 }
500
501 pub fn add_section_to_segment(
503 &mut self,
504 segment: ContextSegment,
505 section: ContextSection,
506 ) -> Result<(), SegmentBudgetError> {
507 let tokens = section.token_count;
508
509 if let Some(ref budget) = self.budget {
511 if !self.usage.can_add(segment, tokens, budget) {
512 return Err(SegmentBudgetError::SegmentExceeded {
513 segment,
514 available: self.segment_remaining(segment),
515 requested: tokens,
516 });
517 }
518 }
519
520 if self.used_tokens + tokens > self.max_tokens {
522 return Err(SegmentBudgetError::TotalExceeded {
523 available: self.max_tokens - self.used_tokens,
524 requested: tokens,
525 });
526 }
527
528 self.used_tokens += tokens;
529 if let Some(ref budget) = self.budget {
530 self.usage.add(segment, tokens, budget);
531 }
532 self.included_sections
533 .push(section.section_type.as_str().to_string());
534 self.assembly_trace.push(AssemblyDecision {
535 timestamp: Utc::now(),
536 action: AssemblyAction::Include,
537 target_type: section.section_type.as_str().to_string(),
538 target_id: Some(section.section_id),
539 reason: "Fits within segment and total budget".to_string(),
540 tokens_affected: section.token_count,
541 });
542 self.sections.push(section);
543
544 Ok(())
545 }
546
547 pub fn segment_remaining(&self, segment: ContextSegment) -> i32 {
549 match &self.budget {
550 Some(budget) => budget.for_segment(segment) - self.usage.for_segment(segment),
551 None => self.max_tokens - self.used_tokens,
552 }
553 }
554
555 pub fn remaining_tokens(&self) -> i32 {
557 self.max_tokens - self.used_tokens
558 }
559
560 pub fn has_room(&self) -> bool {
562 self.used_tokens < self.max_tokens
563 }
564
565 pub fn add_section(&mut self, section: ContextSection) -> bool {
568 if section.token_count <= self.remaining_tokens() {
569 self.used_tokens += section.token_count;
570 self.included_sections
571 .push(section.section_type.as_str().to_string());
572 self.assembly_trace.push(AssemblyDecision {
573 timestamp: Utc::now(),
574 action: AssemblyAction::Include,
575 target_type: section.section_type.as_str().to_string(),
576 target_id: Some(section.section_id),
577 reason: "Fits within budget".to_string(),
578 tokens_affected: section.token_count,
579 });
580 self.sections.push(section);
581 true
582 } else {
583 self.assembly_trace.push(AssemblyDecision {
584 timestamp: Utc::now(),
585 action: AssemblyAction::Exclude,
586 target_type: section.section_type.as_str().to_string(),
587 target_id: Some(section.section_id),
588 reason: format!(
589 "Exceeds budget: needs {} tokens, only {} available",
590 section.token_count,
591 self.remaining_tokens()
592 ),
593 tokens_affected: 0,
594 });
595 false
596 }
597 }
598
599 pub fn add_truncated_section(&mut self, mut section: ContextSection) {
601 let available = self.remaining_tokens();
602 if available <= 0 {
603 self.assembly_trace.push(AssemblyDecision {
604 timestamp: Utc::now(),
605 action: AssemblyAction::Exclude,
606 target_type: section.section_type.as_str().to_string(),
607 target_id: Some(section.section_id),
608 reason: "No budget remaining".to_string(),
609 tokens_affected: 0,
610 });
611 return;
612 }
613
614 let original_tokens = section.token_count;
615 section.content = truncate_to_token_budget(§ion.content, available);
616 section.token_count = estimate_tokens(§ion.content);
617
618 self.used_tokens += section.token_count;
619 self.truncated = true;
620 self.included_sections
621 .push(section.section_type.as_str().to_string());
622 self.assembly_trace.push(AssemblyDecision {
623 timestamp: Utc::now(),
624 action: AssemblyAction::Truncate,
625 target_type: section.section_type.as_str().to_string(),
626 target_id: Some(section.section_id),
627 reason: format!(
628 "Truncated from {} to {} tokens",
629 original_tokens, section.token_count
630 ),
631 tokens_affected: section.token_count,
632 });
633 self.sections.push(section);
634 }
635
636 pub fn as_text(&self) -> String {
638 self.sections
639 .iter()
640 .map(|s| s.content.as_str())
641 .collect::<Vec<_>>()
642 .join("\n\n")
643 }
644}
645
646impl std::fmt::Display for ContextWindow {
647 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
648 write!(f, "{}", self.as_text())
649 }
650}
651
652fn estimate_tokens(text: &str) -> i32 {
658 crate::estimate_tokens(text)
659}
660
661pub fn truncate_to_token_budget(text: &str, budget: i32) -> String {
675 if budget <= 0 {
676 return String::new();
677 }
678
679 if estimate_tokens(text) <= budget {
680 return text.to_string();
681 }
682
683 let mut boundaries = Vec::with_capacity(text.len().min(1024) + 2);
685 boundaries.push(0usize);
686 boundaries.extend(text.char_indices().map(|(idx, _)| idx).skip(1));
687 boundaries.push(text.len());
688
689 let mut lo = 0usize;
690 let mut hi = boundaries.len() - 1;
691 while lo < hi {
692 let mid = (lo + hi).div_ceil(2);
693 let candidate = &text[..boundaries[mid]];
694 if estimate_tokens(candidate) <= budget {
695 lo = mid;
696 } else {
697 hi = mid - 1;
698 }
699 }
700
701 let truncated = &text[..boundaries[lo]];
702
703 if truncated.is_empty() {
704 return String::new();
705 }
706 let truncated_len = truncated.len();
707
708 let last_period = truncated.rfind('.');
710 let last_question = truncated.rfind('?');
711 let last_exclaim = truncated.rfind('!');
712
713 let last_sentence = [last_period, last_question, last_exclaim]
715 .into_iter()
716 .flatten()
717 .max();
718
719 if let Some(pos) = last_sentence {
721 if pos > truncated_len / 2 {
722 return truncated[..=pos].to_string();
723 }
724 }
725
726 if let Some(pos) = truncated.rfind(' ') {
728 if pos > truncated_len * 4 / 5 {
730 return truncated[..pos].to_string();
731 }
732 }
733
734 truncated.to_string()
736}
737
738#[derive(Debug, Clone)]
745pub struct ContextAssembler {
746 config: CellstateConfig,
748 segment_budget: Option<TokenBudget>,
750}
751
752impl ContextAssembler {
753 pub fn new(config: CellstateConfig) -> CellstateResult<Self> {
755 config.validate()?;
756 Ok(Self {
757 config,
758 segment_budget: None,
759 })
760 }
761
762 pub fn with_segment_budget(
764 config: CellstateConfig,
765 budget: TokenBudget,
766 ) -> CellstateResult<Self> {
767 config.validate()?;
768 Ok(Self {
769 config,
770 segment_budget: Some(budget),
771 })
772 }
773
774 fn section_to_segment(section_type: SectionType) -> ContextSegment {
776 match section_type {
777 SectionType::SystemPrompt | SectionType::System => ContextSegment::System,
778 SectionType::Instructions | SectionType::Persona => ContextSegment::Instructions,
779 SectionType::Evidence | SectionType::Artifacts => ContextSegment::Evidence,
780 SectionType::Memory | SectionType::Notes => ContextSegment::Memory,
781 SectionType::ToolResult => ContextSegment::ToolResults,
782 SectionType::ConversationHistory | SectionType::History => ContextSegment::History,
783 SectionType::User => ContextSegment::System, }
785 }
786
787 pub fn assemble(&self, pkg: ContextPackage) -> CellstateResult<ContextWindow> {
799 let mut window = match &self.segment_budget {
801 Some(budget) => ContextWindow::with_budget(budget.clone()),
802 None => ContextWindow::new(self.config.token_budget),
803 };
804
805 let mut sections = self.build_sections(&pkg);
807
808 sections.sort_by_key(|section| std::cmp::Reverse(section.priority));
810
811 for section in sections {
813 let segment = Self::section_to_segment(section.section_type);
814
815 if window.remaining_tokens() <= 0 {
817 window.assembly_trace.push(AssemblyDecision {
818 timestamp: Utc::now(),
819 action: AssemblyAction::Exclude,
820 target_type: section.section_type.as_str().to_string(),
821 target_id: Some(section.section_id),
822 reason: "Total budget exhausted".to_string(),
823 tokens_affected: 0,
824 });
825 continue;
826 }
827
828 if self.segment_budget.is_some() {
830 let segment_remaining = window.segment_remaining(segment);
831 if section.token_count > segment_remaining {
832 window.assembly_trace.push(AssemblyDecision {
833 timestamp: Utc::now(),
834 action: AssemblyAction::Exclude,
835 target_type: section.section_type.as_str().to_string(),
836 target_id: Some(section.section_id),
837 reason: format!(
838 "Segment {:?} budget exhausted ({} remaining, {} requested)",
839 segment, segment_remaining, section.token_count
840 ),
841 tokens_affected: 0,
842 });
843 continue;
844 }
845 }
846
847 if section.token_count <= window.remaining_tokens() {
848 if self.segment_budget.is_some() {
850 if window.add_section_to_segment(segment, section).is_err() {
851 continue;
853 }
854 } else {
855 window.add_section(section);
856 }
857 } else if section.compressible {
858 window.add_truncated_section(section);
860 } else {
861 window.assembly_trace.push(AssemblyDecision {
863 timestamp: Utc::now(),
864 action: AssemblyAction::Exclude,
865 target_type: section.section_type.as_str().to_string(),
866 target_id: Some(section.section_id),
867 reason: format!(
868 "Exceeds budget ({} tokens) and not compressible",
869 section.token_count
870 ),
871 tokens_affected: 0,
872 });
873 }
874 }
875
876 Ok(window)
877 }
878
879 pub fn reassemble(
891 &self,
892 current: &ContextWindow,
893 updated_pkg: ContextPackage,
894 ) -> CellstateResult<(ContextWindow, ContextPageDiff)> {
895 let new_window = self.assemble(updated_pkg)?;
896 let diff = Self::diff_windows(current, &new_window);
897 Ok((new_window, diff))
898 }
899
900 fn diff_windows(old: &ContextWindow, new: &ContextWindow) -> ContextPageDiff {
902 let old_ids: std::collections::HashSet<Uuid> =
903 old.sections.iter().map(|s| s.section_id).collect();
904 let new_ids: std::collections::HashSet<Uuid> =
905 new.sections.iter().map(|s| s.section_id).collect();
906
907 let evicted: Vec<Uuid> = old_ids.difference(&new_ids).copied().collect();
908 let promoted: Vec<Uuid> = new_ids.difference(&old_ids).copied().collect();
909 let retained: Vec<Uuid> = old_ids.intersection(&new_ids).copied().collect();
910
911 ContextPageDiff {
912 evicted,
913 promoted,
914 retained,
915 }
916 }
917
918 fn build_sections(&self, pkg: &ContextPackage) -> Vec<ContextSection> {
920 let mut sections = Vec::new();
921
922 if let Some(ref kernel) = pkg.kernel_config {
924 let content = self.format_kernel_config(kernel);
925 if !content.is_empty() {
926 let mut section = ContextSection::new(
927 SectionType::Persona,
928 content,
929 self.config.section_priorities.persona,
930 );
931 section.compressible = false; sections.push(section);
933 }
934 }
935
936 if let Some(ref input) = pkg.user_input {
938 let mut section = ContextSection::new(
939 SectionType::User,
940 input.clone(),
941 self.config.section_priorities.user,
942 );
943 section.compressible = false; sections.push(section);
945 }
946
947 if !pkg.relevant_notes.is_empty() {
949 let content = self.format_notes(&pkg.relevant_notes);
950 let sources: Vec<SourceRef> = pkg
951 .relevant_notes
952 .iter()
953 .map(|n| SourceRef {
954 source_type: EntityType::Note,
955 id: Some(n.note_id.as_uuid()),
956 relevance_score: None,
957 })
958 .collect();
959 let section = ContextSection::new(
960 SectionType::Notes,
961 content,
962 self.config.section_priorities.notes,
963 )
964 .with_sources(sources);
965 sections.push(section);
966 }
967
968 if !pkg.working_set_entries.is_empty() {
970 let content = self.format_working_set(&pkg.working_set_entries);
971 let section = ContextSection::new(
972 SectionType::Memory,
973 content,
974 self.config.section_priorities.notes,
975 );
976 sections.push(section);
977 }
978
979 if !pkg.recent_artifacts.is_empty() {
981 let content = self.format_artifacts(&pkg.recent_artifacts);
982 let sources: Vec<SourceRef> = pkg
983 .recent_artifacts
984 .iter()
985 .map(|a| SourceRef {
986 source_type: EntityType::Artifact,
987 id: Some(a.artifact_id.as_uuid()),
988 relevance_score: None,
989 })
990 .collect();
991 let section = ContextSection::new(
992 SectionType::Artifacts,
993 content,
994 self.config.section_priorities.artifacts,
995 )
996 .with_sources(sources);
997 sections.push(section);
998 }
999
1000 if !pkg.graph_links.is_empty() {
1002 let content = self.format_graph_links(&pkg.graph_links);
1003 let sources: Vec<SourceRef> = pkg
1004 .graph_links
1005 .iter()
1006 .map(|link| SourceRef {
1007 source_type: EntityType::Edge,
1008 id: Some(link.edge_id.as_uuid()),
1009 relevance_score: None,
1010 })
1011 .collect();
1012 let section = ContextSection::new(
1013 SectionType::Evidence,
1014 content,
1015 self.config.section_priorities.artifacts,
1016 )
1017 .with_sources(sources);
1018 sections.push(section);
1019 }
1020
1021 if !pkg.scope_summaries.is_empty() {
1023 let content = self.format_scope_summaries(&pkg.scope_summaries);
1024 let sources: Vec<SourceRef> = pkg
1025 .scope_summaries
1026 .iter()
1027 .map(|s| SourceRef {
1028 source_type: EntityType::Scope,
1029 id: Some(s.scope_id.as_uuid()),
1030 relevance_score: None,
1031 })
1032 .collect();
1033 let section = ContextSection::new(
1034 SectionType::History,
1035 content,
1036 self.config.section_priorities.history,
1037 )
1038 .with_sources(sources);
1039 sections.push(section);
1040 }
1041
1042 if !pkg.conversation_turns.is_empty() {
1044 let content = self.format_turns(&pkg.conversation_turns);
1045 let sources: Vec<SourceRef> = pkg
1046 .conversation_turns
1047 .iter()
1048 .map(|t| SourceRef {
1049 source_type: EntityType::Turn,
1050 id: Some(t.turn_id.as_uuid()),
1051 relevance_score: None,
1052 })
1053 .collect();
1054 let section = ContextSection::new(
1055 SectionType::ConversationHistory,
1056 content,
1057 self.config.section_priorities.history,
1058 )
1059 .with_sources(sources);
1060 sections.push(section);
1061 }
1062
1063 if !pkg.trajectory_hierarchy.is_empty() {
1065 let content = self.format_hierarchy(&pkg.trajectory_hierarchy);
1066 let sources: Vec<SourceRef> = pkg
1067 .trajectory_hierarchy
1068 .iter()
1069 .map(|t| SourceRef {
1070 source_type: EntityType::Trajectory,
1071 id: Some(t.trajectory_id.as_uuid()),
1072 relevance_score: None,
1073 })
1074 .collect();
1075 let section = ContextSection::new(
1076 SectionType::Evidence,
1077 content,
1078 self.config.section_priorities.history,
1079 )
1080 .with_sources(sources);
1081 sections.push(section);
1082 }
1083
1084 sections
1085 }
1086
1087 fn format_kernel_config(&self, kernel: &KernelConfig) -> String {
1089 let mut parts = Vec::new();
1090
1091 if let Some(ref persona) = kernel.persona {
1092 parts.push(format!("Persona: {}", persona));
1093 }
1094 if let Some(ref tone) = kernel.tone {
1095 parts.push(format!("Tone: {}", tone));
1096 }
1097 if let Some(ref style) = kernel.reasoning_style {
1098 parts.push(format!("Reasoning Style: {}", style));
1099 }
1100 if let Some(ref focus) = kernel.domain_focus {
1101 parts.push(format!("Domain Focus: {}", focus));
1102 }
1103
1104 parts.join("\n")
1105 }
1106
1107 fn format_notes(&self, notes: &[Note]) -> String {
1109 notes
1110 .iter()
1111 .map(|n| format!("[{}] {}: {}", n.note_type as u8, n.title, n.content))
1112 .collect::<Vec<_>>()
1113 .join("\n\n")
1114 }
1115
1116 fn format_artifacts(&self, artifacts: &[Artifact]) -> String {
1118 artifacts
1119 .iter()
1120 .map(|a| format!("[{:?}] {}: {}", a.artifact_type, a.name, a.content))
1121 .collect::<Vec<_>>()
1122 .join("\n\n")
1123 }
1124
1125 fn format_working_set(&self, entries: &[AgentWorkingSetEntry]) -> String {
1127 entries
1128 .iter()
1129 .map(|entry| {
1130 let value = match &entry.value {
1131 serde_json::Value::String(s) => s.clone(),
1132 _ => entry.value.to_string(),
1133 };
1134 format!("[ws] {} = {}", entry.key, value)
1135 })
1136 .collect::<Vec<_>>()
1137 .join("\n")
1138 }
1139
1140 fn format_graph_links(&self, links: &[GraphLink]) -> String {
1142 links
1143 .iter()
1144 .map(|link| {
1145 let weight = link
1146 .weight
1147 .map(|w| format!(" (w={:.2})", w))
1148 .unwrap_or_default();
1149 format!(
1150 "[{}] {}:{} -{}-> {}:{}{}",
1151 link.depth,
1152 link.src_type,
1153 link.src_id,
1154 link.rel_type,
1155 link.dst_type,
1156 link.dst_id,
1157 weight
1158 )
1159 })
1160 .collect::<Vec<_>>()
1161 .join("\n")
1162 }
1163
1164 fn format_turns(&self, turns: &[Turn]) -> String {
1166 let mut sorted = turns.to_vec();
1167 sorted.sort_by_key(|t| t.sequence);
1168 sorted
1169 .iter()
1170 .map(|t| format!("[{}] {}: {}", t.sequence, t.role, t.content))
1171 .collect::<Vec<_>>()
1172 .join("\n")
1173 }
1174
1175 fn format_hierarchy(&self, hierarchy: &[Trajectory]) -> String {
1177 hierarchy
1178 .iter()
1179 .map(|t| {
1180 let desc = t
1181 .description
1182 .as_deref()
1183 .map(|d| format!(" - {}", d))
1184 .unwrap_or_default();
1185 format!("{}{} ({})", t.name, desc, t.trajectory_id)
1186 })
1187 .collect::<Vec<_>>()
1188 .join("\n")
1189 }
1190
1191 fn format_scope_summaries(&self, summaries: &[ScopeSummary]) -> String {
1193 summaries
1194 .iter()
1195 .map(|s| s.summary.clone())
1196 .collect::<Vec<_>>()
1197 .join("\n\n---\n\n")
1198 }
1199
1200 pub fn token_budget(&self) -> i32 {
1202 self.config.token_budget
1203 }
1204}
1205
1206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1215#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1216pub struct TokenBudget {
1217 pub system: i32,
1219 pub instructions: i32,
1221 pub evidence: i32,
1223 pub memory: i32,
1225 pub tool_results: i32,
1227 pub history: i32,
1229 pub slack: i32,
1231}
1232
1233impl TokenBudget {
1234 pub fn total(&self) -> i32 {
1236 self.system
1237 + self.instructions
1238 + self.evidence
1239 + self.memory
1240 + self.tool_results
1241 + self.history
1242 + self.slack
1243 }
1244
1245 pub fn from_total(total: i32) -> Self {
1256 Self {
1257 system: (total as f32 * 0.10) as i32,
1258 instructions: (total as f32 * 0.15) as i32,
1259 evidence: (total as f32 * 0.15) as i32,
1260 memory: (total as f32 * 0.20) as i32,
1261 tool_results: (total as f32 * 0.15) as i32,
1262 history: (total as f32 * 0.20) as i32,
1263 slack: (total as f32 * 0.05) as i32,
1264 }
1265 }
1266
1267 pub fn builder(total: i32) -> TokenBudgetBuilder {
1280 TokenBudgetBuilder::new(total)
1281 }
1282
1283 pub fn for_segment(&self, segment: ContextSegment) -> i32 {
1285 match segment {
1286 ContextSegment::System => self.system,
1287 ContextSegment::Instructions => self.instructions,
1288 ContextSegment::Evidence => self.evidence,
1289 ContextSegment::Memory => self.memory,
1290 ContextSegment::ToolResults => self.tool_results,
1291 ContextSegment::History => self.history,
1292 }
1293 }
1294}
1295
1296impl Default for TokenBudget {
1297 fn default() -> Self {
1298 Self::from_total(8000)
1299 }
1300}
1301
1302#[derive(Debug, Clone)]
1311pub struct TokenBudgetBuilder {
1312 total: i32,
1313 system: f32,
1314 instructions: f32,
1315 evidence: f32,
1316 memory: f32,
1317 tool_results: f32,
1318 history: f32,
1319 slack: f32,
1320}
1321
1322impl TokenBudgetBuilder {
1323 fn new(total: i32) -> Self {
1325 Self {
1326 total,
1327 system: 0.10,
1328 instructions: 0.15,
1329 evidence: 0.15,
1330 memory: 0.20,
1331 tool_results: 0.15,
1332 history: 0.20,
1333 slack: 0.05,
1334 }
1335 }
1336
1337 pub fn system(mut self, ratio: f32) -> Self {
1339 self.system = ratio;
1340 self
1341 }
1342
1343 pub fn instructions(mut self, ratio: f32) -> Self {
1345 self.instructions = ratio;
1346 self
1347 }
1348
1349 pub fn evidence(mut self, ratio: f32) -> Self {
1351 self.evidence = ratio;
1352 self
1353 }
1354
1355 pub fn memory(mut self, ratio: f32) -> Self {
1357 self.memory = ratio;
1358 self
1359 }
1360
1361 pub fn tool_results(mut self, ratio: f32) -> Self {
1363 self.tool_results = ratio;
1364 self
1365 }
1366
1367 pub fn history(mut self, ratio: f32) -> Self {
1369 self.history = ratio;
1370 self
1371 }
1372
1373 pub fn slack(mut self, ratio: f32) -> Self {
1375 self.slack = ratio;
1376 self
1377 }
1378
1379 pub fn build(self) -> TokenBudget {
1384 let ratio_sum = self.system
1385 + self.instructions
1386 + self.evidence
1387 + self.memory
1388 + self.tool_results
1389 + self.history
1390 + self.slack;
1391 debug_assert!(
1392 (ratio_sum - 1.0).abs() < 0.05,
1393 "TokenBudget segment ratios should sum to ~1.0, got {ratio_sum}"
1394 );
1395 TokenBudget {
1396 system: (self.total as f32 * self.system) as i32,
1397 instructions: (self.total as f32 * self.instructions) as i32,
1398 evidence: (self.total as f32 * self.evidence) as i32,
1399 memory: (self.total as f32 * self.memory) as i32,
1400 tool_results: (self.total as f32 * self.tool_results) as i32,
1401 history: (self.total as f32 * self.history) as i32,
1402 slack: (self.total as f32 * self.slack) as i32,
1403 }
1404 }
1405}
1406
1407#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1409#[serde(rename_all = "snake_case")]
1410#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1411pub enum ContextSegment {
1412 System,
1414 Instructions,
1416 Evidence,
1418 Memory,
1420 ToolResults,
1422 History,
1424}
1425
1426impl std::fmt::Display for ContextSegment {
1427 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1428 match self {
1429 ContextSegment::System => write!(f, "system"),
1430 ContextSegment::Instructions => write!(f, "instructions"),
1431 ContextSegment::Evidence => write!(f, "evidence"),
1432 ContextSegment::Memory => write!(f, "memory"),
1433 ContextSegment::ToolResults => write!(f, "tool_results"),
1434 ContextSegment::History => write!(f, "history"),
1435 }
1436 }
1437}
1438
1439#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1443#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1444pub struct SegmentUsage {
1445 pub system_used: i32,
1447 pub instructions_used: i32,
1449 pub evidence_used: i32,
1451 pub memory_used: i32,
1453 pub tool_results_used: i32,
1455 pub history_used: i32,
1457}
1458
1459impl SegmentUsage {
1460 pub fn total(&self) -> i32 {
1462 self.system_used
1463 + self.instructions_used
1464 + self.evidence_used
1465 + self.memory_used
1466 + self.tool_results_used
1467 + self.history_used
1468 }
1469
1470 pub fn for_segment(&self, segment: ContextSegment) -> i32 {
1472 match segment {
1473 ContextSegment::System => self.system_used,
1474 ContextSegment::Instructions => self.instructions_used,
1475 ContextSegment::Evidence => self.evidence_used,
1476 ContextSegment::Memory => self.memory_used,
1477 ContextSegment::ToolResults => self.tool_results_used,
1478 ContextSegment::History => self.history_used,
1479 }
1480 }
1481
1482 pub fn can_add(&self, segment: ContextSegment, tokens: i32, budget: &TokenBudget) -> bool {
1484 self.for_segment(segment) + tokens <= budget.for_segment(segment)
1485 }
1486
1487 pub fn add(&mut self, segment: ContextSegment, tokens: i32, budget: &TokenBudget) -> bool {
1491 if !self.can_add(segment, tokens, budget) {
1492 return false;
1493 }
1494 match segment {
1495 ContextSegment::System => self.system_used += tokens,
1496 ContextSegment::Instructions => self.instructions_used += tokens,
1497 ContextSegment::Evidence => self.evidence_used += tokens,
1498 ContextSegment::Memory => self.memory_used += tokens,
1499 ContextSegment::ToolResults => self.tool_results_used += tokens,
1500 ContextSegment::History => self.history_used += tokens,
1501 }
1502 true
1503 }
1504
1505 pub fn remaining(&self, segment: ContextSegment, budget: &TokenBudget) -> i32 {
1507 budget.for_segment(segment) - self.for_segment(segment)
1508 }
1509}
1510
1511#[derive(Debug, Clone, PartialEq, thiserror::Error)]
1513pub enum SegmentBudgetError {
1514 #[error("Segment {segment} budget exceeded: requested {requested} tokens, only {available} available")]
1516 SegmentExceeded {
1517 segment: ContextSegment,
1519 available: i32,
1521 requested: i32,
1523 },
1524 #[error("Total budget exceeded: requested {requested} tokens, only {available} available")]
1526 TotalExceeded {
1527 available: i32,
1529 requested: i32,
1531 },
1532}
1533
1534#[cfg(test)]
1539mod tests {
1540 use super::*;
1541 use crate::{
1542 ContentHash, ContextPersistence, NoteId, NoteType, RetryConfig, SectionPriorities,
1543 ValidationMode, TTL,
1544 };
1545 use std::time::Duration;
1546
1547 fn make_test_config(token_budget: i32) -> CellstateConfig {
1548 CellstateConfig {
1549 token_budget,
1550 section_priorities: SectionPriorities {
1551 user: 100,
1552 system: 90,
1553 persona: 85,
1554 artifacts: 80,
1555 notes: 70,
1556 history: 60,
1557 custom: vec![],
1558 },
1559 checkpoint_retention: 10,
1560 stale_threshold: Duration::from_secs(3600),
1561 contradiction_threshold: 0.8,
1562 context_window_persistence: ContextPersistence::Ephemeral,
1563 validation_mode: ValidationMode::OnMutation,
1564 embedding_provider: None,
1565 summarization_provider: None,
1566 llm_retry_config: RetryConfig {
1567 max_retries: 3,
1568 initial_backoff: Duration::from_millis(100),
1569 max_backoff: Duration::from_secs(10),
1570 backoff_multiplier: 2.0,
1571 },
1572 lock_timeout: Duration::from_secs(30),
1573 message_retention: Duration::from_secs(86400),
1574 delegation_timeout: Duration::from_secs(300),
1575 }
1576 }
1577
1578 fn make_test_note(title: &str, content: &str) -> Note {
1579 Note {
1580 note_id: NoteId::now_v7(),
1581 note_type: NoteType::Fact,
1582 title: title.to_string(),
1583 content: content.to_string(),
1584 content_hash: ContentHash::default(),
1585 embedding: None,
1586 source_trajectory_ids: vec![],
1587 source_artifact_ids: vec![],
1588 ttl: TTL::Persistent,
1589 created_at: Utc::now(),
1590 updated_at: Utc::now(),
1591 accessed_at: Utc::now(),
1592 access_count: 0,
1593 superseded_by: None,
1594 metadata: None,
1595 abstraction_level: crate::AbstractionLevel::Raw,
1596 source_note_ids: vec![],
1597 }
1598 }
1599
1600 fn make_test_artifact(name: &str, content: &str) -> Artifact {
1601 Artifact {
1602 artifact_id: crate::ArtifactId::now_v7(),
1603 trajectory_id: TrajectoryId::now_v7(),
1604 scope_id: ScopeId::now_v7(),
1605 artifact_type: crate::ArtifactType::Fact,
1606 name: name.to_string(),
1607 content: content.to_string(),
1608 content_hash: ContentHash::default(),
1609 embedding: None,
1610 provenance: crate::Provenance {
1611 source_turn: 1,
1612 extraction_method: crate::ExtractionMethod::Explicit,
1613 confidence: Some(1.0),
1614 },
1615 ttl: TTL::Persistent,
1616 created_at: Utc::now(),
1617 updated_at: Utc::now(),
1618 superseded_by: None,
1619 metadata: None,
1620 }
1621 }
1622
1623 #[test]
1624 fn test_estimate_tokens_empty() {
1625 assert_eq!(estimate_tokens(""), 0);
1626 }
1627
1628 #[test]
1629 fn test_estimate_tokens_short() {
1630 assert_eq!(estimate_tokens("hello"), 2);
1632 }
1633
1634 #[test]
1635 fn test_estimate_tokens_longer() {
1636 let text = "a".repeat(100);
1638 assert_eq!(estimate_tokens(&text), 25);
1639 }
1640
1641 #[test]
1642 fn test_truncate_empty_budget() {
1643 let result = truncate_to_token_budget("hello world", 0);
1644 assert_eq!(result, "");
1645 }
1646
1647 #[test]
1648 fn test_truncate_fits() {
1649 let text = "hello";
1650 let result = truncate_to_token_budget(text, 100);
1651 assert_eq!(result, text);
1652 }
1653
1654 #[test]
1655 fn test_truncate_sentence_boundary() {
1656 let text = "First sentence. Second sentence. Third sentence.";
1657 let result = truncate_to_token_budget(text, 15);
1659 assert!(result.ends_with('.'));
1661 }
1662
1663 #[test]
1664 fn test_context_window_new() {
1665 let window = ContextWindow::new(1000);
1666 assert_eq!(window.max_tokens, 1000);
1667 assert_eq!(window.used_tokens, 0);
1668 assert!(window.sections.is_empty());
1669 }
1670
1671 #[test]
1672 fn test_context_window_add_section() {
1673 let mut window = ContextWindow::new(1000);
1674 let section = ContextSection::new(SectionType::User, "Hello".to_string(), 100);
1675 assert!(window.add_section(section));
1676 assert_eq!(window.sections.len(), 1);
1677 assert!(window.used_tokens > 0);
1678 }
1679
1680 #[test]
1681 fn test_context_assembler_basic() -> CellstateResult<()> {
1682 let config = make_test_config(10000);
1683 let assembler = ContextAssembler::new(config)?;
1684
1685 let pkg = ContextPackage::new(TrajectoryId::now_v7(), ScopeId::now_v7())
1686 .with_user_input("What is the weather?".to_string());
1687
1688 let window = assembler.assemble(pkg)?;
1689 assert!(window.used_tokens > 0);
1690 assert!(window.used_tokens <= window.max_tokens);
1691 Ok(())
1692 }
1693
1694 #[test]
1695 fn test_context_assembler_with_notes() -> CellstateResult<()> {
1696 let config = make_test_config(10000);
1697 let assembler = ContextAssembler::new(config)?;
1698
1699 let notes = vec![
1700 make_test_note("Note 1", "Content of note 1"),
1701 make_test_note("Note 2", "Content of note 2"),
1702 ];
1703
1704 let pkg = ContextPackage::new(TrajectoryId::now_v7(), ScopeId::now_v7())
1705 .with_user_input("Query".to_string())
1706 .with_notes(notes);
1707
1708 let window = assembler.assemble(pkg)?;
1709 assert!(window.sections.len() >= 2); Ok(())
1711 }
1712
1713 #[test]
1714 fn test_context_assembler_respects_budget() -> CellstateResult<()> {
1715 let config = make_test_config(10);
1717 let assembler = ContextAssembler::new(config)?;
1718
1719 let pkg = ContextPackage::new(TrajectoryId::now_v7(), ScopeId::now_v7()).with_user_input(
1720 "This is a very long user input that should exceed the token budget".to_string(),
1721 );
1722
1723 let window = assembler.assemble(pkg)?;
1724 assert!(window.used_tokens <= window.max_tokens);
1726 Ok(())
1727 }
1728
1729 #[test]
1730 fn test_context_package_outline_counts_and_previews() {
1731 let note_a = make_test_note("Alpha", "First");
1732 let note_b = make_test_note("Beta", "Second");
1733 let artifact_a = make_test_artifact("artifact-a", "a");
1734 let artifact_b = make_test_artifact("artifact-b", "b");
1735
1736 let pkg = ContextPackage::new(TrajectoryId::now_v7(), ScopeId::now_v7())
1737 .with_user_input("hello".to_string())
1738 .with_notes(vec![note_a, note_b])
1739 .with_artifacts(vec![artifact_a, artifact_b])
1740 .with_kernel_config(KernelConfig {
1741 persona: Some("assistant".to_string()),
1742 tone: None,
1743 reasoning_style: None,
1744 domain_focus: None,
1745 });
1746
1747 let outline = pkg.outline();
1748 assert_eq!(outline.note_count, 2);
1749 assert_eq!(
1750 outline.note_titles,
1751 vec!["Alpha".to_string(), "Beta".to_string()]
1752 );
1753 assert_eq!(outline.artifact_count, 2);
1754 assert_eq!(
1755 outline.artifact_names,
1756 vec!["artifact-a".to_string(), "artifact-b".to_string()]
1757 );
1758 assert!(outline.has_user_input);
1759 assert!(outline.has_kernel_config);
1760 }
1761}
1762
1763#[cfg(test)]
1768mod prop_tests {
1769 use super::*;
1770 #[allow(unused_imports)]
1771 use crate::{
1772 AbstractionLevel, ArtifactType, ContentHash, ContextPersistence, ExtractionMethod, NoteId,
1773 NoteType, Provenance, RetryConfig, SectionPriorities, ValidationMode, TTL,
1774 };
1775 use proptest::prelude::*;
1776 use std::time::Duration;
1777
1778 fn make_test_config(token_budget: i32) -> CellstateConfig {
1779 CellstateConfig {
1780 token_budget,
1781 section_priorities: SectionPriorities {
1782 user: 100,
1783 system: 90,
1784 persona: 85,
1785 artifacts: 80,
1786 notes: 70,
1787 history: 60,
1788 custom: vec![],
1789 },
1790 checkpoint_retention: 10,
1791 stale_threshold: Duration::from_secs(3600),
1792 contradiction_threshold: 0.8,
1793 context_window_persistence: ContextPersistence::Ephemeral,
1794 validation_mode: ValidationMode::OnMutation,
1795 embedding_provider: None,
1796 summarization_provider: None,
1797 llm_retry_config: RetryConfig {
1798 max_retries: 3,
1799 initial_backoff: Duration::from_millis(100),
1800 max_backoff: Duration::from_secs(10),
1801 backoff_multiplier: 2.0,
1802 },
1803 lock_timeout: Duration::from_secs(30),
1804 message_retention: Duration::from_secs(86400),
1805 delegation_timeout: Duration::from_secs(300),
1806 }
1807 }
1808
1809 fn arb_note() -> impl Strategy<Value = Note> {
1810 (any::<[u8; 16]>(), ".*", ".*").prop_map(|(id_bytes, title, content)| Note {
1811 note_id: crate::NoteId::new(Uuid::from_bytes(id_bytes)),
1812 note_type: NoteType::Fact,
1813 title,
1814 content,
1815 content_hash: ContentHash::default(),
1816 embedding: None,
1817 source_trajectory_ids: vec![],
1818 source_artifact_ids: vec![],
1819 ttl: TTL::Persistent,
1820 created_at: Utc::now(),
1821 updated_at: Utc::now(),
1822 accessed_at: Utc::now(),
1823 access_count: 0,
1824 superseded_by: None,
1825 metadata: None,
1826 abstraction_level: crate::AbstractionLevel::Raw,
1827 source_note_ids: vec![],
1828 })
1829 }
1830
1831 fn arb_artifact() -> impl Strategy<Value = Artifact> {
1832 (
1833 any::<[u8; 16]>(),
1834 any::<[u8; 16]>(),
1835 any::<[u8; 16]>(),
1836 ".*",
1837 ".*",
1838 )
1839 .prop_map(
1840 |(id_bytes, traj_bytes, scope_bytes, name, content)| Artifact {
1841 artifact_id: crate::ArtifactId::new(Uuid::from_bytes(id_bytes)),
1842 trajectory_id: crate::TrajectoryId::new(Uuid::from_bytes(traj_bytes)),
1843 scope_id: crate::ScopeId::new(Uuid::from_bytes(scope_bytes)),
1844 artifact_type: ArtifactType::Fact,
1845 name,
1846 content,
1847 content_hash: ContentHash::default(),
1848 embedding: None,
1849 provenance: Provenance {
1850 source_turn: 1,
1851 extraction_method: ExtractionMethod::Explicit,
1852 confidence: Some(1.0),
1853 },
1854 ttl: TTL::Persistent,
1855 created_at: Utc::now(),
1856 updated_at: Utc::now(),
1857 superseded_by: None,
1858 metadata: None,
1859 },
1860 )
1861 }
1862
1863 proptest! {
1870 #![proptest_config(ProptestConfig::with_cases(100))]
1871
1872 #[test]
1875 fn prop_context_assembly_respects_token_budget(
1876 token_budget in 1i32..10000,
1877 user_input in ".*",
1878 notes in prop::collection::vec(arb_note(), 0..5),
1879 artifacts in prop::collection::vec(arb_artifact(), 0..5),
1880 ) {
1881 let config = make_test_config(token_budget);
1882 let assembler = match ContextAssembler::new(config) {
1883 Ok(assembler) => assembler,
1884 Err(err) => {
1885 prop_assert!(false, "Failed to build ContextAssembler: {:?}", err);
1886 return Ok(());
1887 }
1888 };
1889
1890 let pkg = ContextPackage::new(crate::TrajectoryId::now_v7(), crate::ScopeId::now_v7())
1891 .with_user_input(user_input)
1892 .with_notes(notes)
1893 .with_artifacts(artifacts);
1894
1895 let window = match assembler.assemble(pkg) {
1896 Ok(window) => window,
1897 Err(err) => {
1898 prop_assert!(false, "Failed to assemble context: {:?}", err);
1899 return Ok(());
1900 }
1901 };
1902
1903 prop_assert!(
1904 window.used_tokens <= window.max_tokens,
1905 "used_tokens ({}) should be <= max_tokens ({})",
1906 window.used_tokens,
1907 window.max_tokens
1908 );
1909 }
1910 }
1911
1912 proptest! {
1919 #![proptest_config(ProptestConfig::with_cases(100))]
1920
1921 #[test]
1924 fn prop_context_sections_ordered_by_priority(
1925 token_budget in 1000i32..50000,
1926 user_input in ".{1,100}",
1927 notes in prop::collection::vec(arb_note(), 1..3),
1928 artifacts in prop::collection::vec(arb_artifact(), 1..3),
1929 ) {
1930 let config = make_test_config(token_budget);
1931 let assembler = match ContextAssembler::new(config) {
1932 Ok(assembler) => assembler,
1933 Err(err) => {
1934 prop_assert!(false, "Failed to build ContextAssembler: {:?}", err);
1935 return Ok(());
1936 }
1937 };
1938
1939 let pkg = ContextPackage::new(crate::TrajectoryId::now_v7(), crate::ScopeId::now_v7())
1940 .with_user_input(user_input)
1941 .with_notes(notes)
1942 .with_artifacts(artifacts);
1943
1944 let window = match assembler.assemble(pkg) {
1945 Ok(window) => window,
1946 Err(err) => {
1947 prop_assert!(false, "Failed to assemble context: {:?}", err);
1948 return Ok(());
1949 }
1950 };
1951
1952 for i in 1..window.sections.len() {
1954 prop_assert!(
1955 window.sections[i - 1].priority >= window.sections[i].priority,
1956 "Section {} (priority {}) should have >= priority than section {} (priority {})",
1957 i - 1,
1958 window.sections[i - 1].priority,
1959 i,
1960 window.sections[i].priority
1961 );
1962 }
1963 }
1964 }
1965
1966 proptest! {
1973 #![proptest_config(ProptestConfig::with_cases(100))]
1974
1975 #[test]
1978 fn prop_token_estimation_consistency(text in ".*") {
1979 let tokens = estimate_tokens(&text);
1980
1981 prop_assert!(tokens >= 0, "Token count should be >= 0, got {}", tokens);
1983
1984 if !text.is_empty() {
1987 let expected_approx = (text.len() as f32 * 0.25).ceil() as i32;
1988 prop_assert_eq!(
1989 tokens,
1990 expected_approx,
1991 "Token count {} should equal expected {} for text of length {}",
1992 tokens,
1993 expected_approx,
1994 text.len()
1995 );
1996 }
1997 }
1998
1999 #[test]
2001 fn prop_empty_text_zero_tokens(_iteration in 0..100u32) {
2002 prop_assert_eq!(estimate_tokens(""), 0);
2003 }
2004 }
2005
2006 proptest! {
2013 #![proptest_config(ProptestConfig::with_cases(100))]
2014
2015 #[test]
2018 fn prop_truncation_respects_budget(
2019 text in ".{0,1000}",
2020 budget in 1i32..500,
2021 ) {
2022 let truncated = truncate_to_token_budget(&text, budget);
2023 let truncated_tokens = estimate_tokens(&truncated);
2024
2025 prop_assert!(
2026 truncated_tokens <= budget,
2027 "Truncated text has {} tokens, should be <= budget {}",
2028 truncated_tokens,
2029 budget
2030 );
2031 }
2032
2033 #[test]
2035 fn prop_zero_budget_empty_result(text in ".*") {
2036 let truncated = truncate_to_token_budget(&text, 0);
2037 prop_assert_eq!(truncated, "", "Zero budget should produce empty string");
2038 }
2039
2040 #[test]
2042 fn prop_negative_budget_empty_result(
2043 text in ".*",
2044 budget in i32::MIN..-1,
2045 ) {
2046 let truncated = truncate_to_token_budget(&text, budget);
2047 prop_assert_eq!(truncated, "", "Negative budget should produce empty string");
2048 }
2049
2050 #[test]
2052 fn prop_text_fits_unchanged(text in ".{0,100}") {
2053 let budget = 1000;
2055 let truncated = truncate_to_token_budget(&text, budget);
2056 prop_assert_eq!(
2057 truncated,
2058 text,
2059 "Text that fits should be unchanged"
2060 );
2061 }
2062 }
2063
2064 #[test]
2069 fn test_reassemble_no_change_produces_empty_diff() {
2070 let config = CellstateConfig::default_context(8000);
2071 let assembler = ContextAssembler::new(config).unwrap();
2072
2073 let pkg = ContextPackage::new(TrajectoryId::now_v7(), ScopeId::now_v7())
2074 .with_user_input("hello".to_string());
2075 let window = assembler.assemble(pkg.clone()).unwrap();
2076
2077 let (new_window, diff) = assembler.reassemble(&window, pkg).unwrap();
2078
2079 assert!(!new_window.sections.is_empty());
2083 assert_eq!(diff.evicted.len(), window.sections.len());
2086 assert_eq!(diff.promoted.len(), new_window.sections.len());
2087 }
2088
2089 #[test]
2090 fn test_reassemble_detects_eviction() {
2091 let config = CellstateConfig::default_context(8000);
2092 let assembler = ContextAssembler::new(config).unwrap();
2093
2094 let notes = vec![Note {
2096 note_id: NoteId::now_v7(),
2097 note_type: NoteType::Fact,
2098 title: "test note".to_string(),
2099 content: "This is a test note.".to_string(),
2100 content_hash: crate::ContentHash::default(),
2101 embedding: None,
2102 source_trajectory_ids: vec![],
2103 source_artifact_ids: vec![],
2104 ttl: TTL::Scope,
2105 created_at: chrono::Utc::now(),
2106 updated_at: chrono::Utc::now(),
2107 accessed_at: chrono::Utc::now(),
2108 access_count: 0,
2109 superseded_by: None,
2110 metadata: None,
2111 abstraction_level: crate::AbstractionLevel::Raw,
2112 source_note_ids: vec![],
2113 }];
2114
2115 let pkg1 = ContextPackage::new(TrajectoryId::now_v7(), ScopeId::now_v7())
2116 .with_user_input("hello".to_string())
2117 .with_notes(notes);
2118 let window = assembler.assemble(pkg1).unwrap();
2119
2120 let pkg2 = ContextPackage::new(TrajectoryId::now_v7(), ScopeId::now_v7())
2122 .with_user_input("hello".to_string());
2123 let (new_window, diff) = assembler.reassemble(&window, pkg2).unwrap();
2124
2125 assert!(new_window.sections.len() < window.sections.len() || diff.has_changes());
2127 }
2128
2129 #[test]
2130 fn test_context_page_diff_has_changes() {
2131 let diff_no_changes = ContextPageDiff {
2132 evicted: vec![],
2133 promoted: vec![],
2134 retained: vec![Uuid::now_v7()],
2135 };
2136 assert!(!diff_no_changes.has_changes());
2137
2138 let diff_with_eviction = ContextPageDiff {
2139 evicted: vec![Uuid::now_v7()],
2140 promoted: vec![],
2141 retained: vec![],
2142 };
2143 assert!(diff_with_eviction.has_changes());
2144
2145 let diff_with_promotion = ContextPageDiff {
2146 evicted: vec![],
2147 promoted: vec![Uuid::now_v7()],
2148 retained: vec![],
2149 };
2150 assert!(diff_with_promotion.has_changes());
2151 }
2152}