Skip to main content

aria_core/
factor.rs

1use crate::item::Scoreable;
2use crate::state::ProfileState;
3
4/// Core trait every scoring factor must implement.
5///
6/// Callers define domain logic entirely through factors.
7/// Built-in factors (Challenge, Spacing, Coverage) are provided as
8/// reference implementations — callers may use, replace, or extend them.
9///
10/// A factor returns a score in [0.0, 1.0].
11/// Engine multiplies all factor scores together — multiplicative pipeline.
12/// A near-zero score from any single factor suppresses the item entirely.
13pub trait Factor: Send + Sync {
14    fn name(&self) -> &str;
15    fn score(&self, item: &dyn Scoreable, state: &ProfileState, now: u64) -> f32;
16}
17
18// ---------------------------------------------------------------------------
19// Built-in reference factors
20// ---------------------------------------------------------------------------
21
22/// ChallengeFactor — Gaussian centred at user's target (skill + optimism).
23///
24/// Items at exactly target difficulty score 1.0.
25/// Items far from target score near 0.0.
26/// `bandwidth` controls tolerance: wider = more variety, narrower = strict gating.
27///
28/// Domain mapping:
29///   learning   → item difficulty vs user mastery
30///   ecommerce  → price_ratio vs budget willingness  
31///   travel     → remoteness vs adventurousness
32///   content    → reading level vs literacy
33pub struct ChallengeFactor {
34    pub bandwidth: f32,
35}
36
37impl ChallengeFactor {
38    pub fn new(bandwidth: f32) -> Self {
39        Self { bandwidth }
40    }
41}
42
43impl Default for ChallengeFactor {
44    fn default() -> Self {
45        Self { bandwidth: 0.2 }
46    }
47}
48
49impl Factor for ChallengeFactor {
50    fn name(&self) -> &str {
51        "challenge"
52    }
53
54    fn score(&self, item: &dyn Scoreable, state: &ProfileState, _now: u64) -> f32 {
55        let target = state.target();
56        let diff = item.score_proxy() - target;
57        (-diff * diff / (2.0 * self.bandwidth * self.bandwidth)).exp()
58    }
59}
60
61/// SpacingFactor — forgetting curve on time since item was last seen.
62///
63/// Items never seen score 1.0 (full spacing benefit).
64/// Items seen very recently score near 0.0.
65/// Items seen a long time ago approach 1.0 again.
66///
67/// `optimal_interval_secs` — time after which item is considered due again.
68/// Default: 86400 (24 hours). Callers tune per domain:
69///   learning  → hours/days (spaced repetition)
70///   ecommerce → days/weeks (avoid re-suggesting just-bought items)
71///   travel    → months/years (re-suggest destinations)
72pub struct SpacingFactor {
73    pub optimal_interval_secs: u64,
74}
75
76impl SpacingFactor {
77    pub fn new(optimal_interval_secs: u64) -> Self {
78        Self {
79            optimal_interval_secs,
80        }
81    }
82}
83
84impl Default for SpacingFactor {
85    fn default() -> Self {
86        Self {
87            optimal_interval_secs: 86_400,
88        }
89    }
90}
91
92impl Factor for SpacingFactor {
93    fn name(&self) -> &str {
94        "spacing"
95    }
96
97    fn score(&self, item: &dyn Scoreable, state: &ProfileState, now: u64) -> f32 {
98        match state.elapsed_since(item.id(), now) {
99            None => 1.0, // never seen → highest spacing score
100            Some(elapsed) => {
101                let ratio = elapsed as f32 / self.optimal_interval_secs as f32;
102                1.0 - (-ratio).exp()
103            }
104        }
105    }
106}
107
108/// CoverageFactor — favours categories the user has interacted with less.
109///
110/// Prevents the engine from over-drilling one category.
111/// Items in under-represented categories score higher.
112///
113/// Domain mapping:
114///   learning   → topic spread (algebra, geometry, …)
115///   ecommerce  → category spread (electronics, clothing, …)
116///   travel     → region spread (Asia, Europe, …)
117///   content    → genre spread (thriller, romance, …)
118pub struct CoverageFactor;
119
120impl Default for CoverageFactor {
121    fn default() -> Self {
122        Self
123    }
124}
125
126impl Factor for CoverageFactor {
127    fn name(&self) -> &str {
128        "coverage"
129    }
130
131    fn score(&self, item: &dyn Scoreable, state: &ProfileState, _now: u64) -> f32 {
132        let count = *state.category_count.get(item.category()).unwrap_or(&0) as f32;
133        let mean = state.mean_category_count();
134        1.0 / (1.0 + count / mean)
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::item::Item;
142    use crate::state::ProfileState;
143
144    fn make_state(skill: f32, optimism: f32) -> ProfileState {
145        let mut s = ProfileState::new();
146        s.skill = skill;
147        s.optimism_bias = optimism;
148        s
149    }
150
151    #[test]
152    fn challenge_peaks_at_target() {
153        let factor = ChallengeFactor::default();
154        let state = make_state(0.5, 0.1); // target = 0.6
155        let item = Item::new("x", 0.6, "cat");
156        let score = factor.score(&item, &state, 0);
157        assert!((score - 1.0).abs() < 1e-5);
158    }
159
160    #[test]
161    fn challenge_decays_away_from_target() {
162        let factor = ChallengeFactor::default();
163        let state = make_state(0.5, 0.1);
164        let near = Item::new("near", 0.6, "cat");
165        let far = Item::new("far", 0.0, "cat");
166        assert!(factor.score(&near, &state, 0) > factor.score(&far, &state, 0));
167    }
168
169    #[test]
170    fn spacing_never_seen_is_one() {
171        let factor = SpacingFactor::default();
172        let state = ProfileState::new();
173        let item = Item::new("x", 0.5, "cat");
174        assert!((factor.score(&item, &state, 1000) - 1.0).abs() < 1e-5);
175    }
176
177    #[test]
178    fn spacing_just_seen_is_near_zero() {
179        let factor = SpacingFactor::default();
180        let mut state = ProfileState::new();
181        state.last_seen.insert("x".into(), 1000);
182        let item = Item::new("x", 0.5, "cat");
183        let score = factor.score(&item, &state, 1001); // 1 second elapsed
184        assert!(score < 0.01);
185    }
186
187    #[test]
188    fn coverage_favours_unseen_category() {
189        let factor = CoverageFactor;
190        let mut state = ProfileState::new();
191        state.category_count.insert("math".into(), 10);
192        // "science" not in map
193        let math_item = Item::new("m", 0.5, "math");
194        let sci_item = Item::new("s", 0.5, "science");
195        assert!(
196            factor.score(&sci_item, &state, 0) > factor.score(&math_item, &state, 0)
197        );
198    }
199}