Skip to main content

deepstrike_core/context/
partitions.rs

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