use crate::item::Scoreable;
use crate::state::ProfileState;
pub trait Factor: Send + Sync {
fn name(&self) -> &str;
fn score(&self, item: &dyn Scoreable, state: &ProfileState, now: u64) -> f32;
}
pub struct ChallengeFactor {
pub bandwidth: f32,
}
impl ChallengeFactor {
pub fn new(bandwidth: f32) -> Self {
Self { bandwidth }
}
}
impl Default for ChallengeFactor {
fn default() -> Self {
Self { bandwidth: 0.2 }
}
}
impl Factor for ChallengeFactor {
fn name(&self) -> &str {
"challenge"
}
fn score(&self, item: &dyn Scoreable, state: &ProfileState, _now: u64) -> f32 {
let target = state.target();
let diff = item.score_proxy() - target;
(-diff * diff / (2.0 * self.bandwidth * self.bandwidth)).exp()
}
}
pub struct SpacingFactor {
pub optimal_interval_secs: u64,
}
impl SpacingFactor {
pub fn new(optimal_interval_secs: u64) -> Self {
Self {
optimal_interval_secs,
}
}
}
impl Default for SpacingFactor {
fn default() -> Self {
Self {
optimal_interval_secs: 86_400,
}
}
}
impl Factor for SpacingFactor {
fn name(&self) -> &str {
"spacing"
}
fn score(&self, item: &dyn Scoreable, state: &ProfileState, now: u64) -> f32 {
match state.elapsed_since(item.id(), now) {
None => 1.0, Some(elapsed) => {
let ratio = elapsed as f32 / self.optimal_interval_secs as f32;
1.0 - (-ratio).exp()
}
}
}
}
pub struct CoverageFactor;
impl Default for CoverageFactor {
fn default() -> Self {
Self
}
}
impl Factor for CoverageFactor {
fn name(&self) -> &str {
"coverage"
}
fn score(&self, item: &dyn Scoreable, state: &ProfileState, _now: u64) -> f32 {
let count = *state.category_count.get(item.category()).unwrap_or(&0) as f32;
let mean = state.mean_category_count();
1.0 / (1.0 + count / mean)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item::Item;
use crate::state::ProfileState;
fn make_state(skill: f32, optimism: f32) -> ProfileState {
let mut s = ProfileState::new();
s.skill = skill;
s.optimism_bias = optimism;
s
}
#[test]
fn challenge_peaks_at_target() {
let factor = ChallengeFactor::default();
let state = make_state(0.5, 0.1); let item = Item::new("x", 0.6, "cat");
let score = factor.score(&item, &state, 0);
assert!((score - 1.0).abs() < 1e-5);
}
#[test]
fn challenge_decays_away_from_target() {
let factor = ChallengeFactor::default();
let state = make_state(0.5, 0.1);
let near = Item::new("near", 0.6, "cat");
let far = Item::new("far", 0.0, "cat");
assert!(factor.score(&near, &state, 0) > factor.score(&far, &state, 0));
}
#[test]
fn spacing_never_seen_is_one() {
let factor = SpacingFactor::default();
let state = ProfileState::new();
let item = Item::new("x", 0.5, "cat");
assert!((factor.score(&item, &state, 1000) - 1.0).abs() < 1e-5);
}
#[test]
fn spacing_just_seen_is_near_zero() {
let factor = SpacingFactor::default();
let mut state = ProfileState::new();
state.last_seen.insert("x".into(), 1000);
let item = Item::new("x", 0.5, "cat");
let score = factor.score(&item, &state, 1001); assert!(score < 0.01);
}
#[test]
fn coverage_favours_unseen_category() {
let factor = CoverageFactor;
let mut state = ProfileState::new();
state.category_count.insert("math".into(), 10);
let math_item = Item::new("m", 0.5, "math");
let sci_item = Item::new("s", 0.5, "science");
assert!(
factor.score(&sci_item, &state, 0) > factor.score(&math_item, &state, 0)
);
}
}