Skip to main content

khive_fold/
selector.rs

1//! Selector: many → subset under budget.
2//!
3//! Collapses a set of inputs into a compressed representation that fits a
4//! target budget (tokens, bytes, count). Pure in-memory, synchronous collapse.
5
6#[cfg(feature = "serde")]
7use serde::{Deserialize, Serialize};
8
9use khive_score::DeterministicScore;
10
11use crate::error::FoldError;
12
13/// A single input item to a selector operation.
14#[derive(Debug, Clone)]
15#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
16pub struct SelectorInput<T> {
17    /// Stable string identifier for deterministic tie-breaking.
18    pub id: String,
19    /// The item payload carried through selection.
20    pub content: T,
21    /// Size in the unit of the caller's budget (tokens, bytes, count).
22    pub size: usize,
23    /// Pre-computed relevance score.
24    pub score: f32,
25    /// Optional category for diversity and category-weight scoring.
26    #[cfg_attr(feature = "serde", serde(default))]
27    pub category: Option<String>,
28    /// Pre-computed information gain (KL divergence proxy) for this candidate.
29    ///
30    /// Defaults to 0.0 when `None`. Only affects selection when
31    /// `SelectorWeights.epistemic_weight > 0.0`. See
32    /// crates/khive-fold/docs/design.md#adr-024-bayesian-extensions-selector-budget-packing-and-precision-weighted-scoring
33    /// for why this is caller-supplied rather than computed by the Selector.
34    #[cfg_attr(feature = "serde", serde(default))]
35    pub information_gain: Option<f32>,
36    /// Higher-precision pre-conversion effective score used for rank
37    /// comparisons, when available.
38    ///
39    /// When `None`, ranking widens `score` back to `f64` via
40    /// `DeterministicScore::from_f32`. Does not affect `min_score` filtering,
41    /// which always operates on `score`. `category_weights` multipliers scale
42    /// `rank_score` by the same weight applied to `score`. See
43    /// crates/khive-fold/docs/api/selector.md#rank-score-precision-pr-535 for
44    /// why callers need this instead of relying on the narrowed `score` field.
45    #[cfg_attr(feature = "serde", serde(default))]
46    pub rank_score: Option<f64>,
47}
48
49/// Result of a selector operation.
50#[derive(Debug, Clone)]
51#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
52pub struct SelectorOutput<T> {
53    /// Selected inputs in final order.
54    pub selected: Vec<SelectorInput<T>>,
55    /// Total budget consumed.
56    pub total_size: usize,
57    /// Budget cap the caller requested.
58    pub budget: usize,
59}
60
61/// Learned weights that a selector implementation may use.
62///
63/// Callers persist this across sessions.
64#[derive(Debug, Clone, Default)]
65#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
66pub struct SelectorWeights {
67    /// Weight multiplier by input category.
68    pub category_weights: std::collections::BTreeMap<String, f32>,
69    /// Minimum score threshold (inputs below this are excluded even if budget allows).
70    pub min_score: f32,
71    /// Preference for diversity vs. relevance (0.0 = pure relevance, 1.0 = pure diversity).
72    /// Values outside the inclusive `[0.0, 1.0]` range are rejected.
73    pub diversity_bias: f32,
74    /// Weight for epistemic (uncertainty-reducing) selection.
75    ///
76    /// The effective selection score is `pragmatic_score + epistemic_weight * information_gain`.
77    /// Default 0.0 (pure pragmatic). Higher values prefer candidates that reduce uncertainty.
78    /// When 0.0, behavior is identical to pure pragmatic selection (backwards-compatible).
79    #[cfg_attr(feature = "serde", serde(default))]
80    pub epistemic_weight: f32,
81}
82
83/// The Selector primitive.
84///
85/// An implementation collapses N inputs into a subset that fits a budget,
86/// using weights and an optional query for relevance context.
87pub trait Selector<T>: Send + Sync {
88    /// Select a budget-constrained subset from `inputs`.
89    fn select(
90        &self,
91        inputs: Vec<SelectorInput<T>>,
92        budget: usize,
93        weights: &SelectorWeights,
94    ) -> Result<SelectorOutput<T>, FoldError>;
95}
96
97// ── GreedySelector ──────────────────────────────────────────────────────────
98
99/// Budget-constrained greedy packer.
100///
101/// Filters by `SelectorWeights.min_score`, applies `category_weights` multipliers
102/// to adjust scores, then greedily packs until the budget is exhausted. When
103/// `diversity_bias > 0`, uses a pick-best-remaining loop instead of a single
104/// sort pass. Tie-breaking is deterministic: effective score descending, size
105/// ascending, then id ascending. This is an ordering heuristic, not a knapsack
106/// optimizer, and provides no optimality or approximation-ratio guarantee. See
107/// crates/khive-fold/docs/design.md#adr-024-bayesian-extensions-selector-budget-packing-and-precision-weighted-scoring
108/// for the diversity-penalty formula.
109#[derive(Debug, Clone, Copy, Default)]
110pub struct GreedySelector;
111
112/// Widen `score` (or `rank_score` if set) to `f64` for rank comparisons.
113/// See crates/khive-fold/docs/api/selector.md#rank-score-precision-pr-535.
114#[inline]
115fn rank_base<T>(item: &SelectorInput<T>) -> f64 {
116    item.rank_score.unwrap_or(item.score as f64)
117}
118
119/// Pragmatic score plus the epistemic (information-gain) bonus, in `f64`.
120#[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        // Filter non-finite and below min_score.
207        inputs.retain(|i| i.score.is_finite() && i.score >= weights.min_score);
208
209        // rank_score must be scaled by the same category weight as score, or
210        // it silently defeats category_weights (khive PR #535; see design.md).
211        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        // Sort by effective score desc, size asc, id asc via DeterministicScore
229        // (not raw f32::total_cmp) — see design.md#rank-score-precision-pr-535.
230        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            // Fast path: single-pass greedy.
255            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            // Diversity path: pick-best-remaining with per-step effective score.
263            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// INLINE TEST JUSTIFICATION: selector tests exercise private helper functions
316// (pragmatic_plus_epistemic, effective_score) and internal sort logic that are
317// not accessible from a separate crate-level tests/ file. Consolidating here
318// avoids duplicating the SelectorInput construction scaffolding.
319#[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        // Items with near-usize::MAX sizes must not overflow when checking budget.
575        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        // Budget is 100 — only item "b" fits.
597        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    // ── epistemic weight tests ────────────────────────────────────────────────
620
621    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        // With epistemic_weight=0, result must be identical to the default (no epistemic).
636        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        // Pure score order: a (0.9), b (0.8).
656        assert_eq!(ids_d, vec!["a", "b"]);
657    }
658
659    #[test]
660    fn epistemic_weight_positive_reorders_by_gain() {
661        // a: score=0.5, gain=10.0  → effective = 0.5 + 1.0 * 10.0 = 10.5
662        // b: score=0.9, gain=0.0   → effective = 0.9 + 1.0 * 0.0  = 0.9
663        // With epistemic_weight=1.0, a should be selected first.
664        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        // None and Some(0.0) must produce identical ordering.
680        let with_none = vec![
681            input("a", 100, 0.9), // information_gain: None
682            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        // Combines epistemic and diversity: the effective score incorporates both.
702        // a: score=0.5, gain=10.0, category=x → base effective = 0.5 + 1.0 * 10.0 = 10.5
703        // b: score=0.8, gain=0.0,  category=x → base effective = 0.8
704        // c: score=0.3, gain=0.0,  category=y → base effective = 0.3
705        // Budget=200, bias=0.5: a selected first (10.5 wins), then after a is in x,
706        // b's diversity penalty is 0.8*(1-0.5*1/2)=0.8*0.75=0.6 vs c at 0.3 — b wins.
707        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        // b (eff=0.8*0.75=0.6) > c (eff=0.3) after a is placed in category x.
733        assert_eq!(out.selected[1].id, "b");
734    }
735
736    // ── non-finite validation tests (FOLD-AUD-003) ─────────────────────────────
737
738    #[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        // f64/DeterministicScore ranking no longer overflows on f32::MAX products
810        // (FOLD-AUD); see design.md#test-rationale-notes.
811        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    // ── DeterministicScore rank-comparator tests (fold PR #535) ──
822
823    #[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        // rank_score beyond DeterministicScore's range saturates to MAX rather
838        // than panicking; see design.md#test-rationale-notes.
839        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        // Both saturate to DeterministicScore::MAX and tie; size-ascending wins.
849        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        // 1.0 vs 1.00000004 collapse to identical f32 bits but differ at
856        // khive-score's fixed-point scale; see design.md#test-rationale-notes.
857        let a_score = 1.0_f32;
858        let b_score = 1.0_f32; // identical f32 bits to a after narrowing
859        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        // Regression for PR #535: rank_score must be scaled by category
879        // weight too, or "a" wins despite "high"'s 2.0x weight.
880        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}