1pub use bamboo_compression::{
6 active_messages_for_budget, apply_compression_plan, build_compression_plan_with_summary,
7 build_forced_compression_plan_with_summary, build_summary_prompt, compression_summary_message,
8 context_window_usage_percent, estimate_context_compression_exposure,
9 normalized_trigger_percent, summary_source_messages, CompressionPlan, CompressionPlanError,
10 ContextCompressionExposure,
11};
12pub use bamboo_compression::{
13 create_budget_for_model, prepare_hybrid_context, BudgetError, BudgetStrategy,
14 HeuristicSummarizer, HeuristicTokenCounter, MessageSegmenter, ModelLimitsRegistry,
15 PreparedContext, Summarizer, SummaryManager, SummaryTrigger, TiktokenTokenCounter, TokenBudget,
16 TokenCounter, TokenUsageBreakdown,
17};
18
19use crate::memory_store::TemporalGranularity;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct GranularityBudgetItem {
33 pub id: String,
34 pub granularity: Option<TemporalGranularity>,
35 pub rendered: String,
36}
37
38impl GranularityBudgetItem {
39 pub fn new(
40 id: impl Into<String>,
41 granularity: Option<TemporalGranularity>,
42 rendered: impl Into<String>,
43 ) -> Self {
44 Self {
45 id: id.into(),
46 granularity,
47 rendered: rendered.into(),
48 }
49 }
50}
51
52#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub struct GranularityBudgetSegments {
57 pub prefix: String,
58 pub suffix: String,
59 pub prefix_dropped_ids: Vec<String>,
60 pub suffix_dropped_ids: Vec<String>,
61}
62
63impl GranularityBudgetSegments {
64 pub fn combined(&self) -> String {
67 format!("{}{}", self.prefix, self.suffix)
68 }
69}
70
71pub fn segment_by_granularity_budget(
117 items: &[GranularityBudgetItem],
118 total_budget_chars: usize,
119) -> GranularityBudgetSegments {
120 let mut prefix_items = Vec::new();
121 let mut suffix_items = Vec::new();
122 for item in items {
123 if TemporalGranularity::is_high_churn(item.granularity) {
124 suffix_items.push(item);
125 } else {
126 prefix_items.push(item);
127 }
128 }
129
130 let (prefix, prefix_chars, prefix_dropped_ids) =
131 fill_budget_segment(&prefix_items, total_budget_chars);
132 let remaining_budget = total_budget_chars.saturating_sub(prefix_chars);
137 let (suffix, _suffix_chars, suffix_dropped_ids) =
138 fill_budget_segment(&suffix_items, remaining_budget);
139
140 GranularityBudgetSegments {
141 prefix,
142 suffix,
143 prefix_dropped_ids,
144 suffix_dropped_ids,
145 }
146}
147
148fn fill_budget_segment(
162 items: &[&GranularityBudgetItem],
163 budget_chars: usize,
164) -> (String, usize, Vec<String>) {
165 if budget_chars == 0 {
166 return (
167 String::new(),
168 0,
169 items.iter().map(|item| item.id.clone()).collect(),
170 );
171 }
172
173 let mut rendered = String::new();
174 let mut used_chars = 0usize;
175 let mut dropped_ids = Vec::new();
176 let mut truncated = false;
177
178 for item in items {
179 if truncated {
180 dropped_ids.push(item.id.clone());
181 continue;
182 }
183 let cost = item.rendered.chars().count();
184 if used_chars > 0 && used_chars + cost > budget_chars {
185 truncated = true;
186 dropped_ids.push(item.id.clone());
187 continue;
188 }
189 used_chars += cost;
190 rendered.push_str(&item.rendered);
191 }
192
193 (rendered, used_chars, dropped_ids)
194}
195
196#[cfg(test)]
197mod granularity_budget_tests {
198 use super::*;
199
200 fn item(
201 id: &str,
202 granularity: Option<TemporalGranularity>,
203 rendered: &str,
204 ) -> GranularityBudgetItem {
205 GranularityBudgetItem::new(id, granularity, rendered)
206 }
207
208 #[test]
209 fn coarse_memories_render_before_fine_ones() {
210 let items = vec![
211 item("day-1", Some(TemporalGranularity::Day), "DAY."),
214 item("year-1", Some(TemporalGranularity::Year), "YEAR."),
215 item("week-1", Some(TemporalGranularity::Week), "WEEK."),
216 item("quarter-1", Some(TemporalGranularity::Quarter), "QUARTER."),
217 ];
218
219 let segments = segment_by_granularity_budget(&items, 1_000);
220 assert_eq!(segments.prefix, "YEAR.QUARTER.");
221 assert_eq!(segments.suffix, "DAY.WEEK.");
222 let combined = segments.combined();
223 assert!(combined.find("YEAR").unwrap() < combined.find("DAY").unwrap());
224 assert!(segments.prefix_dropped_ids.is_empty());
225 assert!(segments.suffix_dropped_ids.is_empty());
226 }
227
228 #[test]
229 fn none_granularity_is_treated_as_prefix_eligible() {
230 let items = vec![
233 item("untagged", None, "UNTAGGED."),
234 item("day-1", Some(TemporalGranularity::Day), "DAY."),
235 ];
236 let segments = segment_by_granularity_budget(&items, 1_000);
237 assert_eq!(segments.prefix, "UNTAGGED.");
238 assert_eq!(segments.suffix, "DAY.");
239 }
240
241 #[test]
242 fn changing_or_adding_a_day_memory_does_not_change_the_rendered_prefix_segment() {
243 let coarse = [
244 item("year-1", Some(TemporalGranularity::Year), "YEAR-ONE."),
245 item(
246 "quarter-1",
247 Some(TemporalGranularity::Quarter),
248 "QUARTER-ONE.",
249 ),
250 ];
251
252 let before = vec![
253 coarse[0].clone(),
254 coarse[1].clone(),
255 item("day-1", Some(TemporalGranularity::Day), "DAY-ONE."),
256 ];
257 let segments_before = segment_by_granularity_budget(&before, 500);
258
259 let after = vec![
262 coarse[0].clone(),
263 coarse[1].clone(),
264 item(
265 "day-1",
266 Some(TemporalGranularity::Day),
267 "DAY-ONE-REWRITTEN-WITH-MORE-DETAIL.",
268 ),
269 item(
270 "day-2",
271 Some(TemporalGranularity::Day),
272 "DAY-TWO-BRAND-NEW.",
273 ),
274 ];
275 let segments_after = segment_by_granularity_budget(&after, 500);
276
277 assert_eq!(
278 segments_before.prefix, segments_after.prefix,
279 "prefix segment must be byte-identical regardless of suffix churn"
280 );
281 assert_ne!(
282 segments_before.suffix, segments_after.suffix,
283 "suffix segment is expected to change"
284 );
285 }
286
287 #[test]
288 fn budget_exhaustion_drops_fine_before_coarse() {
289 let items = vec![
290 item("year-1", Some(TemporalGranularity::Year), &"Y".repeat(40)),
291 item("day-1", Some(TemporalGranularity::Day), &"D".repeat(40)),
292 ];
293 let segments = segment_by_granularity_budget(&items, 40);
296 assert_eq!(segments.prefix.chars().count(), 40);
297 assert!(segments.prefix_dropped_ids.is_empty());
298 assert!(
299 segments.suffix.is_empty(),
300 "suffix must be fully starved once the prefix consumes the whole budget"
301 );
302 assert_eq!(segments.suffix_dropped_ids, vec!["day-1".to_string()]);
303 }
304
305 #[test]
306 fn budget_exhaustion_drops_suffix_before_prefix_even_if_suffix_item_was_more_relevant() {
307 let items = vec![
314 item(
315 "day-most-relevant",
316 Some(TemporalGranularity::Day),
317 &"R".repeat(30),
318 ),
319 item(
320 "year-least-relevant",
321 Some(TemporalGranularity::Year),
322 &"C".repeat(30),
323 ),
324 ];
325 let segments = segment_by_granularity_budget(&items, 30);
326 assert_eq!(
327 segments.prefix.chars().count(),
328 30,
329 "coarse item still renders"
330 );
331 assert!(
332 segments.suffix.is_empty(),
333 "fine item is dropped by design, despite higher relevance"
334 );
335 assert_eq!(
336 segments.suffix_dropped_ids,
337 vec!["day-most-relevant".to_string()]
338 );
339 }
340
341 #[test]
342 fn leftover_prefix_budget_is_available_to_the_suffix() {
343 let items = vec![
344 item("year-1", Some(TemporalGranularity::Year), &"Y".repeat(10)),
345 item("day-1", Some(TemporalGranularity::Day), &"D".repeat(10)),
346 ];
347 let segments = segment_by_granularity_budget(&items, 25);
348 assert_eq!(segments.prefix.chars().count(), 10);
349 assert_eq!(segments.suffix.chars().count(), 10);
350 assert!(segments.prefix_dropped_ids.is_empty());
351 assert!(segments.suffix_dropped_ids.is_empty());
352 }
353
354 #[test]
355 fn always_includes_at_least_one_oversized_item_per_segment() {
356 let items = vec![item(
357 "year-huge",
358 Some(TemporalGranularity::Year),
359 &"Y".repeat(500),
360 )];
361 let segments = segment_by_granularity_budget(&items, 10);
362 assert_eq!(segments.prefix.chars().count(), 500);
363 assert!(segments.prefix_dropped_ids.is_empty());
364 }
365
366 #[test]
367 fn zero_budget_drops_everything_and_starves_both_segments() {
368 let items = vec![
369 item("year-1", Some(TemporalGranularity::Year), "Y"),
370 item("day-1", Some(TemporalGranularity::Day), "D"),
371 ];
372 let segments = segment_by_granularity_budget(&items, 0);
373 assert!(segments.prefix.is_empty());
374 assert!(segments.suffix.is_empty());
375 assert_eq!(segments.prefix_dropped_ids, vec!["year-1".to_string()]);
376 assert_eq!(segments.suffix_dropped_ids, vec!["day-1".to_string()]);
377 }
378
379 #[test]
380 fn empty_input_yields_empty_segments() {
381 let segments = segment_by_granularity_budget(&[], 1_000);
382 assert_eq!(segments, GranularityBudgetSegments::default());
383 }
384}