Skip to main content

deepstrike_core/context/
utility.rs

1//! Deterministic value-aware selection over indivisible context units.
2
3use std::cmp::Ordering;
4use std::collections::BTreeSet;
5use std::ops::Range;
6
7use super::token_engine::ContextTokenEngine;
8use super::units::unit_boundaries;
9use crate::types::message::{Content, ContentPart, Message};
10
11pub struct UtilitySelectionContext<'a> {
12    pub goal: &'a str,
13    pub criteria: &'a [String],
14    pub preserved_refs: &'a [String],
15    pub active_directives: &'a [String],
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct UtilityUnitScore {
20    pub range: Range<usize>,
21    pub tokens: u32,
22    pub mandatory: bool,
23    pub goal_overlap: u32,
24    pub has_unresolved: bool,
25    pub referenced_later: bool,
26    pub is_error_or_decision: bool,
27    pub recency: u32,
28    pub token_cost: u32,
29    pub prefix_invalidation_cost: u32,
30    pub utility: i64,
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct UtilityArchivePlan {
35    pub archived_ranges: Vec<Range<usize>>,
36    pub retained_ranges: Vec<Range<usize>>,
37    pub archived_tokens: u32,
38    pub retained_tokens: u32,
39    pub scores: Vec<UtilityUnitScore>,
40}
41
42/// Select complete units to retain under `target_tokens`.
43///
44/// Mandatory dependencies are retained even when they alone exceed the target;
45/// callers can then escalate pressure honestly instead of silently deleting the
46/// evidence required to continue the task.
47pub fn plan_utility_archive(
48    messages: &[Message],
49    total_tokens: u32,
50    target_tokens: u32,
51    preserve_recent_units: usize,
52    engine: &ContextTokenEngine,
53    context: &UtilitySelectionContext<'_>,
54) -> UtilityArchivePlan {
55    let ranges = unit_boundaries(messages);
56    if ranges.is_empty() {
57        return UtilityArchivePlan::default();
58    }
59    let unit_texts = ranges
60        .iter()
61        .map(|range| unit_text(&messages[range.clone()]))
62        .collect::<Vec<_>>();
63    let goal_terms = terms(
64        std::iter::once(context.goal)
65            .chain(context.criteria.iter().map(String::as_str))
66            .collect::<Vec<_>>()
67            .join(" ")
68            .as_str(),
69    );
70    let recent_start = ranges.len().saturating_sub(preserve_recent_units);
71    let denominator = total_tokens.max(1);
72    let unit_count = ranges.len().max(1) as u32;
73    let mut scores = Vec::with_capacity(ranges.len());
74
75    for (index, range) in ranges.iter().enumerate() {
76        let slice = &messages[range.clone()];
77        let text = &unit_texts[index];
78        let tokens = slice
79            .iter()
80            .map(|message| {
81                message
82                    .token_count
83                    .unwrap_or_else(|| engine.count_message(message))
84            })
85            .sum::<u32>();
86        let goal_overlap = overlap_count(&terms(text), &goal_terms);
87        let has_unresolved = has_unresolved(slice, text);
88        let referenced_later = unit_referenced_later(slice, text, &unit_texts[index + 1..]);
89        let is_error_or_decision = is_error_or_decision(slice, text);
90        let dependency = context
91            .preserved_refs
92            .iter()
93            .any(|reference| contains_folded(text, reference))
94            || context
95                .active_directives
96                .iter()
97                .any(|directive| directive_dependency(text, directive));
98        let mandatory = index >= recent_start || has_unresolved || dependency;
99        let recency = ((index as u64 + 1) * 1_000 / u64::from(unit_count)) as u32;
100        let token_cost = (u64::from(tokens) * 1_000 / u64::from(denominator)) as u32;
101        let prefix_invalidation_cost =
102            ((ranges.len() - index) as u64 * 1_000 / u64::from(unit_count)) as u32;
103        let utility = i64::from(goal_overlap) * 4_000
104            + if has_unresolved { 20_000 } else { 0 }
105            + if referenced_later { 5_000 } else { 0 }
106            + if is_error_or_decision { 6_000 } else { 0 }
107            + i64::from(recency) * 2
108            - i64::from(token_cost) * 2
109            - i64::from(prefix_invalidation_cost);
110        scores.push(UtilityUnitScore {
111            range: range.clone(),
112            tokens,
113            mandatory,
114            goal_overlap,
115            has_unresolved,
116            referenced_later,
117            is_error_or_decision,
118            recency,
119            token_cost,
120            prefix_invalidation_cost,
121            utility,
122        });
123    }
124
125    if total_tokens <= target_tokens {
126        return UtilityArchivePlan {
127            archived_ranges: Vec::new(),
128            retained_ranges: ranges,
129            archived_tokens: 0,
130            retained_tokens: scores.iter().map(|score| score.tokens).sum(),
131            scores,
132        };
133    }
134
135    let mut retained = scores
136        .iter()
137        .enumerate()
138        .filter_map(|(index, score)| score.mandatory.then_some(index))
139        .collect::<BTreeSet<_>>();
140    let mut retained_tokens = retained
141        .iter()
142        .map(|index| scores[*index].tokens)
143        .sum::<u32>();
144    let mut optional = scores
145        .iter()
146        .enumerate()
147        .filter_map(|(index, score)| (!score.mandatory).then_some(index))
148        .collect::<Vec<_>>();
149    optional.sort_by(|left, right| compare_density(&scores[*right], &scores[*left]));
150    for index in optional {
151        let tokens = scores[index].tokens;
152        if retained_tokens.saturating_add(tokens) <= target_tokens {
153            retained.insert(index);
154            retained_tokens = retained_tokens.saturating_add(tokens);
155        }
156    }
157
158    let retained_ranges = ranges
159        .iter()
160        .enumerate()
161        .filter_map(|(index, range)| retained.contains(&index).then_some(range.clone()))
162        .collect::<Vec<_>>();
163    let archived_ranges = ranges
164        .iter()
165        .enumerate()
166        .filter_map(|(index, range)| (!retained.contains(&index)).then_some(range.clone()))
167        .collect::<Vec<_>>();
168    let archived_tokens = scores
169        .iter()
170        .enumerate()
171        .filter_map(|(index, score)| (!retained.contains(&index)).then_some(score.tokens))
172        .sum();
173    UtilityArchivePlan {
174        archived_ranges,
175        retained_ranges,
176        archived_tokens,
177        retained_tokens,
178        scores,
179    }
180}
181
182fn compare_density(left: &UtilityUnitScore, right: &UtilityUnitScore) -> Ordering {
183    let left_density = i128::from(left.utility) * i128::from(right.tokens.max(1));
184    let right_density = i128::from(right.utility) * i128::from(left.tokens.max(1));
185    left_density
186        .cmp(&right_density)
187        .then_with(|| left.utility.cmp(&right.utility))
188        .then_with(|| left.range.start.cmp(&right.range.start))
189}
190
191fn unit_text(messages: &[Message]) -> String {
192    let mut parts = Vec::new();
193    for message in messages {
194        match &message.content {
195            Content::Text(text) => parts.push(text.clone()),
196            Content::Parts(content_parts) => {
197                for part in content_parts {
198                    match part {
199                        ContentPart::Text { text } => parts.push(text.clone()),
200                        ContentPart::ToolResult {
201                            call_id, output, ..
202                        } => parts.push(format!("{call_id} {output}")),
203                        ContentPart::Image { url, .. } => {
204                            parts.push(url.clone().unwrap_or_default())
205                        }
206                        ContentPart::Audio { .. } => parts.push("audio".into()),
207                    }
208                }
209            }
210        }
211        for call in &message.tool_calls {
212            parts.push(format!("{} {} {}", call.id, call.name, call.arguments));
213        }
214    }
215    parts.join("\n")
216}
217
218fn terms(text: &str) -> BTreeSet<String> {
219    let mut output = BTreeSet::new();
220    let mut ascii = String::new();
221    for character in text.chars().flat_map(char::to_lowercase) {
222        if character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '/' | '.' | ':') {
223            ascii.push(character);
224            continue;
225        }
226        if !ascii.is_empty() {
227            if ascii.chars().count() > 1 {
228                output.insert(std::mem::take(&mut ascii));
229            } else {
230                ascii.clear();
231            }
232        }
233        if !character.is_whitespace() && !character.is_ascii_punctuation() {
234            output.insert(character.to_string());
235        }
236    }
237    if ascii.chars().count() > 1 {
238        output.insert(ascii);
239    }
240    output
241}
242
243fn overlap_count(left: &BTreeSet<String>, right: &BTreeSet<String>) -> u32 {
244    left.intersection(right).count() as u32
245}
246
247fn contains_folded(text: &str, pattern: &str) -> bool {
248    !pattern.trim().is_empty() && text.to_lowercase().contains(&pattern.to_lowercase())
249}
250
251fn directive_dependency(text: &str, directive: &str) -> bool {
252    if contains_folded(text, directive) {
253        return true;
254    }
255    let directive_terms = terms(directive);
256    if directive_terms.is_empty() {
257        return false;
258    }
259    let threshold = directive_terms.len().min(2);
260    terms(text).intersection(&directive_terms).count() >= threshold
261}
262
263fn has_unresolved(messages: &[Message], text: &str) -> bool {
264    let mut opened = BTreeSet::new();
265    let mut resolved = BTreeSet::new();
266    for message in messages {
267        for call in &message.tool_calls {
268            opened.insert(call.id.to_string());
269        }
270        if let Content::Parts(parts) = &message.content {
271            for part in parts {
272                if let ContentPart::ToolResult {
273                    call_id, is_error, ..
274                } = part
275                {
276                    if *is_error {
277                        return true;
278                    }
279                    resolved.insert(call_id.to_string());
280                }
281            }
282        }
283    }
284    opened.iter().any(|call_id| !resolved.contains(call_id))
285        || marker(
286            text,
287            &[
288                "unresolved",
289                "open question",
290                "retry",
291                "blocked",
292                "待确认",
293                "未解决",
294                "重试",
295                "阻塞",
296            ],
297        )
298}
299
300fn is_error_or_decision(messages: &[Message], text: &str) -> bool {
301    messages.iter().any(|message| {
302        matches!(&message.content, Content::Parts(parts) if parts.iter().any(|part| matches!(part, ContentPart::ToolResult { is_error: true, .. })))
303    }) || marker(
304        text,
305        &[
306            "error", "failed", "failure", "exception", "decision", "decided", "must", "should",
307            "错误", "失败", "异常", "决定", "选择", "必须", "应当",
308        ],
309    )
310}
311
312fn marker(text: &str, markers: &[&str]) -> bool {
313    markers.iter().any(|marker| contains_folded(text, marker))
314}
315
316fn unit_referenced_later(messages: &[Message], text: &str, later: &[String]) -> bool {
317    let mut references = messages
318        .iter()
319        .flat_map(|message| message.tool_calls.iter().map(|call| call.id.to_string()))
320        .collect::<BTreeSet<_>>();
321    references.extend(
322        text.split_whitespace()
323            .map(|token| token.trim_matches(|character: char| character.is_ascii_punctuation()))
324            .filter(|token| token.contains('/') || token.contains("://"))
325            .filter(|token| token.len() > 3)
326            .map(str::to_string),
327    );
328    references.iter().any(|reference| {
329        later
330            .iter()
331            .any(|later_text| contains_folded(later_text, reference))
332    })
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::types::message::{ContentPart, ToolCall};
339
340    #[test]
341    fn unresolved_tool_unit_is_mandatory() {
342        let mut call = Message::assistant("working");
343        call.tool_calls.push(ToolCall {
344            id: "call-1".into(),
345            name: "read".into(),
346            arguments: serde_json::json!({"path": "/work/a"}),
347        });
348        call.token_count = Some(20);
349        let mut recent = Message::user("recent");
350        recent.token_count = Some(20);
351        let messages = vec![call, recent];
352        let plan = plan_utility_archive(
353            &messages,
354            40,
355            20,
356            1,
357            &ContextTokenEngine::char_approx(),
358            &UtilitySelectionContext {
359                goal: "",
360                criteria: &[],
361                preserved_refs: &[],
362                active_directives: &[],
363            },
364        );
365        assert!(plan.scores[0].mandatory);
366        assert!(plan.scores[0].has_unresolved);
367        assert_eq!(plan.retained_tokens, 40);
368    }
369
370    #[test]
371    fn preserved_ref_keeps_complete_tool_unit() {
372        let mut call = Message::assistant("read artifact");
373        call.tool_calls.push(ToolCall {
374            id: "call-keep".into(),
375            name: "read".into(),
376            arguments: serde_json::json!({}),
377        });
378        call.token_count = Some(20);
379        let mut result = Message::tool(vec![ContentPart::ToolResult {
380            call_id: "call-keep".into(),
381            output: "artifact".into(),
382            is_error: false,
383        }]);
384        result.token_count = Some(20);
385        let messages = vec![call, result];
386        let plan = plan_utility_archive(
387            &messages,
388            40,
389            0,
390            0,
391            &ContextTokenEngine::char_approx(),
392            &UtilitySelectionContext {
393                goal: "",
394                criteria: &[],
395                preserved_refs: &["call-keep".into()],
396                active_directives: &[],
397            },
398        );
399        assert!(plan.scores[0].mandatory);
400        assert_eq!(plan.archived_ranges, Vec::<Range<usize>>::new());
401    }
402}