machi_compaction/
select.rs1use machi_types::{Message, Role};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct CompactionRange {
13 pub split_idx: usize,
15}
16
17#[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 msg.role != Role::Tool
28}
29
30#[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 return Some(split_idx);
52 }
53 split_idx = split_idx.saturating_add(1);
54 }
55 Some(n)
57}
58
59#[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 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#[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#[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 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");