Skip to main content

cargo_context_core/
budget.rs

1//! Token budget allocation.
2//!
3//! The pack builder produces a list of *candidate* sections; this module
4//! decides which survive within a token ceiling.
5
6use crate::pack::Section;
7use crate::tokenize::Tokenizer;
8
9/// Strategy for reconciling candidate sections with the token limit.
10#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
11pub enum BudgetStrategy {
12    /// Sort by priority, drop whole sections from the tail until the total
13    /// fits. P0 sections (user prompt) are always retained even if they
14    /// individually exceed the budget.
15    #[default]
16    Priority,
17
18    /// Scale every competing section proportionally to its share of the
19    /// total. Nothing is dropped unless its proportional slot rounds to
20    /// zero tokens. Good when you want a balanced snapshot rather than a
21    /// priority-biased triage.
22    Proportional,
23
24    /// Keep sections in priority order until one overflows; hard-truncate
25    /// that section's content and stop.
26    Truncate,
27}
28
29#[derive(Debug, Clone, Copy)]
30pub struct Budget {
31    pub max_tokens: usize,
32    pub reserve_tokens: usize,
33    pub strategy: BudgetStrategy,
34}
35
36impl Default for Budget {
37    fn default() -> Self {
38        Self {
39            max_tokens: 8000,
40            reserve_tokens: 2000,
41            strategy: BudgetStrategy::default(),
42        }
43    }
44}
45
46impl Budget {
47    /// Tokens available for pack content (i.e. `max - reserve`).
48    pub fn effective(&self) -> usize {
49        self.max_tokens.saturating_sub(self.reserve_tokens)
50    }
51}
52
53/// Numeric priority for a candidate section. Lower = more important.
54pub type Priority = u8;
55
56/// Exempt from budget pressure. Used for the user prompt so the reader's
57/// own question is never dropped.
58pub const P_EXEMPT: Priority = 0;
59pub const P_ERROR: Priority = 1;
60pub const P_DIFF: Priority = 2;
61pub const P_MAP: Priority = 3;
62pub const P_ENTRY: Priority = 4;
63pub const P_TESTS: Priority = 5;
64
65/// Outcome of budget allocation: sections that fit + names of those dropped.
66#[derive(Debug, Default)]
67pub struct Allocation {
68    pub kept: Vec<Section>,
69    pub dropped: Vec<String>,
70    pub tokens_used: usize,
71    pub tokens_budget: usize,
72}
73
74/// Reconcile candidate sections with `budget` using its strategy.
75pub fn allocate(
76    candidates: Vec<(Priority, Section)>,
77    budget: &Budget,
78    tokenizer: &Tokenizer,
79) -> Allocation {
80    let limit = budget.effective();
81    match budget.strategy {
82        BudgetStrategy::Priority => apply_priority(candidates, limit),
83        BudgetStrategy::Proportional => apply_proportional(candidates, limit, tokenizer),
84        BudgetStrategy::Truncate => apply_truncate(candidates, limit, tokenizer),
85    }
86}
87
88fn apply_priority(candidates: Vec<(Priority, Section)>, limit: usize) -> Allocation {
89    // Split exempt sections off; they are unconditionally kept.
90    let (exempt, mut competing): (Vec<Section>, Vec<(Priority, Section)>) =
91        candidates.into_iter().partition_map(|(p, s)| {
92            if p == P_EXEMPT {
93                Left(s)
94            } else {
95                Right((p, s))
96            }
97        });
98    competing.sort_by_key(|(p, _)| *p);
99
100    let exempt_tokens: usize = exempt.iter().map(|s| s.token_estimate).sum();
101    let remaining = limit.saturating_sub(exempt_tokens);
102
103    let mut kept = exempt;
104    let mut dropped: Vec<String> = Vec::new();
105    let mut running: usize = 0;
106
107    for (_p, s) in competing {
108        if running + s.token_estimate <= remaining {
109            running += s.token_estimate;
110            kept.push(s);
111        } else {
112            dropped.push(s.name);
113        }
114    }
115
116    let tokens_used = exempt_tokens + running;
117    Allocation {
118        kept,
119        dropped,
120        tokens_used,
121        tokens_budget: limit,
122    }
123}
124
125fn apply_truncate(
126    candidates: Vec<(Priority, Section)>,
127    limit: usize,
128    tokenizer: &Tokenizer,
129) -> Allocation {
130    let (exempt, mut competing): (Vec<Section>, Vec<(Priority, Section)>) =
131        candidates.into_iter().partition_map(|(p, s)| {
132            if p == P_EXEMPT {
133                Left(s)
134            } else {
135                Right((p, s))
136            }
137        });
138    competing.sort_by_key(|(p, _)| *p);
139
140    let exempt_tokens: usize = exempt.iter().map(|s| s.token_estimate).sum();
141    let mut kept = exempt;
142    let mut dropped: Vec<String> = Vec::new();
143    let mut running: usize = 0;
144    let remaining_after_exempt = limit.saturating_sub(exempt_tokens);
145
146    for (_p, mut s) in competing {
147        let budget_left = remaining_after_exempt.saturating_sub(running);
148        if budget_left == 0 {
149            dropped.push(s.name);
150            continue;
151        }
152        if s.token_estimate <= budget_left {
153            running += s.token_estimate;
154            kept.push(s);
155            continue;
156        }
157        // Oversized: hard-truncate content to roughly `budget_left` tokens.
158        let orig_tokens = s.token_estimate.max(1);
159        let ratio = (budget_left as f64 / orig_tokens as f64).clamp(0.0, 1.0);
160        // Subtract ~5% for the truncation marker.
161        let char_count = s.content.chars().count();
162        let target_chars = ((char_count as f64) * ratio * 0.95) as usize;
163        let mut new_content: String = s.content.chars().take(target_chars).collect();
164        new_content.push_str("\n\n[... truncated by --budget-strategy=truncate ...]");
165        s.content = new_content;
166        s.token_estimate = tokenizer.count(&s.content);
167        running += s.token_estimate;
168        kept.push(s);
169        // Remainder of competing sections get dropped.
170        break;
171    }
172
173    // Any competing section we didn't process (because we broke out of the
174    // loop above) was already fully consumed; nothing else to do.
175    Allocation {
176        kept,
177        dropped,
178        tokens_used: exempt_tokens + running,
179        tokens_budget: limit,
180    }
181}
182
183fn apply_proportional(
184    candidates: Vec<(Priority, Section)>,
185    limit: usize,
186    tokenizer: &Tokenizer,
187) -> Allocation {
188    let (exempt, mut competing): (Vec<Section>, Vec<(Priority, Section)>) =
189        candidates.into_iter().partition_map(|(p, s)| {
190            if p == P_EXEMPT {
191                Left(s)
192            } else {
193                Right((p, s))
194            }
195        });
196    competing.sort_by_key(|(p, _)| *p);
197
198    let exempt_tokens: usize = exempt.iter().map(|s| s.token_estimate).sum();
199    let remaining = limit.saturating_sub(exempt_tokens);
200    let total_competing: usize = competing.iter().map(|(_, s)| s.token_estimate).sum();
201
202    let mut kept = exempt;
203    let mut dropped: Vec<String> = Vec::new();
204    let mut running: usize = 0;
205
206    if total_competing == 0 {
207        return Allocation {
208            kept,
209            dropped,
210            tokens_used: exempt_tokens,
211            tokens_budget: limit,
212        };
213    }
214
215    // Fast path: everything fits, no truncation needed.
216    if total_competing <= remaining {
217        for (_, s) in competing {
218            running += s.token_estimate;
219            kept.push(s);
220        }
221        return Allocation {
222            kept,
223            dropped,
224            tokens_used: exempt_tokens + running,
225            tokens_budget: limit,
226        };
227    }
228
229    // Scale every competing section by the same ratio.
230    let ratio = remaining as f64 / total_competing as f64;
231    let marker = "\n\n[... truncated by --budget-strategy=proportional ...]";
232    let marker_tokens = tokenizer.count(marker);
233    for (_, mut s) in competing {
234        let target_tokens = ((s.token_estimate as f64) * ratio).floor() as usize;
235        if target_tokens == 0 {
236            // Section would be reduced to nothing; drop it rather than emit
237            // an empty rendered section.
238            dropped.push(s.name);
239            continue;
240        }
241        if target_tokens >= s.token_estimate {
242            running += s.token_estimate;
243            kept.push(s);
244            continue;
245        }
246        // Budget for body = target minus the marker's cost.
247        let body_tokens = target_tokens.saturating_sub(marker_tokens);
248        if body_tokens == 0 {
249            dropped.push(s.name);
250            continue;
251        }
252        let orig_chars = s.content.chars().count();
253        let orig_tokens = s.token_estimate.max(1);
254        let char_target =
255            ((orig_chars as f64) * (body_tokens as f64) / (orig_tokens as f64)) as usize;
256        let mut new_content: String = s.content.chars().take(char_target).collect();
257        new_content.push_str(marker);
258        s.content = new_content;
259        s.token_estimate = tokenizer.count(&s.content);
260        running += s.token_estimate;
261        kept.push(s);
262    }
263
264    Allocation {
265        kept,
266        dropped,
267        tokens_used: exempt_tokens + running,
268        tokens_budget: limit,
269    }
270}
271
272// Minimal partition_map to avoid pulling `itertools` just for this.
273enum Either<L, R> {
274    Left(L),
275    Right(R),
276}
277use Either::{Left, Right};
278
279trait PartitionMap<I> {
280    fn partition_map<A, B, F>(self, f: F) -> (Vec<A>, Vec<B>)
281    where
282        F: FnMut(I) -> Either<A, B>;
283}
284
285impl<It, I> PartitionMap<I> for It
286where
287    It: Iterator<Item = I>,
288{
289    fn partition_map<A, B, F>(self, mut f: F) -> (Vec<A>, Vec<B>)
290    where
291        F: FnMut(I) -> Either<A, B>,
292    {
293        let mut a = Vec::new();
294        let mut b = Vec::new();
295        for item in self {
296            match f(item) {
297                Left(x) => a.push(x),
298                Right(x) => b.push(x),
299            }
300        }
301        (a, b)
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    fn mk(name: &str, tokens: usize) -> Section {
310        Section {
311            name: name.into(),
312            content: "x".repeat(tokens * 4),
313            token_estimate: tokens,
314        }
315    }
316
317    #[test]
318    fn effective_subtracts_reserve() {
319        let b = Budget {
320            max_tokens: 8000,
321            reserve_tokens: 2000,
322            strategy: BudgetStrategy::Priority,
323        };
324        assert_eq!(b.effective(), 6000);
325    }
326
327    #[test]
328    fn effective_saturates_at_zero() {
329        let b = Budget {
330            max_tokens: 100,
331            reserve_tokens: 500,
332            strategy: BudgetStrategy::Priority,
333        };
334        assert_eq!(b.effective(), 0);
335    }
336
337    #[test]
338    fn priority_drops_lowest_priority_when_over_budget() {
339        let candidates = vec![
340            (P_ERROR, mk("errors", 200)),
341            (P_DIFF, mk("diff", 500)),
342            (P_MAP, mk("map", 400)),
343        ];
344        let b = Budget {
345            max_tokens: 800,
346            reserve_tokens: 0,
347            strategy: BudgetStrategy::Priority,
348        };
349        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
350        assert_eq!(a.kept.len(), 2);
351        assert_eq!(a.kept[0].name, "errors");
352        assert_eq!(a.kept[1].name, "diff");
353        assert_eq!(a.dropped, vec!["map"]);
354        assert_eq!(a.tokens_used, 700);
355    }
356
357    #[test]
358    fn priority_keeps_prompt_even_when_oversized() {
359        let candidates = vec![
360            (P_EXEMPT, mk("📝 User Prompt", 5000)),
361            (P_ERROR, mk("errors", 100)),
362        ];
363        let b = Budget {
364            max_tokens: 1000,
365            reserve_tokens: 0,
366            strategy: BudgetStrategy::Priority,
367        };
368        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
369        assert!(a.kept.iter().any(|s| s.name.contains("Prompt")));
370        assert_eq!(a.dropped, vec!["errors"]);
371    }
372
373    #[test]
374    fn truncate_hard_cuts_overflowing_section() {
375        let candidates = vec![(P_ERROR, mk("errors", 200)), (P_DIFF, mk("diff", 1500))];
376        let b = Budget {
377            max_tokens: 800,
378            reserve_tokens: 0,
379            strategy: BudgetStrategy::Truncate,
380        };
381        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
382        assert_eq!(a.kept.len(), 2);
383        assert_eq!(a.kept[0].name, "errors");
384        assert_eq!(a.kept[1].name, "diff");
385        // Diff was truncated — its new content contains the marker.
386        assert!(a.kept[1].content.contains("truncated"));
387        assert!(a.tokens_used <= 800);
388    }
389
390    #[test]
391    fn zero_budget_drops_everything_except_exempt() {
392        let candidates = vec![
393            (P_EXEMPT, mk("📝 User Prompt", 50)),
394            (P_ERROR, mk("errors", 100)),
395            (P_DIFF, mk("diff", 100)),
396        ];
397        let b = Budget {
398            max_tokens: 0,
399            reserve_tokens: 0,
400            strategy: BudgetStrategy::Priority,
401        };
402        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
403        assert_eq!(a.kept.len(), 1);
404        assert_eq!(a.kept[0].name, "📝 User Prompt");
405        assert_eq!(a.dropped, vec!["errors", "diff"]);
406    }
407
408    #[test]
409    fn empty_candidates_produce_empty_allocation() {
410        let a = allocate(Vec::new(), &Budget::default(), &Tokenizer::CharsDiv4);
411        assert!(a.kept.is_empty());
412        assert!(a.dropped.is_empty());
413        assert_eq!(a.tokens_used, 0);
414    }
415
416    #[test]
417    fn proportional_scales_all_sections_when_over_budget() {
418        let candidates = vec![(P_ERROR, mk("errors", 400)), (P_DIFF, mk("diff", 600))];
419        let b = Budget {
420            max_tokens: 500,
421            reserve_tokens: 0,
422            strategy: BudgetStrategy::Proportional,
423        };
424        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
425        // Both sections survive (the whole point of proportional: no drops).
426        assert_eq!(a.kept.len(), 2);
427        assert!(a.dropped.is_empty());
428        // Both got truncated.
429        assert!(
430            a.kept.iter().all(|s| s.content.contains("truncated")),
431            "all oversized sections should carry the truncation marker"
432        );
433        // Budget is honored.
434        assert!(
435            a.tokens_used <= b.max_tokens,
436            "tokens_used {} exceeded budget {}",
437            a.tokens_used,
438            b.max_tokens
439        );
440    }
441
442    #[test]
443    fn proportional_keeps_whole_when_under_budget() {
444        let candidates = vec![(P_ERROR, mk("errors", 100)), (P_DIFF, mk("diff", 200))];
445        let b = Budget {
446            max_tokens: 1000,
447            reserve_tokens: 0,
448            strategy: BudgetStrategy::Proportional,
449        };
450        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
451        assert_eq!(a.kept.len(), 2);
452        assert_eq!(a.tokens_used, 300);
453        assert!(a.dropped.is_empty());
454        assert!(
455            a.kept.iter().all(|s| !s.content.contains("truncated")),
456            "under-budget allocation should not truncate"
457        );
458    }
459
460    #[test]
461    fn proportional_always_keeps_exempt() {
462        let candidates = vec![
463            (P_EXEMPT, mk("📝 User Prompt", 100)),
464            (P_ERROR, mk("errors", 500)),
465        ];
466        let b = Budget {
467            max_tokens: 200,
468            reserve_tokens: 0,
469            strategy: BudgetStrategy::Proportional,
470        };
471        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
472        // Prompt kept whole; errors proportionally scaled into remaining 100.
473        assert!(a.kept.iter().any(|s| s.name.contains("Prompt")));
474    }
475
476    #[test]
477    fn proportional_drops_zero_slot_sections() {
478        // Tiny section + huge section with tight budget: tiny's share rounds
479        // to zero. Preferable to emit an empty section? No — drop it and
480        // report.
481        let candidates = vec![(P_ERROR, mk("tiny", 1)), (P_DIFF, mk("huge", 10_000))];
482        let b = Budget {
483            max_tokens: 100,
484            reserve_tokens: 0,
485            strategy: BudgetStrategy::Proportional,
486        };
487        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
488        assert!(
489            a.dropped.contains(&"tiny".to_string()),
490            "zero-slot section should be dropped, got dropped={:?}",
491            a.dropped
492        );
493    }
494}