use crate::fitts::FittsModel;
use crate::integration::{CardState, FittsScheduler, Rating, ReviewInput, ReviewResult};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Clone, Error)]
pub enum SchedulerError {
#[error("Deck '{0}' not found")]
DeckNotFound(String),
#[error("Card '{card_id}' not found in deck '{deck_id}'")]
CardNotFound { card_id: String, deck_id: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DifficultyLevel {
Easy,
Medium,
Hard,
VeryHard,
}
impl DifficultyLevel {
pub fn from_response_time(rt: f64) -> Self {
match rt {
t if t < 4.0 => Self::Easy,
t if t < 10.0 => Self::Medium,
t if t < 20.0 => Self::Hard,
_ => Self::VeryHard,
}
}
pub fn expected_rating(&self) -> Rating {
match self {
Self::Easy => Rating::Easy,
Self::Medium => Rating::Good,
Self::Hard => Rating::Hard,
Self::VeryHard => Rating::Again,
}
}
pub fn description(&self) -> &'static str {
match self {
Self::Easy => "Quick recall",
Self::Medium => "Normal effort",
Self::Hard => "Significant effort",
Self::VeryHard => "Struggling",
}
}
pub fn all() -> [Self; 4] {
[Self::Easy, Self::Medium, Self::Hard, Self::VeryHard]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduledCard {
pub card_id: String,
pub state: CardState,
pub predicted_rt: f64,
pub retrievability: f64,
pub difficulty: DifficultyLevel,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchedulerConfig {
pub max_cards_per_session: usize,
pub new_cards_per_day: usize,
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
max_cards_per_session: 20,
new_cards_per_day: 10,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Deck {
pub cards: HashMap<String, CardState>,
}
#[derive(Debug, Clone)]
pub struct Scheduler {
pub config: SchedulerConfig,
pub fitts: FittsScheduler,
pub decks: HashMap<String, Deck>,
}
impl Default for Scheduler {
fn default() -> Self {
Self::new(SchedulerConfig::default())
}
}
impl Scheduler {
pub fn new(config: SchedulerConfig) -> Self {
Self {
config,
fitts: FittsScheduler::new(),
decks: HashMap::new(),
}
}
pub fn with_fitts(config: SchedulerConfig, fitts: FittsModel) -> Self {
Self {
config,
fitts: FittsScheduler::with_fitts(fitts),
decks: HashMap::new(),
}
}
pub fn add_card(&mut self, deck_id: &str, card_id: &str, state: CardState) {
self.decks
.entry(deck_id.to_string())
.or_default()
.cards
.insert(card_id.to_string(), state);
}
pub fn get_due_cards(&self, deck_id: &str) -> Result<Vec<ScheduledCard>, SchedulerError> {
let deck = self
.decks
.get(deck_id)
.ok_or_else(|| SchedulerError::DeckNotFound(deck_id.to_string()))?;
let mut due: Vec<_> = deck
.cards
.iter()
.filter(|(_, state)| state.is_due() || state.repetitions == 0)
.map(|(id, state)| {
let (rt, retr) = self.fitts.predict(state);
ScheduledCard {
card_id: id.clone(),
state: state.clone(),
predicted_rt: rt,
retrievability: retr,
difficulty: DifficultyLevel::from_response_time(rt),
}
})
.collect();
due.sort_by(|a, b| {
b.predicted_rt
.partial_cmp(&a.predicted_rt)
.unwrap_or(std::cmp::Ordering::Equal)
});
due.truncate(self.config.max_cards_per_session);
Ok(due)
}
pub fn review(
&mut self,
deck_id: &str,
card_id: &str,
input: impl Into<ReviewInput>,
) -> Result<ReviewResult, SchedulerError> {
let deck = self
.decks
.get_mut(deck_id)
.ok_or_else(|| SchedulerError::DeckNotFound(deck_id.to_string()))?;
let state = deck
.cards
.get(card_id)
.ok_or_else(|| SchedulerError::CardNotFound {
card_id: card_id.to_string(),
deck_id: deck_id.to_string(),
})?;
let result = self.fitts.review(state.clone(), input);
deck.cards.insert(card_id.to_string(), result.card.clone());
Ok(result)
}
pub fn deck_stats(&self, deck_id: &str) -> Result<DeckStats, SchedulerError> {
let deck = self
.decks
.get(deck_id)
.ok_or_else(|| SchedulerError::DeckNotFound(deck_id.to_string()))?;
let total = deck.cards.len();
let due = deck.cards.values().filter(|c| c.is_due()).count();
let new = deck.cards.values().filter(|c| c.repetitions == 0).count();
let learning = deck
.cards
.values()
.filter(|c| c.repetitions > 0 && c.interval_days < 21.0)
.count();
let mature = deck
.cards
.values()
.filter(|c| c.interval_days >= 21.0)
.count();
Ok(DeckStats {
total,
due,
new,
learning,
mature,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeckStats {
pub total: usize,
pub due: usize,
pub new: usize,
pub learning: usize,
pub mature: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rating_and_difficulty_have_same_count() {
let ratings = [Rating::Again, Rating::Hard, Rating::Good, Rating::Easy];
let difficulties = DifficultyLevel::all();
assert_eq!(
ratings.len(),
difficulties.len(),
"Rating and DifficultyLevel must have same number of values"
);
}
#[test]
fn test_difficulty_to_expected_rating_mapping() {
assert_eq!(DifficultyLevel::Easy.expected_rating(), Rating::Easy);
assert_eq!(DifficultyLevel::Medium.expected_rating(), Rating::Good);
assert_eq!(DifficultyLevel::Hard.expected_rating(), Rating::Hard);
assert_eq!(DifficultyLevel::VeryHard.expected_rating(), Rating::Again);
}
#[test]
fn test_difficulty_levels_are_ordered() {
assert_eq!(
DifficultyLevel::from_response_time(3.9),
DifficultyLevel::Easy
);
assert_eq!(
DifficultyLevel::from_response_time(4.0),
DifficultyLevel::Medium
);
assert_eq!(
DifficultyLevel::from_response_time(9.9),
DifficultyLevel::Medium
);
assert_eq!(
DifficultyLevel::from_response_time(10.0),
DifficultyLevel::Hard
);
assert_eq!(
DifficultyLevel::from_response_time(19.9),
DifficultyLevel::Hard
);
assert_eq!(
DifficultyLevel::from_response_time(20.0),
DifficultyLevel::VeryHard
);
}
#[test]
fn test_all_ratings_are_success_or_failure() {
let ratings = [Rating::Again, Rating::Hard, Rating::Good, Rating::Easy];
let failures: Vec<_> = ratings.iter().filter(|r| !r.is_success()).collect();
let successes: Vec<_> = ratings.iter().filter(|r| r.is_success()).collect();
assert_eq!(
failures.len(),
1,
"Exactly one rating should be failure (Again)"
);
assert_eq!(
successes.len(),
3,
"Three ratings should be success (Hard, Good, Easy)"
);
assert_eq!(failures[0], &Rating::Again);
}
#[test]
fn test_difficulty_level_classification() {
assert_eq!(
DifficultyLevel::from_response_time(1.0),
DifficultyLevel::Easy
);
assert_eq!(
DifficultyLevel::from_response_time(3.0),
DifficultyLevel::Easy
);
assert_eq!(
DifficultyLevel::from_response_time(5.0),
DifficultyLevel::Medium
);
assert_eq!(
DifficultyLevel::from_response_time(15.0),
DifficultyLevel::Hard
);
assert_eq!(
DifficultyLevel::from_response_time(25.0),
DifficultyLevel::VeryHard
);
}
#[test]
fn test_difficulty_descriptions() {
assert_eq!(DifficultyLevel::Easy.description(), "Quick recall");
assert_eq!(DifficultyLevel::Medium.description(), "Normal effort");
assert_eq!(DifficultyLevel::Hard.description(), "Significant effort");
assert_eq!(DifficultyLevel::VeryHard.description(), "Struggling");
}
#[test]
fn test_add_and_get_due() {
let mut scheduler = Scheduler::default();
scheduler.add_card("deck1", "card1", CardState::default());
scheduler.add_card("deck1", "card2", CardState::default());
let due = scheduler.get_due_cards("deck1").unwrap();
assert_eq!(due.len(), 2); }
#[test]
fn test_review() {
let mut scheduler = Scheduler::default();
scheduler.add_card("deck1", "card1", CardState::default());
let result = scheduler.review("deck1", "card1", Rating::Good).unwrap();
assert_eq!(result.card.repetitions, 1);
assert_eq!(result.card.interval_days, 1.0);
let stats = scheduler.deck_stats("deck1").unwrap();
assert_eq!(stats.new, 0);
assert_eq!(stats.learning, 1);
}
#[test]
fn test_deck_not_found() {
let scheduler = Scheduler::default();
let result = scheduler.get_due_cards("nonexistent");
assert!(matches!(result, Err(SchedulerError::DeckNotFound(_))));
}
#[test]
fn test_card_not_found() {
let mut scheduler = Scheduler::default();
scheduler.add_card("deck1", "card1", CardState::default());
let result = scheduler.review("deck1", "card2", Rating::Good);
assert!(matches!(result, Err(SchedulerError::CardNotFound { .. })));
}
#[test]
fn test_difficulty_ordering() {
let mut scheduler = Scheduler::default();
scheduler.add_card(
"deck1",
"easy",
CardState {
interval_days: 1.0,
ease_factor: 2.5,
..Default::default()
},
);
scheduler.add_card(
"deck1",
"hard",
CardState {
interval_days: 30.0,
ease_factor: 1.3,
..Default::default()
},
);
let due = scheduler.get_due_cards("deck1").unwrap();
assert!(
due[0].predicted_rt >= due[1].predicted_rt,
"Hardest should be first"
);
}
#[test]
fn test_deck_stats() {
let mut scheduler = Scheduler::default();
scheduler.add_card("deck1", "new1", CardState::default());
scheduler.add_card("deck1", "new2", CardState::default());
scheduler.add_card(
"deck1",
"learning",
CardState {
repetitions: 3,
interval_days: 7.0,
..Default::default()
},
);
scheduler.add_card(
"deck1",
"mature",
CardState {
repetitions: 10,
interval_days: 30.0,
..Default::default()
},
);
let stats = scheduler.deck_stats("deck1").unwrap();
assert_eq!(stats.total, 4);
assert_eq!(stats.new, 2);
assert_eq!(stats.learning, 1);
assert_eq!(stats.mature, 1);
}
}