1use std::collections::HashMap;
20
21use serde::{Deserialize, Serialize};
22
23use crate::receipt::{DropReason, DroppedContribution};
24use crate::source::Contribution;
25use crate::types::{Priority, SourceId, TokenBudget};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31#[non_exhaustive]
32pub enum PruneStrategy {
33 #[default]
37 ImportanceFirst,
38 RoundRobin,
43}
44
45#[derive(Debug, Clone)]
53pub struct WeightedContribution {
54 pub source: SourceId,
56 pub priority: Priority,
58 pub actual_tokens: usize,
60 pub contribution: Contribution,
62 pub source_index: usize,
65}
66
67pub fn apply_budget(
100 contributions: Vec<WeightedContribution>,
101 budget: &TokenBudget,
102 strategy: PruneStrategy,
103) -> (Vec<WeightedContribution>, Vec<DroppedContribution>) {
104 let prompt_budget = budget.prompt_budget();
105 let total_tokens: usize = contributions.iter().map(|c| c.actual_tokens).sum();
106
107 if total_tokens <= prompt_budget {
109 return (contributions, Vec::new());
110 }
111
112 let indexed: Vec<(usize, WeightedContribution)> =
113 contributions.into_iter().enumerate().collect();
114
115 let mut dropped_records: Vec<DroppedContribution> = Vec::new();
116 let mut dropped_set: std::collections::HashSet<usize> = std::collections::HashSet::new();
117 let mut bucket_tokens = bucket_token_map(&indexed);
118 let mut running_total = total_tokens;
119 let floors = &budget.floor_per_priority;
120
121 let droppable_priorities = [Priority::Low, Priority::Normal, Priority::High];
125
126 let mut floored_skips: Vec<usize> = Vec::new();
129
130 for priority in droppable_priorities {
131 if running_total <= prompt_budget {
132 break;
133 }
134
135 let bucket_indices = drop_order_for_priority(&indexed, priority, strategy);
137
138 for original_idx in bucket_indices {
139 if running_total <= prompt_budget {
140 break;
141 }
142 let Some((_, entry)) = indexed.iter().find(|(i, _)| *i == original_idx) else {
143 continue;
144 };
145 let tokens = entry.actual_tokens;
146 let source = entry.source.clone();
147
148 if let Some(&floor) = floors.get(&priority) {
150 let bucket = bucket_tokens.get(&priority).copied().unwrap_or(0);
151 if bucket.saturating_sub(tokens) < floor {
152 floored_skips.push(original_idx);
153 continue;
154 }
155 }
156
157 dropped_set.insert(original_idx);
158 dropped_records.push(DroppedContribution {
159 source,
160 reason: DropReason::OverBudget,
161 tokens,
162 });
163 running_total -= tokens;
164 if let Some(bucket) = bucket_tokens.get_mut(&priority) {
165 *bucket = bucket.saturating_sub(tokens);
166 }
167 }
168
169 if running_total > prompt_budget {
175 let borrow_indices: Vec<usize> = floored_skips
176 .iter()
177 .copied()
178 .filter(|i| !dropped_set.contains(i))
179 .collect();
180
181 for original_idx in borrow_indices {
182 if running_total <= prompt_budget {
183 break;
184 }
185 let Some((_, entry)) = indexed.iter().find(|(i, _)| *i == original_idx) else {
186 continue;
187 };
188 let tokens = entry.actual_tokens;
189 let source = entry.source.clone();
190 let p = entry.priority;
191
192 dropped_set.insert(original_idx);
193 dropped_records.push(DroppedContribution {
194 source,
195 reason: DropReason::OverBudget,
196 tokens,
197 });
198 running_total -= tokens;
199 if let Some(bucket) = bucket_tokens.get_mut(&p) {
200 *bucket = bucket.saturating_sub(tokens);
201 }
202 }
203 }
204 }
205
206 let kept: Vec<WeightedContribution> = indexed
207 .into_iter()
208 .filter_map(|(i, c)| {
209 if dropped_set.contains(&i) {
210 None
211 } else {
212 Some(c)
213 }
214 })
215 .collect();
216
217 (kept, dropped_records)
218}
219
220fn drop_order_for_priority(
226 indexed: &[(usize, WeightedContribution)],
227 priority: Priority,
228 strategy: PruneStrategy,
229) -> Vec<usize> {
230 if priority == Priority::Critical {
231 return Vec::new();
232 }
233 let mut candidates: Vec<&(usize, WeightedContribution)> = indexed
234 .iter()
235 .filter(|(_, c)| c.priority == priority)
236 .collect();
237
238 match strategy {
239 PruneStrategy::ImportanceFirst => {
240 candidates.sort_by(|a, b| {
243 a.1.contribution
244 .importance
245 .partial_cmp(&b.1.contribution.importance)
246 .unwrap_or(std::cmp::Ordering::Less)
247 .then_with(|| b.1.source_index.cmp(&a.1.source_index))
248 });
249 candidates.into_iter().map(|(i, _)| *i).collect()
250 }
251 PruneStrategy::RoundRobin => {
252 let mut by_source: HashMap<SourceId, Vec<&(usize, WeightedContribution)>> =
254 HashMap::new();
255 for entry in &candidates {
256 by_source
257 .entry(entry.1.source.clone())
258 .or_default()
259 .push(entry);
260 }
261 for entries in by_source.values_mut() {
266 entries.sort_by(|a, b| {
267 b.1.contribution
268 .importance
269 .partial_cmp(&a.1.contribution.importance)
270 .unwrap_or(std::cmp::Ordering::Less)
271 .then_with(|| a.1.source_index.cmp(&b.1.source_index))
272 });
273 }
274 let mut source_ids: Vec<SourceId> = by_source.keys().cloned().collect();
277 source_ids.sort();
278
279 let mut ordered: Vec<usize> = Vec::with_capacity(candidates.len());
280 let mut exhausted_sources = 0;
281 while exhausted_sources < source_ids.len() {
282 exhausted_sources = 0;
283 for sid in &source_ids {
284 if let Some(entries) = by_source.get_mut(sid) {
285 if let Some(entry) = entries.pop() {
286 ordered.push(entry.0);
287 } else {
288 exhausted_sources += 1;
289 }
290 } else {
291 exhausted_sources += 1;
292 }
293 }
294 }
295 ordered
296 }
297 }
298}
299
300fn bucket_token_map(indexed: &[(usize, WeightedContribution)]) -> HashMap<Priority, usize> {
301 let mut out: HashMap<Priority, usize> = HashMap::new();
302 for (_, entry) in indexed {
303 *out.entry(entry.priority).or_insert(0) += entry.actual_tokens;
304 }
305 out
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 use crate::source::{Contribution, ContributionContent};
313 use crate::types::{Role, SourceId};
314
315 fn wc(
316 source: &str,
317 priority: Priority,
318 tokens: usize,
319 importance: f32,
320 source_index: usize,
321 ) -> WeightedContribution {
322 WeightedContribution {
323 source: SourceId::new(source),
324 priority,
325 actual_tokens: tokens,
326 source_index,
327 contribution: Contribution {
328 content: ContributionContent::Text {
329 role: Role::User,
330 content: "x".repeat(tokens * 4),
331 },
332 estimated_tokens: tokens,
333 importance,
334 redactable: true,
335 tags: Vec::new(),
336 },
337 }
338 }
339
340 #[test]
341 fn under_budget_keeps_everything() {
342 let items = vec![
343 wc("a", Priority::Normal, 10, 0.5, 0),
344 wc("b", Priority::Normal, 20, 0.3, 1),
345 ];
346 let budget = TokenBudget::new(1000, 0);
347 let (kept, dropped) = apply_budget(items, &budget, PruneStrategy::default());
348 assert_eq!(kept.len(), 2);
349 assert!(dropped.is_empty());
350 }
351
352 #[test]
353 fn over_budget_drops_lowest_importance_first() {
354 let items = vec![
355 wc("a", Priority::Normal, 100, 0.9, 0),
356 wc("b", Priority::Normal, 100, 0.1, 1),
357 wc("c", Priority::Normal, 100, 0.5, 2),
358 ];
359 let budget = TokenBudget::new(200, 0);
361 let (kept, dropped) = apply_budget(items, &budget, PruneStrategy::ImportanceFirst);
362 assert_eq!(kept.len(), 2);
363 assert_eq!(dropped.len(), 1);
364 let kept_sources: Vec<&str> = kept.iter().map(|c| c.source.as_str()).collect();
366 assert!(kept_sources.contains(&"a"));
367 assert!(kept_sources.contains(&"c"));
368 assert_eq!(dropped[0].source.as_str(), "b");
369 }
370
371 #[test]
372 fn critical_priority_is_never_dropped() {
373 let items = vec![
374 wc("crit", Priority::Critical, 500, 0.1, 0),
375 wc("low", Priority::Low, 100, 0.9, 1),
376 ];
377 let budget = TokenBudget::new(200, 0);
378 let (kept, dropped) = apply_budget(items, &budget, PruneStrategy::default());
379 assert_eq!(kept.len(), 1);
382 assert_eq!(kept[0].source.as_str(), "crit");
383 assert_eq!(dropped.len(), 1);
384 assert_eq!(dropped[0].source.as_str(), "low");
385 }
386
387 #[test]
388 fn priority_floor_protects_a_bucket() {
389 let items = vec![
392 wc("h", Priority::High, 200, 0.9, 0),
393 wc("n1", Priority::Normal, 80, 0.5, 1),
394 wc("n2", Priority::Normal, 80, 0.3, 2),
395 wc("l", Priority::Low, 100, 0.7, 3),
396 ];
397 let budget = TokenBudget::new(200, 0).with_floor(Priority::Normal, 100);
399 let (kept, dropped) = apply_budget(items, &budget, PruneStrategy::ImportanceFirst);
400 let kept_sources: Vec<&str> = kept.iter().map(|c| c.source.as_str()).collect();
407 assert!(kept_sources.contains(&"h"));
409 let dropped_sources: Vec<&str> = dropped.iter().map(|c| c.source.as_str()).collect();
411 assert!(dropped_sources.contains(&"l"));
412 }
413
414 #[test]
415 fn round_robin_spreads_drops_across_sources() {
416 let items = vec![
417 wc("a", Priority::Normal, 50, 0.5, 0),
418 wc("a", Priority::Normal, 50, 0.4, 1),
419 wc("a", Priority::Normal, 50, 0.3, 2),
420 wc("b", Priority::Normal, 50, 0.5, 0),
421 wc("b", Priority::Normal, 50, 0.4, 1),
422 wc("b", Priority::Normal, 50, 0.3, 2),
423 ];
424 let budget = TokenBudget::new(150, 0);
426 let (kept, dropped) = apply_budget(items, &budget, PruneStrategy::RoundRobin);
427 assert_eq!(kept.len(), 3);
428 assert_eq!(dropped.len(), 3);
429 let drop_count_a = dropped.iter().filter(|d| d.source.as_str() == "a").count();
431 let drop_count_b = dropped.iter().filter(|d| d.source.as_str() == "b").count();
432 assert!(
435 drop_count_a == 1 && drop_count_b == 2 || drop_count_a == 2 && drop_count_b == 1,
436 "round-robin should spread: got a={drop_count_a} b={drop_count_b}"
437 );
438 }
439
440 #[test]
441 fn kept_preserves_input_order_within_priority() {
442 let items = vec![
443 wc("a", Priority::Normal, 10, 0.9, 0),
444 wc("a", Priority::Normal, 10, 0.5, 1),
445 wc("a", Priority::Normal, 10, 0.7, 2),
446 wc("a", Priority::Normal, 10, 0.1, 3),
447 ];
448 let budget = TokenBudget::new(20, 0);
451 let (kept, _dropped) = apply_budget(items, &budget, PruneStrategy::ImportanceFirst);
452 assert_eq!(kept.len(), 2);
453 assert_eq!(kept[0].source_index, 0);
454 assert_eq!(kept[1].source_index, 2);
455 }
456}