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