use crate::pack::Section;
use crate::tokenize::Tokenizer;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum BudgetStrategy {
#[default]
Priority,
Proportional,
Truncate,
}
#[derive(Debug, Clone, Copy)]
pub struct Budget {
pub max_tokens: usize,
pub reserve_tokens: usize,
pub strategy: BudgetStrategy,
}
impl Default for Budget {
fn default() -> Self {
Self {
max_tokens: 8000,
reserve_tokens: 2000,
strategy: BudgetStrategy::default(),
}
}
}
impl Budget {
pub fn effective(&self) -> usize {
self.max_tokens.saturating_sub(self.reserve_tokens)
}
}
pub type Priority = u8;
pub const P_EXEMPT: Priority = 0;
pub const P_ERROR: Priority = 1;
pub const P_DIFF: Priority = 2;
pub const P_MAP: Priority = 3;
pub const P_ENTRY: Priority = 4;
pub const P_TESTS: Priority = 5;
#[derive(Debug, Default)]
pub struct Allocation {
pub kept: Vec<Section>,
pub dropped: Vec<String>,
pub tokens_used: usize,
pub tokens_budget: usize,
}
pub fn allocate(
candidates: Vec<(Priority, Section)>,
budget: &Budget,
tokenizer: &Tokenizer,
) -> Allocation {
let limit = budget.effective();
match budget.strategy {
BudgetStrategy::Priority => apply_priority(candidates, limit),
BudgetStrategy::Proportional => apply_proportional(candidates, limit, tokenizer),
BudgetStrategy::Truncate => apply_truncate(candidates, limit, tokenizer),
}
}
fn apply_priority(candidates: Vec<(Priority, Section)>, limit: usize) -> Allocation {
let (exempt, mut competing): (Vec<Section>, Vec<(Priority, Section)>) =
candidates.into_iter().partition_map(|(p, s)| {
if p == P_EXEMPT {
Left(s)
} else {
Right((p, s))
}
});
competing.sort_by_key(|(p, _)| *p);
let exempt_tokens: usize = exempt.iter().map(|s| s.token_estimate).sum();
let remaining = limit.saturating_sub(exempt_tokens);
let mut kept = exempt;
let mut dropped: Vec<String> = Vec::new();
let mut running: usize = 0;
for (_p, s) in competing {
if running + s.token_estimate <= remaining {
running += s.token_estimate;
kept.push(s);
} else {
dropped.push(s.name);
}
}
let tokens_used = exempt_tokens + running;
Allocation {
kept,
dropped,
tokens_used,
tokens_budget: limit,
}
}
fn apply_truncate(
candidates: Vec<(Priority, Section)>,
limit: usize,
tokenizer: &Tokenizer,
) -> Allocation {
let (exempt, mut competing): (Vec<Section>, Vec<(Priority, Section)>) =
candidates.into_iter().partition_map(|(p, s)| {
if p == P_EXEMPT {
Left(s)
} else {
Right((p, s))
}
});
competing.sort_by_key(|(p, _)| *p);
let exempt_tokens: usize = exempt.iter().map(|s| s.token_estimate).sum();
let mut kept = exempt;
let mut dropped: Vec<String> = Vec::new();
let mut running: usize = 0;
let remaining_after_exempt = limit.saturating_sub(exempt_tokens);
for (_p, mut s) in competing {
let budget_left = remaining_after_exempt.saturating_sub(running);
if budget_left == 0 {
dropped.push(s.name);
continue;
}
if s.token_estimate <= budget_left {
running += s.token_estimate;
kept.push(s);
continue;
}
let orig_tokens = s.token_estimate.max(1);
let ratio = (budget_left as f64 / orig_tokens as f64).clamp(0.0, 1.0);
let char_count = s.content.chars().count();
let target_chars = ((char_count as f64) * ratio * 0.95) as usize;
let mut new_content: String = s.content.chars().take(target_chars).collect();
new_content.push_str("\n\n[... truncated by --budget-strategy=truncate ...]");
s.content = new_content;
s.token_estimate = tokenizer.count(&s.content);
running += s.token_estimate;
kept.push(s);
break;
}
Allocation {
kept,
dropped,
tokens_used: exempt_tokens + running,
tokens_budget: limit,
}
}
fn apply_proportional(
candidates: Vec<(Priority, Section)>,
limit: usize,
tokenizer: &Tokenizer,
) -> Allocation {
let (exempt, mut competing): (Vec<Section>, Vec<(Priority, Section)>) =
candidates.into_iter().partition_map(|(p, s)| {
if p == P_EXEMPT {
Left(s)
} else {
Right((p, s))
}
});
competing.sort_by_key(|(p, _)| *p);
let exempt_tokens: usize = exempt.iter().map(|s| s.token_estimate).sum();
let remaining = limit.saturating_sub(exempt_tokens);
let total_competing: usize = competing.iter().map(|(_, s)| s.token_estimate).sum();
let mut kept = exempt;
let mut dropped: Vec<String> = Vec::new();
let mut running: usize = 0;
if total_competing == 0 {
return Allocation {
kept,
dropped,
tokens_used: exempt_tokens,
tokens_budget: limit,
};
}
if total_competing <= remaining {
for (_, s) in competing {
running += s.token_estimate;
kept.push(s);
}
return Allocation {
kept,
dropped,
tokens_used: exempt_tokens + running,
tokens_budget: limit,
};
}
let ratio = remaining as f64 / total_competing as f64;
let marker = "\n\n[... truncated by --budget-strategy=proportional ...]";
let marker_tokens = tokenizer.count(marker);
for (_, mut s) in competing {
let target_tokens = ((s.token_estimate as f64) * ratio).floor() as usize;
if target_tokens == 0 {
dropped.push(s.name);
continue;
}
if target_tokens >= s.token_estimate {
running += s.token_estimate;
kept.push(s);
continue;
}
let body_tokens = target_tokens.saturating_sub(marker_tokens);
if body_tokens == 0 {
dropped.push(s.name);
continue;
}
let orig_chars = s.content.chars().count();
let orig_tokens = s.token_estimate.max(1);
let char_target =
((orig_chars as f64) * (body_tokens as f64) / (orig_tokens as f64)) as usize;
let mut new_content: String = s.content.chars().take(char_target).collect();
new_content.push_str(marker);
s.content = new_content;
s.token_estimate = tokenizer.count(&s.content);
running += s.token_estimate;
kept.push(s);
}
Allocation {
kept,
dropped,
tokens_used: exempt_tokens + running,
tokens_budget: limit,
}
}
enum Either<L, R> {
Left(L),
Right(R),
}
use Either::{Left, Right};
trait PartitionMap<I> {
fn partition_map<A, B, F>(self, f: F) -> (Vec<A>, Vec<B>)
where
F: FnMut(I) -> Either<A, B>;
}
impl<It, I> PartitionMap<I> for It
where
It: Iterator<Item = I>,
{
fn partition_map<A, B, F>(self, mut f: F) -> (Vec<A>, Vec<B>)
where
F: FnMut(I) -> Either<A, B>,
{
let mut a = Vec::new();
let mut b = Vec::new();
for item in self {
match f(item) {
Left(x) => a.push(x),
Right(x) => b.push(x),
}
}
(a, b)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mk(name: &str, tokens: usize) -> Section {
Section {
name: name.into(),
content: "x".repeat(tokens * 4),
token_estimate: tokens,
}
}
#[test]
fn effective_subtracts_reserve() {
let b = Budget {
max_tokens: 8000,
reserve_tokens: 2000,
strategy: BudgetStrategy::Priority,
};
assert_eq!(b.effective(), 6000);
}
#[test]
fn effective_saturates_at_zero() {
let b = Budget {
max_tokens: 100,
reserve_tokens: 500,
strategy: BudgetStrategy::Priority,
};
assert_eq!(b.effective(), 0);
}
#[test]
fn priority_drops_lowest_priority_when_over_budget() {
let candidates = vec![
(P_ERROR, mk("errors", 200)),
(P_DIFF, mk("diff", 500)),
(P_MAP, mk("map", 400)),
];
let b = Budget {
max_tokens: 800,
reserve_tokens: 0,
strategy: BudgetStrategy::Priority,
};
let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
assert_eq!(a.kept.len(), 2);
assert_eq!(a.kept[0].name, "errors");
assert_eq!(a.kept[1].name, "diff");
assert_eq!(a.dropped, vec!["map"]);
assert_eq!(a.tokens_used, 700);
}
#[test]
fn priority_keeps_prompt_even_when_oversized() {
let candidates = vec![
(P_EXEMPT, mk("📝 User Prompt", 5000)),
(P_ERROR, mk("errors", 100)),
];
let b = Budget {
max_tokens: 1000,
reserve_tokens: 0,
strategy: BudgetStrategy::Priority,
};
let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
assert!(a.kept.iter().any(|s| s.name.contains("Prompt")));
assert_eq!(a.dropped, vec!["errors"]);
}
#[test]
fn truncate_hard_cuts_overflowing_section() {
let candidates = vec![(P_ERROR, mk("errors", 200)), (P_DIFF, mk("diff", 1500))];
let b = Budget {
max_tokens: 800,
reserve_tokens: 0,
strategy: BudgetStrategy::Truncate,
};
let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
assert_eq!(a.kept.len(), 2);
assert_eq!(a.kept[0].name, "errors");
assert_eq!(a.kept[1].name, "diff");
assert!(a.kept[1].content.contains("truncated"));
assert!(a.tokens_used <= 800);
}
#[test]
fn zero_budget_drops_everything_except_exempt() {
let candidates = vec![
(P_EXEMPT, mk("📝 User Prompt", 50)),
(P_ERROR, mk("errors", 100)),
(P_DIFF, mk("diff", 100)),
];
let b = Budget {
max_tokens: 0,
reserve_tokens: 0,
strategy: BudgetStrategy::Priority,
};
let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
assert_eq!(a.kept.len(), 1);
assert_eq!(a.kept[0].name, "📝 User Prompt");
assert_eq!(a.dropped, vec!["errors", "diff"]);
}
#[test]
fn empty_candidates_produce_empty_allocation() {
let a = allocate(Vec::new(), &Budget::default(), &Tokenizer::CharsDiv4);
assert!(a.kept.is_empty());
assert!(a.dropped.is_empty());
assert_eq!(a.tokens_used, 0);
}
#[test]
fn proportional_scales_all_sections_when_over_budget() {
let candidates = vec![(P_ERROR, mk("errors", 400)), (P_DIFF, mk("diff", 600))];
let b = Budget {
max_tokens: 500,
reserve_tokens: 0,
strategy: BudgetStrategy::Proportional,
};
let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
assert_eq!(a.kept.len(), 2);
assert!(a.dropped.is_empty());
assert!(
a.kept.iter().all(|s| s.content.contains("truncated")),
"all oversized sections should carry the truncation marker"
);
assert!(
a.tokens_used <= b.max_tokens,
"tokens_used {} exceeded budget {}",
a.tokens_used,
b.max_tokens
);
}
#[test]
fn proportional_keeps_whole_when_under_budget() {
let candidates = vec![(P_ERROR, mk("errors", 100)), (P_DIFF, mk("diff", 200))];
let b = Budget {
max_tokens: 1000,
reserve_tokens: 0,
strategy: BudgetStrategy::Proportional,
};
let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
assert_eq!(a.kept.len(), 2);
assert_eq!(a.tokens_used, 300);
assert!(a.dropped.is_empty());
assert!(
a.kept.iter().all(|s| !s.content.contains("truncated")),
"under-budget allocation should not truncate"
);
}
#[test]
fn proportional_always_keeps_exempt() {
let candidates = vec![
(P_EXEMPT, mk("📝 User Prompt", 100)),
(P_ERROR, mk("errors", 500)),
];
let b = Budget {
max_tokens: 200,
reserve_tokens: 0,
strategy: BudgetStrategy::Proportional,
};
let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
assert!(a.kept.iter().any(|s| s.name.contains("Prompt")));
}
#[test]
fn proportional_drops_zero_slot_sections() {
let candidates = vec![(P_ERROR, mk("tiny", 1)), (P_DIFF, mk("huge", 10_000))];
let b = Budget {
max_tokens: 100,
reserve_tokens: 0,
strategy: BudgetStrategy::Proportional,
};
let a = allocate(candidates, &b, &Tokenizer::CharsDiv4);
assert!(
a.dropped.contains(&"tiny".to_string()),
"zero-slot section should be dropped, got dropped={:?}",
a.dropped
);
}
}