1use crate::anchor::{Anchor, AnchorGraph};
4use crate::error::FoldError;
5use crate::objective::{Objective, ObjectiveContext};
6use crate::selector::{Selector, SelectorInput, SelectorOutput, SelectorWeights};
7
8pub struct ComposePipeline<T> {
10 pub anchor: Box<dyn Anchor>,
12 pub objective: Box<dyn Objective<T>>,
14 pub selector: Box<dyn Selector<T>>,
16}
17
18impl<T: Clone + Send + Sync + 'static> ComposePipeline<T> {
19 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 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 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 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 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}