1use std::collections::{HashMap, HashSet};
22
23use areev_cal::store_types::GrainTypeDiversityConfig;
24use areev_core::types::GrainType;
25
26#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum Allocation {
29 Full,
31 Summary,
33 Omit,
35}
36
37pub struct ScoredEntry {
39 pub priority: f32,
40 pub full_tokens: usize,
41 pub summary_tokens: usize,
42 pub original_index: usize,
44 pub grain_type: GrainType,
46 pub force_omit: bool,
48 pub prefer_full: bool,
51}
52
53impl ScoredEntry {
54 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
74fn 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
85pub 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 return unbudgeted(entries);
106 };
107
108 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 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; }
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 }
136
137 result_by_original
138}
139
140pub 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 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 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 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 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 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 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(>).copied().unwrap_or(0);
227 if count <= 1 {
228 continue; }
230 reserved.remove(&idx);
231 current_tokens -= entries[idx].full_tokens;
232 type_counts.insert(gt, count - 1);
233 }
234 }
235
236 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 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 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 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 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 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 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 let allocs = allocate(&mut entries, Some(200));
342 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 let allocs = allocate(&mut entries, Some(1000));
353 assert_eq!(allocs[0], Allocation::Full);
354 assert_eq!(allocs[1], Allocation::Summary);
355 }
356
357 #[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 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 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 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, };
419 let allocs = allocate_with_diversity(&mut entries, Some(1000), &cfg);
420 assert_eq!(allocs[0], Allocation::Full);
425 assert_eq!(allocs[1], Allocation::Full);
426 assert_eq!(allocs[2], Allocation::Full);
427 }
428
429 #[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 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 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 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}