x-pipe-rs 0.1.0

Composable recommendation/feed pipeline framework built on comp-cat-rs
Documentation
//! Score and scored candidate types.
//!
//! [`Score`] is a newtype over `f64` that implements
//! [`JoinSemilattice`] from `comp-cat-rs`.  The join is `max`,
//! which categorically is a coproduct in the posetal category,
//! hence a colimit, hence a left Kan extension.
//!
//! [`ScoredCandidate`] pairs an item with its score.

use comp_cat_rs::foundation::semilattice::JoinSemilattice;
use std::fmt;

/// A relevance score.
///
/// Wraps `f64`, rejecting `NaN` at construction.  Ordered by
/// the natural `f64` ordering on non-NaN values.
///
/// Implements [`JoinSemilattice`] where `join = max`, connecting
/// score merging to the categorical collapse hierarchy.
#[derive(Debug, Clone, Copy)]
pub struct Score(f64);

impl Score {
    /// Create a new score, returning `None` if the value is `NaN`.
    ///
    /// # Examples
    ///
    /// ```
    /// use x_pipe_rs::Score;
    ///
    /// let s = Score::new(0.75);
    /// assert!(s.is_some());
    ///
    /// let nan = Score::new(f64::NAN);
    /// assert!(nan.is_none());
    /// ```
    #[must_use]
    pub fn new(value: f64) -> Option<Self> {
        if value.is_nan() {
            None
        } else {
            Some(Self(value))
        }
    }

    /// The zero score.
    #[must_use]
    pub fn zero() -> Self {
        Self(0.0)
    }

    /// The underlying `f64` value.
    #[must_use]
    pub fn value(self) -> f64 {
        self.0
    }

}

impl std::ops::Add for Score {
    type Output = Self;

    /// Additive combination of two scores.
    fn add(self, other: Self) -> Self {
        Self(self.0 + other.0)
    }
}

impl std::ops::Mul for Score {
    type Output = Self;

    /// Multiplicative combination of two scores (e.g. weighting).
    fn mul(self, other: Self) -> Self {
        Self(self.0 * other.0)
    }
}

impl PartialEq for Score {
    fn eq(&self, other: &Self) -> bool {
        self.0 == other.0
    }
}

impl Eq for Score {}

impl PartialOrd for Score {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Score {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.0.total_cmp(&other.0)
    }
}

impl JoinSemilattice for Score {
    /// The least upper bound of two scores: `max`.
    ///
    /// Categorically, this is the coproduct in the posetal
    /// category of scores, which is a colimit, which is a
    /// left Kan extension.
    fn join(&self, other: &Self) -> Self {
        match self.cmp(other) {
            std::cmp::Ordering::Less => *other,
            _ => *self,
        }
    }
}

impl fmt::Display for Score {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.4}", self.0)
    }
}

/// A candidate item paired with its relevance score.
#[must_use]
#[derive(Debug, Clone)]
pub struct ScoredCandidate<I> {
    item: I,
    score: Score,
}

impl<I> ScoredCandidate<I> {
    /// Create a scored candidate.
    pub fn new(item: I, score: Score) -> Self {
        Self { item, score }
    }

    /// The candidate item.
    #[must_use]
    pub fn item(&self) -> &I {
        &self.item
    }

    /// The relevance score.
    #[must_use]
    pub fn score(&self) -> Score {
        self.score
    }

    /// Consume and return the item, discarding the score.
    #[must_use]
    pub fn into_item(self) -> I {
        self.item
    }

    /// Consume and return both parts.
    #[must_use]
    pub fn into_parts(self) -> (I, Score) {
        (self.item, self.score)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::ops::Add;

    #[test]
    fn score_rejects_nan() {
        assert!(Score::new(f64::NAN).is_none());
    }

    #[test]
    fn score_accepts_finite() {
        assert!(Score::new(1.0).is_some());
        assert!(Score::new(-3.5).is_some());
        assert!(Score::new(0.0).is_some());
    }

    #[test]
    fn score_accepts_infinity() {
        assert!(Score::new(f64::INFINITY).is_some());
        assert!(Score::new(f64::NEG_INFINITY).is_some());
    }

    #[test]
    fn join_is_max() -> Result<(), &'static str> {
        let a = Score::new(1.0).ok_or("a")?;
        let b = Score::new(3.0).ok_or("b")?;
        assert_eq!(a.join(&b), b);
        Ok(())
    }

    #[test]
    fn join_is_commutative() -> Result<(), &'static str> {
        let a = Score::new(2.0).ok_or("a")?;
        let b = Score::new(5.0).ok_or("b")?;
        assert_eq!(a.join(&b), b.join(&a));
        Ok(())
    }

    #[test]
    fn join_is_idempotent() -> Result<(), &'static str> {
        let a = Score::new(4.0).ok_or("a")?;
        assert_eq!(a.join(&a), a);
        Ok(())
    }

    #[test]
    #[allow(clippy::float_cmp)] // Small integer sums are exact in f64.
    fn additive_combination() -> Result<(), &'static str> {
        let a = Score::new(1.0).ok_or("a")?;
        let b = Score::new(2.0).ok_or("b")?;
        assert_eq!(a.add(b).value(), 3.0);
        Ok(())
    }
}