1use crate::item::Scoreable;
2use crate::state::ProfileState;
3
4pub trait Factor: Send + Sync {
14 fn name(&self) -> &str;
15 fn score(&self, item: &dyn Scoreable, state: &ProfileState, now: u64) -> f32;
16}
17
18pub 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
61pub 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, Some(elapsed) => {
101 let ratio = elapsed as f32 / self.optimal_interval_secs as f32;
102 1.0 - (-ratio).exp()
103 }
104 }
105 }
106}
107
108pub 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); 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); 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 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}