aria-core 0.1.0

Generic adaptive sequencing engine — zero dependencies, domain-agnostic. Suggest(), feedback(). Works from item one.
Documentation
use std::collections::{HashMap, HashSet};

/// Generic user profile state. Domain-agnostic.
///
/// `skill`          — normalised user ability/preference proxy [0.0, 1.0]
///                    In learning: mastery. In travel: adventurousness.
///                    In ecommerce: budget willingness. Caller decides semantics.
///
/// `optimism_bias`  — how far above current skill the engine targets [0.05, 0.35]
///                    Always has a hard floor (OPTIMISM_FLOOR) so engine always
///                    targets growth. Never configurable to 0.
///
/// `last_seen`      — item_id → unix timestamp (seconds). Drives spacing factor.
///
/// `category_count` — category → interaction count. Drives coverage factor.
///
/// `resolved_set`   — set of item IDs user has successfully completed.
///                    Used for prerequisite gating.
///
/// `interaction_count` — total interactions. Useful for exploration decay.
///
/// `extended`       — arbitrary caller key-value store for custom factor/updater data.

pub const OPTIMISM_FLOOR: f32 = 0.05;
pub const OPTIMISM_CEIL: f32 = 0.35;
pub const DEFAULT_SKILL: f32 = 0.0;
pub const DEFAULT_OPTIMISM: f32 = 0.1;

#[derive(Debug, Clone)]
pub struct ProfileState {
    pub skill: f32,
    pub optimism_bias: f32,
    pub last_seen: HashMap<String, u64>,
    pub category_count: HashMap<String, u32>,
    pub resolved_set: HashSet<String>,
    pub interaction_count: u64,
    /// Caller-defined arbitrary state. Custom factors/updaters read/write here.
    pub extended: HashMap<String, f32>,
    /// String-valued extended state for non-numeric caller data.
    pub extended_str: HashMap<String, String>,
}

impl ProfileState {
    pub fn new() -> Self {
        Self {
            skill: DEFAULT_SKILL,
            optimism_bias: DEFAULT_OPTIMISM,
            last_seen: HashMap::new(),
            category_count: HashMap::new(),
            resolved_set: HashSet::new(),
            interaction_count: 0,
            extended: HashMap::new(),
            extended_str: HashMap::new(),
        }
    }

    /// Effective target = skill + optimism, clamped to [0, 1].
    pub fn target(&self) -> f32 {
        (self.skill + self.optimism_bias).clamp(0.0, 1.0)
    }

    /// Mean interactions per category. Used by coverage factor.
    pub fn mean_category_count(&self) -> f32 {
        if self.category_count.is_empty() {
            return 1.0;
        }
        let total: u32 = self.category_count.values().sum();
        total as f32 / self.category_count.len() as f32
    }

    /// Seconds elapsed since item was last seen. Returns None if never seen.
    pub fn elapsed_since(&self, item_id: &str, now: u64) -> Option<u64> {
        self.last_seen.get(item_id).map(|&t| now.saturating_sub(t))
    }
}

impl Default for ProfileState {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn target_clamps_to_one() {
        let mut s = ProfileState::new();
        s.skill = 0.9;
        s.optimism_bias = 0.35;
        assert!(s.target() <= 1.0);
    }

    #[test]
    fn mean_category_count_empty() {
        let s = ProfileState::new();
        assert_eq!(s.mean_category_count(), 1.0);
    }

    #[test]
    fn mean_category_count_correct() {
        let mut s = ProfileState::new();
        s.category_count.insert("a".into(), 4);
        s.category_count.insert("b".into(), 2);
        assert!((s.mean_category_count() - 3.0).abs() < 1e-6);
    }

    #[test]
    fn elapsed_since_never_seen() {
        let s = ProfileState::new();
        assert_eq!(s.elapsed_since("x", 100), None);
    }
}