khive-fold 0.2.0

Cognitive primitives — Fold, Anchor, Objective, Selector
Documentation
//! Objective function framework — scoring, selection, composition.

pub mod builtin;
pub mod compose;
mod context;
pub mod error;
mod selection;
mod traits;

pub use context::ObjectiveContext;
pub use error::{ObjectiveError, ObjectiveResult};
pub use selection::Selection;
pub use traits::{objective_fn, DeterministicObjective, Objective};

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ordering::HasId;
    use crate::ObjectiveError;
    use uuid::Uuid;

    #[test]
    fn test_simple_objective() {
        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);

        let candidates = vec![1, 5, 3, 8, 2];
        let selection = objective
            .select(&candidates, &ObjectiveContext::new())
            .unwrap();

        assert_eq!(*selection.item, 8);
        assert_eq!(selection.score, 8.0);
        assert_eq!(selection.index, 3);
    }

    #[test]
    fn test_threshold() {
        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);

        let candidates = vec![1, 5, 3, 8, 2];
        let context = ObjectiveContext::new().with_min_score(4.0);
        let selection = objective.select(&candidates, &context).unwrap();

        assert_eq!(*selection.item, 8);
        assert_eq!(selection.passed, 2);
    }

    #[test]
    fn test_no_candidates() {
        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);

        let candidates: Vec<i32> = vec![];
        let result = objective.select(&candidates, &ObjectiveContext::new());

        assert!(matches!(result, Err(ObjectiveError::NoCandidates)));
    }

    #[test]
    fn test_no_match() {
        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);

        let candidates = vec![1, 2, 3];
        let context = ObjectiveContext::new().with_min_score(10.0);
        let result = objective.select(&candidates, &context);

        assert!(matches!(result, Err(ObjectiveError::NoMatch(_))));
    }

    #[test]
    fn test_select_top() {
        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);

        let candidates = vec![1, 5, 3, 8, 2];
        let top = objective.select_top(&candidates, 3, &ObjectiveContext::new());

        assert_eq!(top.len(), 3);
        assert_eq!(*top[0].item, 8);
        assert_eq!(*top[1].item, 5);
        assert_eq!(*top[2].item, 3);
    }

    #[test]
    fn test_nan_score_never_selected() {
        let objective = objective_fn(
            |n: &i32, _ctx: &ObjectiveContext| {
                if *n == 5 {
                    f64::NAN
                } else {
                    *n as f64
                }
            },
        );

        let candidates = vec![1, 5, 3];
        let selection = objective
            .select(&candidates, &ObjectiveContext::new())
            .unwrap();

        assert_eq!(*selection.item, 3);
        assert_eq!(selection.score, 3.0);
        assert_eq!(selection.passed, 2);
    }

    #[test]
    fn test_infinite_score_never_selected() {
        let objective = objective_fn(
            |n: &i32, _ctx: &ObjectiveContext| {
                if *n == 5 {
                    f64::INFINITY
                } else {
                    *n as f64
                }
            },
        );

        let candidates = vec![1, 5, 3];
        let selection = objective
            .select(&candidates, &ObjectiveContext::new())
            .unwrap();

        assert_eq!(*selection.item, 3);
        assert_eq!(selection.score, 3.0);
        assert_eq!(selection.passed, 2);
    }

    #[test]
    fn test_max_candidates_respected() {
        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);

        let candidates = vec![1, 5, 3, 8, 2];
        let context = ObjectiveContext::new().with_max_candidates(2);
        let selection = objective.select(&candidates, &context).unwrap();

        assert_eq!(*selection.item, 5);
        assert_eq!(selection.considered, 2);
    }

    // ========================================================================
    // DeterministicObjective Tests
    // ========================================================================

    #[derive(Debug, Clone)]
    struct TestCandidate {
        id: Uuid,
        value: i32,
    }

    impl TestCandidate {
        fn new(value: i32) -> Self {
            Self {
                id: Uuid::new_v4(),
                value,
            }
        }

        fn with_id(id: Uuid, value: i32) -> Self {
            Self { id, value }
        }
    }

    impl HasId for TestCandidate {
        fn id(&self) -> Uuid {
            self.id
        }
    }

    #[test]
    fn test_deterministic_select_basic() {
        let objective = objective_fn(|c: &TestCandidate, _ctx: &ObjectiveContext| c.value as f64);

        let candidates = vec![
            TestCandidate::new(1),
            TestCandidate::new(5),
            TestCandidate::new(3),
        ];

        let selection = objective
            .select_deterministic(&candidates, &ObjectiveContext::new())
            .unwrap();

        assert_eq!(selection.item.value, 5);
        assert_eq!(selection.score, 5.0);
    }

    #[test]
    fn test_deterministic_select_equal_scores_uses_uuid_tiebreaker() {
        let objective = objective_fn(|_c: &TestCandidate, _ctx: &ObjectiveContext| 1.0);

        let id1 = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
        let id2 = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
        let id3 = Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap();

        let candidates = vec![
            TestCandidate::with_id(id2, 100),
            TestCandidate::with_id(id3, 200),
            TestCandidate::with_id(id1, 300),
        ];

        let selection = objective
            .select_deterministic(&candidates, &ObjectiveContext::new())
            .unwrap();

        assert_eq!(selection.item.id, id1);
        assert_eq!(selection.item.value, 300);
    }

    #[test]
    fn test_deterministic_select_top_ordering() {
        let objective = objective_fn(|_c: &TestCandidate, _ctx: &ObjectiveContext| 1.0);

        let id1 = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
        let id2 = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
        let id3 = Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap();

        let candidates = vec![
            TestCandidate::with_id(id3, 300),
            TestCandidate::with_id(id1, 100),
            TestCandidate::with_id(id2, 200),
        ];

        let top = objective.select_top_deterministic(&candidates, 3, &ObjectiveContext::new());

        assert_eq!(top.len(), 3);
        assert_eq!(top[0].item.id, id1);
        assert_eq!(top[1].item.id, id2);
        assert_eq!(top[2].item.id, id3);
    }

    #[test]
    fn test_deterministic_reproducibility() {
        let objective = objective_fn(|_c: &TestCandidate, _ctx: &ObjectiveContext| 1.0);

        let id1 = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
        let id2 = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
        let id3 = Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap();

        let candidates = vec![
            TestCandidate::with_id(id2, 1),
            TestCandidate::with_id(id3, 2),
            TestCandidate::with_id(id1, 3),
        ];

        for _ in 0..100 {
            let selection = objective
                .select_deterministic(&candidates, &ObjectiveContext::new())
                .unwrap();
            assert_eq!(selection.item.id, id1, "Determinism violated!");

            let top = objective.select_top_deterministic(&candidates, 3, &ObjectiveContext::new());
            assert_eq!(top[0].item.id, id1);
            assert_eq!(top[1].item.id, id2);
            assert_eq!(top[2].item.id, id3);
        }
    }

    // ========================================================================
    // Precision (ADR-059) Tests
    // ========================================================================

    #[test]
    fn precision_default_returns_one() {
        // The closure-based Objective inherits the default precision() → 1.0.
        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);
        let ctx = ObjectiveContext::new();
        assert_eq!(objective.precision(&42, &ctx), 1.0);
    }

    #[test]
    fn precision_one_leaves_ranking_unchanged() {
        // When all precisions are 1.0, select behaves identically to raw score ranking.
        let objective = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64);
        let candidates = vec![1, 5, 3, 8, 2];
        let sel = objective
            .select(&candidates, &ObjectiveContext::new())
            .unwrap();
        assert_eq!(*sel.item, 8);
        assert_eq!(sel.precision, 1.0);
    }

    #[test]
    fn precision_reorders_candidates_when_lower() {
        // Candidate with score 10.0 and precision 0.1 → effective 1.0.
        // Candidate with score 3.0 and precision 1.0 → effective 3.0.
        // The lower-score but precise candidate should win.
        struct PrecisionObjective;
        impl Objective<(f64, f64)> for PrecisionObjective {
            fn score(&self, c: &(f64, f64), _ctx: &ObjectiveContext) -> f64 {
                c.0
            }
            fn precision(&self, c: &(f64, f64), _ctx: &ObjectiveContext) -> f64 {
                c.1
            }
        }

        let candidates = vec![(10.0f64, 0.1f64), (3.0f64, 1.0f64)];
        let sel = PrecisionObjective
            .select(&candidates, &ObjectiveContext::new())
            .unwrap();
        // 3.0 * 1.0 = 3.0  >  10.0 * 0.1 = 1.0
        assert_eq!(sel.item.0, 3.0);
        assert_eq!(sel.precision, 1.0);
    }

    #[test]
    fn selection_stores_precision_from_winning_candidate() {
        struct HalfPrecision;
        impl Objective<i32> for HalfPrecision {
            fn score(&self, n: &i32, _ctx: &ObjectiveContext) -> f64 {
                *n as f64
            }
            fn precision(&self, _n: &i32, _ctx: &ObjectiveContext) -> f64 {
                0.5
            }
        }
        let candidates = vec![1, 2, 3];
        let sel = HalfPrecision
            .select(&candidates, &ObjectiveContext::new())
            .unwrap();
        assert_eq!(sel.precision, 0.5);
    }

    #[test]
    fn non_finite_precision_treated_as_one() {
        // Non-finite precision should not panic and should behave as if precision = 1.0.
        struct NanPrecision;
        impl Objective<i32> for NanPrecision {
            fn score(&self, n: &i32, _ctx: &ObjectiveContext) -> f64 {
                *n as f64
            }
            fn precision(&self, _n: &i32, _ctx: &ObjectiveContext) -> f64 {
                f64::NAN
            }
        }
        let candidates = vec![1, 5, 3];
        let sel = NanPrecision
            .select(&candidates, &ObjectiveContext::new())
            .unwrap();
        // NaN precision → treat as 1.0 → raw score ordering → 5 wins.
        assert_eq!(*sel.item, 5);
    }
}