Skip to main content

machi_compaction/
select.rs

1//! Compaction range selection with tool-pair invariant (W4.1).
2//!
3//! Maturity: **core**
4//!
5//! A split index must never land inside an assistant tool-call run followed by
6//! its tool results. Snapping moves the split past any orphaned tool messages.
7
8use machi_types::{Message, Role};
9
10/// Plan for compacting `messages[0..split_idx]` and keeping the tail.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct CompactionRange {
13    /// First index of the kept tail (`messages[split_idx..]`).
14    pub split_idx: usize,
15}
16
17/// Whether `split_idx` is a safe boundary (not mid tool-result run).
18#[must_use]
19pub fn is_safe_split(messages: &[Message], split_idx: usize) -> bool {
20    if split_idx >= messages.len() {
21        return true;
22    }
23    let Some(msg) = messages.get(split_idx) else {
24        return true;
25    };
26    // Split must not start on a Tool message (orphans results from their assistant).
27    msg.role != Role::Tool
28}
29
30/// Snap `split_idx` forward until the kept prefix no longer ends mid tool-run
31/// and the tail does not begin on a `Tool` role.
32///
33/// Returns `None` when snapping would keep nothing (entire list unsafe/consumed).
34/// `split_idx == messages.len()` is always safe (drop whole list / keep only system via
35/// [`apply_range`]).
36#[must_use]
37pub fn snap_split_forward(messages: &[Message], mut split_idx: usize) -> Option<usize> {
38    let n = messages.len();
39    if n == 0 {
40        return None;
41    }
42    if split_idx == 0 {
43        return None;
44    }
45    if split_idx > n {
46        split_idx = n;
47    }
48    while split_idx < n {
49        if is_safe_split(messages, split_idx) {
50            // Safe if first kept is not Tool (orphan results without assistant).
51            return Some(split_idx);
52        }
53        split_idx = split_idx.saturating_add(1);
54    }
55    // End-of-list is a safe split (compact away entire non-system body).
56    Some(n)
57}
58
59/// Choose a split that keeps at most `keep_tail` messages from the end,
60/// after preserving a leading system message, then snap for tool-pair safety.
61///
62/// Returns `None` when no compaction is needed or no safe split exists.
63/// `split_idx == messages.len()` is allowed (keep system only / empty tail).
64#[must_use]
65pub fn select_compaction_range(messages: &[Message], keep_tail: usize) -> Option<CompactionRange> {
66    if messages.is_empty() || keep_tail == 0 {
67        return None;
68    }
69    if messages.len() <= keep_tail {
70        return None;
71    }
72
73    let has_system = messages.first().is_some_and(|m| m.role == Role::System);
74    let rest_len = messages.len().saturating_sub(usize::from(has_system));
75    let keep_rest = keep_tail
76        .saturating_sub(usize::from(has_system))
77        .min(rest_len);
78    // Index into full list: after system + drop oldest rest.
79    let drop_rest = rest_len.saturating_sub(keep_rest);
80    let split_idx = usize::from(has_system).saturating_add(drop_rest);
81
82    if split_idx == 0 {
83        return None;
84    }
85
86    let split_idx = snap_split_forward(messages, split_idx)?;
87    if split_idx == 0 {
88        return None;
89    }
90    Some(CompactionRange { split_idx })
91}
92
93/// Apply a range: drop prefix, keep tail; optionally insert a summary as the
94/// first post-system message.
95#[must_use]
96pub fn apply_range(
97    messages: Vec<Message>,
98    range: CompactionRange,
99    summary: Option<Message>,
100) -> Vec<Message> {
101    let n = messages.len();
102    let split = range.split_idx.min(n);
103    let (head, tail) = messages.split_at(split);
104    let mut out = Vec::with_capacity(tail.len().saturating_add(2));
105    if let Some(first) = head.first()
106        && first.role == Role::System
107    {
108        out.push(first.clone());
109    }
110    if let Some(sum) = summary {
111        out.push(sum);
112    }
113    out.extend(tail.iter().cloned());
114    out
115}
116
117/// Invariant check for tests / fuzz: no kept Tool without a prior assistant
118/// tool-call message still in the list (or system/user only prefix is ok if no tools).
119#[must_use]
120pub fn tool_pair_invariant_holds(messages: &[Message]) -> bool {
121    let mut open_tool_calls: usize = 0;
122    for m in messages {
123        match m.role {
124            Role::Assistant if !m.tool_calls.is_empty() => {
125                open_tool_calls = open_tool_calls.saturating_add(m.tool_calls.len());
126            }
127            Role::Tool => {
128                if open_tool_calls == 0 {
129                    return false;
130                }
131                open_tool_calls = open_tool_calls.saturating_sub(1);
132            }
133            _ => {}
134        }
135    }
136    true
137}
138
139#[cfg(test)]
140#[allow(clippy::expect_used, reason = "unit tests")]
141mod tests {
142    use super::*;
143    use machi_types::{ToolCall, ToolCallId};
144    use serde_json::json;
145
146    fn tool_call(name: &str) -> ToolCall {
147        ToolCall {
148            id: ToolCallId::new("c1").expect("id"),
149            name: name.into(),
150            arguments: json!({}),
151        }
152    }
153
154    fn assistant_tools() -> Message {
155        Message::assistant_tools(vec![tool_call("x")])
156    }
157
158    fn tool_result() -> Message {
159        Message::tool_result(ToolCallId::new("c1").expect("id"), "x", "ok")
160    }
161
162    #[test]
163    fn snap_avoids_starting_on_tool() {
164        let msgs = vec![
165            Message::user("u1"),
166            assistant_tools(),
167            tool_result(),
168            Message::user("u2"),
169        ];
170        // Naïve split at 2 lands on tool_result.
171        assert!(!is_safe_split(&msgs, 2));
172        let snapped = snap_split_forward(&msgs, 2).expect("snap");
173        assert_eq!(snapped, 3);
174        assert!(is_safe_split(&msgs, snapped));
175    }
176
177    #[test]
178    fn select_range_preserves_invariant() {
179        let mut msgs = vec![Message::system("s")];
180        for i in 0..10 {
181            msgs.push(Message::user(format!("u{i}")));
182            msgs.push(assistant_tools());
183            msgs.push(tool_result());
184        }
185        let range = select_compaction_range(&msgs, 6).expect("range");
186        let out = apply_range(msgs, range, None);
187        assert!(tool_pair_invariant_holds(&out), "{out:?}");
188    }
189
190    #[test]
191    fn fuzz_random_splits_after_snap() {
192        let msgs = vec![
193            Message::system("s"),
194            Message::user("a"),
195            assistant_tools(),
196            tool_result(),
197            Message::user("b"),
198            assistant_tools(),
199            tool_result(),
200            Message::user("c"),
201        ];
202        for split in 1..msgs.len() {
203            if let Some(s) = snap_split_forward(&msgs, split) {
204                let out = apply_range(msgs.clone(), CompactionRange { split_idx: s }, None);
205                assert!(
206                    tool_pair_invariant_holds(&out),
207                    "split {split} -> {s}: {out:?}"
208                );
209            }
210        }
211    }
212}
213
214include!("select_matrix.rs");