1use serde::{Deserialize, Serialize};
7
8use crate::pack::Section;
9use crate::tokenize::Tokenizer;
10
11#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum BudgetStrategy {
15 #[default]
19 Priority,
20
21 Proportional,
26
27 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 pub fn effective(&self) -> usize {
52 self.max_tokens.saturating_sub(self.reserve_tokens)
53 }
54}
55
56pub type Priority = u8;
58
59pub 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#[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
96pub 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 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 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 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 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 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 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 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 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 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
427enum 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 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 assert_eq!(a.kept.len(), 2);
585 assert!(a.dropped.is_empty());
586 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 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 assert!(a.kept.iter().any(|s| s.name.contains("Prompt")));
638 }
639
640 #[test]
641 fn proportional_drops_zero_slot_sections() {
642 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}