Skip to main content

areev_context/
budget.rs

1//! Token budget allocation.
2//!
3//! Given a budget and scored grains, decides which get full rendering,
4//! which degrade to a compact summary, and which are omitted — progressive
5//! disclosure: Full while under ~70% of the budget, then Summary while
6//! under ~95%, then Omit. Degrading beats dropping: a grain that survives
7//! as one line still tells the model it exists.
8//!
9//! Two allocation strategies:
10//! - `allocate()` — pure priority-based allocation.
11//! - `allocate_with_diversity()` — reserves slots per grain type before
12//!   filling by priority, ensuring rare types are not crowded out.
13//!
14//! Both honour two per-entry inputs a decision backend can set (decision-
15//! backend phase 3, row A2; the assembler sets them only for a CALIBRATED
16//! backend): `force_omit` takes an entry out before any budget is spent —
17//! even with no budget — and `prefer_full` lets an entry take the Full
18//! render up to the ~95% line instead of stopping at ~70%. Neither can push
19//! the allocation past 95%: the budget rule stays final.
20
21use std::collections::{HashMap, HashSet};
22
23use areev_cal::store_types::GrainTypeDiversityConfig;
24use areev_core::types::GrainType;
25
26/// Allocation decision for a single grain.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum Allocation {
29    /// Include with full render.
30    Full,
31    /// Include with compact summary render.
32    Summary,
33    /// Omit entirely (budget exhausted).
34    Omit,
35}
36
37/// A grain scored and measured for budget allocation.
38pub struct ScoredEntry {
39    pub priority: f32,
40    pub full_tokens: usize,
41    pub summary_tokens: usize,
42    /// Original index in the input slice (for stable ordering after sort).
43    pub original_index: usize,
44    /// Grain type — used by `allocate_with_diversity()` for type-aware reservations.
45    pub grain_type: GrainType,
46    /// Omit regardless of budget (a calibrated backend judged it off-topic).
47    pub force_omit: bool,
48    /// Full may use the budget up to the ~95% line, not only ~70% (a
49    /// calibrated backend judged a summary would lose what the query needs).
50    pub prefer_full: bool,
51}
52
53impl ScoredEntry {
54    /// An entry with neither decision input set — today's allocation.
55    pub fn new(
56        priority: f32,
57        full_tokens: usize,
58        summary_tokens: usize,
59        original_index: usize,
60        grain_type: GrainType,
61    ) -> Self {
62        Self {
63            priority,
64            full_tokens,
65            summary_tokens,
66            original_index,
67            grain_type,
68            force_omit: false,
69            prefer_full: false,
70        }
71    }
72}
73
74/// Everything Full except the `force_omit` entries — the no-budget answer.
75fn unbudgeted(entries: &[ScoredEntry]) -> Vec<Allocation> {
76    let mut out = vec![Allocation::Full; entries.len()];
77    for e in entries {
78        if e.force_omit {
79            out[e.original_index] = Allocation::Omit;
80        }
81    }
82    out
83}
84
85/// Allocate budget across grains using priority-based allocation.
86///
87/// Algorithm:
88/// 1. Sort entries by priority descending.
89/// 2. Allocate Full to highest-priority grains until ~70% budget consumed.
90/// 3. Degrade to Summary while under ~95% of the budget.
91/// 4. Remaining: Omit.
92///
93/// When token_budget is None, everything gets Full.
94///
95/// Returns a `Vec<Allocation>` aligned with the *original* entry order (by original_index),
96/// not the sorted order. This allows callers to zip allocations with their input slice.
97pub fn allocate(entries: &mut [ScoredEntry], token_budget: Option<usize>) -> Vec<Allocation> {
98    let n = entries.len();
99    if n == 0 {
100        return Vec::new();
101    }
102
103    let Some(budget) = token_budget else {
104        // No budget: everything gets Full (a forced omit still omits).
105        return unbudgeted(entries);
106    };
107
108    // Sort by priority descending (stable sort preserves original_index order for ties)
109    entries.sort_by(|a, b| {
110        b.priority
111            .partial_cmp(&a.priority)
112            .unwrap_or(std::cmp::Ordering::Equal)
113    });
114
115    let full_threshold = budget * 70 / 100;
116    let summary_threshold = budget * 95 / 100;
117
118    // Allocate by sorted order, then scatter results back to original positions
119    let mut result_by_original = vec![Allocation::Omit; n];
120    let mut used = 0usize;
121
122    for entry in entries.iter() {
123        if entry.force_omit {
124            continue; // stays Omit
125        }
126        let full_ceiling = if entry.prefer_full { summary_threshold } else { full_threshold };
127        if used + entry.full_tokens <= full_ceiling {
128            result_by_original[entry.original_index] = Allocation::Full;
129            used += entry.full_tokens;
130        } else if used + entry.summary_tokens <= summary_threshold {
131            result_by_original[entry.original_index] = Allocation::Summary;
132            used += entry.summary_tokens;
133        }
134        // else: stays Omit
135    }
136
137    result_by_original
138}
139
140/// Allocate budget with grain type diversity floor.
141///
142/// Guarantees a minimum number of grains per represented grain type are
143/// included before filling the remaining budget by priority. This prevents
144/// dominant types from crowding out rarer types that carry unique signal.
145///
146/// ## Algorithm (5 phases)
147///
148/// 1. **Group** entries by grain type.
149/// 2. **Reserve** up to `min_per_type` highest-priority entries per type.
150/// 3. **Trim** if reserved tokens exceed `max_reservation_pct * budget`
151///    (remove lowest-priority reservations, but keep at least 1 per type).
152/// 4. **Mark** reserved entries as `Full`.
153/// 5. **Fill** remaining budget with standard priority sort (70% threshold).
154///
155/// When `token_budget` is `None`, everything gets `Full` (same as `allocate`).
156pub fn allocate_with_diversity(
157    entries: &mut [ScoredEntry],
158    token_budget: Option<usize>,
159    diversity: &GrainTypeDiversityConfig,
160) -> Vec<Allocation> {
161    let n = entries.len();
162    if n == 0 {
163        return Vec::new();
164    }
165
166    let Some(budget) = token_budget else {
167        return unbudgeted(entries);
168    };
169
170    // Phase 1: Group entry indices by grain type. A forced omit is never
171    // reserved — a diversity floor must not resurrect what was judged
172    // off-topic.
173    let mut groups: HashMap<GrainType, Vec<usize>> = HashMap::new();
174    for (i, entry) in entries.iter().enumerate() {
175        if entry.force_omit {
176            continue;
177        }
178        groups.entry(entry.grain_type).or_default().push(i);
179    }
180
181    // Sort each group by priority descending.
182    for indices in groups.values_mut() {
183        indices.sort_by(|&a, &b| {
184            entries[b]
185                .priority
186                .partial_cmp(&entries[a].priority)
187                .unwrap_or(std::cmp::Ordering::Equal)
188        });
189    }
190
191    // Phase 2: Reserve up to min_per_type highest-priority entries per type.
192    let mut reserved: HashSet<usize> = HashSet::new();
193    for indices in groups.values() {
194        let take = (diversity.min_per_type as usize).min(indices.len());
195        for &idx in &indices[..take] {
196            reserved.insert(idx);
197        }
198    }
199
200    // Phase 3: Trim if reserved tokens exceed cap.
201    let cap = (budget as f64 * diversity.max_reservation_pct as f64) as usize;
202    let reserved_tokens: usize = reserved.iter().map(|&i| entries[i].full_tokens).sum();
203
204    if reserved_tokens > cap {
205        // Sort reserved by priority ASC (lowest priority removed first).
206        let mut removable: Vec<usize> = reserved.iter().copied().collect();
207        removable.sort_by(|&a, &b| {
208            entries[a]
209                .priority
210                .partial_cmp(&entries[b].priority)
211                .unwrap_or(std::cmp::Ordering::Equal)
212        });
213
214        // Count reserved entries per type to enforce keep-at-least-1.
215        let mut type_counts: HashMap<GrainType, usize> = HashMap::new();
216        for &idx in &reserved {
217            *type_counts.entry(entries[idx].grain_type).or_insert(0) += 1;
218        }
219
220        let mut current_tokens = reserved_tokens;
221        for &idx in &removable {
222            if current_tokens <= cap {
223                break;
224            }
225            let gt = entries[idx].grain_type;
226            let count = type_counts.get(&gt).copied().unwrap_or(0);
227            if count <= 1 {
228                continue; // Keep at least 1 per type.
229            }
230            reserved.remove(&idx);
231            current_tokens -= entries[idx].full_tokens;
232            type_counts.insert(gt, count - 1);
233        }
234    }
235
236    // Phase 4: Mark reserved entries as Full.
237    let mut result = vec![Allocation::Omit; n];
238    let reserved_used: usize = reserved.iter().map(|&i| entries[i].full_tokens).sum();
239    for &i in &reserved {
240        result[entries[i].original_index] = Allocation::Full;
241    }
242
243    // Phase 5: Fill remaining budget by priority — Full under the ~70%
244    // threshold, degrading to Summary under ~95%, then Omit.
245    let remaining_budget = budget.saturating_sub(reserved_used);
246    let threshold = remaining_budget * 70 / 100;
247    let summary_threshold = remaining_budget * 95 / 100;
248
249    // Collect non-reserved indices, sorted by priority descending.
250    let mut non_reserved: Vec<usize> = (0..n)
251        .filter(|i| !reserved.contains(i) && !entries[*i].force_omit)
252        .collect();
253    non_reserved.sort_by(|&a, &b| {
254        entries[b]
255            .priority
256            .partial_cmp(&entries[a].priority)
257            .unwrap_or(std::cmp::Ordering::Equal)
258    });
259
260    let mut used = 0usize;
261    for idx in non_reserved {
262        let full_ceiling = if entries[idx].prefer_full { summary_threshold } else { threshold };
263        if used + entries[idx].full_tokens <= full_ceiling {
264            result[entries[idx].original_index] = Allocation::Full;
265            used += entries[idx].full_tokens;
266        } else if used + entries[idx].summary_tokens <= summary_threshold {
267            result[entries[idx].original_index] = Allocation::Summary;
268            used += entries[idx].summary_tokens;
269        }
270    }
271
272    result
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    /// Helper to create a `ScoredEntry` with default grain type (Fact).
280    fn entry(priority: f32, full_tokens: usize, summary_tokens: usize, idx: usize) -> ScoredEntry {
281        ScoredEntry::new(priority, full_tokens, summary_tokens, idx, GrainType::Fact)
282    }
283
284    /// Helper to create a `ScoredEntry` with a specific grain type.
285    fn typed_entry(
286        priority: f32,
287        full_tokens: usize,
288        grain_type: GrainType,
289        idx: usize,
290    ) -> ScoredEntry {
291        ScoredEntry::new(priority, full_tokens, full_tokens / 3, idx, grain_type)
292    }
293
294    #[test]
295    fn test_no_budget_all_full() {
296        let mut entries = vec![entry(0.5, 100, 30, 0), entry(0.8, 200, 60, 1)];
297        let allocs = allocate(&mut entries, None);
298        assert_eq!(allocs, vec![Allocation::Full, Allocation::Full]);
299    }
300
301    #[test]
302    fn test_empty_entries() {
303        let mut entries: Vec<ScoredEntry> = Vec::new();
304        let allocs = allocate(&mut entries, Some(1000));
305        assert!(allocs.is_empty());
306    }
307
308    #[test]
309    fn test_budget_overflow_degrades_then_omits() {
310        let mut entries = vec![entry(0.9, 800, 200, 0), entry(0.5, 800, 200, 1)];
311        // Budget=300: full_threshold=210, summary_threshold=285.
312        // Entry 0: 800 > 210 -> summary 200 <= 285 -> Summary, used=200.
313        // Entry 1: full and summary both exceed what's left -> Omit.
314        let allocs = allocate(&mut entries, Some(300));
315        assert_eq!(allocs[0], Allocation::Summary);
316        assert_eq!(allocs[1], Allocation::Omit);
317    }
318
319    #[test]
320    fn test_budget_full_then_summary() {
321        let mut entries = vec![
322            entry(0.9, 500, 150, 0),
323            entry(0.7, 500, 150, 1),
324            entry(0.3, 500, 150, 2),
325        ];
326        // Budget=1000: full_threshold=700, summary_threshold=950.
327        // Entry 0 (pri 0.9): 500 <= 700 -> Full, used=500
328        // Entry 1 (pri 0.7): full would blow 700; summary 500+150=650 <= 950 -> Summary
329        // Entry 2 (pri 0.3): summary 650+150=800 <= 950 -> Summary
330        let allocs = allocate(&mut entries, Some(1000));
331        assert_eq!(allocs[0], Allocation::Full);
332        assert_eq!(allocs[1], Allocation::Summary);
333        assert_eq!(allocs[2], Allocation::Summary);
334    }
335
336    #[test]
337    fn test_priority_ordering_respected() {
338        let mut entries = vec![entry(0.3, 100, 30, 0), entry(0.9, 100, 30, 1)];
339        // Budget=200: full_threshold=140. Entry with pri 0.9 gets Full first (100 <= 140).
340        // Entry with pri 0.3: full would blow 140; summary 100+30=130 <= 190 -> Summary.
341        let allocs = allocate(&mut entries, Some(200));
342        // original_index 1 (high pri) -> Full, original_index 0 (low pri) degrades.
343        assert_eq!(allocs[0], Allocation::Summary);
344        assert_eq!(allocs[1], Allocation::Full);
345    }
346
347    #[test]
348    fn test_exceeds_full_threshold_degrades_to_summary() {
349        let mut entries = vec![entry(0.9, 600, 100, 0), entry(0.5, 600, 100, 1)];
350        // Budget=1000: full_threshold=700. Entry 0: 600 <= 700 -> Full.
351        // Entry 1: full 600+600 blows 700; summary 600+100=700 <= 950 -> Summary.
352        let allocs = allocate(&mut entries, Some(1000));
353        assert_eq!(allocs[0], Allocation::Full);
354        assert_eq!(allocs[1], Allocation::Summary);
355    }
356
357    // -----------------------------------------------------------------------
358    // Diversity allocation tests
359    // -----------------------------------------------------------------------
360
361    #[test]
362    fn test_diversity_no_budget_all_full() {
363        let mut entries = vec![
364            typed_entry(0.9, 100, GrainType::Fact, 0),
365            typed_entry(0.5, 100, GrainType::Goal, 1),
366        ];
367        let cfg = GrainTypeDiversityConfig::default();
368        let allocs = allocate_with_diversity(&mut entries, None, &cfg);
369        assert_eq!(allocs, vec![Allocation::Full, Allocation::Full]);
370    }
371
372    #[test]
373    fn test_diversity_empty() {
374        let mut entries: Vec<ScoredEntry> = Vec::new();
375        let cfg = GrainTypeDiversityConfig::default();
376        let allocs = allocate_with_diversity(&mut entries, Some(1000), &cfg);
377        assert!(allocs.is_empty());
378    }
379
380    #[test]
381    fn test_diversity_reserves_rare_type() {
382        // 3 high-priority Facts + 1 low-priority Goal.
383        // Without diversity, Goal (pri 0.1) would be omitted.
384        // With diversity, Goal is reserved.
385        let mut entries = vec![
386            typed_entry(0.9, 200, GrainType::Fact, 0),
387            typed_entry(0.8, 200, GrainType::Fact, 1),
388            typed_entry(0.7, 200, GrainType::Fact, 2),
389            typed_entry(0.1, 200, GrainType::Goal, 3),
390        ];
391        let cfg = GrainTypeDiversityConfig {
392            min_per_type: 1,
393            max_reservation_pct: 0.50,
394        };
395        // Budget=1000, cap=500 tokens for reservations.
396        // Reservations: 1 Fact (pri 0.9, 200 tok) + 1 Goal (pri 0.1, 200 tok) = 400 <= 500.
397        // Remaining budget = 1000-400 = 600, threshold = 420.
398        // Non-reserved sorted by pri: Fact(0.8, 200), Fact(0.7, 200).
399        // 200 <= 420 -> Full, 200+200=400 <= 420 -> Full.
400        let allocs = allocate_with_diversity(&mut entries, Some(1000), &cfg);
401        assert_eq!(allocs[3], Allocation::Full, "Goal should be reserved");
402        assert_eq!(allocs[0], Allocation::Full, "Top Fact should be reserved");
403    }
404
405    #[test]
406    fn test_diversity_trims_when_over_cap() {
407        // 3 types, each with 1 grain of 300 tokens. Budget=1000, cap=0.05 -> 50 tokens.
408        // Reservations would be 900 tokens, far over 50. Trim to 1 per type.
409        // Since each grain is 300 > cap, trimming stops when only 1 per type remains.
410        let mut entries = vec![
411            typed_entry(0.9, 300, GrainType::Fact, 0),
412            typed_entry(0.5, 300, GrainType::Event, 1),
413            typed_entry(0.1, 300, GrainType::Goal, 2),
414        ];
415        let cfg = GrainTypeDiversityConfig {
416            min_per_type: 1,
417            max_reservation_pct: 0.05, // Very tight cap: 50 tokens
418        };
419        let allocs = allocate_with_diversity(&mut entries, Some(1000), &cfg);
420        // Even with tight cap, at least 1 per type is kept.
421        // Total reserved = 900 > 50, but can't trim below 1 per type.
422        // All 3 reserved as Full. Remaining budget = 1000-900=100, threshold=70.
423        // No non-reserved entries.
424        assert_eq!(allocs[0], Allocation::Full);
425        assert_eq!(allocs[1], Allocation::Full);
426        assert_eq!(allocs[2], Allocation::Full);
427    }
428
429    // -----------------------------------------------------------------------
430    // Decision inputs (phase 3, row A2)
431    // -----------------------------------------------------------------------
432
433    #[test]
434    fn force_omit_omits_even_without_a_budget() {
435        let mut entries = vec![entry(0.9, 10, 3, 0), entry(0.9, 10, 3, 1)];
436        entries[1].force_omit = true;
437        assert_eq!(allocate(&mut entries, None), vec![Allocation::Full, Allocation::Omit]);
438        let cfg = GrainTypeDiversityConfig::default();
439        let mut entries = vec![entry(0.9, 10, 3, 0), entry(0.9, 10, 3, 1)];
440        entries[0].force_omit = true;
441        assert_eq!(
442            allocate_with_diversity(&mut entries, Some(1000), &cfg),
443            vec![Allocation::Omit, Allocation::Full]
444        );
445    }
446
447    #[test]
448    fn a_forced_omit_is_never_reserved_by_the_diversity_floor() {
449        // The only Goal is judged off-topic: the floor must not bring it back.
450        let mut entries = vec![
451            typed_entry(0.9, 100, GrainType::Fact, 0),
452            typed_entry(0.9, 100, GrainType::Goal, 1),
453        ];
454        entries[1].force_omit = true;
455        let cfg = GrainTypeDiversityConfig { min_per_type: 1, max_reservation_pct: 0.9 };
456        let allocs = allocate_with_diversity(&mut entries, Some(1000), &cfg);
457        assert_eq!(allocs[1], Allocation::Omit);
458    }
459
460    #[test]
461    fn prefer_full_uses_the_95_line_and_no_further() {
462        // Budget 1000: 70% = 700, 95% = 950. First entry takes 500 Full.
463        // The second (400) would be Summary under the 70% rule; preferring
464        // Full it fits under 950 (500+400 = 900).
465        let mut plain = vec![entry(0.9, 500, 100, 0), entry(0.5, 400, 100, 1)];
466        assert_eq!(allocate(&mut plain, Some(1000))[1], Allocation::Summary);
467        let mut pref = vec![entry(0.9, 500, 100, 0), entry(0.5, 400, 100, 1)];
468        pref[1].prefer_full = true;
469        assert_eq!(allocate(&mut pref, Some(1000))[1], Allocation::Full);
470        // 500 + 500 = 1000 > 950: the budget rule stays final → Summary.
471        let mut over = vec![entry(0.9, 500, 100, 0), entry(0.5, 500, 100, 1)];
472        over[1].prefer_full = true;
473        assert_eq!(allocate(&mut over, Some(1000))[1], Allocation::Summary);
474    }
475}