1#[cfg(feature = "serde")]
7use serde::{Deserialize, Serialize};
8
9use khive_score::DeterministicScore;
10
11use crate::error::FoldError;
12
13#[derive(Debug, Clone)]
15#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
16pub struct SelectorInput<T> {
17 pub id: String,
19 pub content: T,
21 pub size: usize,
23 pub score: f32,
25 #[cfg_attr(feature = "serde", serde(default))]
27 pub category: Option<String>,
28 #[cfg_attr(feature = "serde", serde(default))]
35 pub information_gain: Option<f32>,
36 #[cfg_attr(feature = "serde", serde(default))]
46 pub rank_score: Option<f64>,
47}
48
49#[derive(Debug, Clone)]
51#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
52pub struct SelectorOutput<T> {
53 pub selected: Vec<SelectorInput<T>>,
55 pub total_size: usize,
57 pub budget: usize,
59}
60
61#[derive(Debug, Clone, Default)]
65#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
66pub struct SelectorWeights {
67 pub category_weights: std::collections::BTreeMap<String, f32>,
69 pub min_score: f32,
71 pub diversity_bias: f32,
74 #[cfg_attr(feature = "serde", serde(default))]
80 pub epistemic_weight: f32,
81}
82
83pub trait Selector<T>: Send + Sync {
88 fn select(
90 &self,
91 inputs: Vec<SelectorInput<T>>,
92 budget: usize,
93 weights: &SelectorWeights,
94 ) -> Result<SelectorOutput<T>, FoldError>;
95}
96
97#[derive(Debug, Clone, Copy, Default)]
110pub struct GreedySelector;
111
112#[inline]
115fn rank_base<T>(item: &SelectorInput<T>) -> f64 {
116 item.rank_score.unwrap_or(item.score as f64)
117}
118
119#[inline]
121fn pragmatic_plus_epistemic<T>(item: &SelectorInput<T>, epistemic_weight: f32) -> f64 {
122 let base = rank_base(item);
123 if epistemic_weight == 0.0 {
124 return base;
125 }
126 base + epistemic_weight as f64 * item.information_gain.unwrap_or(0.0) as f64
127}
128
129fn effective_score<T>(
130 item: &SelectorInput<T>,
131 counts: &std::collections::BTreeMap<String, usize>,
132 bias: f32,
133 epistemic_weight: f32,
134) -> f64 {
135 let base = pragmatic_plus_epistemic(item, epistemic_weight);
136 if bias == 0.0 {
137 return base;
138 }
139 let count = item
140 .category
141 .as_ref()
142 .and_then(|c| counts.get(c))
143 .copied()
144 .unwrap_or(0);
145 base * (1.0 - bias as f64 * count as f64 / (count as f64 + 1.0))
146}
147
148fn validate_selector_weights(weights: &SelectorWeights) -> Result<(), FoldError> {
149 if !weights.min_score.is_finite() {
150 return Err(FoldError::InvalidInput(
151 "SelectorWeights.min_score must be finite".to_string(),
152 ));
153 }
154 if !weights.diversity_bias.is_finite() {
155 return Err(FoldError::InvalidInput(
156 "SelectorWeights.diversity_bias must be finite".to_string(),
157 ));
158 }
159 if !(0.0..=1.0).contains(&weights.diversity_bias) {
160 return Err(FoldError::InvalidInput(
161 "SelectorWeights.diversity_bias must be within [0.0, 1.0]".to_string(),
162 ));
163 }
164 if !weights.epistemic_weight.is_finite() {
165 return Err(FoldError::InvalidInput(
166 "SelectorWeights.epistemic_weight must be finite".to_string(),
167 ));
168 }
169 for (category, weight) in &weights.category_weights {
170 if !weight.is_finite() {
171 return Err(FoldError::InvalidInput(format!(
172 "SelectorWeights.category_weights['{category}'] must be finite"
173 )));
174 }
175 }
176 Ok(())
177}
178
179impl<T: Clone> Selector<T> for GreedySelector {
180 fn select(
181 &self,
182 mut inputs: Vec<SelectorInput<T>>,
183 budget: usize,
184 weights: &SelectorWeights,
185 ) -> Result<SelectorOutput<T>, FoldError> {
186 validate_selector_weights(weights)?;
187 for input in &inputs {
188 if let Some(gain) = input.information_gain {
189 if !gain.is_finite() {
190 return Err(FoldError::InvalidInput(format!(
191 "information_gain for '{}' must be finite",
192 input.id
193 )));
194 }
195 }
196 if let Some(rank_score) = input.rank_score {
197 if !rank_score.is_finite() {
198 return Err(FoldError::InvalidInput(format!(
199 "rank_score for '{}' must be finite",
200 input.id
201 )));
202 }
203 }
204 }
205
206 inputs.retain(|i| i.score.is_finite() && i.score >= weights.min_score);
208
209 if !weights.category_weights.is_empty() {
212 for item in &mut inputs {
213 if let Some(ref cat) = item.category {
214 if let Some(&w) = weights.category_weights.get(cat.as_str()) {
215 let w = w.max(0.0);
216 item.score *= w;
217 if let Some(rank_score) = item.rank_score {
218 item.rank_score = Some(rank_score * w as f64);
219 }
220 }
221 }
222 }
223 inputs.retain(|i| i.score.is_finite() && i.score >= weights.min_score);
224 }
225
226 let ew = weights.epistemic_weight;
227
228 let mut ranked = Vec::with_capacity(inputs.len());
231 for input in inputs {
232 let effective = pragmatic_plus_epistemic(&input, ew);
233 if !effective.is_finite() {
234 return Err(FoldError::InvalidInput(format!(
235 "effective score for '{}' must be finite",
236 input.id
237 )));
238 }
239 let det_score = DeterministicScore::from_f64(effective);
240 ranked.push((input, det_score));
241 }
242 ranked.sort_by(|(a, a_det), (b, b_det)| {
243 b_det
244 .cmp(a_det)
245 .then_with(|| a.size.cmp(&b.size))
246 .then_with(|| a.id.cmp(&b.id))
247 });
248 let inputs: Vec<_> = ranked.into_iter().map(|(input, _)| input).collect();
249
250 let mut selected = Vec::new();
251 let mut total_size = 0usize;
252
253 if weights.diversity_bias == 0.0 {
254 for input in inputs {
256 if input.size <= budget.saturating_sub(total_size) {
257 total_size += input.size;
258 selected.push(input);
259 }
260 }
261 } else {
262 let mut remaining = inputs;
264 let mut category_counts: std::collections::BTreeMap<String, usize> =
265 std::collections::BTreeMap::new();
266
267 while !remaining.is_empty() && total_size < budget {
268 let mut candidates = Vec::with_capacity(remaining.len());
269 for (i, item) in remaining.iter().enumerate() {
270 if item.size > budget.saturating_sub(total_size) {
271 continue;
272 }
273 let eff = effective_score(item, &category_counts, weights.diversity_bias, ew);
274 if !eff.is_finite() {
275 return Err(FoldError::InvalidInput(format!(
276 "effective score for '{}' must be finite",
277 item.id
278 )));
279 }
280 candidates.push((i, DeterministicScore::from_f64(eff)));
281 }
282
283 let best_idx = candidates
284 .into_iter()
285 .max_by(|&(i, a_det), &(j, b_det)| {
286 a_det
287 .cmp(&b_det)
288 .then_with(|| remaining[j].size.cmp(&remaining[i].size))
289 .then_with(|| remaining[j].id.cmp(&remaining[i].id))
290 })
291 .map(|(i, _)| i);
292
293 match best_idx {
294 Some(idx) => {
295 let item = remaining.swap_remove(idx);
296 if let Some(ref cat) = item.category {
297 *category_counts.entry(cat.clone()).or_default() += 1;
298 }
299 total_size += item.size;
300 selected.push(item);
301 }
302 None => break,
303 }
304 }
305 }
306
307 Ok(SelectorOutput {
308 selected,
309 total_size,
310 budget,
311 })
312 }
313}
314
315#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn input(id: &str, size: usize, score: f32) -> SelectorInput<()> {
324 SelectorInput {
325 id: id.to_string(),
326 content: (),
327 size,
328 score,
329 category: None,
330 information_gain: None,
331 rank_score: None,
332 }
333 }
334
335 fn input_cat(id: &str, size: usize, score: f32, cat: &str) -> SelectorInput<()> {
336 SelectorInput {
337 id: id.to_string(),
338 content: (),
339 size,
340 score,
341 category: Some(cat.to_string()),
342 information_gain: None,
343 rank_score: None,
344 }
345 }
346
347 fn weights(min_score: f32) -> SelectorWeights {
348 SelectorWeights {
349 min_score,
350 ..Default::default()
351 }
352 }
353
354 #[test]
355 fn empty_input() {
356 let inputs: Vec<SelectorInput<()>> = vec![];
357 let out = GreedySelector.select(inputs, 1000, &weights(0.0)).unwrap();
358 assert!(out.selected.is_empty());
359 assert_eq!(out.total_size, 0);
360 assert_eq!(out.budget, 1000);
361 }
362
363 #[test]
364 fn packs_highest_scores_first() {
365 let inputs = vec![
366 input("a", 100, 0.5),
367 input("b", 100, 0.9),
368 input("c", 100, 0.7),
369 ];
370 let out = GreedySelector.select(inputs, 200, &weights(0.0)).unwrap();
371 assert_eq!(out.selected.len(), 2);
372 assert_eq!(out.selected[0].id, "b");
373 assert_eq!(out.selected[1].id, "c");
374 assert_eq!(out.total_size, 200);
375 }
376
377 #[test]
378 fn respects_budget() {
379 let inputs = vec![
380 input("a", 300, 0.9),
381 input("b", 300, 0.8),
382 input("c", 300, 0.7),
383 ];
384 let out = GreedySelector.select(inputs, 500, &weights(0.0)).unwrap();
385 assert_eq!(out.selected.len(), 1);
386 assert_eq!(out.selected[0].id, "a");
387 assert_eq!(out.total_size, 300);
388 }
389
390 #[test]
391 fn filters_below_min_score() {
392 let inputs = vec![
393 input("a", 10, 0.8),
394 input("b", 10, 0.1),
395 input("c", 10, 0.5),
396 ];
397 let out = GreedySelector.select(inputs, 1000, &weights(0.3)).unwrap();
398 assert_eq!(out.selected.len(), 2);
399 assert_eq!(out.selected[0].id, "a");
400 assert_eq!(out.selected[1].id, "c");
401 }
402
403 #[test]
404 fn filters_nan_and_inf() {
405 let inputs = vec![
406 input("nan", 10, f32::NAN),
407 input("inf", 10, f32::INFINITY),
408 input("neg_inf", 10, f32::NEG_INFINITY),
409 input("ok", 10, 0.5),
410 ];
411 let out = GreedySelector.select(inputs, 1000, &weights(0.0)).unwrap();
412 assert_eq!(out.selected.len(), 1);
413 assert_eq!(out.selected[0].id, "ok");
414 }
415
416 #[test]
417 fn tie_break_size_ascending() {
418 let inputs = vec![input("big", 200, 0.5), input("small", 50, 0.5)];
419 let out = GreedySelector.select(inputs, 1000, &weights(0.0)).unwrap();
420 assert_eq!(out.selected[0].id, "small");
421 assert_eq!(out.selected[1].id, "big");
422 }
423
424 #[test]
425 fn tie_break_id_ascending() {
426 let inputs = vec![input("z", 100, 0.5), input("a", 100, 0.5)];
427 let out = GreedySelector.select(inputs, 1000, &weights(0.0)).unwrap();
428 assert_eq!(out.selected[0].id, "a");
429 assert_eq!(out.selected[1].id, "z");
430 }
431
432 #[test]
433 fn skips_oversized_items_takes_smaller() {
434 let inputs = vec![
435 input("huge", 900, 0.9),
436 input("small1", 40, 0.3),
437 input("small2", 40, 0.2),
438 ];
439 let out = GreedySelector.select(inputs, 100, &weights(0.0)).unwrap();
440 assert_eq!(out.selected.len(), 2);
441 assert_eq!(out.selected[0].id, "small1");
442 assert_eq!(out.selected[1].id, "small2");
443 assert_eq!(out.total_size, 80);
444 }
445
446 #[test]
447 fn zero_budget() {
448 let inputs = vec![input("a", 1, 0.9)];
449 let out = GreedySelector.select(inputs, 0, &weights(0.0)).unwrap();
450 assert!(out.selected.is_empty());
451 }
452
453 #[test]
454 fn deterministic_across_input_order() {
455 let a = vec![
456 input("x", 50, 0.7),
457 input("y", 50, 0.7),
458 input("z", 50, 0.7),
459 ];
460 let b = vec![
461 input("z", 50, 0.7),
462 input("x", 50, 0.7),
463 input("y", 50, 0.7),
464 ];
465 let out_a = GreedySelector.select(a, 100, &weights(0.0)).unwrap();
466 let out_b = GreedySelector.select(b, 100, &weights(0.0)).unwrap();
467 let ids_a: Vec<&str> = out_a.selected.iter().map(|i| i.id.as_str()).collect();
468 let ids_b: Vec<&str> = out_b.selected.iter().map(|i| i.id.as_str()).collect();
469 assert_eq!(ids_a, ids_b);
470 assert_eq!(ids_a, vec!["x", "y"]);
471 }
472
473 #[test]
474 fn exact_budget_fit() {
475 let inputs = vec![input("a", 50, 0.9), input("b", 50, 0.8)];
476 let out = GreedySelector.select(inputs, 100, &weights(0.0)).unwrap();
477 assert_eq!(out.selected.len(), 2);
478 assert_eq!(out.total_size, 100);
479 }
480
481 #[test]
482 fn category_weights_boost_preferred_category() {
483 let inputs = vec![
484 input_cat("a", 100, 0.9, "low"),
485 input_cat("b", 100, 0.5, "high"),
486 ];
487 let w = SelectorWeights {
488 category_weights: [("high".to_string(), 2.0f32), ("low".to_string(), 1.0f32)]
489 .into_iter()
490 .collect(),
491 ..Default::default()
492 };
493 let out = GreedySelector.select(inputs, 100, &w).unwrap();
494 assert_eq!(out.selected.len(), 1);
495 assert_eq!(out.selected[0].id, "b");
496 }
497
498 #[test]
499 fn category_weights_can_push_below_min_score() {
500 let inputs = vec![
501 input_cat("a", 10, 0.4, "bad"),
502 input_cat("b", 10, 0.8, "good"),
503 ];
504 let w = SelectorWeights {
505 min_score: 0.3,
506 category_weights: [("bad".to_string(), 0.5f32)].into_iter().collect(),
507 ..Default::default()
508 };
509 let out = GreedySelector.select(inputs, 1000, &w).unwrap();
510 assert_eq!(out.selected.len(), 1);
511 assert_eq!(out.selected[0].id, "b");
512 }
513
514 #[test]
515 fn diversity_bias_zero_identical_to_greedy() {
516 let make = || {
517 vec![
518 input_cat("a", 100, 0.9, "x"),
519 input_cat("b", 100, 0.8, "x"),
520 input_cat("c", 100, 0.7, "y"),
521 ]
522 };
523 let w_greedy = SelectorWeights {
524 ..Default::default()
525 };
526 let w_bias0 = SelectorWeights {
527 diversity_bias: 0.0,
528 ..Default::default()
529 };
530 let out_g = GreedySelector.select(make(), 200, &w_greedy).unwrap();
531 let out_b = GreedySelector.select(make(), 200, &w_bias0).unwrap();
532 let ids_g: Vec<&str> = out_g.selected.iter().map(|i| i.id.as_str()).collect();
533 let ids_b: Vec<&str> = out_b.selected.iter().map(|i| i.id.as_str()).collect();
534 assert_eq!(ids_g, ids_b);
535 }
536
537 #[test]
538 fn diversity_bias_prefers_different_categories() {
539 let inputs = vec![
540 input_cat("a", 100, 0.9, "x"),
541 input_cat("b", 100, 0.8, "x"),
542 input_cat("c", 100, 0.7, "y"),
543 ];
544 let w = SelectorWeights {
545 diversity_bias: 1.0,
546 ..Default::default()
547 };
548 let out = GreedySelector.select(inputs, 200, &w).unwrap();
549 assert_eq!(out.selected.len(), 2);
550 let ids: Vec<&str> = out.selected.iter().map(|i| i.id.as_str()).collect();
551 assert!(ids.contains(&"a"), "a should always be selected");
552 assert!(
553 ids.contains(&"c"),
554 "c should be preferred over b due to diversity"
555 );
556 }
557
558 #[test]
559 fn diversity_path_tie_breaks_id_ascending() {
560 let inputs = vec![input("z", 1, 0.5), input("a", 1, 0.5)];
561 let w = SelectorWeights {
562 diversity_bias: 0.5,
563 ..Default::default()
564 };
565
566 let out = GreedySelector.select(inputs, 1, &w).unwrap();
567
568 assert_eq!(out.selected.len(), 1);
569 assert_eq!(out.selected[0].id, "a");
570 }
571
572 #[test]
573 fn no_overflow_near_usize_max() {
574 let large = usize::MAX - 1;
576 let inputs = vec![
577 SelectorInput {
578 id: "a".to_string(),
579 content: (),
580 size: large,
581 score: 0.9,
582 category: None,
583 information_gain: None,
584 rank_score: None,
585 },
586 SelectorInput {
587 id: "b".to_string(),
588 content: (),
589 size: 10,
590 score: 0.8,
591 category: None,
592 information_gain: None,
593 rank_score: None,
594 },
595 ];
596 let out = GreedySelector.select(inputs, 100, &weights(0.0)).unwrap();
598 assert_eq!(out.selected.len(), 1);
599 assert_eq!(out.selected[0].id, "b");
600 }
601
602 #[test]
603 fn diversity_bias_no_categories_unaffected() {
604 let inputs = vec![
605 input("a", 100, 0.9),
606 input("b", 100, 0.8),
607 input("c", 100, 0.7),
608 ];
609 let w = SelectorWeights {
610 diversity_bias: 1.0,
611 ..Default::default()
612 };
613 let out = GreedySelector.select(inputs, 200, &w).unwrap();
614 assert_eq!(out.selected.len(), 2);
615 assert_eq!(out.selected[0].id, "a");
616 assert_eq!(out.selected[1].id, "b");
617 }
618
619 fn input_with_gain(id: &str, size: usize, score: f32, gain: f32) -> SelectorInput<()> {
622 SelectorInput {
623 id: id.to_string(),
624 content: (),
625 size,
626 score,
627 category: None,
628 information_gain: Some(gain),
629 rank_score: None,
630 }
631 }
632
633 #[test]
634 fn epistemic_weight_zero_preserves_behavior() {
635 let make = || {
637 vec![
638 input_with_gain("a", 100, 0.9, 10.0),
639 input_with_gain("b", 100, 0.8, 0.0),
640 input_with_gain("c", 100, 0.7, 5.0),
641 ]
642 };
643 let w_default = SelectorWeights {
644 ..Default::default()
645 };
646 let w_zero = SelectorWeights {
647 epistemic_weight: 0.0,
648 ..Default::default()
649 };
650 let out_d = GreedySelector.select(make(), 200, &w_default).unwrap();
651 let out_z = GreedySelector.select(make(), 200, &w_zero).unwrap();
652 let ids_d: Vec<&str> = out_d.selected.iter().map(|i| i.id.as_str()).collect();
653 let ids_z: Vec<&str> = out_z.selected.iter().map(|i| i.id.as_str()).collect();
654 assert_eq!(ids_d, ids_z);
655 assert_eq!(ids_d, vec!["a", "b"]);
657 }
658
659 #[test]
660 fn epistemic_weight_positive_reorders_by_gain() {
661 let inputs = vec![
665 input_with_gain("a", 100, 0.5, 10.0),
666 input_with_gain("b", 100, 0.9, 0.0),
667 ];
668 let w = SelectorWeights {
669 epistemic_weight: 1.0,
670 ..Default::default()
671 };
672 let out = GreedySelector.select(inputs, 100, &w).unwrap();
673 assert_eq!(out.selected.len(), 1);
674 assert_eq!(out.selected[0].id, "a");
675 }
676
677 #[test]
678 fn information_gain_none_equivalent_to_zero() {
679 let with_none = vec![
681 input("a", 100, 0.9), input("b", 100, 0.8),
683 ];
684 let with_zero = vec![
685 input_with_gain("a", 100, 0.9, 0.0),
686 input_with_gain("b", 100, 0.8, 0.0),
687 ];
688 let w = SelectorWeights {
689 epistemic_weight: 1.0,
690 ..Default::default()
691 };
692 let out_none = GreedySelector.select(with_none, 200, &w).unwrap();
693 let out_zero = GreedySelector.select(with_zero, 200, &w).unwrap();
694 let ids_none: Vec<&str> = out_none.selected.iter().map(|i| i.id.as_str()).collect();
695 let ids_zero: Vec<&str> = out_zero.selected.iter().map(|i| i.id.as_str()).collect();
696 assert_eq!(ids_none, ids_zero);
697 }
698
699 #[test]
700 fn epistemic_weight_works_with_diversity_bias() {
701 let inputs = vec![
708 {
709 let mut i = input_with_gain("a", 100, 0.5, 10.0);
710 i.category = Some("x".to_string());
711 i
712 },
713 {
714 let mut i = input_with_gain("b", 100, 0.8, 0.0);
715 i.category = Some("x".to_string());
716 i
717 },
718 {
719 let mut i = input_with_gain("c", 100, 0.3, 0.0);
720 i.category = Some("y".to_string());
721 i
722 },
723 ];
724 let w = SelectorWeights {
725 epistemic_weight: 1.0,
726 diversity_bias: 0.5,
727 ..Default::default()
728 };
729 let out = GreedySelector.select(inputs, 200, &w).unwrap();
730 assert_eq!(out.selected.len(), 2);
731 assert_eq!(out.selected[0].id, "a");
732 assert_eq!(out.selected[1].id, "b");
734 }
735
736 #[test]
739 fn greedy_selector_rejects_nan_information_gain() {
740 let inputs = vec![
741 input_with_gain("a", 100, 0.1, f32::NAN),
742 input_with_gain("b", 100, 0.9, 0.0),
743 ];
744 let w = SelectorWeights {
745 epistemic_weight: 1.0,
746 ..Default::default()
747 };
748 let err = GreedySelector.select(inputs, 100, &w).unwrap_err();
749 assert!(matches!(err, FoldError::InvalidInput(_)));
750 }
751
752 #[test]
753 fn greedy_selector_rejects_non_finite_epistemic_weight() {
754 for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
755 let inputs = vec![input("a", 100, 0.5)];
756 let w = SelectorWeights {
757 epistemic_weight: bad,
758 ..Default::default()
759 };
760 let err = GreedySelector.select(inputs, 100, &w).unwrap_err();
761 assert!(matches!(err, FoldError::InvalidInput(_)));
762 }
763 }
764
765 #[test]
766 fn greedy_selector_rejects_non_finite_diversity_bias() {
767 for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
768 let inputs = vec![input("a", 100, 0.5)];
769 let w = SelectorWeights {
770 diversity_bias: bad,
771 ..Default::default()
772 };
773 let err = GreedySelector.select(inputs, 100, &w).unwrap_err();
774 assert!(matches!(err, FoldError::InvalidInput(_)));
775 }
776 }
777
778 #[test]
779 fn greedy_selector_rejects_out_of_range_diversity_bias() {
780 for bad in [-0.1, 1.1] {
781 let inputs = vec![input("a", 100, 0.5)];
782 let w = SelectorWeights {
783 diversity_bias: bad,
784 ..Default::default()
785 };
786
787 let err = GreedySelector.select(inputs, 100, &w).unwrap_err();
788
789 assert_eq!(
790 err.to_string(),
791 "invalid input: SelectorWeights.diversity_bias must be within [0.0, 1.0]"
792 );
793 }
794 }
795
796 #[test]
797 fn greedy_selector_rejects_non_finite_category_weight() {
798 let inputs = vec![input_cat("a", 100, 0.5, "x")];
799 let w = SelectorWeights {
800 category_weights: [("x".to_string(), f32::NAN)].into_iter().collect(),
801 ..Default::default()
802 };
803 let err = GreedySelector.select(inputs, 100, &w).unwrap_err();
804 assert!(matches!(err, FoldError::InvalidInput(_)));
805 }
806
807 #[test]
808 fn greedy_selector_handles_extreme_f32_products_without_overflow() {
809 let inputs = vec![input_with_gain("a", 100, f32::MAX, f32::MAX)];
812 let w = SelectorWeights {
813 epistemic_weight: f32::MAX,
814 ..Default::default()
815 };
816 let out = GreedySelector.select(inputs, 100, &w).unwrap();
817 assert_eq!(out.selected.len(), 1);
818 assert_eq!(out.selected[0].id, "a");
819 }
820
821 #[test]
824 fn rejects_non_finite_rank_score() {
825 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
826 let mut item = input("a", 100, 0.5);
827 item.rank_score = Some(bad);
828 let err = GreedySelector
829 .select(vec![item], 100, &weights(0.0))
830 .unwrap_err();
831 assert!(matches!(err, FoldError::InvalidInput(_)));
832 }
833 }
834
835 #[test]
836 fn rank_score_saturates_at_deterministic_score_max_without_panic() {
837 let mut big = input("big", 200, 0.0);
840 big.rank_score = Some(f64::MAX);
841 let mut small = input("small", 50, 0.0);
842 small.rank_score = Some(f64::MAX / 2.0);
843
844 let out = GreedySelector
845 .select(vec![big, small], 1000, &weights(0.0))
846 .unwrap();
847 assert_eq!(out.selected.len(), 2);
848 assert_eq!(out.selected[0].id, "small");
850 assert_eq!(out.selected[1].id, "big");
851 }
852
853 #[test]
854 fn rank_score_distinguishes_values_within_f32_ulp_of_one() {
855 let a_score = 1.0_f32;
858 let b_score = 1.0_f32; assert_eq!(a_score.to_bits(), b_score.to_bits());
860
861 let mut a = input("a", 100, a_score);
862 a.rank_score = Some(1.0);
863 let mut b = input("b", 100, b_score);
864 b.rank_score = Some(1.000_000_04);
865
866 let out = GreedySelector
867 .select(vec![a, b], 100, &weights(0.0))
868 .unwrap();
869 assert_eq!(out.selected.len(), 1);
870 assert_eq!(
871 out.selected[0].id, "b",
872 "higher rank_score must win despite tied f32 score"
873 );
874 }
875
876 #[test]
877 fn category_weights_reorder_candidates_when_rank_score_present() {
878 let mut a = input_cat("a", 100, 0.9, "low");
881 a.rank_score = Some(0.9);
882 let mut b = input_cat("b", 100, 0.5, "high");
883 b.rank_score = Some(0.5);
884
885 let w = SelectorWeights {
886 category_weights: [("high".to_string(), 2.0f32), ("low".to_string(), 1.0f32)]
887 .into_iter()
888 .collect(),
889 ..Default::default()
890 };
891 let out = GreedySelector.select(vec![a, b], 100, &w).unwrap();
892 assert_eq!(out.selected.len(), 1);
893 assert_eq!(
894 out.selected[0].id, "b",
895 "category weight must still reorder candidates when rank_score is present"
896 );
897 }
898
899 #[test]
900 fn rank_score_zero_ties_break_deterministically() {
901 let mut a = input("z", 100, 0.0);
902 a.rank_score = Some(0.0);
903 let mut b = input("a", 100, 0.0);
904 b.rank_score = Some(0.0);
905
906 let out = GreedySelector
907 .select(vec![a, b], 1000, &weights(0.0))
908 .unwrap();
909 assert_eq!(out.selected.len(), 2);
910 assert_eq!(out.selected[0].id, "a");
911 assert_eq!(out.selected[1].id, "z");
912 }
913}