Skip to main content

khive_fold/
pipeline.rs

1//! ComposePipeline: score candidates then pack to budget.
2
3use crate::anchor::{Anchor, AnchorGraph};
4use crate::error::FoldError;
5use crate::objective::{Objective, ObjectiveContext};
6use crate::selector::{Selector, SelectorInput, SelectorOutput, SelectorWeights};
7
8/// Pipeline that scores candidates with an objective then packs to budget via a selector.
9pub struct ComposePipeline<T> {
10    /// Graph anchor used for causal provenance traversal before scoring.
11    pub anchor: Box<dyn Anchor>,
12    /// Objective that assigns scores to each candidate.
13    pub objective: Box<dyn Objective<T>>,
14    /// Selector that packs the scored candidates under a budget.
15    pub selector: Box<dyn Selector<T>>,
16}
17
18impl<T: Clone + Send + Sync + 'static> ComposePipeline<T> {
19    /// Score candidates with the objective, then pack under budget with the selector.
20    pub fn execute(
21        &self,
22        _graph: &AnchorGraph,
23        candidates: Vec<SelectorInput<T>>,
24        budget: usize,
25        weights: &SelectorWeights,
26        context: &ObjectiveContext,
27    ) -> Result<SelectorOutput<T>, FoldError> {
28        let mut scored = Vec::with_capacity(candidates.len());
29        for mut candidate in candidates {
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            anchor: Box::new(crate::anchor::BfsAnchor),
107            objective: Box::new(TupleObjective),
108            selector: Box::new(crate::selector::GreedySelector),
109        }
110    }
111
112    #[test]
113    fn compose_pipeline_ranks_by_precision_weighted_score() {
114        let pipeline = pipeline();
115        let candidates = vec![input("a", 10.0, 0.1), input("b", 2.0, 1.0)];
116        let out = pipeline
117            .execute(
118                &AnchorGraph::new(),
119                candidates,
120                1,
121                &SelectorWeights::default(),
122                &ObjectiveContext::new(),
123            )
124            .unwrap();
125        assert_eq!(out.selected.len(), 1);
126        assert_eq!(out.selected[0].id, "b");
127    }
128
129    #[test]
130    fn compose_pipeline_applies_objective_min_score_before_selector() {
131        let pipeline = pipeline();
132        let candidates = vec![input("a", 1.0, 1.0)];
133        let context = ObjectiveContext::new().with_min_score(2.0);
134        let out = pipeline
135            .execute(
136                &AnchorGraph::new(),
137                candidates,
138                10,
139                &SelectorWeights::default(),
140                &context,
141            )
142            .unwrap();
143        assert!(out.selected.is_empty());
144    }
145
146    #[test]
147    fn compose_pipeline_ranks_correctly_within_f32_ulp_around_one() {
148        // 1.0 and 1.00000004 collapse to the identical f32 bit pattern (delta is
149        // below the f32 ulp at magnitude 1.0), but are distinct at the
150        // khive-score 2^32 fixed-point scale. Without carrying the f64
151        // `rank_score` into the selector, both candidates would tie on `score`
152        // and fall back to id ordering (picking "a"); the fix must still rank
153        // "b" ahead since its true effective score is higher.
154        let pipeline = pipeline();
155        let candidates = vec![input("a", 1.0, 1.0), input("b", 1.000_000_04, 1.0)];
156        let out = pipeline
157            .execute(
158                &AnchorGraph::new(),
159                candidates,
160                1,
161                &SelectorWeights::default(),
162                &ObjectiveContext::new(),
163            )
164            .unwrap();
165        assert_eq!(out.selected.len(), 1);
166        assert_eq!(out.selected[0].id, "b");
167    }
168
169    #[test]
170    fn compose_pipeline_ranks_score_zero_ties_deterministically() {
171        // Equal effective scores of exactly zero must still tie-break
172        // deterministically (id ascending), same as any other tie.
173        let pipeline = pipeline();
174        let candidates = vec![input("z", 0.0, 1.0), input("a", 0.0, 1.0)];
175        let out = pipeline
176            .execute(
177                &AnchorGraph::new(),
178                candidates,
179                10,
180                &SelectorWeights::default(),
181                &ObjectiveContext::new(),
182            )
183            .unwrap();
184        assert_eq!(out.selected.len(), 2);
185        assert_eq!(out.selected[0].id, "a");
186        assert_eq!(out.selected[1].id, "z");
187    }
188
189    #[test]
190    fn compose_pipeline_category_weights_still_reorder_with_rank_score() {
191        // Mirrors selector.rs's `category_weights_boost_preferred_category`,
192        // but drives it through `ComposePipeline::execute`, which always sets
193        // `rank_score` (see the `execute` comment above). Before the fix,
194        // `ComposePipeline` candidates carrying `rank_score` were immune to
195        // `SelectorWeights.category_weights`: the comparator read the
196        // unweighted `rank_score` while the weight only touched `score`. "a"
197        // (raw effective 0.9, "low") would beat "b" (raw effective 0.5,
198        // "high", weight 2.0) despite the weight. The fix scales `rank_score`
199        // by the category weight too, so "b" must win.
200        let pipeline = pipeline();
201        let candidates = vec![
202            input_cat("a", 0.9, 1.0, "low"),
203            input_cat("b", 0.5, 1.0, "high"),
204        ];
205        let weights = SelectorWeights {
206            category_weights: [("high".to_string(), 2.0f32), ("low".to_string(), 1.0f32)]
207                .into_iter()
208                .collect(),
209            ..Default::default()
210        };
211        let out = pipeline
212            .execute(
213                &AnchorGraph::new(),
214                candidates,
215                1,
216                &weights,
217                &ObjectiveContext::new(),
218            )
219            .unwrap();
220        assert_eq!(out.selected.len(), 1);
221        assert_eq!(
222            out.selected[0].id, "b",
223            "category weight must still reorder ComposePipeline candidates carrying rank_score"
224        );
225    }
226
227    #[test]
228    fn compose_pipeline_rejects_effective_score_outside_f32_range() {
229        let pipeline = pipeline();
230        let candidates = vec![input("a", f64::MAX, 1.0)];
231        let err = pipeline
232            .execute(
233                &AnchorGraph::new(),
234                candidates,
235                10,
236                &SelectorWeights::default(),
237                &ObjectiveContext::new(),
238            )
239            .unwrap_err();
240        assert!(matches!(err, FoldError::InvalidInput(_)));
241    }
242}