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