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 serde::{Deserialize, Serialize};
7
8use crate::pack::Section;
9use crate::tokenize::Tokenizer;
10
11/// Strategy for reconciling candidate sections with the token limit.
12#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum BudgetStrategy {
15    /// Sort by priority, drop whole sections from the tail until the total
16    /// fits. P0 sections (user prompt) are always retained even if they
17    /// individually exceed the budget.
18    #[default]
19    Priority,
20
21    /// Scale every competing section proportionally to its share of the
22    /// total. Nothing is dropped unless its proportional slot rounds to
23    /// zero tokens. Good when you want a balanced snapshot rather than a
24    /// priority-biased triage.
25    Proportional,
26
27    /// Keep sections in priority order until one overflows; hard-truncate
28    /// that section's content and stop.
29    Truncate,
30}
31
32#[derive(Debug, Clone, Copy)]
33pub struct Budget {
34    pub max_tokens: usize,
35    pub reserve_tokens: usize,
36    pub strategy: BudgetStrategy,
37}
38
39impl Default for Budget {
40    fn default() -> Self {
41        Self {
42            max_tokens: 8000,
43            reserve_tokens: 2000,
44            strategy: BudgetStrategy::default(),
45        }
46    }
47}
48
49impl Budget {
50    /// Tokens available for pack content (i.e. `max - reserve`).
51    pub fn effective(&self) -> usize {
52        self.max_tokens.saturating_sub(self.reserve_tokens)
53    }
54}
55
56/// Numeric priority for a candidate section. Lower = more important.
57pub type Priority = u8;
58
59/// Exempt from budget pressure. Used for the user prompt so the reader's
60/// own question is never dropped.
61pub const P_EXEMPT: Priority = 0;
62pub const P_ERROR: Priority = 1;
63pub const P_DIFF: Priority = 2;
64pub const P_MAP: Priority = 3;
65pub const P_ENTRY: Priority = 4;
66pub const P_TESTS: Priority = 5;
67
68/// Outcome of budget allocation: sections that fit + names of those dropped.
69#[derive(Debug, Default)]
70pub struct Allocation {
71    pub kept: Vec<Section>,
72    pub dropped: Vec<String>,
73    pub tokens_used: usize,
74    pub tokens_budget: usize,
75    pub decisions: Vec<BudgetDecision>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct BudgetDecision {
80    pub section: String,
81    pub priority: Priority,
82    pub original_tokens: usize,
83    pub final_tokens: usize,
84    pub outcome: BudgetOutcome,
85    pub reason: Option<String>,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum BudgetOutcome {
91    Kept,
92    Truncated,
93    Dropped,
94}
95
96/// Reconcile candidate sections with `budget` using its strategy.
97pub fn allocate(
98    candidates: Vec<(Priority, Section)>,
99    budget: &Budget,
100    tokenizer: &Tokenizer,
101) -> Allocation {
102    let limit = budget.effective();
103    match budget.strategy {
104        BudgetStrategy::Priority => apply_priority(candidates, limit),
105        BudgetStrategy::Proportional => apply_proportional(candidates, limit, tokenizer),
106        BudgetStrategy::Truncate => apply_truncate(candidates, limit, tokenizer),
107    }
108}
109
110fn apply_priority(candidates: Vec<(Priority, Section)>, limit: usize) -> Allocation {
111    // Split exempt sections off; they are unconditionally kept.
112    let (exempt, mut competing): (Vec<Section>, Vec<(Priority, Section)>) =
113        candidates.into_iter().partition_map(|(p, s)| {
114            if p == P_EXEMPT {
115                Left(s)
116            } else {
117                Right((p, s))
118            }
119        });
120    competing.sort_by_key(|(p, _)| *p);
121
122    let exempt_tokens: usize = exempt.iter().map(|s| s.token_estimate).sum();
123    let remaining = limit.saturating_sub(exempt_tokens);
124
125    let mut kept = exempt;
126    let mut dropped: Vec<String> = Vec::new();
127    let mut decisions: Vec<BudgetDecision> = kept
128        .iter()
129        .map(|s| BudgetDecision {
130            section: s.name.clone(),
131            priority: P_EXEMPT,
132            original_tokens: s.token_estimate,
133            final_tokens: s.token_estimate,
134            outcome: BudgetOutcome::Kept,
135            reason: Some("exempt".into()),
136        })
137        .collect();
138    let mut running: usize = 0;
139
140    for (p, s) in competing {
141        let original_tokens = s.token_estimate;
142        if running + s.token_estimate <= remaining {
143            running += s.token_estimate;
144            decisions.push(BudgetDecision {
145                section: s.name.clone(),
146                priority: p,
147                original_tokens,
148                final_tokens: s.token_estimate,
149                outcome: BudgetOutcome::Kept,
150                reason: None,
151            });
152            kept.push(s);
153        } else {
154            decisions.push(BudgetDecision {
155                section: s.name.clone(),
156                priority: p,
157                original_tokens,
158                final_tokens: 0,
159                outcome: BudgetOutcome::Dropped,
160                reason: Some("priority budget exhausted".into()),
161            });
162            dropped.push(s.name);
163        }
164    }
165
166    let tokens_used = exempt_tokens + running;
167    Allocation {
168        kept,
169        dropped,
170        tokens_used,
171        tokens_budget: limit,
172        decisions,
173    }
174}
175
176fn apply_truncate(
177    candidates: Vec<(Priority, Section)>,
178    limit: usize,
179    tokenizer: &Tokenizer,
180) -> Allocation {
181    let (exempt, mut competing): (Vec<Section>, Vec<(Priority, Section)>) =
182        candidates.into_iter().partition_map(|(p, s)| {
183            if p == P_EXEMPT {
184                Left(s)
185            } else {
186                Right((p, s))
187            }
188        });
189    competing.sort_by_key(|(p, _)| *p);
190
191    let exempt_tokens: usize = exempt.iter().map(|s| s.token_estimate).sum();
192    let mut kept = exempt;
193    let mut dropped: Vec<String> = Vec::new();
194    let mut decisions: Vec<BudgetDecision> = kept
195        .iter()
196        .map(|s| BudgetDecision {
197            section: s.name.clone(),
198            priority: P_EXEMPT,
199            original_tokens: s.token_estimate,
200            final_tokens: s.token_estimate,
201            outcome: BudgetOutcome::Kept,
202            reason: Some("exempt".into()),
203        })
204        .collect();
205    let mut running: usize = 0;
206    let remaining_after_exempt = limit.saturating_sub(exempt_tokens);
207
208    let mut iter = competing.into_iter();
209    while let Some((p, mut s)) = iter.next() {
210        let original_tokens = s.token_estimate;
211        let budget_left = remaining_after_exempt.saturating_sub(running);
212        if budget_left == 0 {
213            decisions.push(BudgetDecision {
214                section: s.name.clone(),
215                priority: p,
216                original_tokens,
217                final_tokens: 0,
218                outcome: BudgetOutcome::Dropped,
219                reason: Some("truncate budget exhausted".into()),
220            });
221            dropped.push(s.name);
222            continue;
223        }
224        if s.token_estimate <= budget_left {
225            running += s.token_estimate;
226            decisions.push(BudgetDecision {
227                section: s.name.clone(),
228                priority: p,
229                original_tokens,
230                final_tokens: s.token_estimate,
231                outcome: BudgetOutcome::Kept,
232                reason: None,
233            });
234            kept.push(s);
235            continue;
236        }
237        // Oversized: hard-truncate content to roughly `budget_left` tokens.
238        let orig_tokens = s.token_estimate.max(1);
239        let ratio = (budget_left as f64 / orig_tokens as f64).clamp(0.0, 1.0);
240        // Subtract ~5% for the truncation marker.
241        let char_count = s.content.chars().count();
242        let target_chars = ((char_count as f64) * ratio * 0.95) as usize;
243        let mut new_content: String = s.content.chars().take(target_chars).collect();
244        new_content.push_str("\n\n[... truncated by --budget-strategy=truncate ...]");
245        s.content = new_content;
246        s.token_estimate = tokenizer.count(&s.content);
247        running += s.token_estimate;
248        decisions.push(BudgetDecision {
249            section: s.name.clone(),
250            priority: p,
251            original_tokens,
252            final_tokens: s.token_estimate,
253            outcome: BudgetOutcome::Truncated,
254            reason: Some("hard truncated to remaining budget".into()),
255        });
256        kept.push(s);
257        // Remainder of competing sections get dropped.
258        for (p, s) in iter {
259            decisions.push(BudgetDecision {
260                section: s.name.clone(),
261                priority: p,
262                original_tokens: s.token_estimate,
263                final_tokens: 0,
264                outcome: BudgetOutcome::Dropped,
265                reason: Some("truncate stops after first overflow".into()),
266            });
267            dropped.push(s.name);
268        }
269        break;
270    }
271
272    // Any competing section we didn't process (because we broke out of the
273    // loop above) was already fully consumed; nothing else to do.
274    Allocation {
275        kept,
276        dropped,
277        tokens_used: exempt_tokens + running,
278        tokens_budget: limit,
279        decisions,
280    }
281}
282
283fn apply_proportional(
284    candidates: Vec<(Priority, Section)>,
285    limit: usize,
286    tokenizer: &Tokenizer,
287) -> Allocation {
288    let (exempt, mut competing): (Vec<Section>, Vec<(Priority, Section)>) =
289        candidates.into_iter().partition_map(|(p, s)| {
290            if p == P_EXEMPT {
291                Left(s)
292            } else {
293                Right((p, s))
294            }
295        });
296    competing.sort_by_key(|(p, _)| *p);
297
298    let exempt_tokens: usize = exempt.iter().map(|s| s.token_estimate).sum();
299    let remaining = limit.saturating_sub(exempt_tokens);
300    let total_competing: usize = competing.iter().map(|(_, s)| s.token_estimate).sum();
301
302    let mut kept = exempt;
303    let mut dropped: Vec<String> = Vec::new();
304    let mut decisions: Vec<BudgetDecision> = kept
305        .iter()
306        .map(|s| BudgetDecision {
307            section: s.name.clone(),
308            priority: P_EXEMPT,
309            original_tokens: s.token_estimate,
310            final_tokens: s.token_estimate,
311            outcome: BudgetOutcome::Kept,
312            reason: Some("exempt".into()),
313        })
314        .collect();
315    let mut running: usize = 0;
316
317    if total_competing == 0 {
318        return Allocation {
319            kept,
320            dropped,
321            tokens_used: exempt_tokens,
322            tokens_budget: limit,
323            decisions,
324        };
325    }
326
327    // Fast path: everything fits, no truncation needed.
328    if total_competing <= remaining {
329        for (p, s) in competing {
330            running += s.token_estimate;
331            decisions.push(BudgetDecision {
332                section: s.name.clone(),
333                priority: p,
334                original_tokens: s.token_estimate,
335                final_tokens: s.token_estimate,
336                outcome: BudgetOutcome::Kept,
337                reason: None,
338            });
339            kept.push(s);
340        }
341        return Allocation {
342            kept,
343            dropped,
344            tokens_used: exempt_tokens + running,
345            tokens_budget: limit,
346            decisions,
347        };
348    }
349
350    // Scale every competing section by the same ratio.
351    let ratio = remaining as f64 / total_competing as f64;
352    let marker = "\n\n[... truncated by --budget-strategy=proportional ...]";
353    let marker_tokens = tokenizer.count(marker);
354    for (p, mut s) in competing {
355        let original_tokens = s.token_estimate;
356        let target_tokens = ((s.token_estimate as f64) * ratio).floor() as usize;
357        if target_tokens == 0 {
358            // Section would be reduced to nothing; drop it rather than emit
359            // an empty rendered section.
360            decisions.push(BudgetDecision {
361                section: s.name.clone(),
362                priority: p,
363                original_tokens,
364                final_tokens: 0,
365                outcome: BudgetOutcome::Dropped,
366                reason: Some("proportional share rounded to zero".into()),
367            });
368            dropped.push(s.name);
369            continue;
370        }
371        if target_tokens >= s.token_estimate {
372            running += s.token_estimate;
373            decisions.push(BudgetDecision {
374                section: s.name.clone(),
375                priority: p,
376                original_tokens,
377                final_tokens: s.token_estimate,
378                outcome: BudgetOutcome::Kept,
379                reason: None,
380            });
381            kept.push(s);
382            continue;
383        }
384        // Budget for body = target minus the marker's cost.
385        let body_tokens = target_tokens.saturating_sub(marker_tokens);
386        if body_tokens == 0 {
387            decisions.push(BudgetDecision {
388                section: s.name.clone(),
389                priority: p,
390                original_tokens,
391                final_tokens: 0,
392                outcome: BudgetOutcome::Dropped,
393                reason: Some("proportional share cannot fit truncation marker".into()),
394            });
395            dropped.push(s.name);
396            continue;
397        }
398        let orig_chars = s.content.chars().count();
399        let orig_tokens = s.token_estimate.max(1);
400        let char_target =
401            ((orig_chars as f64) * (body_tokens as f64) / (orig_tokens as f64)) as usize;
402        let mut new_content: String = s.content.chars().take(char_target).collect();
403        new_content.push_str(marker);
404        s.content = new_content;
405        s.token_estimate = tokenizer.count(&s.content);
406        running += s.token_estimate;
407        decisions.push(BudgetDecision {
408            section: s.name.clone(),
409            priority: p,
410            original_tokens,
411            final_tokens: s.token_estimate,
412            outcome: BudgetOutcome::Truncated,
413            reason: Some("proportionally truncated".into()),
414        });
415        kept.push(s);
416    }
417
418    Allocation {
419        kept,
420        dropped,
421        tokens_used: exempt_tokens + running,
422        tokens_budget: limit,
423        decisions,
424    }
425}
426
427// Minimal partition_map to avoid pulling `itertools` just for this.
428enum Either<L, R> {
429    Left(L),
430    Right(R),
431}
432use Either::{Left, Right};
433
434trait PartitionMap<I> {
435    fn partition_map<A, B, F>(self, f: F) -> (Vec<A>, Vec<B>)
436    where
437        F: FnMut(I) -> Either<A, B>;
438}
439
440impl<It, I> PartitionMap<I> for It
441where
442    It: Iterator<Item = I>,
443{
444    fn partition_map<A, B, F>(self, mut f: F) -> (Vec<A>, Vec<B>)
445    where
446        F: FnMut(I) -> Either<A, B>,
447    {
448        let mut a = Vec::new();
449        let mut b = Vec::new();
450        for item in self {
451            match f(item) {
452                Left(x) => a.push(x),
453                Right(x) => b.push(x),
454            }
455        }
456        (a, b)
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    fn mk(name: &str, tokens: usize) -> Section {
465        Section {
466            name: name.into(),
467            content: "x".repeat(tokens * 4),
468            token_estimate: tokens,
469        }
470    }
471
472    #[test]
473    fn effective_subtracts_reserve() {
474        let b = Budget {
475            max_tokens: 8000,
476            reserve_tokens: 2000,
477            strategy: BudgetStrategy::Priority,
478        };
479        assert_eq!(b.effective(), 6000);
480    }
481
482    #[test]
483    fn effective_saturates_at_zero() {
484        let b = Budget {
485            max_tokens: 100,
486            reserve_tokens: 500,
487            strategy: BudgetStrategy::Priority,
488        };
489        assert_eq!(b.effective(), 0);
490    }
491
492    #[test]
493    fn priority_drops_lowest_priority_when_over_budget() {
494        let candidates = vec![
495            (P_ERROR, mk("errors", 200)),
496            (P_DIFF, mk("diff", 500)),
497            (P_MAP, mk("map", 400)),
498        ];
499        let b = Budget {
500            max_tokens: 800,
501            reserve_tokens: 0,
502            strategy: BudgetStrategy::Priority,
503        };
504        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
505        assert_eq!(a.kept.len(), 2);
506        assert_eq!(a.kept[0].name, "errors");
507        assert_eq!(a.kept[1].name, "diff");
508        assert_eq!(a.dropped, vec!["map"]);
509        assert_eq!(a.tokens_used, 700);
510        assert_eq!(a.decisions.len(), 3);
511        assert_eq!(a.decisions[2].outcome, BudgetOutcome::Dropped);
512    }
513
514    #[test]
515    fn priority_keeps_prompt_even_when_oversized() {
516        let candidates = vec![
517            (P_EXEMPT, mk("📝 User Prompt", 5000)),
518            (P_ERROR, mk("errors", 100)),
519        ];
520        let b = Budget {
521            max_tokens: 1000,
522            reserve_tokens: 0,
523            strategy: BudgetStrategy::Priority,
524        };
525        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
526        assert!(a.kept.iter().any(|s| s.name.contains("Prompt")));
527        assert_eq!(a.dropped, vec!["errors"]);
528    }
529
530    #[test]
531    fn truncate_hard_cuts_overflowing_section() {
532        let candidates = vec![(P_ERROR, mk("errors", 200)), (P_DIFF, mk("diff", 1500))];
533        let b = Budget {
534            max_tokens: 800,
535            reserve_tokens: 0,
536            strategy: BudgetStrategy::Truncate,
537        };
538        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
539        assert_eq!(a.kept.len(), 2);
540        assert_eq!(a.kept[0].name, "errors");
541        assert_eq!(a.kept[1].name, "diff");
542        // Diff was truncated — its new content contains the marker.
543        assert!(a.kept[1].content.contains("truncated"));
544        assert!(a.tokens_used <= 800);
545        assert_eq!(a.decisions[1].outcome, BudgetOutcome::Truncated);
546    }
547
548    #[test]
549    fn zero_budget_drops_everything_except_exempt() {
550        let candidates = vec![
551            (P_EXEMPT, mk("📝 User Prompt", 50)),
552            (P_ERROR, mk("errors", 100)),
553            (P_DIFF, mk("diff", 100)),
554        ];
555        let b = Budget {
556            max_tokens: 0,
557            reserve_tokens: 0,
558            strategy: BudgetStrategy::Priority,
559        };
560        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
561        assert_eq!(a.kept.len(), 1);
562        assert_eq!(a.kept[0].name, "📝 User Prompt");
563        assert_eq!(a.dropped, vec!["errors", "diff"]);
564    }
565
566    #[test]
567    fn empty_candidates_produce_empty_allocation() {
568        let a = allocate(Vec::new(), &Budget::default(), &Tokenizer::CharsDiv4);
569        assert!(a.kept.is_empty());
570        assert!(a.dropped.is_empty());
571        assert_eq!(a.tokens_used, 0);
572    }
573
574    #[test]
575    fn proportional_scales_all_sections_when_over_budget() {
576        let candidates = vec![(P_ERROR, mk("errors", 400)), (P_DIFF, mk("diff", 600))];
577        let b = Budget {
578            max_tokens: 500,
579            reserve_tokens: 0,
580            strategy: BudgetStrategy::Proportional,
581        };
582        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
583        // Both sections survive (the whole point of proportional: no drops).
584        assert_eq!(a.kept.len(), 2);
585        assert!(a.dropped.is_empty());
586        // Both got truncated.
587        assert!(
588            a.kept.iter().all(|s| s.content.contains("truncated")),
589            "all oversized sections should carry the truncation marker"
590        );
591        assert!(
592            a.decisions
593                .iter()
594                .all(|d| d.outcome == BudgetOutcome::Truncated),
595            "all decisions should report truncation"
596        );
597        // Budget is honored.
598        assert!(
599            a.tokens_used <= b.max_tokens,
600            "tokens_used {} exceeded budget {}",
601            a.tokens_used,
602            b.max_tokens
603        );
604    }
605
606    #[test]
607    fn proportional_keeps_whole_when_under_budget() {
608        let candidates = vec![(P_ERROR, mk("errors", 100)), (P_DIFF, mk("diff", 200))];
609        let b = Budget {
610            max_tokens: 1000,
611            reserve_tokens: 0,
612            strategy: BudgetStrategy::Proportional,
613        };
614        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
615        assert_eq!(a.kept.len(), 2);
616        assert_eq!(a.tokens_used, 300);
617        assert!(a.dropped.is_empty());
618        assert!(
619            a.kept.iter().all(|s| !s.content.contains("truncated")),
620            "under-budget allocation should not truncate"
621        );
622    }
623
624    #[test]
625    fn proportional_always_keeps_exempt() {
626        let candidates = vec![
627            (P_EXEMPT, mk("📝 User Prompt", 100)),
628            (P_ERROR, mk("errors", 500)),
629        ];
630        let b = Budget {
631            max_tokens: 200,
632            reserve_tokens: 0,
633            strategy: BudgetStrategy::Proportional,
634        };
635        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
636        // Prompt kept whole; errors proportionally scaled into remaining 100.
637        assert!(a.kept.iter().any(|s| s.name.contains("Prompt")));
638    }
639
640    #[test]
641    fn proportional_drops_zero_slot_sections() {
642        // Tiny section + huge section with tight budget: tiny's share rounds
643        // to zero. Preferable to emit an empty section? No — drop it and
644        // report.
645        let candidates = vec![(P_ERROR, mk("tiny", 1)), (P_DIFF, mk("huge", 10_000))];
646        let b = Budget {
647            max_tokens: 100,
648            reserve_tokens: 0,
649            strategy: BudgetStrategy::Proportional,
650        };
651        let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
652        assert!(
653            a.dropped.contains(&"tiny".to_string()),
654            "zero-slot section should be dropped, got dropped={:?}",
655            a.dropped
656        );
657    }
658
659    #[test]
660    fn non_exempt_strategies_do_not_exceed_effective_budget() {
661        for strategy in [
662            BudgetStrategy::Priority,
663            BudgetStrategy::Proportional,
664            BudgetStrategy::Truncate,
665        ] {
666            let candidates = vec![
667                (P_ERROR, mk("errors", 300)),
668                (P_DIFF, mk("diff", 800)),
669                (P_MAP, mk("map", 200)),
670                (P_TESTS, mk("tests", 500)),
671            ];
672            let b = Budget {
673                max_tokens: 700,
674                reserve_tokens: 100,
675                strategy,
676            };
677            let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
678            assert!(
679                a.tokens_used <= b.effective(),
680                "{strategy:?} used {} tokens over effective budget {}",
681                a.tokens_used,
682                b.effective()
683            );
684        }
685    }
686}