Skip to main content

khive_fold/
pipeline.rs

1//! ComposePipeline: score candidates then pack to budget.
2
3use crate::error::FoldError;
4use crate::objective::{Objective, ObjectiveContext};
5use crate::selector::{Selector, SelectorInput, SelectorOutput, SelectorWeights};
6
7/// Pipeline that scores candidates with an objective then packs to budget via a selector.
8pub struct ComposePipeline<T> {
9    /// Objective that assigns scores to each candidate.
10    pub objective: Box<dyn Objective<T>>,
11    /// Selector that packs the scored candidates under a budget.
12    pub selector: Box<dyn Selector<T>>,
13}
14
15impl<T: Clone + Send + Sync + 'static> ComposePipeline<T> {
16    /// Score at most `context.max_candidates` inputs in caller order, then pack under budget.
17    pub fn execute(
18        &self,
19        candidates: Vec<SelectorInput<T>>,
20        budget: usize,
21        weights: &SelectorWeights,
22        context: &ObjectiveContext,
23    ) -> Result<SelectorOutput<T>, FoldError> {
24        let considered = context
25            .max_candidates
26            .unwrap_or(candidates.len())
27            .min(candidates.len());
28        let mut scored = Vec::with_capacity(considered);
29        for mut candidate in candidates.into_iter().take(considered) {
30            let score = self.objective.score(&candidate.content, context);
31            if !self.objective.passes_score(score, context) {
32                continue;
33            }
34
35            let precision = self.objective.precision(&candidate.content, context);
36            let precision = if precision.is_finite() {
37                precision
38            } else {
39                1.0
40            };
41            let effective = score * precision;
42
43            if !effective.is_finite() || effective < f32::MIN as f64 || effective > f32::MAX as f64
44            {
45                return Err(FoldError::InvalidInput(format!(
46                    "objective effective score for '{}' is outside finite f32 range",
47                    candidate.id
48                )));
49            }
50
51            candidate.score = effective as f32;
52            // Carry the full f64 precision into selector ranking so rank
53            // comparisons go through the khive-score fixed-point comparators
54            // instead of re-deriving from the narrowed f32 `score` field —
55            // mirrors the RankedIndex pattern in objective/traits.rs.
56            candidate.rank_score = Some(effective);
57            scored.push(candidate);
58        }
59        self.selector.select(scored, budget, weights)
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use crate::objective::Objective;
67
68    struct TupleObjective;
69
70    impl Objective<(f64, f64)> for TupleObjective {
71        fn score(&self, candidate: &(f64, f64), _context: &ObjectiveContext) -> f64 {
72            candidate.0
73        }
74
75        fn precision(&self, candidate: &(f64, f64), _context: &ObjectiveContext) -> f64 {
76            candidate.1
77        }
78    }
79
80    fn input(id: &str, score: f64, precision: f64) -> SelectorInput<(f64, f64)> {
81        SelectorInput {
82            id: id.to_string(),
83            content: (score, precision),
84            size: 1,
85            score: 0.0,
86            category: None,
87            information_gain: None,
88            rank_score: None,
89        }
90    }
91
92    fn input_cat(
93        id: &str,
94        score: f64,
95        precision: f64,
96        category: &str,
97    ) -> SelectorInput<(f64, f64)> {
98        SelectorInput {
99            category: Some(category.to_string()),
100            ..input(id, score, precision)
101        }
102    }
103
104    fn pipeline() -> ComposePipeline<(f64, f64)> {
105        ComposePipeline {
106            objective: Box::new(TupleObjective),
107            selector: Box::new(crate::selector::GreedySelector),
108        }
109    }
110
111    #[test]
112    fn compose_pipeline_ranks_by_precision_weighted_score() {
113        let pipeline = pipeline();
114        let candidates = vec![input("a", 10.0, 0.1), input("b", 2.0, 1.0)];
115        let out = pipeline
116            .execute(
117                candidates,
118                1,
119                &SelectorWeights::default(),
120                &ObjectiveContext::new(),
121            )
122            .unwrap();
123        assert_eq!(out.selected.len(), 1);
124        assert_eq!(out.selected[0].id, "b");
125    }
126
127    #[test]
128    fn compose_pipeline_applies_objective_min_score_before_selector() {
129        let pipeline = pipeline();
130        let candidates = vec![input("a", 1.0, 1.0)];
131        let context = ObjectiveContext::new().with_min_score(2.0);
132        let out = pipeline
133            .execute(candidates, 10, &SelectorWeights::default(), &context)
134            .unwrap();
135        assert!(out.selected.is_empty());
136    }
137
138    #[test]
139    fn compose_pipeline_respects_max_candidates_before_scoring() {
140        let pipeline = pipeline();
141        let candidates = vec![input("first", 1.0, 1.0), input("outside-limit", 10.0, 1.0)];
142        let context = ObjectiveContext::new().with_max_candidates(1);
143        let out = pipeline
144            .execute(candidates, 10, &SelectorWeights::default(), &context)
145            .unwrap();
146
147        assert_eq!(out.selected.len(), 1);
148        assert_eq!(out.selected[0].id, "first");
149    }
150
151    #[test]
152    fn compose_pipeline_ranks_correctly_within_f32_ulp_around_one() {
153        // 1.0 and 1.00000004 collapse to the identical f32 bit pattern (delta is
154        // below the f32 ulp at magnitude 1.0), but are distinct at the
155        // khive-score 2^32 fixed-point scale. Without carrying the f64
156        // `rank_score` into the selector, both candidates would tie on `score`
157        // and fall back to id ordering (picking "a"); the fix must still rank
158        // "b" ahead since its true effective score is higher.
159        let pipeline = pipeline();
160        let candidates = vec![input("a", 1.0, 1.0), input("b", 1.000_000_04, 1.0)];
161        let out = pipeline
162            .execute(
163                candidates,
164                1,
165                &SelectorWeights::default(),
166                &ObjectiveContext::new(),
167            )
168            .unwrap();
169        assert_eq!(out.selected.len(), 1);
170        assert_eq!(out.selected[0].id, "b");
171    }
172
173    #[test]
174    fn compose_pipeline_ranks_score_zero_ties_deterministically() {
175        // Equal effective scores of exactly zero must still tie-break
176        // deterministically (id ascending), same as any other tie.
177        let pipeline = pipeline();
178        let candidates = vec![input("z", 0.0, 1.0), input("a", 0.0, 1.0)];
179        let out = pipeline
180            .execute(
181                candidates,
182                10,
183                &SelectorWeights::default(),
184                &ObjectiveContext::new(),
185            )
186            .unwrap();
187        assert_eq!(out.selected.len(), 2);
188        assert_eq!(out.selected[0].id, "a");
189        assert_eq!(out.selected[1].id, "z");
190    }
191
192    #[test]
193    fn compose_pipeline_category_weights_still_reorder_with_rank_score() {
194        // Mirrors selector.rs's `category_weights_boost_preferred_category`,
195        // but drives it through `ComposePipeline::execute`, which always sets
196        // `rank_score` (see the `execute` comment above). Before the fix,
197        // `ComposePipeline` candidates carrying `rank_score` were immune to
198        // `SelectorWeights.category_weights`: the comparator read the
199        // unweighted `rank_score` while the weight only touched `score`. "a"
200        // (raw effective 0.9, "low") would beat "b" (raw effective 0.5,
201        // "high", weight 2.0) despite the weight. The fix scales `rank_score`
202        // by the category weight too, so "b" must win.
203        let pipeline = pipeline();
204        let candidates = vec![
205            input_cat("a", 0.9, 1.0, "low"),
206            input_cat("b", 0.5, 1.0, "high"),
207        ];
208        let weights = SelectorWeights {
209            category_weights: [("high".to_string(), 2.0f32), ("low".to_string(), 1.0f32)]
210                .into_iter()
211                .collect(),
212            ..Default::default()
213        };
214        let out = pipeline
215            .execute(candidates, 1, &weights, &ObjectiveContext::new())
216            .unwrap();
217        assert_eq!(out.selected.len(), 1);
218        assert_eq!(
219            out.selected[0].id, "b",
220            "category weight must still reorder ComposePipeline candidates carrying rank_score"
221        );
222    }
223
224    #[test]
225    fn compose_pipeline_rejects_effective_score_outside_f32_range() {
226        let pipeline = pipeline();
227        let candidates = vec![input("a", f64::MAX, 1.0)];
228        let err = pipeline
229            .execute(
230                candidates,
231                10,
232                &SelectorWeights::default(),
233                &ObjectiveContext::new(),
234            )
235            .unwrap_err();
236        assert!(matches!(err, FoldError::InvalidInput(_)));
237    }
238}