use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::receipt::{DropReason, DroppedContribution};
use crate::source::Contribution;
use crate::types::{Priority, SourceId, TokenBudget};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PruneStrategy {
#[default]
ImportanceFirst,
RoundRobin,
}
#[derive(Debug, Clone)]
pub struct WeightedContribution {
pub source: SourceId,
pub priority: Priority,
pub actual_tokens: usize,
pub contribution: Contribution,
pub source_index: usize,
}
pub fn apply_budget(
contributions: Vec<WeightedContribution>,
budget: &TokenBudget,
strategy: PruneStrategy,
) -> (Vec<WeightedContribution>, Vec<DroppedContribution>) {
let prompt_budget = budget.prompt_budget();
let total_tokens: usize = contributions.iter().map(|c| c.actual_tokens).sum();
if total_tokens <= prompt_budget {
return (contributions, Vec::new());
}
let indexed: Vec<(usize, WeightedContribution)> =
contributions.into_iter().enumerate().collect();
let mut dropped_records: Vec<DroppedContribution> = Vec::new();
let mut dropped_set: std::collections::HashSet<usize> = std::collections::HashSet::new();
let mut bucket_tokens = bucket_token_map(&indexed);
let mut running_total = total_tokens;
let floors = &budget.floor_per_priority;
let droppable_priorities = [Priority::Low, Priority::Normal, Priority::High];
let mut floored_skips: Vec<usize> = Vec::new();
for priority in droppable_priorities {
if running_total <= prompt_budget {
break;
}
let bucket_indices = drop_order_for_priority(&indexed, priority, strategy);
for original_idx in bucket_indices {
if running_total <= prompt_budget {
break;
}
let Some((_, entry)) = indexed.iter().find(|(i, _)| *i == original_idx) else {
continue;
};
let tokens = entry.actual_tokens;
let source = entry.source.clone();
if let Some(&floor) = floors.get(&priority) {
let bucket = bucket_tokens.get(&priority).copied().unwrap_or(0);
if bucket.saturating_sub(tokens) < floor {
floored_skips.push(original_idx);
continue;
}
}
dropped_set.insert(original_idx);
dropped_records.push(DroppedContribution {
source,
reason: DropReason::OverBudget,
tokens,
});
running_total -= tokens;
if let Some(bucket) = bucket_tokens.get_mut(&priority) {
*bucket = bucket.saturating_sub(tokens);
}
}
if running_total > prompt_budget {
let borrow_indices: Vec<usize> = floored_skips
.iter()
.copied()
.filter(|i| !dropped_set.contains(i))
.collect();
for original_idx in borrow_indices {
if running_total <= prompt_budget {
break;
}
let Some((_, entry)) = indexed.iter().find(|(i, _)| *i == original_idx) else {
continue;
};
let tokens = entry.actual_tokens;
let source = entry.source.clone();
let p = entry.priority;
dropped_set.insert(original_idx);
dropped_records.push(DroppedContribution {
source,
reason: DropReason::OverBudget,
tokens,
});
running_total -= tokens;
if let Some(bucket) = bucket_tokens.get_mut(&p) {
*bucket = bucket.saturating_sub(tokens);
}
}
}
}
let kept: Vec<WeightedContribution> = indexed
.into_iter()
.filter_map(|(i, c)| {
if dropped_set.contains(&i) {
None
} else {
Some(c)
}
})
.collect();
(kept, dropped_records)
}
fn drop_order_for_priority(
indexed: &[(usize, WeightedContribution)],
priority: Priority,
strategy: PruneStrategy,
) -> Vec<usize> {
if priority == Priority::Critical {
return Vec::new();
}
let mut candidates: Vec<&(usize, WeightedContribution)> = indexed
.iter()
.filter(|(_, c)| c.priority == priority)
.collect();
match strategy {
PruneStrategy::ImportanceFirst => {
candidates.sort_by(|a, b| {
a.1.contribution
.importance
.partial_cmp(&b.1.contribution.importance)
.unwrap_or(std::cmp::Ordering::Less)
.then_with(|| b.1.source_index.cmp(&a.1.source_index))
});
candidates.into_iter().map(|(i, _)| *i).collect()
}
PruneStrategy::RoundRobin => {
let mut by_source: HashMap<SourceId, Vec<&(usize, WeightedContribution)>> =
HashMap::new();
for entry in &candidates {
by_source
.entry(entry.1.source.clone())
.or_default()
.push(entry);
}
for entries in by_source.values_mut() {
entries.sort_by(|a, b| {
b.1.contribution
.importance
.partial_cmp(&a.1.contribution.importance)
.unwrap_or(std::cmp::Ordering::Less)
.then_with(|| a.1.source_index.cmp(&b.1.source_index))
});
}
let mut source_ids: Vec<SourceId> = by_source.keys().cloned().collect();
source_ids.sort();
let mut ordered: Vec<usize> = Vec::with_capacity(candidates.len());
let mut exhausted_sources = 0;
while exhausted_sources < source_ids.len() {
exhausted_sources = 0;
for sid in &source_ids {
if let Some(entries) = by_source.get_mut(sid) {
if let Some(entry) = entries.pop() {
ordered.push(entry.0);
} else {
exhausted_sources += 1;
}
} else {
exhausted_sources += 1;
}
}
}
ordered
}
}
}
fn bucket_token_map(indexed: &[(usize, WeightedContribution)]) -> HashMap<Priority, usize> {
let mut out: HashMap<Priority, usize> = HashMap::new();
for (_, entry) in indexed {
*out.entry(entry.priority).or_insert(0) += entry.actual_tokens;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::source::{Contribution, ContributionContent};
use crate::types::{Role, SourceId};
fn wc(
source: &str,
priority: Priority,
tokens: usize,
importance: f32,
source_index: usize,
) -> WeightedContribution {
WeightedContribution {
source: SourceId::new(source),
priority,
actual_tokens: tokens,
source_index,
contribution: Contribution {
content: ContributionContent::Text {
role: Role::User,
content: "x".repeat(tokens * 4),
},
estimated_tokens: tokens,
importance,
redactable: true,
tags: Vec::new(),
},
}
}
#[test]
fn under_budget_keeps_everything() {
let items = vec![
wc("a", Priority::Normal, 10, 0.5, 0),
wc("b", Priority::Normal, 20, 0.3, 1),
];
let budget = TokenBudget::new(1000, 0);
let (kept, dropped) = apply_budget(items, &budget, PruneStrategy::default());
assert_eq!(kept.len(), 2);
assert!(dropped.is_empty());
}
#[test]
fn over_budget_drops_lowest_importance_first() {
let items = vec![
wc("a", Priority::Normal, 100, 0.9, 0),
wc("b", Priority::Normal, 100, 0.1, 1),
wc("c", Priority::Normal, 100, 0.5, 2),
];
let budget = TokenBudget::new(200, 0);
let (kept, dropped) = apply_budget(items, &budget, PruneStrategy::ImportanceFirst);
assert_eq!(kept.len(), 2);
assert_eq!(dropped.len(), 1);
let kept_sources: Vec<&str> = kept.iter().map(|c| c.source.as_str()).collect();
assert!(kept_sources.contains(&"a"));
assert!(kept_sources.contains(&"c"));
assert_eq!(dropped[0].source.as_str(), "b");
}
#[test]
fn critical_priority_is_never_dropped() {
let items = vec![
wc("crit", Priority::Critical, 500, 0.1, 0),
wc("low", Priority::Low, 100, 0.9, 1),
];
let budget = TokenBudget::new(200, 0);
let (kept, dropped) = apply_budget(items, &budget, PruneStrategy::default());
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].source.as_str(), "crit");
assert_eq!(dropped.len(), 1);
assert_eq!(dropped[0].source.as_str(), "low");
}
#[test]
fn priority_floor_protects_a_bucket() {
let items = vec![
wc("h", Priority::High, 200, 0.9, 0),
wc("n1", Priority::Normal, 80, 0.5, 1),
wc("n2", Priority::Normal, 80, 0.3, 2),
wc("l", Priority::Low, 100, 0.7, 3),
];
let budget = TokenBudget::new(200, 0).with_floor(Priority::Normal, 100);
let (kept, dropped) = apply_budget(items, &budget, PruneStrategy::ImportanceFirst);
let kept_sources: Vec<&str> = kept.iter().map(|c| c.source.as_str()).collect();
assert!(kept_sources.contains(&"h"));
let dropped_sources: Vec<&str> = dropped.iter().map(|c| c.source.as_str()).collect();
assert!(dropped_sources.contains(&"l"));
}
#[test]
fn round_robin_spreads_drops_across_sources() {
let items = vec![
wc("a", Priority::Normal, 50, 0.5, 0),
wc("a", Priority::Normal, 50, 0.4, 1),
wc("a", Priority::Normal, 50, 0.3, 2),
wc("b", Priority::Normal, 50, 0.5, 0),
wc("b", Priority::Normal, 50, 0.4, 1),
wc("b", Priority::Normal, 50, 0.3, 2),
];
let budget = TokenBudget::new(150, 0);
let (kept, dropped) = apply_budget(items, &budget, PruneStrategy::RoundRobin);
assert_eq!(kept.len(), 3);
assert_eq!(dropped.len(), 3);
let drop_count_a = dropped.iter().filter(|d| d.source.as_str() == "a").count();
let drop_count_b = dropped.iter().filter(|d| d.source.as_str() == "b").count();
assert!(
drop_count_a == 1 && drop_count_b == 2 || drop_count_a == 2 && drop_count_b == 1,
"round-robin should spread: got a={drop_count_a} b={drop_count_b}"
);
}
#[test]
fn kept_preserves_input_order_within_priority() {
let items = vec![
wc("a", Priority::Normal, 10, 0.9, 0),
wc("a", Priority::Normal, 10, 0.5, 1),
wc("a", Priority::Normal, 10, 0.7, 2),
wc("a", Priority::Normal, 10, 0.1, 3),
];
let budget = TokenBudget::new(20, 0);
let (kept, _dropped) = apply_budget(items, &budget, PruneStrategy::ImportanceFirst);
assert_eq!(kept.len(), 2);
assert_eq!(kept[0].source_index, 0);
assert_eq!(kept[1].source_index, 2);
}
}