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    pub diversity_bias: f32,
73    /// Weight for epistemic (uncertainty-reducing) selection.
74    ///
75    /// The effective selection score is `pragmatic_score + epistemic_weight * information_gain`.
76    /// Default 0.0 (pure pragmatic). Higher values prefer candidates that reduce uncertainty.
77    /// When 0.0, behavior is identical to pure pragmatic selection (backwards-compatible).
78    #[cfg_attr(feature = "serde", serde(default))]
79    pub epistemic_weight: f32,
80}
81
82/// The Selector primitive.
83///
84/// An implementation collapses N inputs into a subset that fits a budget,
85/// using weights and an optional query for relevance context.
86pub trait Selector<T>: Send + Sync {
87    /// Select a budget-constrained subset from `inputs`.
88    fn select(
89        &self,
90        inputs: Vec<SelectorInput<T>>,
91        budget: usize,
92        weights: &SelectorWeights,
93    ) -> Result<SelectorOutput<T>, FoldError>;
94}
95
96// ── GreedySelector ──────────────────────────────────────────────────────────
97
98/// Budget-constrained greedy packer.
99///
100/// Filters by `SelectorWeights.min_score`, applies `category_weights` multipliers
101/// to adjust scores, then greedily packs until the budget is exhausted. When
102/// `diversity_bias > 0`, uses a pick-best-remaining loop instead of a single
103/// sort pass. Tie-breaking is deterministic: effective score descending, size
104/// ascending, then id ascending. See
105/// crates/khive-fold/docs/design.md#adr-024-bayesian-extensions-selector-budget-packing-and-precision-weighted-scoring
106/// for the diversity-penalty formula.
107#[derive(Debug, Clone, Copy, Default)]
108pub struct GreedySelector;
109
110/// Widen `score` (or `rank_score` if set) to `f64` for rank comparisons.
111/// See crates/khive-fold/docs/api/selector.md#rank-score-precision-pr-535.
112#[inline]
113fn rank_base<T>(item: &SelectorInput<T>) -> f64 {
114    item.rank_score.unwrap_or(item.score as f64)
115}
116
117/// Pragmatic score plus the epistemic (information-gain) bonus, in `f64`.
118#[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        // Filter non-finite and below min_score.
200        inputs.retain(|i| i.score.is_finite() && i.score >= weights.min_score);
201
202        // rank_score must be scaled by the same category weight as score, or
203        // it silently defeats category_weights (khive PR #535; see design.md).
204        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        // Sort by effective score desc, size asc, id asc via DeterministicScore
222        // (not raw f32::total_cmp) — see design.md#rank-score-precision-pr-535.
223        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            // Fast path: single-pass greedy.
248            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            // Diversity path: pick-best-remaining with per-step effective score.
256            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// INLINE TEST JUSTIFICATION: selector tests exercise private helper functions
309// (pragmatic_plus_epistemic, effective_score) and internal sort logic that are
310// not accessible from a separate crate-level tests/ file. Consolidating here
311// avoids duplicating the SelectorInput construction scaffolding.
312#[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        // Items with near-usize::MAX sizes must not overflow when checking budget.
554        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        // Budget is 100 — only item "b" fits.
576        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    // ── epistemic weight tests ────────────────────────────────────────────────
599
600    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        // With epistemic_weight=0, result must be identical to the default (no epistemic).
615        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        // Pure score order: a (0.9), b (0.8).
635        assert_eq!(ids_d, vec!["a", "b"]);
636    }
637
638    #[test]
639    fn epistemic_weight_positive_reorders_by_gain() {
640        // a: score=0.5, gain=10.0  → effective = 0.5 + 1.0 * 10.0 = 10.5
641        // b: score=0.9, gain=0.0   → effective = 0.9 + 1.0 * 0.0  = 0.9
642        // With epistemic_weight=1.0, a should be selected first.
643        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        // None and Some(0.0) must produce identical ordering.
659        let with_none = vec![
660            input("a", 100, 0.9), // information_gain: None
661            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        // Combines epistemic and diversity: the effective score incorporates both.
681        // a: score=0.5, gain=10.0, category=x → base effective = 0.5 + 1.0 * 10.0 = 10.5
682        // b: score=0.8, gain=0.0,  category=x → base effective = 0.8
683        // c: score=0.3, gain=0.0,  category=y → base effective = 0.3
684        // Budget=200, bias=0.5: a selected first (10.5 wins), then after a is in x,
685        // 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.
686        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        // b (eff=0.8*0.75=0.6) > c (eff=0.3) after a is placed in category x.
712        assert_eq!(out.selected[1].id, "b");
713    }
714
715    // ── non-finite validation tests (FOLD-AUD-003) ─────────────────────────────
716
717    #[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        // f64/DeterministicScore ranking no longer overflows on f32::MAX products
771        // (FOLD-AUD); see design.md#test-rationale-notes.
772        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    // ── DeterministicScore rank-comparator tests (fold PR #535) ──
783
784    #[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        // rank_score beyond DeterministicScore's range saturates to MAX rather
799        // than panicking; see design.md#test-rationale-notes.
800        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        // Both saturate to DeterministicScore::MAX and tie; size-ascending wins.
810        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        // 1.0 vs 1.00000004 collapse to identical f32 bits but differ at
817        // khive-score's fixed-point scale; see design.md#test-rationale-notes.
818        let a_score = 1.0_f32;
819        let b_score = 1.0_f32; // identical f32 bits to a after narrowing
820        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        // Regression for PR #535: rank_score must be scaled by category
840        // weight too, or "a" wins despite "high"'s 2.0x weight.
841        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}