aria-core 0.1.0

Generic adaptive sequencing engine — zero dependencies, domain-agnostic. Suggest(), feedback(). Works from item one.
Documentation
use crate::item::Scoreable;
use crate::state::ProfileState;

/// Core trait every scoring factor must implement.
///
/// Callers define domain logic entirely through factors.
/// Built-in factors (Challenge, Spacing, Coverage) are provided as
/// reference implementations — callers may use, replace, or extend them.
///
/// A factor returns a score in [0.0, 1.0].
/// Engine multiplies all factor scores together — multiplicative pipeline.
/// A near-zero score from any single factor suppresses the item entirely.
pub trait Factor: Send + Sync {
    fn name(&self) -> &str;
    fn score(&self, item: &dyn Scoreable, state: &ProfileState, now: u64) -> f32;
}

// ---------------------------------------------------------------------------
// Built-in reference factors
// ---------------------------------------------------------------------------

/// ChallengeFactor — Gaussian centred at user's target (skill + optimism).
///
/// Items at exactly target difficulty score 1.0.
/// Items far from target score near 0.0.
/// `bandwidth` controls tolerance: wider = more variety, narrower = strict gating.
///
/// Domain mapping:
///   learning   → item difficulty vs user mastery
///   ecommerce  → price_ratio vs budget willingness  
///   travel     → remoteness vs adventurousness
///   content    → reading level vs literacy
pub struct ChallengeFactor {
    pub bandwidth: f32,
}

impl ChallengeFactor {
    pub fn new(bandwidth: f32) -> Self {
        Self { bandwidth }
    }
}

impl Default for ChallengeFactor {
    fn default() -> Self {
        Self { bandwidth: 0.2 }
    }
}

impl Factor for ChallengeFactor {
    fn name(&self) -> &str {
        "challenge"
    }

    fn score(&self, item: &dyn Scoreable, state: &ProfileState, _now: u64) -> f32 {
        let target = state.target();
        let diff = item.score_proxy() - target;
        (-diff * diff / (2.0 * self.bandwidth * self.bandwidth)).exp()
    }
}

/// SpacingFactor — forgetting curve on time since item was last seen.
///
/// Items never seen score 1.0 (full spacing benefit).
/// Items seen very recently score near 0.0.
/// Items seen a long time ago approach 1.0 again.
///
/// `optimal_interval_secs` — time after which item is considered due again.
/// Default: 86400 (24 hours). Callers tune per domain:
///   learning  → hours/days (spaced repetition)
///   ecommerce → days/weeks (avoid re-suggesting just-bought items)
///   travel    → months/years (re-suggest destinations)
pub struct SpacingFactor {
    pub optimal_interval_secs: u64,
}

impl SpacingFactor {
    pub fn new(optimal_interval_secs: u64) -> Self {
        Self {
            optimal_interval_secs,
        }
    }
}

impl Default for SpacingFactor {
    fn default() -> Self {
        Self {
            optimal_interval_secs: 86_400,
        }
    }
}

impl Factor for SpacingFactor {
    fn name(&self) -> &str {
        "spacing"
    }

    fn score(&self, item: &dyn Scoreable, state: &ProfileState, now: u64) -> f32 {
        match state.elapsed_since(item.id(), now) {
            None => 1.0, // never seen → highest spacing score
            Some(elapsed) => {
                let ratio = elapsed as f32 / self.optimal_interval_secs as f32;
                1.0 - (-ratio).exp()
            }
        }
    }
}

/// CoverageFactor — favours categories the user has interacted with less.
///
/// Prevents the engine from over-drilling one category.
/// Items in under-represented categories score higher.
///
/// Domain mapping:
///   learning   → topic spread (algebra, geometry, …)
///   ecommerce  → category spread (electronics, clothing, …)
///   travel     → region spread (Asia, Europe, …)
///   content    → genre spread (thriller, romance, …)
pub struct CoverageFactor;

impl Default for CoverageFactor {
    fn default() -> Self {
        Self
    }
}

impl Factor for CoverageFactor {
    fn name(&self) -> &str {
        "coverage"
    }

    fn score(&self, item: &dyn Scoreable, state: &ProfileState, _now: u64) -> f32 {
        let count = *state.category_count.get(item.category()).unwrap_or(&0) as f32;
        let mean = state.mean_category_count();
        1.0 / (1.0 + count / mean)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::item::Item;
    use crate::state::ProfileState;

    fn make_state(skill: f32, optimism: f32) -> ProfileState {
        let mut s = ProfileState::new();
        s.skill = skill;
        s.optimism_bias = optimism;
        s
    }

    #[test]
    fn challenge_peaks_at_target() {
        let factor = ChallengeFactor::default();
        let state = make_state(0.5, 0.1); // target = 0.6
        let item = Item::new("x", 0.6, "cat");
        let score = factor.score(&item, &state, 0);
        assert!((score - 1.0).abs() < 1e-5);
    }

    #[test]
    fn challenge_decays_away_from_target() {
        let factor = ChallengeFactor::default();
        let state = make_state(0.5, 0.1);
        let near = Item::new("near", 0.6, "cat");
        let far = Item::new("far", 0.0, "cat");
        assert!(factor.score(&near, &state, 0) > factor.score(&far, &state, 0));
    }

    #[test]
    fn spacing_never_seen_is_one() {
        let factor = SpacingFactor::default();
        let state = ProfileState::new();
        let item = Item::new("x", 0.5, "cat");
        assert!((factor.score(&item, &state, 1000) - 1.0).abs() < 1e-5);
    }

    #[test]
    fn spacing_just_seen_is_near_zero() {
        let factor = SpacingFactor::default();
        let mut state = ProfileState::new();
        state.last_seen.insert("x".into(), 1000);
        let item = Item::new("x", 0.5, "cat");
        let score = factor.score(&item, &state, 1001); // 1 second elapsed
        assert!(score < 0.01);
    }

    #[test]
    fn coverage_favours_unseen_category() {
        let factor = CoverageFactor;
        let mut state = ProfileState::new();
        state.category_count.insert("math".into(), 10);
        // "science" not in map
        let math_item = Item::new("m", 0.5, "math");
        let sci_item = Item::new("s", 0.5, "science");
        assert!(
            factor.score(&sci_item, &state, 0) > factor.score(&math_item, &state, 0)
        );
    }
}