Skip to main content

dejadb_context/
budget.rs

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