Skip to main content

aria_core/
state.rs

1use std::collections::{HashMap, HashSet};
2
3/// Generic user profile state. Domain-agnostic.
4///
5/// `skill`          — normalised user ability/preference proxy [0.0, 1.0]
6///                    In learning: mastery. In travel: adventurousness.
7///                    In ecommerce: budget willingness. Caller decides semantics.
8///
9/// `optimism_bias`  — how far above current skill the engine targets [0.05, 0.35]
10///                    Always has a hard floor (OPTIMISM_FLOOR) so engine always
11///                    targets growth. Never configurable to 0.
12///
13/// `last_seen`      — item_id → unix timestamp (seconds). Drives spacing factor.
14///
15/// `category_count` — category → interaction count. Drives coverage factor.
16///
17/// `resolved_set`   — set of item IDs user has successfully completed.
18///                    Used for prerequisite gating.
19///
20/// `interaction_count` — total interactions. Useful for exploration decay.
21///
22/// `extended`       — arbitrary caller key-value store for custom factor/updater data.
23
24pub const OPTIMISM_FLOOR: f32 = 0.05;
25pub const OPTIMISM_CEIL: f32 = 0.35;
26pub const DEFAULT_SKILL: f32 = 0.0;
27pub const DEFAULT_OPTIMISM: f32 = 0.1;
28
29#[derive(Debug, Clone)]
30pub struct ProfileState {
31    pub skill: f32,
32    pub optimism_bias: f32,
33    pub last_seen: HashMap<String, u64>,
34    pub category_count: HashMap<String, u32>,
35    pub resolved_set: HashSet<String>,
36    pub interaction_count: u64,
37    /// Caller-defined arbitrary state. Custom factors/updaters read/write here.
38    pub extended: HashMap<String, f32>,
39    /// String-valued extended state for non-numeric caller data.
40    pub extended_str: HashMap<String, String>,
41}
42
43impl ProfileState {
44    pub fn new() -> Self {
45        Self {
46            skill: DEFAULT_SKILL,
47            optimism_bias: DEFAULT_OPTIMISM,
48            last_seen: HashMap::new(),
49            category_count: HashMap::new(),
50            resolved_set: HashSet::new(),
51            interaction_count: 0,
52            extended: HashMap::new(),
53            extended_str: HashMap::new(),
54        }
55    }
56
57    /// Effective target = skill + optimism, clamped to [0, 1].
58    pub fn target(&self) -> f32 {
59        (self.skill + self.optimism_bias).clamp(0.0, 1.0)
60    }
61
62    /// Mean interactions per category. Used by coverage factor.
63    pub fn mean_category_count(&self) -> f32 {
64        if self.category_count.is_empty() {
65            return 1.0;
66        }
67        let total: u32 = self.category_count.values().sum();
68        total as f32 / self.category_count.len() as f32
69    }
70
71    /// Seconds elapsed since item was last seen. Returns None if never seen.
72    pub fn elapsed_since(&self, item_id: &str, now: u64) -> Option<u64> {
73        self.last_seen.get(item_id).map(|&t| now.saturating_sub(t))
74    }
75}
76
77impl Default for ProfileState {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn target_clamps_to_one() {
89        let mut s = ProfileState::new();
90        s.skill = 0.9;
91        s.optimism_bias = 0.35;
92        assert!(s.target() <= 1.0);
93    }
94
95    #[test]
96    fn mean_category_count_empty() {
97        let s = ProfileState::new();
98        assert_eq!(s.mean_category_count(), 1.0);
99    }
100
101    #[test]
102    fn mean_category_count_correct() {
103        let mut s = ProfileState::new();
104        s.category_count.insert("a".into(), 4);
105        s.category_count.insert("b".into(), 2);
106        assert!((s.mean_category_count() - 3.0).abs() < 1e-6);
107    }
108
109    #[test]
110    fn elapsed_since_never_seen() {
111        let s = ProfileState::new();
112        assert_eq!(s.elapsed_since("x", 100), None);
113    }
114}