cellstate_core/
context.rs

1//! CELLSTATE Context - Context Assembly
2//!
3//! Provides intelligent context assembly with token budget management.
4//! Combines all inputs into a single coherent prompt following the Context Conveyor pattern.
5
6use 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// ============================================================================
16// CONTEXT PACKAGE (Task 7.1)
17// ============================================================================
18
19/// Context package - all inputs for assembly.
20/// Similar to ContextPackage in the TypeScript CRM pattern.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct ContextPackage {
23    /// Trajectory this context belongs to
24    pub trajectory_id: TrajectoryId,
25    /// Scope this context belongs to
26    pub scope_id: ScopeId,
27    /// Current user query/input
28    pub user_input: Option<String>,
29    /// Relevant notes (semantic memory)
30    pub relevant_notes: Vec<Note>,
31    /// Recent artifacts from current trajectory
32    pub recent_artifacts: Vec<Artifact>,
33    /// Agent working-set entries (ephemeral workbench memory)
34    pub working_set_entries: Vec<AgentWorkingSetEntry>,
35    /// Graph-derived context hints (adjacency projection)
36    pub graph_links: Vec<GraphLink>,
37    /// Conversation turns (ephemeral buffer)
38    pub conversation_turns: Vec<Turn>,
39    /// Scope summaries (compressed history)
40    pub scope_summaries: Vec<ScopeSummary>,
41    /// Trajectory hierarchy (parent chain for context inheritance)
42    pub trajectory_hierarchy: Vec<Trajectory>,
43    /// Session markers (active context)
44    pub session_markers: SessionMarkers,
45    /// Kernel/persona configuration
46    pub kernel_config: Option<KernelConfig>,
47}
48
49/// Lightweight outline of a context package for progressive disclosure.
50///
51/// Carries counts and title/name previews without loading full content.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
54pub struct ContextOutline {
55    /// Trajectory this context belongs to
56    pub trajectory_id: TrajectoryId,
57    /// Scope this context belongs to
58    pub scope_id: ScopeId,
59    /// Number of relevant notes available
60    pub note_count: usize,
61    /// Titles of relevant notes
62    pub note_titles: Vec<String>,
63    /// Number of recent artifacts available
64    pub artifact_count: usize,
65    /// Names of recent artifacts
66    pub artifact_names: Vec<String>,
67    /// Number of working-set entries
68    pub working_set_count: usize,
69    /// Number of graph link hints
70    pub graph_link_count: usize,
71    /// Number of conversation turns
72    pub turn_count: usize,
73    /// Number of scope summaries
74    pub summary_count: usize,
75    /// Parent hierarchy depth
76    pub hierarchy_depth: usize,
77    /// Whether user input is present
78    pub has_user_input: bool,
79    /// Whether kernel config is present
80    pub has_kernel_config: bool,
81}
82
83/// Summary of a scope for context assembly.
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct ScopeSummary {
86    /// ID of the scope being summarized
87    pub scope_id: ScopeId,
88    /// Summary text
89    pub summary: String,
90    /// Token count of the summary
91    pub token_count: i32,
92}
93
94/// Session markers for tracking active context.
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
96pub struct SessionMarkers {
97    /// Currently active trajectory
98    pub active_trajectory_id: Option<TrajectoryId>,
99    /// Currently active scope
100    pub active_scope_id: Option<ScopeId>,
101    /// Recently accessed artifact IDs
102    pub recent_artifact_ids: Vec<ArtifactId>,
103    /// Current agent ID (if multi-agent)
104    pub agent_id: Option<AgentId>,
105}
106
107/// Kernel configuration for persona and behavior.
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
109pub struct KernelConfig {
110    /// Persona description
111    pub persona: Option<String>,
112    /// Tone of responses
113    pub tone: Option<String>,
114    /// Reasoning style preference
115    pub reasoning_style: Option<String>,
116    /// Domain focus area
117    pub domain_focus: Option<String>,
118}
119
120impl ContextPackage {
121    /// Create a new context package with required fields.
122    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    /// Set the user input.
140    pub fn with_user_input(mut self, input: String) -> Self {
141        self.user_input = Some(input);
142        self
143    }
144
145    /// Add relevant notes.
146    pub fn with_notes(mut self, notes: Vec<Note>) -> Self {
147        self.relevant_notes = notes;
148        self
149    }
150
151    /// Add recent artifacts.
152    pub fn with_artifacts(mut self, artifacts: Vec<Artifact>) -> Self {
153        self.recent_artifacts = artifacts;
154        self
155    }
156
157    /// Add working-set entries.
158    pub fn with_working_set(mut self, entries: Vec<AgentWorkingSetEntry>) -> Self {
159        self.working_set_entries = entries;
160        self
161    }
162
163    /// Add graph links.
164    pub fn with_graph_links(mut self, links: Vec<GraphLink>) -> Self {
165        self.graph_links = links;
166        self
167    }
168
169    /// Add conversation turns.
170    pub fn with_turns(mut self, turns: Vec<Turn>) -> Self {
171        self.conversation_turns = turns;
172        self
173    }
174
175    /// Add trajectory hierarchy (parent chain).
176    pub fn with_hierarchy(mut self, hierarchy: Vec<Trajectory>) -> Self {
177        self.trajectory_hierarchy = hierarchy;
178        self
179    }
180
181    /// Set session markers.
182    pub fn with_session_markers(mut self, markers: SessionMarkers) -> Self {
183        self.session_markers = markers;
184        self
185    }
186
187    /// Set kernel config.
188    pub fn with_kernel_config(mut self, config: KernelConfig) -> Self {
189        self.kernel_config = Some(config);
190        self
191    }
192
193    /// Produce a lightweight outline for progressive disclosure.
194    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/// Graph link hint for context assembly (from SQL adjacency projection).
222#[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// ============================================================================
243// CONTEXT WINDOW AND SECTION (Task 7.2)
244// ============================================================================
245
246/// Type of context section.
247#[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    /// System prompt (highest level instructions)
252    SystemPrompt,
253    /// pack instructions/persona configuration
254    Instructions,
255    /// Evidence/provenance data
256    Evidence,
257    /// User memory injection
258    Memory,
259    /// Tool/LLM result history
260    ToolResult,
261    /// Conversation history
262    ConversationHistory,
263    /// System instructions (shares budget with SystemPrompt)
264    System,
265    /// Persona/kernel configuration (shares budget with Instructions)
266    Persona,
267    /// Relevant notes from semantic memory (shares budget with Memory)
268    Notes,
269    /// Scope summaries (shares budget with ConversationHistory)
270    History,
271    /// Artifacts from current trajectory (shares budget with Evidence)
272    Artifacts,
273    /// User input/query
274    User,
275}
276
277impl SectionType {
278    /// Returns the canonical snake_case name matching `#[serde(rename_all = "snake_case")]`.
279    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/// Reference to a source entity.
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
300pub struct SourceRef {
301    /// Type of the source entity
302    pub source_type: EntityType,
303    /// ID of the source entity (if applicable)
304    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = "uuid"))]
305    pub id: Option<Uuid>,
306    /// Relevance score (if computed)
307    pub relevance_score: Option<f32>,
308}
309
310/// A section of the assembled context.
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
313pub struct ContextSection {
314    /// Unique identifier for this section
315    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
316    pub section_id: Uuid,
317    /// Type of this section
318    pub section_type: SectionType,
319    /// Content of this section
320    pub content: String,
321    /// Token count for this section
322    pub token_count: i32,
323    /// Priority (higher = more important)
324    pub priority: i32,
325    /// Whether this section can be compressed
326    pub compressible: bool,
327    /// Sources that contributed to this section
328    pub sources: Vec<SourceRef>,
329}
330
331/// Action taken during context assembly.
332#[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    /// Section was included in full
337    Include,
338    /// Section was excluded due to budget
339    Exclude,
340    /// Section was compressed
341    Compress,
342    /// Section was truncated to fit budget
343    Truncate,
344}
345
346/// Decision made during context assembly for audit trail.
347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
348#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
349pub struct AssemblyDecision {
350    /// When this decision was made
351    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
352    pub timestamp: Timestamp,
353    /// Action taken
354    pub action: AssemblyAction,
355    /// Type of target (e.g., "note", "artifact")
356    pub target_type: String,
357    /// ID of target entity (if applicable)
358    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = "uuid"))]
359    pub target_id: Option<Uuid>,
360    /// Reason for this decision
361    pub reason: String,
362    /// Tokens affected by this decision
363    pub tokens_affected: i32,
364}
365
366/// Assembled context window with token budget management.
367#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
368#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
369pub struct ContextWindow {
370    /// Unique identifier for this window
371    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
372    pub window_id: Uuid,
373    /// When this window was assembled
374    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
375    pub assembled_at: Timestamp,
376    /// Maximum token budget
377    pub max_tokens: i32,
378    /// Tokens currently used
379    pub used_tokens: i32,
380    /// Sections in priority order
381    pub sections: Vec<ContextSection>,
382    /// Whether any section was truncated
383    pub truncated: bool,
384    /// Names of included sections
385    pub included_sections: Vec<String>,
386    /// Full audit trail of assembly decisions
387    pub assembly_trace: Vec<AssemblyDecision>,
388    /// Segment-based token budget allocation
389    pub budget: Option<TokenBudget>,
390    /// Per-segment usage tracking
391    pub usage: SegmentUsage,
392}
393
394/// Diff between two context windows after re-paging.
395///
396/// Tracks which sections were evicted (removed), promoted (newly added),
397/// or retained (survived the repage). Used as an audit trail and broadcast
398/// to subscribers via WebSocket.
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
401pub struct ContextPageDiff {
402    /// Section IDs that were in the old window but not the new one.
403    pub evicted: Vec<Uuid>,
404    /// Section IDs that are in the new window but were not in the old one.
405    pub promoted: Vec<Uuid>,
406    /// Section IDs present in both windows.
407    pub retained: Vec<Uuid>,
408}
409
410impl ContextPageDiff {
411    /// True when the repage actually changed the window.
412    pub fn has_changes(&self) -> bool {
413        !self.evicted.is_empty() || !self.promoted.is_empty()
414    }
415}
416
417impl ContextSection {
418    /// Create a new context section.
419    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    /// Set whether this section is compressible.
433    pub fn with_compressible(mut self, compressible: bool) -> Self {
434        self.compressible = compressible;
435        self
436    }
437
438    /// Add source references.
439    pub fn with_sources(mut self, sources: Vec<SourceRef>) -> Self {
440        self.sources = sources;
441        self
442    }
443}
444
445impl ContextWindow {
446    /// Create a new empty context window with the given token budget.
447    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    /// Create a new context window with segment-based budget.
463    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    /// Add content to a specific segment.
480    ///
481    /// Returns an error if the segment budget would be exceeded.
482    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    /// Add a pre-built section to a segment, preserving section metadata/sources.
502    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        // Check segment budget if configured
510        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        // Check total budget
521        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    /// Get remaining tokens for a specific segment.
548    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    /// Get remaining token budget.
556    pub fn remaining_tokens(&self) -> i32 {
557        self.max_tokens - self.used_tokens
558    }
559
560    /// Check if the window has room for more content.
561    pub fn has_room(&self) -> bool {
562        self.used_tokens < self.max_tokens
563    }
564
565    /// Add a section to the window.
566    /// Returns true if the section was added, false if it didn't fit.
567    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    /// Add a truncated section to the window.
600    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(&section.content, available);
616        section.token_count = estimate_tokens(&section.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    /// Get the assembled content as a single string.
637    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
652// ============================================================================
653// TOKEN UTILITIES (Task 7.3)
654// ============================================================================
655
656/// Estimate tokens using the default heuristic tokenizer.
657fn estimate_tokens(text: &str) -> i32 {
658    crate::estimate_tokens(text)
659}
660
661// ============================================================================
662// SMART TRUNCATION (Task 7.4)
663// ============================================================================
664
665/// Truncate text to fit within token budget.
666/// Prefers sentence boundaries, falls back to word boundaries.
667///
668/// # Arguments
669/// * `text` - The text to truncate
670/// * `budget` - Maximum token budget
671///
672/// # Returns
673/// Truncated text that fits within the budget
674pub 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    // Binary search the longest UTF-8-safe prefix within token budget.
684    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    // Try to find a sentence boundary (., ?, !)
709    let last_period = truncated.rfind('.');
710    let last_question = truncated.rfind('?');
711    let last_exclaim = truncated.rfind('!');
712
713    // Find the latest sentence boundary
714    let last_sentence = [last_period, last_question, last_exclaim]
715        .into_iter()
716        .flatten()
717        .max();
718
719    // If we found a sentence boundary in the latter half, use it
720    if let Some(pos) = last_sentence {
721        if pos > truncated_len / 2 {
722            return truncated[..=pos].to_string();
723        }
724    }
725
726    // Fall back to word boundary
727    if let Some(pos) = truncated.rfind(' ') {
728        // Only use word boundary if it's in the latter 80% of the text
729        if pos > truncated_len * 4 / 5 {
730            return truncated[..pos].to_string();
731        }
732    }
733
734    // Last resort: just use the truncated text
735    truncated.to_string()
736}
737
738// ============================================================================
739// CONTEXT ASSEMBLER (Task 7.5)
740// ============================================================================
741
742/// Context assembler that builds context windows from packages.
743/// Adds sections by priority until budget is exhausted.
744#[derive(Debug, Clone)]
745pub struct ContextAssembler {
746    /// Configuration for assembly
747    config: CellstateConfig,
748    /// Segment-based token budget (optional)
749    segment_budget: Option<TokenBudget>,
750}
751
752impl ContextAssembler {
753    /// Create a new context assembler with the given configuration.
754    pub fn new(config: CellstateConfig) -> CellstateResult<Self> {
755        config.validate()?;
756        Ok(Self {
757            config,
758            segment_budget: None,
759        })
760    }
761
762    /// Create a context assembler with segment-based budget.
763    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    /// Map SectionType to ContextSegment for budget tracking.
775    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, // User input treated as system segment
784        }
785    }
786
787    /// Assemble context from a package with token budget management.
788    /// Sections are added in priority order until budget is exhausted.
789    ///
790    /// When segment budget is configured, sections are also checked against
791    /// their respective segment budgets.
792    ///
793    /// # Arguments
794    /// * `pkg` - The context package to assemble
795    ///
796    /// # Returns
797    /// An assembled ContextWindow with sections ordered by priority
798    pub fn assemble(&self, pkg: ContextPackage) -> CellstateResult<ContextWindow> {
799        // Create window with segment budget if available
800        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        // Build sections from the package
806        let mut sections = self.build_sections(&pkg);
807
808        // Sort sections by priority (descending - higher priority first)
809        sections.sort_by_key(|section| std::cmp::Reverse(section.priority));
810
811        // Add sections in priority order until budget is exhausted
812        for section in sections {
813            let segment = Self::section_to_segment(section.section_type);
814
815            // Check total budget
816            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            // Check segment budget if configured
829            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                // Section fits completely - track segment usage
849                if self.segment_budget.is_some() {
850                    if window.add_section_to_segment(segment, section).is_err() {
851                        // Segment budget exceeded (shouldn't happen due to check above)
852                        continue;
853                    }
854                } else {
855                    window.add_section(section);
856                }
857            } else if section.compressible {
858                // Section doesn't fit but can be truncated
859                window.add_truncated_section(section);
860            } else {
861                // Section doesn't fit and can't be truncated
862                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    /// Re-assemble a context window mid-scope after scoring changes.
880    ///
881    /// Takes the current window and an updated context package (with re-scored
882    /// notes/artifacts), reassembles from scratch using the same budget rules,
883    /// then diffs the old and new windows to produce a `ContextPageDiff`.
884    ///
885    /// This is a pure function — no I/O. The diff provides the audit trail
886    /// for what was evicted, promoted, or retained.
887    ///
888    /// # Returns
889    /// Tuple of (new window, diff describing what changed).
890    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    /// Compare two context windows and produce a diff.
901    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    /// Build sections from a context package.
919    fn build_sections(&self, pkg: &ContextPackage) -> Vec<ContextSection> {
920        let mut sections = Vec::new();
921
922        // Add persona/kernel config section (highest priority typically)
923        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; // Persona shouldn't be truncated
932                sections.push(section);
933            }
934        }
935
936        // Add user input section
937        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; // User input shouldn't be truncated
944            sections.push(section);
945        }
946
947        // Add notes section
948        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        // Add working-set section
969        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        // Add artifacts section
980        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        // Add graph hints section
1001        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        // Add history section (scope summaries)
1022        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        // Add conversation turns section
1043        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        // Add trajectory hierarchy section
1064        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    /// Format kernel config into a string.
1088    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    /// Format notes into a string.
1108    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    /// Format artifacts into a string.
1117    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    /// Format working-set entries into a string.
1126    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    /// Format graph links into a string.
1141    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    /// Format conversation turns into a string.
1165    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    /// Format trajectory hierarchy into a string.
1176    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    /// Format scope summaries into a string.
1192    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    /// Get the token budget from config.
1201    pub fn token_budget(&self) -> i32 {
1202        self.config.token_budget
1203    }
1204}
1205
1206// ============================================================================
1207// TOKEN BUDGET SEGMENTATION (Phase 4)
1208// ============================================================================
1209
1210/// Segment-based token budget allocation.
1211///
1212/// Divides the total token budget into segments for different purposes,
1213/// allowing fine-grained control over context assembly.
1214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1215#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1216pub struct TokenBudget {
1217    /// System prompt allocation
1218    pub system: i32,
1219    /// pack instructions/persona
1220    pub instructions: i32,
1221    /// Evidence/provenance data
1222    pub evidence: i32,
1223    /// User memory injection
1224    pub memory: i32,
1225    /// Tool/LLM result history
1226    pub tool_results: i32,
1227    /// Conversation history
1228    pub history: i32,
1229    /// Safety margin (typically 5-10%)
1230    pub slack: i32,
1231}
1232
1233impl TokenBudget {
1234    /// Get the total budget across all segments.
1235    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    /// Create a budget from a total with default ratios.
1246    ///
1247    /// Default allocation:
1248    /// - System: 10%
1249    /// - Instructions: 15%
1250    /// - Evidence: 15%
1251    /// - Memory: 20%
1252    /// - Tool Results: 15%
1253    /// - History: 20%
1254    /// - Slack: 5%
1255    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    /// Create a builder for custom ratio configuration.
1268    ///
1269    /// # Example
1270    ///
1271    /// ```
1272    /// use cellstate_core::TokenBudget;
1273    ///
1274    /// let budget = TokenBudget::builder(8000)
1275    ///     .system(0.10)
1276    ///     .memory(0.25)  // Override default
1277    ///     .build();
1278    /// ```
1279    pub fn builder(total: i32) -> TokenBudgetBuilder {
1280        TokenBudgetBuilder::new(total)
1281    }
1282
1283    /// Get the budget for a specific segment.
1284    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// ============================================================================
1303// TOKEN BUDGET BUILDER
1304// ============================================================================
1305
1306/// Builder for constructing TokenBudget with custom ratios.
1307///
1308/// Provides a fluent API for configuring token budget allocation.
1309/// All ratios default to the standard allocation if not specified.
1310#[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    /// Create a new builder with default ratios.
1324    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    /// Set the system prompt ratio (default: 0.10).
1338    pub fn system(mut self, ratio: f32) -> Self {
1339        self.system = ratio;
1340        self
1341    }
1342
1343    /// Set the instructions ratio (default: 0.15).
1344    pub fn instructions(mut self, ratio: f32) -> Self {
1345        self.instructions = ratio;
1346        self
1347    }
1348
1349    /// Set the evidence ratio (default: 0.15).
1350    pub fn evidence(mut self, ratio: f32) -> Self {
1351        self.evidence = ratio;
1352        self
1353    }
1354
1355    /// Set the memory ratio (default: 0.20).
1356    pub fn memory(mut self, ratio: f32) -> Self {
1357        self.memory = ratio;
1358        self
1359    }
1360
1361    /// Set the tool results ratio (default: 0.15).
1362    pub fn tool_results(mut self, ratio: f32) -> Self {
1363        self.tool_results = ratio;
1364        self
1365    }
1366
1367    /// Set the history ratio (default: 0.20).
1368    pub fn history(mut self, ratio: f32) -> Self {
1369        self.history = ratio;
1370        self
1371    }
1372
1373    /// Set the slack ratio (default: 0.05).
1374    pub fn slack(mut self, ratio: f32) -> Self {
1375        self.slack = ratio;
1376        self
1377    }
1378
1379    /// Build the TokenBudget.
1380    ///
1381    /// Converts ratios to absolute token counts based on total.
1382    /// Validates that segment ratios sum to 1.0 (±0.05 tolerance).
1383    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/// Context segment types for budget tracking.
1408#[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 prompt
1413    System,
1414    /// pack instructions/persona
1415    Instructions,
1416    /// Evidence/provenance data
1417    Evidence,
1418    /// User memory injection
1419    Memory,
1420    /// Tool/LLM result history
1421    ToolResults,
1422    /// Conversation history
1423    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/// Segment usage tracking.
1440///
1441/// Tracks how many tokens have been used in each segment.
1442#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1443#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1444pub struct SegmentUsage {
1445    /// Tokens used in system segment
1446    pub system_used: i32,
1447    /// Tokens used in instructions segment
1448    pub instructions_used: i32,
1449    /// Tokens used in evidence segment
1450    pub evidence_used: i32,
1451    /// Tokens used in memory segment
1452    pub memory_used: i32,
1453    /// Tokens used in tool results segment
1454    pub tool_results_used: i32,
1455    /// Tokens used in history segment
1456    pub history_used: i32,
1457}
1458
1459impl SegmentUsage {
1460    /// Get total tokens used across all segments.
1461    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    /// Get usage for a specific segment.
1471    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    /// Check if we can add tokens to a segment.
1483    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    /// Add tokens to a segment.
1488    ///
1489    /// Returns true if successful, false if budget exceeded.
1490    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    /// Get remaining tokens in a segment.
1506    pub fn remaining(&self, segment: ContextSegment, budget: &TokenBudget) -> i32 {
1507        budget.for_segment(segment) - self.for_segment(segment)
1508    }
1509}
1510
1511/// Error type for segment budget violations.
1512#[derive(Debug, Clone, PartialEq, thiserror::Error)]
1513pub enum SegmentBudgetError {
1514    /// A specific segment's budget was exceeded
1515    #[error("Segment {segment} budget exceeded: requested {requested} tokens, only {available} available")]
1516    SegmentExceeded {
1517        /// The segment that was exceeded
1518        segment: ContextSegment,
1519        /// Tokens available in the segment
1520        available: i32,
1521        /// Tokens that were requested
1522        requested: i32,
1523    },
1524    /// The total budget was exceeded
1525    #[error("Total budget exceeded: requested {requested} tokens, only {available} available")]
1526    TotalExceeded {
1527        /// Tokens available in total
1528        available: i32,
1529        /// Tokens that were requested
1530        requested: i32,
1531    },
1532}
1533
1534// ============================================================================
1535// TESTS
1536// ============================================================================
1537
1538#[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        // "hello" = 5 chars * 0.25 (GPT-4 default) = 1.25, ceil = 2
1631        assert_eq!(estimate_tokens("hello"), 2);
1632    }
1633
1634    #[test]
1635    fn test_estimate_tokens_longer() {
1636        // 100 chars * 0.25 (GPT-4 default) = 25 tokens
1637        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        // Budget for ~20 chars (15 tokens)
1658        let result = truncate_to_token_budget(text, 15);
1659        // Should truncate at a sentence boundary
1660        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); // User + Notes
1710        Ok(())
1711    }
1712
1713    #[test]
1714    fn test_context_assembler_respects_budget() -> CellstateResult<()> {
1715        // Very small budget
1716        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        // Should respect budget
1725        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// ============================================================================
1764// PROPERTY-BASED TESTS (Task 7.6)
1765// ============================================================================
1766
1767#[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    // ========================================================================
1864    // Property 8: Context assembly respects token budget
1865    // Feature: cellstate-core-implementation, Property 8: Context assembly respects token budget
1866    // Validates: Requirements 9.3
1867    // ========================================================================
1868
1869    proptest! {
1870        #![proptest_config(ProptestConfig::with_cases(100))]
1871
1872        /// Property 8: For any ContextWindow assembled with max_tokens = N,
1873        /// used_tokens SHALL be <= N
1874        #[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    // ========================================================================
1913    // Property 11: Context sections ordered by priority
1914    // Feature: cellstate-core-implementation, Property 11: Context sections ordered by priority
1915    // Validates: Requirements 9.2
1916    // ========================================================================
1917
1918    proptest! {
1919        #![proptest_config(ProptestConfig::with_cases(100))]
1920
1921        /// Property 11: For any assembled ContextWindow, sections SHALL be
1922        /// ordered by descending priority
1923        #[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            // Check that sections are in descending priority order
1953            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    // ========================================================================
1967    // Property 12: Token estimation consistency
1968    // Feature: cellstate-core-implementation, Property 12: Token estimation consistency
1969    // Validates: Context assembly token management
1970    // ========================================================================
1971
1972    proptest! {
1973        #![proptest_config(ProptestConfig::with_cases(100))]
1974
1975        /// Property 12: For any text T, estimate_tokens(T) SHALL be >= 0
1976        /// AND approximately proportional to T.len()
1977        #[test]
1978        fn prop_token_estimation_consistency(text in ".*") {
1979            let tokens = estimate_tokens(&text);
1980
1981            // Tokens should always be non-negative
1982            prop_assert!(tokens >= 0, "Token count should be >= 0, got {}", tokens);
1983
1984            // Tokens should be approximately proportional to length
1985            // With 0.25 tokens per char (GPT-4 default), tokens should be roughly 0.25 * len
1986            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        /// Property 12: Empty text should have 0 tokens
2000        #[test]
2001        fn prop_empty_text_zero_tokens(_iteration in 0..100u32) {
2002            prop_assert_eq!(estimate_tokens(""), 0);
2003        }
2004    }
2005
2006    // ========================================================================
2007    // Property 13: Truncation respects budget
2008    // Feature: cellstate-core-implementation, Property 13: Truncation respects budget
2009    // Validates: Context assembly truncation
2010    // ========================================================================
2011
2012    proptest! {
2013        #![proptest_config(ProptestConfig::with_cases(100))]
2014
2015        /// Property 13: For any text T and budget B,
2016        /// estimate_tokens(truncate_to_token_budget(T, B)) SHALL be <= B
2017        #[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        /// Property 13: Zero budget should produce empty string
2034        #[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        /// Property 13: Negative budget should produce empty string
2041        #[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        /// Property 13: If text fits in budget, it should be unchanged
2051        #[test]
2052        fn prop_text_fits_unchanged(text in ".{0,100}") {
2053            // Large budget that should fit any text up to 100 chars
2054            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    // ========================================================================
2065    // Context Paging (reassemble) tests
2066    // ========================================================================
2067
2068    #[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        // Same input should produce retained sections (section IDs differ because
2080        // assemble() creates new UUIDs each time, so evicted/promoted will be
2081        // non-empty — this tests the diff mechanism works)
2082        assert!(!new_window.sections.is_empty());
2083        // The diff should show the old sections evicted and new ones promoted
2084        // because section_ids are unique per assembly
2085        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        // Build an initial window with notes
2095        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        // Reassemble without the note
2121        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        // The new window should have fewer sections (no notes section)
2126        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}