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
14use std::collections::{HashMap, HashSet};
15
16use areev_cal::store_types::GrainTypeDiversityConfig;
17use areev_core::types::GrainType;
18
19/// Allocation decision for a single grain.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub enum Allocation {
22    /// Include with full render.
23    Full,
24    /// Include with compact summary render.
25    Summary,
26    /// Omit entirely (budget exhausted).
27    Omit,
28}
29
30/// A grain scored and measured for budget allocation.
31pub struct ScoredEntry {
32    pub priority: f32,
33    pub full_tokens: usize,
34    pub summary_tokens: usize,
35    /// Original index in the input slice (for stable ordering after sort).
36    pub original_index: usize,
37    /// Grain type — used by `allocate_with_diversity()` for type-aware reservations.
38    pub grain_type: GrainType,
39}
40
41/// Allocate budget across grains using priority-based allocation.
42///
43/// Algorithm:
44/// 1. Sort entries by priority descending.
45/// 2. Allocate Full to highest-priority grains until ~70% budget consumed.
46/// 3. Degrade to Summary while under ~95% of the budget.
47/// 4. Remaining: Omit.
48///
49/// When token_budget is None, everything gets Full.
50///
51/// Returns a `Vec<Allocation>` aligned with the *original* entry order (by original_index),
52/// not the sorted order. This allows callers to zip allocations with their input slice.
53pub fn allocate(entries: &mut [ScoredEntry], token_budget: Option<usize>) -> Vec<Allocation> {
54    let n = entries.len();
55    if n == 0 {
56        return Vec::new();
57    }
58
59    let Some(budget) = token_budget else {
60        // No budget: everything gets Full
61        return vec![Allocation::Full; n];
62    };
63
64    // Sort by priority descending (stable sort preserves original_index order for ties)
65    entries.sort_by(|a, b| {
66        b.priority
67            .partial_cmp(&a.priority)
68            .unwrap_or(std::cmp::Ordering::Equal)
69    });
70
71    let full_threshold = budget * 70 / 100;
72    let summary_threshold = budget * 95 / 100;
73
74    // Allocate by sorted order, then scatter results back to original positions
75    let mut result_by_original = vec![Allocation::Omit; n];
76    let mut used = 0usize;
77
78    for entry in entries.iter() {
79        if used + entry.full_tokens <= full_threshold {
80            result_by_original[entry.original_index] = Allocation::Full;
81            used += entry.full_tokens;
82        } else if used + entry.summary_tokens <= summary_threshold {
83            result_by_original[entry.original_index] = Allocation::Summary;
84            used += entry.summary_tokens;
85        }
86        // else: stays Omit
87    }
88
89    result_by_original
90}
91
92/// Allocate budget with grain type diversity floor.
93///
94/// Guarantees a minimum number of grains per represented grain type are
95/// included before filling the remaining budget by priority. This prevents
96/// dominant types from crowding out rarer types that carry unique signal.
97///
98/// ## Algorithm (5 phases)
99///
100/// 1. **Group** entries by grain type.
101/// 2. **Reserve** up to `min_per_type` highest-priority entries per type.
102/// 3. **Trim** if reserved tokens exceed `max_reservation_pct * budget`
103///    (remove lowest-priority reservations, but keep at least 1 per type).
104/// 4. **Mark** reserved entries as `Full`.
105/// 5. **Fill** remaining budget with standard priority sort (70% threshold).
106///
107/// When `token_budget` is `None`, everything gets `Full` (same as `allocate`).
108pub fn allocate_with_diversity(
109    entries: &mut [ScoredEntry],
110    token_budget: Option<usize>,
111    diversity: &GrainTypeDiversityConfig,
112) -> Vec<Allocation> {
113    let n = entries.len();
114    if n == 0 {
115        return Vec::new();
116    }
117
118    let Some(budget) = token_budget else {
119        return vec![Allocation::Full; n];
120    };
121
122    // Phase 1: Group entry indices by grain type.
123    let mut groups: HashMap<GrainType, Vec<usize>> = HashMap::new();
124    for (i, entry) in entries.iter().enumerate() {
125        groups.entry(entry.grain_type).or_default().push(i);
126    }
127
128    // Sort each group by priority descending.
129    for indices in groups.values_mut() {
130        indices.sort_by(|&a, &b| {
131            entries[b]
132                .priority
133                .partial_cmp(&entries[a].priority)
134                .unwrap_or(std::cmp::Ordering::Equal)
135        });
136    }
137
138    // Phase 2: Reserve up to min_per_type highest-priority entries per type.
139    let mut reserved: HashSet<usize> = HashSet::new();
140    for indices in groups.values() {
141        let take = (diversity.min_per_type as usize).min(indices.len());
142        for &idx in &indices[..take] {
143            reserved.insert(idx);
144        }
145    }
146
147    // Phase 3: Trim if reserved tokens exceed cap.
148    let cap = (budget as f64 * diversity.max_reservation_pct as f64) as usize;
149    let reserved_tokens: usize = reserved.iter().map(|&i| entries[i].full_tokens).sum();
150
151    if reserved_tokens > cap {
152        // Sort reserved by priority ASC (lowest priority removed first).
153        let mut removable: Vec<usize> = reserved.iter().copied().collect();
154        removable.sort_by(|&a, &b| {
155            entries[a]
156                .priority
157                .partial_cmp(&entries[b].priority)
158                .unwrap_or(std::cmp::Ordering::Equal)
159        });
160
161        // Count reserved entries per type to enforce keep-at-least-1.
162        let mut type_counts: HashMap<GrainType, usize> = HashMap::new();
163        for &idx in &reserved {
164            *type_counts.entry(entries[idx].grain_type).or_insert(0) += 1;
165        }
166
167        let mut current_tokens = reserved_tokens;
168        for &idx in &removable {
169            if current_tokens <= cap {
170                break;
171            }
172            let gt = entries[idx].grain_type;
173            let count = type_counts.get(&gt).copied().unwrap_or(0);
174            if count <= 1 {
175                continue; // Keep at least 1 per type.
176            }
177            reserved.remove(&idx);
178            current_tokens -= entries[idx].full_tokens;
179            type_counts.insert(gt, count - 1);
180        }
181    }
182
183    // Phase 4: Mark reserved entries as Full.
184    let mut result = vec![Allocation::Omit; n];
185    let reserved_used: usize = reserved.iter().map(|&i| entries[i].full_tokens).sum();
186    for &i in &reserved {
187        result[entries[i].original_index] = Allocation::Full;
188    }
189
190    // Phase 5: Fill remaining budget by priority — Full under the ~70%
191    // threshold, degrading to Summary under ~95%, then Omit.
192    let remaining_budget = budget.saturating_sub(reserved_used);
193    let threshold = remaining_budget * 70 / 100;
194    let summary_threshold = remaining_budget * 95 / 100;
195
196    // Collect non-reserved indices, sorted by priority descending.
197    let mut non_reserved: Vec<usize> = (0..n).filter(|i| !reserved.contains(i)).collect();
198    non_reserved.sort_by(|&a, &b| {
199        entries[b]
200            .priority
201            .partial_cmp(&entries[a].priority)
202            .unwrap_or(std::cmp::Ordering::Equal)
203    });
204
205    let mut used = 0usize;
206    for idx in non_reserved {
207        if used + entries[idx].full_tokens <= threshold {
208            result[entries[idx].original_index] = Allocation::Full;
209            used += entries[idx].full_tokens;
210        } else if used + entries[idx].summary_tokens <= summary_threshold {
211            result[entries[idx].original_index] = Allocation::Summary;
212            used += entries[idx].summary_tokens;
213        }
214    }
215
216    result
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    /// Helper to create a `ScoredEntry` with default grain type (Fact).
224    fn entry(priority: f32, full_tokens: usize, summary_tokens: usize, idx: usize) -> ScoredEntry {
225        ScoredEntry {
226            priority,
227            full_tokens,
228            summary_tokens,
229            original_index: idx,
230            grain_type: GrainType::Fact,
231        }
232    }
233
234    /// Helper to create a `ScoredEntry` with a specific grain type.
235    fn typed_entry(
236        priority: f32,
237        full_tokens: usize,
238        grain_type: GrainType,
239        idx: usize,
240    ) -> ScoredEntry {
241        ScoredEntry {
242            priority,
243            full_tokens,
244            summary_tokens: full_tokens / 3,
245            original_index: idx,
246            grain_type,
247        }
248    }
249
250    #[test]
251    fn test_no_budget_all_full() {
252        let mut entries = vec![entry(0.5, 100, 30, 0), entry(0.8, 200, 60, 1)];
253        let allocs = allocate(&mut entries, None);
254        assert_eq!(allocs, vec![Allocation::Full, Allocation::Full]);
255    }
256
257    #[test]
258    fn test_empty_entries() {
259        let mut entries: Vec<ScoredEntry> = Vec::new();
260        let allocs = allocate(&mut entries, Some(1000));
261        assert!(allocs.is_empty());
262    }
263
264    #[test]
265    fn test_budget_overflow_degrades_then_omits() {
266        let mut entries = vec![entry(0.9, 800, 200, 0), entry(0.5, 800, 200, 1)];
267        // Budget=300: full_threshold=210, summary_threshold=285.
268        // Entry 0: 800 > 210 -> summary 200 <= 285 -> Summary, used=200.
269        // Entry 1: full and summary both exceed what's left -> Omit.
270        let allocs = allocate(&mut entries, Some(300));
271        assert_eq!(allocs[0], Allocation::Summary);
272        assert_eq!(allocs[1], Allocation::Omit);
273    }
274
275    #[test]
276    fn test_budget_full_then_summary() {
277        let mut entries = vec![
278            entry(0.9, 500, 150, 0),
279            entry(0.7, 500, 150, 1),
280            entry(0.3, 500, 150, 2),
281        ];
282        // Budget=1000: full_threshold=700, summary_threshold=950.
283        // Entry 0 (pri 0.9): 500 <= 700 -> Full, used=500
284        // Entry 1 (pri 0.7): full would blow 700; summary 500+150=650 <= 950 -> Summary
285        // Entry 2 (pri 0.3): summary 650+150=800 <= 950 -> Summary
286        let allocs = allocate(&mut entries, Some(1000));
287        assert_eq!(allocs[0], Allocation::Full);
288        assert_eq!(allocs[1], Allocation::Summary);
289        assert_eq!(allocs[2], Allocation::Summary);
290    }
291
292    #[test]
293    fn test_priority_ordering_respected() {
294        let mut entries = vec![entry(0.3, 100, 30, 0), entry(0.9, 100, 30, 1)];
295        // Budget=200: full_threshold=140. Entry with pri 0.9 gets Full first (100 <= 140).
296        // Entry with pri 0.3: full would blow 140; summary 100+30=130 <= 190 -> Summary.
297        let allocs = allocate(&mut entries, Some(200));
298        // original_index 1 (high pri) -> Full, original_index 0 (low pri) degrades.
299        assert_eq!(allocs[0], Allocation::Summary);
300        assert_eq!(allocs[1], Allocation::Full);
301    }
302
303    #[test]
304    fn test_exceeds_full_threshold_degrades_to_summary() {
305        let mut entries = vec![entry(0.9, 600, 100, 0), entry(0.5, 600, 100, 1)];
306        // Budget=1000: full_threshold=700. Entry 0: 600 <= 700 -> Full.
307        // Entry 1: full 600+600 blows 700; summary 600+100=700 <= 950 -> Summary.
308        let allocs = allocate(&mut entries, Some(1000));
309        assert_eq!(allocs[0], Allocation::Full);
310        assert_eq!(allocs[1], Allocation::Summary);
311    }
312
313    // -----------------------------------------------------------------------
314    // Diversity allocation tests
315    // -----------------------------------------------------------------------
316
317    #[test]
318    fn test_diversity_no_budget_all_full() {
319        let mut entries = vec![
320            typed_entry(0.9, 100, GrainType::Fact, 0),
321            typed_entry(0.5, 100, GrainType::Goal, 1),
322        ];
323        let cfg = GrainTypeDiversityConfig::default();
324        let allocs = allocate_with_diversity(&mut entries, None, &cfg);
325        assert_eq!(allocs, vec![Allocation::Full, Allocation::Full]);
326    }
327
328    #[test]
329    fn test_diversity_empty() {
330        let mut entries: Vec<ScoredEntry> = Vec::new();
331        let cfg = GrainTypeDiversityConfig::default();
332        let allocs = allocate_with_diversity(&mut entries, Some(1000), &cfg);
333        assert!(allocs.is_empty());
334    }
335
336    #[test]
337    fn test_diversity_reserves_rare_type() {
338        // 3 high-priority Facts + 1 low-priority Goal.
339        // Without diversity, Goal (pri 0.1) would be omitted.
340        // With diversity, Goal is reserved.
341        let mut entries = vec![
342            typed_entry(0.9, 200, GrainType::Fact, 0),
343            typed_entry(0.8, 200, GrainType::Fact, 1),
344            typed_entry(0.7, 200, GrainType::Fact, 2),
345            typed_entry(0.1, 200, GrainType::Goal, 3),
346        ];
347        let cfg = GrainTypeDiversityConfig {
348            min_per_type: 1,
349            max_reservation_pct: 0.50,
350        };
351        // Budget=1000, cap=500 tokens for reservations.
352        // Reservations: 1 Fact (pri 0.9, 200 tok) + 1 Goal (pri 0.1, 200 tok) = 400 <= 500.
353        // Remaining budget = 1000-400 = 600, threshold = 420.
354        // Non-reserved sorted by pri: Fact(0.8, 200), Fact(0.7, 200).
355        // 200 <= 420 -> Full, 200+200=400 <= 420 -> Full.
356        let allocs = allocate_with_diversity(&mut entries, Some(1000), &cfg);
357        assert_eq!(allocs[3], Allocation::Full, "Goal should be reserved");
358        assert_eq!(allocs[0], Allocation::Full, "Top Fact should be reserved");
359    }
360
361    #[test]
362    fn test_diversity_trims_when_over_cap() {
363        // 3 types, each with 1 grain of 300 tokens. Budget=1000, cap=0.05 -> 50 tokens.
364        // Reservations would be 900 tokens, far over 50. Trim to 1 per type.
365        // Since each grain is 300 > cap, trimming stops when only 1 per type remains.
366        let mut entries = vec![
367            typed_entry(0.9, 300, GrainType::Fact, 0),
368            typed_entry(0.5, 300, GrainType::Event, 1),
369            typed_entry(0.1, 300, GrainType::Goal, 2),
370        ];
371        let cfg = GrainTypeDiversityConfig {
372            min_per_type: 1,
373            max_reservation_pct: 0.05, // Very tight cap: 50 tokens
374        };
375        let allocs = allocate_with_diversity(&mut entries, Some(1000), &cfg);
376        // Even with tight cap, at least 1 per type is kept.
377        // Total reserved = 900 > 50, but can't trim below 1 per type.
378        // All 3 reserved as Full. Remaining budget = 1000-900=100, threshold=70.
379        // No non-reserved entries.
380        assert_eq!(allocs[0], Allocation::Full);
381        assert_eq!(allocs[1], Allocation::Full);
382        assert_eq!(allocs[2], Allocation::Full);
383    }
384}