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::signal::Signal;
use crate::state::{ProfileState, OPTIMISM_CEIL, OPTIMISM_FLOOR};

/// Trait for state update logic after feedback.
///
/// Callers can provide their own implementation to completely control
/// how skill and other state fields evolve after each interaction.
///
/// Default implementation follows the ARIA update rules from the PRD.
pub trait StateUpdater: Send + Sync {
    /// Produce a new ProfileState given current state, the item interacted with,
    /// the signal reported, and the current timestamp.
    ///
    /// Returns an owned new state — immutable update pattern.
    fn update(
        &self,
        state: &ProfileState,
        item: &dyn Scoreable,
        signal: &Signal,
        now: u64,
    ) -> ProfileState;
}

/// Default ARIA state updater.
///
/// Skill update:
///   performance = success × (0.5 + 0.5 × (1 - effort))
///   skill       = skill + alpha × (performance - skill)
///
/// Optimism update:
///   success + effort < 0.4  → optimism += 0.02   (easy win → push harder)
///   !success                → optimism -= 0.01   (failure → ease back)
///   otherwise               → unchanged
///   always clamped to [OPTIMISM_FLOOR, OPTIMISM_CEIL]
///
/// resolved_set: item added if success == true
pub struct DefaultStateUpdater {
    /// Learning rate for skill update. Default 0.05.
    pub alpha: f32,
}

impl DefaultStateUpdater {
    pub fn new(alpha: f32) -> Self {
        Self { alpha }
    }
}

impl Default for DefaultStateUpdater {
    fn default() -> Self {
        Self { alpha: 0.05 }
    }
}

impl StateUpdater for DefaultStateUpdater {
    fn update(
        &self,
        state: &ProfileState,
        item: &dyn Scoreable,
        signal: &Signal,
        now: u64,
    ) -> ProfileState {
        let mut next = state.clone();

        // Skill update
        let performance = signal.performance();
        next.skill = (state.skill + self.alpha * (performance - state.skill)).clamp(0.0, 1.0);

        // Optimism update
        next.optimism_bias = if signal.success && signal.effort < 0.4 {
            (state.optimism_bias + 0.02).clamp(OPTIMISM_FLOOR, OPTIMISM_CEIL)
        } else if !signal.success {
            (state.optimism_bias - 0.01).clamp(OPTIMISM_FLOOR, OPTIMISM_CEIL)
        } else {
            state.optimism_bias
        };

        // Mark item as seen
        next.last_seen.insert(item.id().to_string(), now);

        // Update category count
        *next.category_count.entry(item.category().to_string()).or_insert(0) += 1;

        // Mark resolved if success
        if signal.success {
            next.resolved_set.insert(item.id().to_string());
        }

        next.interaction_count += 1;

        next
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::item::Item;
    use crate::state::{DEFAULT_OPTIMISM, DEFAULT_SKILL};

    fn base_state() -> ProfileState {
        ProfileState::new()
    }

    #[test]
    fn skill_increases_on_success() {
        let updater = DefaultStateUpdater::default();
        let state = base_state();
        let item = Item::new("x", 0.3, "cat");
        let signal = Signal::new(true, 0.5);
        let next = updater.update(&state, &item, &signal, 100);
        assert!(next.skill > DEFAULT_SKILL);
    }

    #[test]
    fn skill_stays_at_zero_on_failure_from_zero() {
        let updater = DefaultStateUpdater::default();
        let state = base_state();
        let item = Item::new("x", 0.5, "cat");
        let signal = Signal::new(false, 0.8);
        let next = updater.update(&state, &item, &signal, 100);
        // performance = 0.0, skill + alpha*(0 - 0) = 0
        assert!((next.skill - 0.0).abs() < 1e-6);
    }

    #[test]
    fn optimism_increases_on_easy_success() {
        let updater = DefaultStateUpdater::default();
        let state = base_state();
        let item = Item::new("x", 0.1, "cat");
        let signal = Signal::new(true, 0.1); // effort < 0.4
        let next = updater.update(&state, &item, &signal, 100);
        assert!(next.optimism_bias > DEFAULT_OPTIMISM);
    }

    #[test]
    fn optimism_decreases_on_failure() {
        let updater = DefaultStateUpdater::default();
        let state = base_state();
        let item = Item::new("x", 0.5, "cat");
        let signal = Signal::new(false, 0.9);
        let next = updater.update(&state, &item, &signal, 100);
        assert!(next.optimism_bias < DEFAULT_OPTIMISM);
    }

    #[test]
    fn optimism_never_below_floor() {
        let updater = DefaultStateUpdater::default();
        let mut state = base_state();
        state.optimism_bias = OPTIMISM_FLOOR; // already at floor
        let item = Item::new("x", 0.5, "cat");
        let signal = Signal::new(false, 1.0);
        let next = updater.update(&state, &item, &signal, 100);
        assert!(next.optimism_bias >= OPTIMISM_FLOOR);
    }

    #[test]
    fn resolved_set_updated_on_success() {
        let updater = DefaultStateUpdater::default();
        let state = base_state();
        let item = Item::new("x", 0.5, "cat");
        let signal = Signal::new(true, 0.5);
        let next = updater.update(&state, &item, &signal, 100);
        assert!(next.resolved_set.contains("x"));
    }

    #[test]
    fn resolved_set_not_updated_on_failure() {
        let updater = DefaultStateUpdater::default();
        let state = base_state();
        let item = Item::new("x", 0.5, "cat");
        let signal = Signal::new(false, 0.5);
        let next = updater.update(&state, &item, &signal, 100);
        assert!(!next.resolved_set.contains("x"));
    }

    #[test]
    fn last_seen_updated() {
        let updater = DefaultStateUpdater::default();
        let state = base_state();
        let item = Item::new("x", 0.5, "cat");
        let signal = Signal::new(true, 0.5);
        let next = updater.update(&state, &item, &signal, 9999);
        assert_eq!(next.last_seen["x"], 9999);
    }
}