1use crate::pack::Section;
7use crate::tokenize::Tokenizer;
8
9#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
11pub enum BudgetStrategy {
12 #[default]
16 Priority,
17
18 Proportional,
23
24 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 pub fn effective(&self) -> usize {
49 self.max_tokens.saturating_sub(self.reserve_tokens)
50 }
51}
52
53pub type Priority = u8;
55
56pub 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#[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
74pub 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 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 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 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 break;
171 }
172
173 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 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 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 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 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
272enum 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 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 assert_eq!(a.kept.len(), 2);
427 assert!(a.dropped.is_empty());
428 assert!(
430 a.kept.iter().all(|s| s.content.contains("truncated")),
431 "all oversized sections should carry the truncation marker"
432 );
433 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 assert!(a.kept.iter().any(|s| s.name.contains("Prompt")));
474 }
475
476 #[test]
477 fn proportional_drops_zero_slot_sections() {
478 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}