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    /// Callers pre-compute this because the Selector is pure-math and has no
31    /// access to the embedding space required to estimate KL divergence. When
32    /// `None` (the default), the value is treated as 0.0. Only has an effect
33    /// when `SelectorWeights.epistemic_weight > 0.0`.
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    /// Callers that compute the score in `f64` (e.g. `ComposePipeline`, which
40    /// multiplies an objective score by a precision weight) should set this
41    /// instead of relying solely on the narrowed `score: f32` field — ranking
42    /// widens `score` back through `DeterministicScore::from_f32` when this is
43    /// `None`, but two `f64` scores that differ by less than an `f32` ulp would
44    /// otherwise collapse to the same `f32` bit pattern before the selector
45    /// ever compares them (khive-score contract: ADR fixed-point ranking).
46    /// Does not affect `min_score` filtering, which continues to operate on
47    /// `score`. `category_weights` multipliers DO scale `rank_score` (by the
48    /// same weight applied to `score`) so a caller-supplied high-precision
49    /// rank base still reflects category weighting during rank comparisons.
50    #[cfg_attr(feature = "serde", serde(default))]
51    pub rank_score: Option<f64>,
52}
53
54/// Result of a selector operation.
55#[derive(Debug, Clone)]
56#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
57pub struct SelectorOutput<T> {
58    /// Selected inputs in final order.
59    pub selected: Vec<SelectorInput<T>>,
60    /// Total budget consumed.
61    pub total_size: usize,
62    /// Budget cap the caller requested.
63    pub budget: usize,
64}
65
66/// Learned weights that a selector implementation may use.
67///
68/// Callers persist this across sessions.
69#[derive(Debug, Clone, Default)]
70#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
71pub struct SelectorWeights {
72    /// Weight multiplier by input category.
73    pub category_weights: std::collections::BTreeMap<String, f32>,
74    /// Minimum score threshold (inputs below this are excluded even if budget allows).
75    pub min_score: f32,
76    /// Preference for diversity vs. relevance (0.0 = pure relevance, 1.0 = pure diversity).
77    pub diversity_bias: f32,
78    /// Weight for epistemic (uncertainty-reducing) selection.
79    ///
80    /// The effective selection score is `pragmatic_score + epistemic_weight * information_gain`.
81    /// Default 0.0 (pure pragmatic). Higher values prefer candidates that reduce uncertainty.
82    /// When 0.0, behavior is identical to pure pragmatic selection (backwards-compatible).
83    #[cfg_attr(feature = "serde", serde(default))]
84    pub epistemic_weight: f32,
85}
86
87/// The Selector primitive.
88///
89/// An implementation collapses N inputs into a subset that fits a budget,
90/// using weights and an optional query for relevance context.
91pub trait Selector<T>: Send + Sync {
92    /// Select a budget-constrained subset from `inputs`.
93    fn select(
94        &self,
95        inputs: Vec<SelectorInput<T>>,
96        budget: usize,
97        weights: &SelectorWeights,
98    ) -> Result<SelectorOutput<T>, FoldError>;
99}
100
101// ── GreedySelector ──────────────────────────────────────────────────────────
102
103/// Budget-constrained greedy packer.
104///
105/// Filters by `SelectorWeights.min_score`, applies `category_weights` multipliers
106/// to adjust scores, then greedily packs until the budget is exhausted.
107///
108/// When `diversity_bias > 0`, uses a pick-best-remaining loop: at each step the
109/// item with the highest *effective* score (after diversity penalty) is selected.
110/// The penalty is `score * (1 - bias * n / (n + 1))` where `n` is the number of
111/// already-selected items in the same category. At bias=0 this collapses to a
112/// single-pass sort (backward-compatible).
113///
114/// Tie-breaking is deterministic: size ascending, then id ascending.
115#[derive(Debug, Clone, Copy, Default)]
116pub struct GreedySelector;
117
118/// Widen `score` (and any `rank_score` override) into the `f64` domain used
119/// for rank-comparison arithmetic.
120///
121/// `rank_score`, when present, carries the caller's full-precision effective
122/// score (e.g. `ComposePipeline`'s `objective_score * precision` in `f64`
123/// before it was narrowed to `f32` for the `score` field). Falling back to
124/// `score as f64` for plain callers (e.g. `khive-pack-knowledge`'s direct
125/// JSON-supplied candidates) is lossless — those scores were never more
126/// precise than `f32` to begin with.
127#[inline]
128fn rank_base<T>(item: &SelectorInput<T>) -> f64 {
129    item.rank_score.unwrap_or(item.score as f64)
130}
131
132/// Compute the base pragmatic score adjusted for epistemic weight, in `f64`.
133///
134/// `base` is the pragmatic score (after category-weight multipliers, which
135/// still apply to `score` before this is called). `epistemic_weight *
136/// information_gain` is the epistemic bonus.
137#[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        // Filter non-finite and below min_score.
219        inputs.retain(|i| i.score.is_finite() && i.score >= weights.min_score);
220
221        // Apply category_weights multipliers. `rank_score`, when present, is
222        // the caller's higher-precision f64 rank base (see `rank_base` doc) —
223        // it must be scaled by the same weight or rank comparisons would see
224        // the unweighted value while `min_score` filtering sees the weighted
225        // one, silently defeating `category_weights` for any candidate that
226        // set `rank_score` (khive/PR#535 internal review round 2 finding 1).
227        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        // Initial sort: effective score (pragmatic + epistemic bonus) desc, size asc, id asc —
245        // deterministic across platforms. Non-finite effective scores are rejected rather
246        // than silently sorted, since a checked score can still overflow to non-finite.
247        // Comparisons run through `DeterministicScore` (khive-score's i64 fixed-point
248        // contract), not raw `f32::total_cmp` — matches the objective-path ranking
249        // pattern in `objective::traits::RankedIndex` and preserves precision that a
250        // caller-supplied `rank_score` (f64) carries past what `f32` can hold.
251        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            // Fast path: single-pass greedy.
276            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            // Diversity path: pick-best-remaining with per-step effective score.
284            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// INLINE TEST JUSTIFICATION: selector tests exercise private helper functions
337// (pragmatic_plus_epistemic, effective_score) and internal sort logic that are
338// not accessible from a separate crate-level tests/ file. Consolidating here
339// avoids duplicating the SelectorInput construction scaffolding.
340#[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        // Items with near-usize::MAX sizes must not overflow when checking budget.
582        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        // Budget is 100 — only item "b" fits.
604        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    // ── epistemic weight tests ────────────────────────────────────────────────
627
628    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        // With epistemic_weight=0, result must be identical to the default (no epistemic).
643        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        // Pure score order: a (0.9), b (0.8).
663        assert_eq!(ids_d, vec!["a", "b"]);
664    }
665
666    #[test]
667    fn epistemic_weight_positive_reorders_by_gain() {
668        // a: score=0.5, gain=10.0  → effective = 0.5 + 1.0 * 10.0 = 10.5
669        // b: score=0.9, gain=0.0   → effective = 0.9 + 1.0 * 0.0  = 0.9
670        // With epistemic_weight=1.0, a should be selected first.
671        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        // None and Some(0.0) must produce identical ordering.
687        let with_none = vec![
688            input("a", 100, 0.9), // information_gain: None
689            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        // Combines epistemic and diversity: the effective score incorporates both.
709        // a: score=0.5, gain=10.0, category=x → base effective = 0.5 + 1.0 * 10.0 = 10.5
710        // b: score=0.8, gain=0.0,  category=x → base effective = 0.8
711        // c: score=0.3, gain=0.0,  category=y → base effective = 0.3
712        // Budget=200, bias=0.5: a selected first (10.5 wins), then after a is in x,
713        // 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.
714        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        // b (eff=0.8*0.75=0.6) > c (eff=0.3) after a is placed in category x.
740        assert_eq!(out.selected[1].id, "b");
741    }
742
743    // ── non-finite validation tests (FOLD-AUD-003) ─────────────────────────────
744
745    #[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        // Ranking now runs in f64/DeterministicScore rather than raw f32
799        // arithmetic: `f32::MAX * f32::MAX` (~1.15e77) no longer overflows to
800        // `f32::INFINITY` the way it did under the old f32-only computation —
801        // it's a large-but-finite f64 value, saturated into DeterministicScore
802        // rather than rejected. This is exactly the precision upgrade FOLD-AUD
803        // (khive-score fixed-point contract) requires: a real magnitude, not a
804        // spurious f32 rounding artifact, decides the outcome.
805        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    // ── DeterministicScore rank-comparator tests (fold PR #535 internal review round 1) ──
816
817    #[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        // Two candidates whose rank_score both exceed DeterministicScore's
832        // i64/2^32 representable range must both saturate to MAX rather than
833        // panicking or overflowing, then fall back to the deterministic
834        // size/id tie-break — same contract as DeterministicScore's own
835        // saturating arithmetic (khive-score score.rs `from_rounded_arithmetic`).
836        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        // Both saturate to DeterministicScore::MAX and tie; size-ascending wins.
846        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        // 1.0 and 1.00000004 collapse to the identical f32 bit pattern (delta
853        // is below the f32 ulp at magnitude 1.0) but are distinct at the
854        // khive-score 2^32 fixed-point scale — `rank_score` must be the value
855        // that decides ranking, not the narrowed `score` field.
856        let a_score = 1.0_f32;
857        let b_score = 1.0_f32; // identical f32 bits to a after narrowing
858        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        // Same shape as `category_weights_boost_preferred_category`, but both
878        // candidates carry an explicit `rank_score` equal to `score` widened
879        // to f64. Before the fix, rank comparisons read the unweighted
880        // `rank_score` (weights only ever touched `score`), so "a" (raw 0.9,
881        // "low") would win despite "high" carrying a 2.0x weight. The fix
882        // must scale `rank_score` by the same weight so "b" still wins.
883        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}