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