Skip to main content

bamboo_memory/budget/
mod.rs

1//! Token budget management — re-exported from bamboo-compression, plus
2//! granularity-aware prefix/suffix segmentation for durable-memory recall
3//! (issue #61 phase 2, "Prefix Cache Friendly" constraint).
4
5pub 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/// One memory's already-rendered contribution to a recalled-memory prompt section,
22/// tagged with the temporal granularity that decides which side of the prefix/
23/// suffix split it lands on (issue #61). `rendered` is whatever markdown/text form
24/// the caller uses for a single recalled memory (title, summary, freshness note,
25/// ...) — this module only cares about the granularity tag and the resulting char
26/// cost, not the rendering format.
27///
28/// Callers are expected to pass items in priority order (e.g. recall's relevance
29/// ranking — see `recall::sort_recall_candidates`); this module preserves that
30/// relative order within each of the two output segments.
31#[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/// Result of [`segment_by_granularity_budget`]: the PREFIX (coarse, cache-stable)
53/// and SUFFIX (fine, volatile) text blocks, plus which candidate ids were dropped
54/// from each side when the budget ran out.
55#[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    /// Prefix followed by suffix — coarse-before-fine render order, per the
65    /// issue's Prefix Cache Friendly constraint.
66    pub fn combined(&self) -> String {
67        format!("{}{}", self.prefix, self.suffix)
68    }
69}
70
71/// Split already-selected, already-ordered recall candidates into a cache-stable
72/// PREFIX segment (coarse granularity: `None`/month/quarter/year) and a volatile
73/// SUFFIX segment (fine granularity: week/day), so the prefix can sit at a stable
74/// position in the LLM prompt and benefit from provider-side prompt-prefix caching
75/// even while the suffix churns turn to turn.
76///
77/// # Why the prefix gets first claim on the budget, not a shared running total
78///
79/// The prefix region only stays prompt-cache stable if its rendered bytes never
80/// change in response to something happening in the suffix region. If this
81/// function spent one shared running budget over the combined, priority-ordered
82/// list (the way a single flat render loop naturally would), adding or growing a
83/// `day`-granularity memory earlier in that combined order could push a later
84/// `year`-granularity memory out of the budget — silently changing the prefix in
85/// response to fine-grained churn, exactly what the issue's constraint forbids.
86///
87/// Instead, the two groups get INDEPENDENT budgets: the coarse (prefix) group is
88/// filled first, using only its own items' sizes and the total budget; whatever
89/// budget remains afterward goes to the fine (suffix) group. Coarse inclusion is
90/// therefore a pure function of the coarse items and the total budget — it can
91/// never be perturbed by suffix content, which is exactly the "day-level churn
92/// cannot invalidate the prefix region" property this function guarantees (see
93/// `changing_or_adding_a_day_memory_does_not_change_the_rendered_prefix_segment`).
94///
95/// # Interaction with the recall ordering tie-break (Phase 1, `recall::sort_recall_candidates`)
96///
97/// Recall selection ranks candidates by RELEVANCE first and only breaks exact ties
98/// by granularity (see recall.rs's
99/// `higher_score_still_wins_over_cache_stable_granularity` test) — a highly
100/// relevant `day` memory legitimately outranks a barely relevant `year` one when
101/// deciding what makes the (small) recalled shortlist. That relevance contract
102/// governs *selection* — which memories are worth showing at all — and this
103/// function does not revisit it; every item passed in here already cleared that
104/// bar.
105///
106/// This function governs a different, later decision: once the (typically tiny)
107/// selected shortlist doesn't fit in the render budget, WHICH side gives way. By
108/// design, that decision favors cache stability: a coarse item is never dropped in
109/// favor of a fine item, even a more-relevant one, because the whole point of the
110/// segmentation is a prefix whose presence and byte content the fine/volatile side
111/// can never influence. In practice this almost never bites — recall shortlists
112/// are tiny (3 items in the engine caller) and comfortably fit typical section
113/// budgets — but when it does, cache stability wins over relevance-at-the-margin
114/// by explicit design; see
115/// `budget_exhaustion_drops_suffix_before_prefix_even_if_suffix_item_was_more_relevant`.
116pub 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    // Fine-grained content only spends what the coarse segment didn't need, so
133    // growing/adding a day-level memory can shrink or empty the SUFFIX segment but
134    // can never reach back and shrink, reorder, or drop anything from the PREFIX
135    // segment.
136    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
148/// Greedily concatenate `items` (already in priority order) up to `budget_chars`.
149/// Always includes at least the first item even if it alone exceeds the budget —
150/// so one oversized memory doesn't silently produce an empty segment — mirroring
151/// the truncation convention `bamboo-engine`'s `load_relevant_memory_snippets`
152/// already uses for the flat (pre-segmentation) render loop. Stops at the first
153/// item that doesn't fit rather than skipping ahead to a smaller later one, so
154/// segment order always matches input order and every dropped id is contiguous
155/// from the cutoff to the end.
156///
157/// A `budget_chars` of `0` renders nothing and drops every item — this is how a
158/// fully-exhausted-by-the-prefix budget correctly starves the suffix segment
159/// entirely rather than force-including one oversized item into a segment that
160/// was allotted no room at all.
161fn 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            // Deliberately interleaved input order — output must still group
212            // coarse-before-fine regardless of input order.
213            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        // `None` mirrors Phase 1's "most stable" treatment (cache_stability_rank
231        // rank 0) — untagged memories belong in the prefix, not the suffix.
232        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        // Mutate the day memory's content AND add a brand new day memory — both
260        // are pure suffix-side churn.
261        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        // Budget covers the coarse item comfortably but leaves nothing for the
294        // fine one.
295        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        // By the time items reach this function they already cleared recall's
308        // relevance bar (see the module doc comment on `segment_by_granularity_budget`).
309        // Put the "more relevant" fine item FIRST in priority order — recall would
310        // have ranked it there — and confirm the granularity split still wins over
311        // that ordering once the shared budget can't fit both: the coarse item is
312        // exactly wide enough to consume all remaining room.
313        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}