Skip to main content

deepstrike_core/context/
partitions.rs

1use super::config::ContextConfig;
2use super::task_state::TaskState;
3use super::token_engine::ContextTokenEngine;
4use crate::mm::value::{RetentionFeatures, RetentionKind, deterministic_retention_score};
5use crate::types::message::Message;
6
7/// A single context partition — a named bucket of messages with a token counter.
8#[derive(Debug, Clone)]
9pub struct Partition {
10    pub messages: Vec<Message>,
11    pub token_count: u32,
12}
13
14impl Partition {
15    pub fn new() -> Self {
16        Self {
17            messages: Vec::new(),
18            token_count: 0,
19        }
20    }
21
22    pub fn push(&mut self, mut msg: Message, token_count: u32) {
23        msg.token_count = Some(token_count);
24        self.token_count += token_count;
25        self.messages.push(msg);
26    }
27
28    pub fn clear(&mut self) {
29        self.messages.clear();
30        self.token_count = 0;
31    }
32
33    pub fn len(&self) -> usize {
34        self.messages.len()
35    }
36    pub fn is_empty(&self) -> bool {
37        self.messages.is_empty()
38    }
39}
40
41impl Default for Partition {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47/// One durable knowledge entry. Unlike history messages, knowledge entries have IDENTITY —
48/// a host-assigned key enabling upsert (refresh a pinned reference) and targeted removal —
49/// plus lifecycle flags driving the boundary sweep (K1/K2 of the dynamic-control spec).
50#[derive(Debug, Clone)]
51pub struct KnowledgeEntry {
52    /// `None` ⇒ legacy unkeyed append (initialMemory, old snapshots). Keyed entries upsert.
53    pub key: Option<compact_str::CompactString>,
54    pub message: Message,
55    pub tokens: u32,
56    /// Host-pinned ⇒ never budget-evicted (K2). Skill pins are NOT host-pinned (K3 governs them).
57    pub pinned: bool,
58    /// Marked for removal at the next compaction/renewal boundary. Knowledge renders into the
59    /// cached system[1] block, so existing bytes are only rewritten where the prompt-cache prefix
60    /// is being rebuilt anyway — the same principle as `reset_collapse_generation`.
61    pub evict_at_boundary: bool,
62    /// Deferred upsert: a same-key push mid-generation stages its replacement here instead of
63    /// rewriting rendered bytes; applied by [`KnowledgePartition::sweep_at_boundary`].
64    pub pending: Option<Box<(Message, u32)>>,
65    /// Deterministic evidence that later input actually referenced this entry.
66    pub use_count: u64,
67    pub last_used_step: Option<u64>,
68}
69
70/// Outcome of one boundary sweep, for the `KnowledgeSwept` kernel observation.
71#[derive(Debug, Clone, Default)]
72pub struct KnowledgeSweep {
73    pub removed_keys: Vec<String>,
74    pub tokens_freed: u32,
75    /// True when the sweep changed anything (removal OR applied upsert).
76    pub changed: bool,
77}
78
79/// The knowledge partition: durable, identity-bearing entries rendered into system[1].
80/// Appends are immediate (they extend the cached prefix — the cheap direction); mutation and
81/// removal of existing entries are boundary-deferred (see [`KnowledgeEntry::evict_at_boundary`]).
82#[derive(Debug, Clone, Default)]
83pub struct KnowledgePartition {
84    pub entries: Vec<KnowledgeEntry>,
85    pub token_count: u32,
86}
87
88impl KnowledgePartition {
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Unkeyed immediate append — exactly `push_entry(None, msg, tokens, false)`.
94    pub fn push(&mut self, msg: Message, token_count: u32) {
95        self.push_entry(None, msg, token_count, false);
96    }
97
98    /// Keyed push: a fresh key (or `None`) appends immediately; an existing key stages a
99    /// boundary-deferred upsert (and clears any pending eviction — the entry is wanted again).
100    /// `pinned` takes effect immediately in both cases (it is bookkeeping, not rendered bytes).
101    pub fn push_entry(
102        &mut self,
103        key: Option<compact_str::CompactString>,
104        mut msg: Message,
105        tokens: u32,
106        pinned: bool,
107    ) {
108        msg.token_count = Some(tokens);
109        if let Some(ref k) = key {
110            if let Some(entry) = self.entries.iter_mut().find(|e| e.key.as_ref() == Some(k)) {
111                entry.pending = Some(Box::new((msg, tokens)));
112                entry.evict_at_boundary = false;
113                entry.pinned = pinned;
114                return;
115            }
116        }
117        self.token_count += tokens;
118        self.entries.push(KnowledgeEntry {
119            key,
120            message: msg,
121            tokens,
122            pinned,
123            evict_at_boundary: false,
124            pending: None,
125            use_count: 0,
126            last_used_step: None,
127        });
128    }
129
130    /// Record references from a journal-derived history message. This is deliberately driven by
131    /// committed input, never by render-time wall clocks. Exact keyed references always count;
132    /// otherwise two shared content terms (one for a one-term entry) are required.
133    pub fn observe_references(&mut self, message: &Message, step: u64) {
134        let text = searchable_message_text(message);
135        let input_terms = lexical_terms(&text);
136        if text.is_empty() || input_terms.is_empty() {
137            return;
138        }
139        let folded = text.to_lowercase();
140        for entry in &mut self.entries {
141            if entry.evict_at_boundary {
142                continue;
143            }
144            let exact_key = entry
145                .key
146                .as_deref()
147                .filter(|key| key.len() >= 3)
148                .is_some_and(|key| folded.contains(&key.to_lowercase()));
149            let entry_text = searchable_message_text(&entry.message);
150            let entry_terms = lexical_terms(&entry_text);
151            let overlap = entry_terms.intersection(&input_terms).count();
152            let lexical_hit =
153                !entry_terms.is_empty() && overlap >= if entry_terms.len() == 1 { 1 } else { 2 };
154            if exact_key || lexical_hit {
155                entry.use_count = entry.use_count.saturating_add(1);
156                entry.last_used_step = Some(step);
157            }
158        }
159    }
160
161    pub fn retention_score(&self, index: usize, current_step: u64) -> Option<i64> {
162        let entry = self.entries.get(index)?;
163        Some(deterministic_retention_score(RetentionFeatures {
164            pinned: entry.pinned,
165            use_count: entry.use_count,
166            last_used_step: entry.last_used_step,
167            current_step,
168            lease_remaining_steps: None,
169            kind: retention_kind(entry.key.as_deref()),
170            tokens: entry.tokens,
171            confidence_ppm: 0,
172            stale_discount_ppm: 0,
173        }))
174    }
175
176    /// Mark the keyed entry for removal at the next boundary. Errs-open: unknown key is a no-op.
177    /// Returns whether a matching entry was marked.
178    pub fn remove(&mut self, key: &str) -> bool {
179        match self
180            .entries
181            .iter_mut()
182            .find(|e| e.key.as_deref() == Some(key))
183        {
184            Some(entry) => {
185                entry.evict_at_boundary = true;
186                entry.pending = None;
187                true
188            }
189            None => false,
190        }
191    }
192
193    /// Apply pending upserts and drop marked entries. Call ONLY at compaction/renewal
194    /// boundaries — this is the one place existing system[1] bytes may be rewritten.
195    pub fn sweep_at_boundary(&mut self) -> KnowledgeSweep {
196        let mut sweep = KnowledgeSweep::default();
197        for entry in &mut self.entries {
198            if let Some(replacement) = entry.pending.take() {
199                let (msg, tokens) = *replacement;
200                self.token_count = self.token_count - entry.tokens + tokens;
201                entry.message = msg;
202                entry.tokens = tokens;
203                sweep.changed = true;
204            }
205        }
206        let before = self.entries.len();
207        self.entries.retain(|e| {
208            if e.evict_at_boundary {
209                if let Some(ref k) = e.key {
210                    sweep.removed_keys.push(k.to_string());
211                }
212                sweep.tokens_freed += e.tokens;
213                false
214            } else {
215                true
216            }
217        });
218        if self.entries.len() != before {
219            self.token_count = self.token_count.saturating_sub(sweep.tokens_freed);
220            sweep.changed = true;
221        }
222        sweep
223    }
224
225    /// The rendered messages, in entry order (renderer / snapshot surface).
226    pub fn messages(&self) -> impl Iterator<Item = &Message> {
227        self.entries.iter().map(|e| &e.message)
228    }
229
230    pub fn len(&self) -> usize {
231        self.entries.len()
232    }
233    pub fn is_empty(&self) -> bool {
234        self.entries.is_empty()
235    }
236}
237
238fn retention_kind(key: Option<&str>) -> RetentionKind {
239    match key
240        .unwrap_or_default()
241        .split(':')
242        .next()
243        .unwrap_or_default()
244    {
245        "user" => RetentionKind::User,
246        "feedback" => RetentionKind::Feedback,
247        "project" | "memory" => RetentionKind::Project,
248        "reference" | "ref" => RetentionKind::Reference,
249        "skill" => RetentionKind::Skill,
250        "artifact" => RetentionKind::Artifact,
251        _ => RetentionKind::Other,
252    }
253}
254
255fn searchable_message_text(message: &Message) -> String {
256    let mut values = Vec::new();
257    match &message.content {
258        crate::types::message::Content::Text(text) => values.push(text.clone()),
259        crate::types::message::Content::Parts(parts) => {
260            for part in parts {
261                match part {
262                    crate::types::message::ContentPart::Text { text } => values.push(text.clone()),
263                    crate::types::message::ContentPart::ToolResult { output, .. } => {
264                        values.push(output.clone())
265                    }
266                    crate::types::message::ContentPart::Image { url: Some(url), .. } => {
267                        values.push(url.clone())
268                    }
269                    _ => {}
270                }
271            }
272        }
273    }
274    for call in &message.tool_calls {
275        values.push(call.name.to_string());
276        values.push(call.arguments.to_string());
277    }
278    values.join(" ")
279}
280
281fn lexical_terms(text: &str) -> std::collections::BTreeSet<String> {
282    let mut terms = std::collections::BTreeSet::new();
283    let mut segment = String::new();
284    let flush = |segment: &mut String, terms: &mut std::collections::BTreeSet<String>| {
285        if segment.is_empty() {
286            return;
287        }
288        let folded = segment.to_lowercase();
289        terms.insert(folded.clone());
290        let chars = folded.chars().collect::<Vec<_>>();
291        if chars.iter().any(|character| is_han(*character)) {
292            for pair in chars.windows(2) {
293                terms.insert(pair.iter().collect());
294            }
295        }
296        segment.clear();
297    };
298    for character in text.chars() {
299        if character.is_alphanumeric() {
300            segment.push(character);
301        } else {
302            flush(&mut segment, &mut terms);
303        }
304    }
305    flush(&mut segment, &mut terms);
306    terms
307}
308
309fn is_han(character: char) -> bool {
310    matches!(character as u32,
311        0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x3134F)
312}
313
314/// Four-slot context model aligned with LLM API slots (five fields — slot 3 spans
315/// `task_state` + `signals`):
316///
317///   Slot 1 — Identity  (system):    who the agent is; role, rules, constraints.
318///                                    Maps to: Anthropic system[0] cache_control, OpenAI system role.
319///                                    Never changes within a run.
320///
321///   Slot 2 — Knowledge (knowledge): what the agent knows; memory retrievals, skill
322///                                    definitions, artifacts. Low-frequency changes.
323///                                    Maps to: Anthropic system[1] cache_control.
324///
325///   Slot 3 — State     (task_state + signals): what the agent is doing right now.
326///                                    task_state = goal/plan/progress (structured).
327///                                    signals = runtime events (rollback notes, interrupts).
328///                                    Maps to: messages[0] user turn, rebuilt every call.
329///
330///   Slot 4 — History   (history):   what the agent has done; conversation turns,
331///                                    tool calls and results. Compression pipeline target.
332///                                    Maps to: messages[1..N].
333pub struct ContextPartitions {
334    pub system: Partition,
335    pub knowledge: KnowledgePartition,
336    pub task_state: TaskState,
337    /// Runtime signals injected into the current turn (rollback notes, interrupts).
338    /// Rendering is read-only. The prefix delivered to a provider request is consumed only when
339    /// that request's correlated `ProviderResult` commits; later arrivals remain for the next turn.
340    pub signals: Vec<String>,
341    pub history: Partition,
342}
343
344impl ContextPartitions {
345    pub fn new(_config: &ContextConfig) -> Self {
346        Self {
347            system: Partition::new(),
348            knowledge: KnowledgePartition::new(),
349            task_state: TaskState::default(),
350            signals: Vec::new(),
351            history: Partition::new(),
352        }
353    }
354
355    /// Total token count across all slots.
356    /// task_state tokens are measured from its rendered compact form.
357    pub fn total_tokens(&self, engine: &ContextTokenEngine) -> u32 {
358        // An empty task_state renders to nothing and must cost zero tokens; the engine's
359        // per-message floor (`.max(1)`) would otherwise charge a phantom token for the empty
360        // string and inflate the fixed-context deduction the utility selector budgets against.
361        let task_state = self.task_state.format_compact();
362        let task_state_tokens = if task_state.is_empty() {
363            0
364        } else {
365            engine.count(&task_state)
366        };
367        self.system.token_count
368            + self.knowledge.token_count
369            + task_state_tokens
370            + self.history.token_count
371    }
372}
373
374impl Default for ContextPartitions {
375    fn default() -> Self {
376        Self::new(&ContextConfig::default())
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::context::config::ContextConfig;
384    use crate::context::token_engine::ContextTokenEngine;
385    use crate::types::message::Message;
386
387    fn engine() -> ContextTokenEngine {
388        ContextTokenEngine::char_approx()
389    }
390
391    #[test]
392    fn push_updates_token_count() {
393        let mut ctx = ContextPartitions::new(&ContextConfig::default());
394        let base = ctx.total_tokens(&engine());
395        ctx.system.push(Message::system("rules"), 10);
396        ctx.history.push(Message::user("hello"), 5);
397        assert_eq!(ctx.total_tokens(&engine()), base + 15);
398    }
399
400    #[test]
401    fn task_state_tokens_included_in_total() {
402        use crate::context::task_state::TaskState;
403        let mut ctx = ContextPartitions::new(&ContextConfig::default());
404        let before = ctx.total_tokens(&engine());
405        ctx.task_state = TaskState {
406            goal: "do something important".to_string(),
407            ..Default::default()
408        };
409        let after = ctx.total_tokens(&engine());
410        assert!(
411            after > before,
412            "task_state should contribute to total_tokens"
413        );
414    }
415
416    #[test]
417    fn knowledge_tokens_included_in_total() {
418        let mut ctx = ContextPartitions::new(&ContextConfig::default());
419        let before = ctx.total_tokens(&engine());
420        ctx.knowledge.push(Message::system("skill: debug"), 20);
421        assert_eq!(ctx.total_tokens(&engine()), before + 20);
422    }
423
424    // ── K1: keyed knowledge entries ──────────────────────────────────────────
425
426    fn text_of(p: &KnowledgePartition) -> Vec<String> {
427        p.messages()
428            .filter_map(|m| m.content.as_text().map(str::to_string))
429            .collect()
430    }
431
432    #[test]
433    fn keyed_upsert_defers_to_boundary() {
434        let mut p = KnowledgePartition::new();
435        p.push_entry(Some("ref".into()), Message::system("v1"), 10, false);
436        p.push_entry(Some("ref".into()), Message::system("v2"), 12, false);
437        // Mid-generation: still ONE entry rendering the ORIGINAL bytes (system[1] untouched).
438        assert_eq!(p.len(), 1);
439        assert_eq!(text_of(&p), vec!["v1"]);
440        assert_eq!(p.token_count, 10);
441
442        let sweep = p.sweep_at_boundary();
443        assert!(sweep.changed);
444        assert!(
445            sweep.removed_keys.is_empty(),
446            "upsert-only sweep removes nothing"
447        );
448        assert_eq!(text_of(&p), vec!["v2"]);
449        assert_eq!(p.token_count, 12);
450    }
451
452    #[test]
453    fn remove_marks_then_sweep_drops() {
454        let mut p = KnowledgePartition::new();
455        p.push_entry(Some("ref".into()), Message::system("pinned ref"), 8, false);
456        assert!(p.remove("ref"));
457        // Still rendered until the boundary (no mid-generation byte rewrite).
458        assert_eq!(p.len(), 1);
459        assert_eq!(text_of(&p), vec!["pinned ref"]);
460
461        let sweep = p.sweep_at_boundary();
462        assert!(sweep.changed);
463        assert_eq!(sweep.removed_keys, vec!["ref".to_string()]);
464        assert_eq!(sweep.tokens_freed, 8);
465        assert!(p.is_empty());
466        assert_eq!(p.token_count, 0);
467    }
468
469    #[test]
470    fn remove_unknown_key_errs_open() {
471        let mut p = KnowledgePartition::new();
472        p.push(Message::system("unkeyed"), 5);
473        assert!(!p.remove("missing"));
474        assert!(!p.sweep_at_boundary().changed);
475        assert_eq!(p.len(), 1);
476    }
477
478    #[test]
479    fn same_key_push_after_remove_revives_entry() {
480        let mut p = KnowledgePartition::new();
481        p.push_entry(Some("ref".into()), Message::system("v1"), 5, false);
482        p.remove("ref");
483        // Re-pushing the key means the entry is wanted again — the eviction mark clears and the
484        // fresh content lands as a deferred upsert.
485        p.push_entry(Some("ref".into()), Message::system("v2"), 6, false);
486        let sweep = p.sweep_at_boundary();
487        assert!(sweep.removed_keys.is_empty());
488        assert_eq!(text_of(&p), vec!["v2"]);
489    }
490
491    #[test]
492    fn fresh_keys_and_unkeyed_append_immediately() {
493        let mut p = KnowledgePartition::new();
494        p.push(Message::system("legacy"), 3);
495        p.push_entry(Some("a".into()), Message::system("fresh"), 4, true);
496        // Appends are visible right away (cache-cheap direction: prefix only extends).
497        assert_eq!(text_of(&p), vec!["legacy", "fresh"]);
498        assert_eq!(p.token_count, 7);
499        assert!(p.entries[1].pinned);
500    }
501
502    #[test]
503    fn committed_message_updates_reference_usage_without_a_clock() {
504        let mut p = KnowledgePartition::new();
505        p.push_entry(
506            Some("project:orchid".into()),
507            Message::system("Atlas storage engine for ORCHID"),
508            8,
509            false,
510        );
511        p.push_entry(
512            Some("reference:unrelated".into()),
513            Message::system("Mercury deployment guide"),
514            8,
515            false,
516        );
517
518        p.observe_references(&Message::assistant("Use project:orchid and Atlas"), 7);
519
520        assert_eq!(p.entries[0].use_count, 1);
521        assert_eq!(p.entries[0].last_used_step, Some(7));
522        assert_eq!(p.entries[1].use_count, 0);
523        assert!(p.retention_score(0, 9) > p.retention_score(1, 9));
524    }
525}