Skip to main content

khive_fold/objective/
mod.rs

1//! Objective function framework — scoring, selection, composition.
2
3pub mod builtin;
4pub mod compose;
5mod context;
6pub mod error;
7mod selection;
8mod traits;
9
10pub use context::ObjectiveContext;
11pub use error::{ObjectiveError, ObjectiveResult};
12pub use selection::Selection;
13pub use traits::{objective_fn, DeterministicObjective, Objective};
14
15#[cfg(test)]
16mod tests {
17    use super::*;
18    use crate::ordering::HasId;
19    use crate::ObjectiveError;
20    use uuid::Uuid;
21
22    #[test]
23    fn test_simple_objective() {
24        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);
25
26        let candidates = vec![1, 5, 3, 8, 2];
27        let selection = objective
28            .select(&candidates, &ObjectiveContext::new())
29            .unwrap();
30
31        assert_eq!(*selection.item, 8);
32        assert_eq!(selection.score, 8.0);
33        assert_eq!(selection.index, 3);
34    }
35
36    #[test]
37    fn test_threshold() {
38        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);
39
40        let candidates = vec![1, 5, 3, 8, 2];
41        let context = ObjectiveContext::new().with_min_score(4.0);
42        let selection = objective.select(&candidates, &context).unwrap();
43
44        assert_eq!(*selection.item, 8);
45        assert_eq!(selection.passed, 2);
46    }
47
48    #[test]
49    fn test_no_candidates() {
50        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);
51
52        let candidates: Vec<i32> = vec![];
53        let result = objective.select(&candidates, &ObjectiveContext::new());
54
55        assert!(matches!(result, Err(ObjectiveError::NoCandidates)));
56    }
57
58    #[test]
59    fn test_no_match() {
60        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);
61
62        let candidates = vec![1, 2, 3];
63        let context = ObjectiveContext::new().with_min_score(10.0);
64        let result = objective.select(&candidates, &context);
65
66        assert!(matches!(result, Err(ObjectiveError::NoMatch(_))));
67    }
68
69    #[test]
70    fn test_select_top() {
71        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);
72
73        let candidates = vec![1, 5, 3, 8, 2];
74        let top = objective.select_top(&candidates, 3, &ObjectiveContext::new());
75
76        assert_eq!(top.len(), 3);
77        assert_eq!(*top[0].item, 8);
78        assert_eq!(*top[1].item, 5);
79        assert_eq!(*top[2].item, 3);
80    }
81
82    #[test]
83    fn test_nan_score_never_selected() {
84        let objective = objective_fn(
85            |n: &i32, _ctx: &ObjectiveContext| {
86                if *n == 5 {
87                    f64::NAN
88                } else {
89                    *n as f64
90                }
91            },
92        );
93
94        let candidates = vec![1, 5, 3];
95        let selection = objective
96            .select(&candidates, &ObjectiveContext::new())
97            .unwrap();
98
99        assert_eq!(*selection.item, 3);
100        assert_eq!(selection.score, 3.0);
101        assert_eq!(selection.passed, 2);
102    }
103
104    #[test]
105    fn test_infinite_score_never_selected() {
106        let objective = objective_fn(
107            |n: &i32, _ctx: &ObjectiveContext| {
108                if *n == 5 {
109                    f64::INFINITY
110                } else {
111                    *n as f64
112                }
113            },
114        );
115
116        let candidates = vec![1, 5, 3];
117        let selection = objective
118            .select(&candidates, &ObjectiveContext::new())
119            .unwrap();
120
121        assert_eq!(*selection.item, 3);
122        assert_eq!(selection.score, 3.0);
123        assert_eq!(selection.passed, 2);
124    }
125
126    #[test]
127    fn test_max_candidates_respected() {
128        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);
129
130        let candidates = vec![1, 5, 3, 8, 2];
131        let context = ObjectiveContext::new().with_max_candidates(2);
132        let selection = objective.select(&candidates, &context).unwrap();
133
134        assert_eq!(*selection.item, 5);
135        assert_eq!(selection.considered, 2);
136    }
137
138    // ========================================================================
139    // DeterministicObjective Tests
140    // ========================================================================
141
142    #[derive(Debug, Clone)]
143    struct TestCandidate {
144        id: Uuid,
145        value: i32,
146    }
147
148    impl TestCandidate {
149        fn new(value: i32) -> Self {
150            Self {
151                id: Uuid::new_v4(),
152                value,
153            }
154        }
155
156        fn with_id(id: Uuid, value: i32) -> Self {
157            Self { id, value }
158        }
159    }
160
161    impl HasId for TestCandidate {
162        fn id(&self) -> Uuid {
163            self.id
164        }
165    }
166
167    #[test]
168    fn test_deterministic_select_basic() {
169        let objective = objective_fn(|c: &TestCandidate, _ctx: &ObjectiveContext| c.value as f64);
170
171        let candidates = vec![
172            TestCandidate::new(1),
173            TestCandidate::new(5),
174            TestCandidate::new(3),
175        ];
176
177        let selection = objective
178            .select_deterministic(&candidates, &ObjectiveContext::new())
179            .unwrap();
180
181        assert_eq!(selection.item.value, 5);
182        assert_eq!(selection.score, 5.0);
183    }
184
185    #[test]
186    fn test_deterministic_select_equal_scores_uses_uuid_tiebreaker() {
187        let objective = objective_fn(|_c: &TestCandidate, _ctx: &ObjectiveContext| 1.0);
188
189        let id1 = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
190        let id2 = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
191        let id3 = Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap();
192
193        let candidates = vec![
194            TestCandidate::with_id(id2, 100),
195            TestCandidate::with_id(id3, 200),
196            TestCandidate::with_id(id1, 300),
197        ];
198
199        let selection = objective
200            .select_deterministic(&candidates, &ObjectiveContext::new())
201            .unwrap();
202
203        assert_eq!(selection.item.id, id1);
204        assert_eq!(selection.item.value, 300);
205    }
206
207    #[test]
208    fn test_deterministic_select_top_ordering() {
209        let objective = objective_fn(|_c: &TestCandidate, _ctx: &ObjectiveContext| 1.0);
210
211        let id1 = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
212        let id2 = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
213        let id3 = Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap();
214
215        let candidates = vec![
216            TestCandidate::with_id(id3, 300),
217            TestCandidate::with_id(id1, 100),
218            TestCandidate::with_id(id2, 200),
219        ];
220
221        let top = objective.select_top_deterministic(&candidates, 3, &ObjectiveContext::new());
222
223        assert_eq!(top.len(), 3);
224        assert_eq!(top[0].item.id, id1);
225        assert_eq!(top[1].item.id, id2);
226        assert_eq!(top[2].item.id, id3);
227    }
228
229    #[test]
230    fn test_deterministic_reproducibility() {
231        let objective = objective_fn(|_c: &TestCandidate, _ctx: &ObjectiveContext| 1.0);
232
233        let id1 = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
234        let id2 = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
235        let id3 = Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap();
236
237        let candidates = vec![
238            TestCandidate::with_id(id2, 1),
239            TestCandidate::with_id(id3, 2),
240            TestCandidate::with_id(id1, 3),
241        ];
242
243        for _ in 0..100 {
244            let selection = objective
245                .select_deterministic(&candidates, &ObjectiveContext::new())
246                .unwrap();
247            assert_eq!(selection.item.id, id1, "Determinism violated!");
248
249            let top = objective.select_top_deterministic(&candidates, 3, &ObjectiveContext::new());
250            assert_eq!(top[0].item.id, id1);
251            assert_eq!(top[1].item.id, id2);
252            assert_eq!(top[2].item.id, id3);
253        }
254    }
255
256    // ========================================================================
257    // Precision (ADR-059) Tests
258    // ========================================================================
259
260    #[test]
261    fn precision_default_returns_one() {
262        // The closure-based Objective inherits the default precision() → 1.0.
263        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);
264        let ctx = ObjectiveContext::new();
265        assert_eq!(objective.precision(&42, &ctx), 1.0);
266    }
267
268    #[test]
269    fn precision_one_leaves_ranking_unchanged() {
270        // When all precisions are 1.0, select behaves identically to raw score ranking.
271        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);
272        let candidates = vec![1, 5, 3, 8, 2];
273        let sel = objective
274            .select(&candidates, &ObjectiveContext::new())
275            .unwrap();
276        assert_eq!(*sel.item, 8);
277        assert_eq!(sel.precision, 1.0);
278    }
279
280    #[test]
281    fn precision_reorders_candidates_when_lower() {
282        // Candidate with score 10.0 and precision 0.1 → effective 1.0.
283        // Candidate with score 3.0 and precision 1.0 → effective 3.0.
284        // The lower-score but precise candidate should win.
285        struct PrecisionObjective;
286        impl Objective<(f64, f64)> for PrecisionObjective {
287            fn score(&self, c: &(f64, f64), _ctx: &ObjectiveContext) -> f64 {
288                c.0
289            }
290            fn precision(&self, c: &(f64, f64), _ctx: &ObjectiveContext) -> f64 {
291                c.1
292            }
293        }
294
295        let candidates = vec![(10.0f64, 0.1f64), (3.0f64, 1.0f64)];
296        let sel = PrecisionObjective
297            .select(&candidates, &ObjectiveContext::new())
298            .unwrap();
299        // 3.0 * 1.0 = 3.0  >  10.0 * 0.1 = 1.0
300        assert_eq!(sel.item.0, 3.0);
301        assert_eq!(sel.precision, 1.0);
302    }
303
304    #[test]
305    fn selection_stores_precision_from_winning_candidate() {
306        struct HalfPrecision;
307        impl Objective<i32> for HalfPrecision {
308            fn score(&self, n: &i32, _ctx: &ObjectiveContext) -> f64 {
309                *n as f64
310            }
311            fn precision(&self, _n: &i32, _ctx: &ObjectiveContext) -> f64 {
312                0.5
313            }
314        }
315        let candidates = vec![1, 2, 3];
316        let sel = HalfPrecision
317            .select(&candidates, &ObjectiveContext::new())
318            .unwrap();
319        assert_eq!(sel.precision, 0.5);
320    }
321
322    #[test]
323    fn non_finite_precision_treated_as_one() {
324        // Non-finite precision should not panic and should behave as if precision = 1.0.
325        struct NanPrecision;
326        impl Objective<i32> for NanPrecision {
327            fn score(&self, n: &i32, _ctx: &ObjectiveContext) -> f64 {
328                *n as f64
329            }
330            fn precision(&self, _n: &i32, _ctx: &ObjectiveContext) -> f64 {
331                f64::NAN
332            }
333        }
334        let candidates = vec![1, 5, 3];
335        let sel = NanPrecision
336            .select(&candidates, &ObjectiveContext::new())
337            .unwrap();
338        // NaN precision → treat as 1.0 → raw score ordering → 5 wins.
339        assert_eq!(*sel.item, 5);
340    }
341}