Skip to main content

cel_brief/
budget.rs

1//! [`PruneStrategy`] + [`apply_budget`].
2//!
3//! The builder calls [`apply_budget`] once per
4//! turn against the fanned-out, tokenized contributions; it returns a `(kept,
5//! dropped)` split that honours both the global [`crate::types::TokenBudget`]
6//! and any per-priority floors set on it.
7//!
8//! The crate ships two strategies:
9//! - [`PruneStrategy::ImportanceFirst`] — default; drops the lowest
10//!   `(priority, importance)` items first.
11//! - [`PruneStrategy::RoundRobin`] — sweeps lowest priority across all
12//!   sources before touching the next-higher bucket. Useful when several
13//!   sources of equal priority should suffer pruning symmetrically rather
14//!   than one source losing all of its contributions.
15//!
16//! Custom strategies are out of scope for Phase 2; the enum stays
17//! non-exhaustive so we can add variants without a breaking change.
18
19use 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/// How [`apply_budget`] orders dropped contributions when the assembled set
28/// exceeds the prompt budget.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31#[non_exhaustive]
32pub enum PruneStrategy {
33    /// Default. Sort dropped candidates by priority ascending, then
34    /// importance ascending — lowest-importance items in the lowest priority
35    /// bucket are dropped first.
36    #[default]
37    ImportanceFirst,
38    /// Sweep one source at a time within each priority bucket, dropping the
39    /// least-important contribution from each source in turn. Useful when
40    /// several sources share a priority and you don't want one source to
41    /// keep all of its content while another loses everything.
42    RoundRobin,
43}
44
45/// One contribution as the budget layer sees it: the original
46/// [`Contribution`], the source that produced it, the ground-truth
47/// `actual_tokens` count from the [`crate::tokenizer::Tokenizer`], and the
48/// source's priority at admission time.
49///
50/// `actual_tokens` is the value the budget uses for ceiling enforcement —
51/// `Contribution::estimated_tokens` is a hint only.
52#[derive(Debug, Clone)]
53pub struct WeightedContribution {
54    /// Source that produced the contribution.
55    pub source: SourceId,
56    /// Source's priority at admission time.
57    pub priority: Priority,
58    /// Ground-truth token count from the active tokenizer.
59    pub actual_tokens: usize,
60    /// The contribution itself.
61    pub contribution: Contribution,
62    /// Stable index inside the source's contribution list. Used by
63    /// `apply_budget` to preserve source-defined order within priority.
64    pub source_index: usize,
65}
66
67/// Apply the configured prune strategy to bring `contributions` under
68/// `budget.prompt_budget()`, honouring any per-priority floors.
69///
70/// Returns `(kept, dropped)`:
71/// - `kept` is the post-prune list **in input order** (so callers can rely
72///   on source-defined ordering surviving the budget pass).
73/// - `dropped` is the list of [`DroppedContribution`] records for the
74///   receipt.
75///
76/// Behaviour:
77/// - Items in [`Priority::Critical`] are never dropped (Critical is the
78///   "must-keep" bucket). If Critical alone exceeds the
79///   budget, every non-Critical item is dropped and `kept` may still be
80///   over-budget — the builder turns that into a
81///   [`crate::error::BriefError::BudgetUnsatisfiable`].
82/// - Floors in `budget.floor_per_priority` are honoured **best-effort**:
83///   when over budget, the pruner will break a lower priority's floor
84///   before dropping a higher-priority item, matching the docstring on
85///   [`TokenBudget::floor_per_priority`] ("a higher priority can borrow
86///   from a lower floor when over budget").
87///
88/// Algorithm:
89/// 1. Iterate non-Critical priority buckets from [`Priority::Low`] →
90///    [`Priority::High`].
91/// 2. Within each bucket, drop items in strategy-defined order, honouring
92///    the bucket's floor (items that would breach the floor are skipped
93///    and marked as floored).
94/// 3. Before moving up to the next-higher bucket, run a borrow pass over
95///    every floored item at this or lower priorities; drop until under
96///    budget or out of floored candidates.
97/// 4. Critical bucket is left untouched. The caller turns any residual
98///    over-budget total into [`crate::error::BriefError::BudgetUnsatisfiable`].
99pub 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    // Fast path: everything fits.
108    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    // Process buckets low → high so lower-priority items drop first.
122    // `Priority::ALL` is `[Low, Normal, High, Critical]`; we exclude
123    // Critical.
124    let droppable_priorities = [Priority::Low, Priority::Normal, Priority::High];
125
126    // Track which items got skipped because of a floor — eligible for
127    // the borrow pass below.
128    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        // Compute drop order restricted to this bucket.
136        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            // Honour the floor for this bucket (skip if breaching).
149            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        // Before moving to the next-higher bucket, see if we can satisfy
170        // the budget by borrowing from floored items at this or lower
171        // priorities. This implements the "higher priority can borrow
172        // from a lower floor" guarantee — we'd rather break a low-prio
173        // floor than drop a high-prio item.
174        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
220/// Returns the list of original indices for items at exactly `priority`,
221/// in the order they should be dropped according to `strategy`.
222///
223/// Critical-priority items are filtered out by the caller — calling this
224/// with `Priority::Critical` returns an empty vector.
225fn 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            // Sort by (importance asc, source_index desc).
241            // NaN importance sorts to the front (drop first) — defensive.
242            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            // Group by source within this priority.
253            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            // Within each source: sort by importance asc, source_index
262            // desc (later entries in a source drop before earlier ones
263            // for round-robin fairness — `pop()` below pulls from the
264            // end).
265            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            // Stable order for sources — sort by SourceId so test output
275            // is deterministic.
276            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        // Budget for ~200 tokens of content.
360        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        // The 0.1 importance entry should be the dropped one.
365        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        // Critical kept; low dropped because budget is 200 and crit alone
380        // is 500 (over budget but critical can't be touched).
381        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        // Floor of 100 on Normal means the pruner stops removing Normal
390        // contributions once that bucket hits 100 tokens.
391        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        // Budget for 200 tokens. Need to drop 260 tokens.
398        let budget = TokenBudget::new(200, 0).with_floor(Priority::Normal, 100);
399        let (kept, dropped) = apply_budget(items, &budget, PruneStrategy::ImportanceFirst);
400        // After best-effort and borrow pass: total is High(200) + maybe
401        // some normal/low. Floors are best-effort, so the borrow pass
402        // can take normal below the floor when over budget. We don't
403        // assert exact keep set — we assert the floor was honoured on
404        // the best-effort pass by checking the drop order included low
405        // before normal.
406        let kept_sources: Vec<&str> = kept.iter().map(|c| c.source.as_str()).collect();
407        // High must be kept.
408        assert!(kept_sources.contains(&"h"));
409        // Low should have been dropped (lower priority).
410        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        // Total 300; budget 150 → drop 150 tokens (3 items).
425        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        // Round robin should drop ~the same count from each source.
430        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        // With 3 drops across 2 sources, we expect (2,1) or (1,2), never
433        // (3,0) or (0,3).
434        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        // Drop 20 tokens (2 items). Expect the 0.1 and 0.5 to drop, 0.9
449        // and 0.7 to remain *in original order* (index 0, 2).
450        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}