use std::collections::VecDeque;
use crate::{
card::Card,
scheduler::{Grade, Scheduler, SchedulerKind},
store::{MAX_STAGE, Store},
time,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum Order {
#[default]
Scheduled,
Sequential,
}
#[derive(Clone, Copy, Debug)]
pub struct SessionOptions {
pub max_new: usize,
pub limit: Option<usize>,
pub cram: bool,
pub order: Order,
}
impl Default for SessionOptions {
fn default() -> Self {
Self {
max_new: 10,
limit: None,
cram: false,
order: Order::Scheduled,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SessionStats {
pub reviews: usize,
pub passed: usize,
pub failed: usize,
}
pub type StageHistogram = [usize; 6];
pub struct Session {
cards: Vec<Card>,
queue: VecDeque<usize>,
scheduler: Box<dyn Scheduler>,
kind: SchedulerKind,
options: SessionOptions,
dep_ranks: Vec<usize>,
pub initial_size: usize,
pub stats: SessionStats,
}
impl Session {
pub fn new(
cards: Vec<Card>,
store: &Store,
kind: SchedulerKind,
options: SessionOptions,
now_ms: u64,
) -> Self {
Self::new_with_deps(cards, store, kind, options, Vec::new(), now_ms)
}
pub fn new_with_deps(
cards: Vec<Card>,
store: &Store,
kind: SchedulerKind,
options: SessionOptions,
dep_ranks: Vec<usize>,
now_ms: u64,
) -> Self {
let scheduler = kind.scheduler();
let queue = build_queue(
&cards,
store,
&*scheduler,
kind,
options,
&dep_ranks,
now_ms,
);
let initial_size = queue.len();
Self {
cards,
queue,
scheduler,
kind,
options,
dep_ranks,
initial_size,
stats: SessionStats::default(),
}
}
pub fn restart(&mut self, store: &Store, now_ms: u64) -> bool {
let queue = build_queue(
&self.cards,
store,
&*self.scheduler,
self.kind,
self.options,
&self.dep_ranks,
now_ms,
);
if queue.is_empty() {
return false;
}
self.initial_size = queue.len();
self.queue = queue;
self.stats = SessionStats::default();
true
}
pub fn has_due_now(&self, store: &Store, now_ms: u64) -> bool {
!build_queue(
&self.cards,
store,
&*self.scheduler,
self.kind,
self.options,
&self.dep_ranks,
now_ms,
)
.is_empty()
}
pub fn next_due_at(&self, store: &Store) -> Option<u64> {
self.cards
.iter()
.filter_map(|card| store.get(card.id()))
.map(|state| self.scheduler.due_at(state))
.min()
}
pub fn current(&self) -> Option<&Card> {
self.queue.front().map(|&i| &self.cards[i])
}
pub fn current_mut(&mut self) -> Option<&mut Card> {
let i = *self.queue.front()?;
Some(&mut self.cards[i])
}
pub fn cards(&self) -> &[Card] {
&self.cards
}
pub fn remaining(&self) -> usize {
self.queue.len()
}
pub fn is_finished(&self) -> bool {
self.queue.is_empty()
}
pub fn grade(&mut self, store: &mut Store, grade: Grade, now_ms: u64) {
let Some(index) = self.queue.pop_front() else {
return;
};
let card = &self.cards[index];
let state = store.get_or_insert(card.id(), now_ms);
self.scheduler.apply(state, grade, now_ms);
if state.stage > MAX_STAGE {
state.stage = MAX_STAGE;
}
self.stats.reviews += 1;
if grade.passed() {
self.stats.passed += 1;
} else {
self.stats.failed += 1;
self.queue.push_back(index);
}
}
pub fn skip(&mut self) {
if let Some(index) = self.queue.pop_front() {
self.queue.push_back(index);
}
}
pub fn remove_current(&mut self) -> Vec<Card> {
let Some(index) = self.queue.pop_front() else {
return Vec::new();
};
let group = sibling_group(&self.cards[index]);
let mut removed = vec![self.cards[index].clone()];
let mut kept = VecDeque::with_capacity(self.queue.len());
for &i in &self.queue {
if sibling_group(&self.cards[i]) == group {
removed.push(self.cards[i].clone());
} else {
kept.push_back(i);
}
}
self.queue = kept;
removed
}
pub fn stage_histogram(&self, store: &Store) -> StageHistogram {
histogram(&self.cards, store)
}
pub fn top_stage(&self) -> u8 {
MAX_STAGE
}
}
fn build_queue(
cards: &[Card],
store: &Store,
scheduler: &dyn Scheduler,
kind: SchedulerKind,
options: SessionOptions,
dep_ranks: &[usize],
now_ms: u64,
) -> VecDeque<usize> {
let mut due: Vec<usize> = Vec::new();
let mut fresh: Vec<usize> = Vec::new();
for (i, card) in cards.iter().enumerate() {
match store.get(card.id()) {
Some(_) if is_retired(card, store) => {}
Some(state) => {
if options.cram || scheduler.is_due(state, now_ms) {
due.push(i);
}
}
None => fresh.push(i),
}
}
match kind {
SchedulerKind::Leitner => {
due.sort_by_key(|&i| {
let state = store.get(cards[i].id()).unwrap();
(std::cmp::Reverse(state.stage), state.stage_entered_ms)
});
}
SchedulerKind::Sm2 => {
due.sort_by_key(|&i| {
let state = store.get(cards[i].id()).unwrap();
scheduler.due_at(state)
});
}
}
let mut order: Vec<usize> = due;
order.extend(fresh.into_iter().take(options.max_new));
if options.order == Order::Sequential {
order.sort_unstable();
}
if !dep_ranks.is_empty() {
order.sort_by_key(|&i| dep_ranks[i]);
}
if let Some(limit) = options.limit {
order.truncate(limit);
}
separate_siblings(order, cards)
}
fn sibling_group(card: &Card) -> (&str, usize) {
(card.subject.as_ref(), card.line)
}
fn separate_siblings(order: Vec<usize>, cards: &[Card]) -> VecDeque<usize> {
let mut remaining: VecDeque<usize> = order.into();
let mut queue = VecDeque::with_capacity(remaining.len());
let mut last: Option<usize> = None;
while !remaining.is_empty() {
let pos = remaining
.iter()
.position(|&i| {
last.is_none_or(|l| sibling_group(&cards[i]) != sibling_group(&cards[l]))
})
.unwrap_or(0);
let index = remaining.remove(pos).unwrap();
last = Some(index);
queue.push_back(index);
}
queue
}
pub fn is_retired(card: &Card, store: &Store) -> bool {
store
.get(card.id())
.is_some_and(|s| s.stage >= MAX_STAGE && s.streak >= 1)
}
pub fn has_reviewable(
cards: &[Card],
store: &Store,
scheduler: &dyn Scheduler,
now_ms: u64,
) -> bool {
cards.iter().any(|card| match store.get(card.id()) {
Some(_) if is_retired(card, store) => false,
Some(state) => scheduler.is_due(state, now_ms),
None => true,
})
}
pub fn histogram(cards: &[Card], store: &Store) -> StageHistogram {
let mut h = [0usize; 6];
for card in cards {
match store.get(card.id()) {
Some(state) => h[state.stage.clamp(1, 5) as usize] += 1,
None => h[0] += 1,
}
}
h
}
pub fn now_ms() -> u64 {
time::now_ms()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::store::Store;
fn card(subject: &str, n: usize) -> Card {
Card::plain(
Arc::from(subject),
format!("front {n}"),
vec![format!("back {n}")],
None,
n,
)
}
fn cards(n: usize) -> Vec<Card> {
(0..n).map(|i| card("deck.txt", i)).collect()
}
fn empty_store() -> (Store, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path().join("p.json")).unwrap();
(store, dir)
}
#[test]
fn new_cards_enter_up_to_max_new() {
let (store, _dir) = empty_store();
let session = Session::new(
cards(20),
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 5,
..Default::default()
},
1000,
);
assert_eq!(5, session.initial_size);
}
#[test]
fn due_cards_take_priority_over_new_under_limit() {
let (mut store, _dir) = empty_store();
let all = cards(10);
for c in &all[7..] {
store.get_or_insert(c.id(), 0);
}
let session = Session::new(
all.clone(),
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 10,
limit: Some(3),
cram: false,
order: Order::Scheduled,
},
1000,
);
assert_eq!(3, session.initial_size);
assert_eq!("front 7", session.current().unwrap().front);
}
#[test]
fn leitner_orders_higher_stages_first() {
let (mut store, _dir) = empty_store();
let all = cards(3);
store.get_or_insert(all[0].id(), 0).stage = 2;
store.get_or_insert(all[1].id(), 0).stage = 5;
let now = 2 * 604_800_000; let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
now,
);
assert_eq!("front 1", session.current().unwrap().front); session.grade(&mut store, Grade::Pass, now);
assert_eq!("front 0", session.current().unwrap().front); session.grade(&mut store, Grade::Pass, now);
assert_eq!("front 2", session.current().unwrap().front); }
#[test]
fn sequential_order_follows_deck_order() {
let (mut store, _dir) = empty_store();
let all = cards(3);
store.get_or_insert(all[0].id(), 0).stage = 2;
store.get_or_insert(all[1].id(), 0).stage = 5;
let now = 2 * 604_800_000;
let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions {
order: Order::Sequential,
..Default::default()
},
now,
);
assert_eq!("front 0", session.current().unwrap().front);
session.grade(&mut store, Grade::Pass, now);
assert_eq!("front 1", session.current().unwrap().front);
session.grade(&mut store, Grade::Pass, now);
assert_eq!("front 2", session.current().unwrap().front);
}
#[test]
fn dependency_rank_orders_prerequisites_first() {
let (mut store, _dir) = empty_store();
let all = vec![
card("adv.txt", 0),
card("adv.txt", 1),
card("basics.txt", 2),
card("basics.txt", 3),
];
let ranks = vec![1, 1, 0, 0];
let mut session = Session::new_with_deps(
all,
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 10,
..Default::default()
},
ranks,
1000,
);
assert_eq!("basics.txt", &*session.current().unwrap().subject);
session.grade(&mut store, Grade::Pass, 1000);
assert_eq!("basics.txt", &*session.current().unwrap().subject);
session.grade(&mut store, Grade::Pass, 1000);
assert_eq!("adv.txt", &*session.current().unwrap().subject);
}
#[test]
fn cards_on_cooldown_are_not_due() {
let (mut store, _dir) = empty_store();
let all = cards(1);
let now = 5_000_000;
store.get_or_insert(all[0].id(), now).stage = 2;
let session = Session::new(
all.clone(),
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 0,
..Default::default()
},
now + 1,
);
assert!(session.is_finished());
let session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 0,
limit: None,
cram: true,
order: Order::Scheduled,
},
now + 1,
);
assert_eq!(1, session.initial_size);
}
#[test]
fn failed_card_is_requeued_until_passed() {
let (mut store, _dir) = empty_store();
let mut session = Session::new(
cards(2),
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
assert_eq!(2, session.remaining());
let first = session.current().unwrap().front.clone();
session.grade(&mut store, Grade::Fail, 1000);
assert_eq!(2, session.remaining());
session.grade(&mut store, Grade::Pass, 1001);
assert_eq!(1, session.remaining());
assert_eq!(first, session.current().unwrap().front);
session.grade(&mut store, Grade::Pass, 1002);
assert!(session.is_finished());
assert_eq!(3, session.stats.reviews);
assert_eq!(2, session.stats.passed);
assert_eq!(1, session.stats.failed);
}
#[test]
fn grading_updates_store_stages() {
let (mut store, _dir) = empty_store();
let all = cards(1);
let id = all[0].id();
let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
session.grade(&mut store, Grade::Pass, 1000);
assert_eq!(2, store.get(id).unwrap().stage);
}
#[test]
fn skip_rotates_queue() {
let (mut store, _dir) = empty_store();
let mut session = Session::new(
cards(2),
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
let first = session.current().unwrap().front.clone();
session.skip();
assert_ne!(first, session.current().unwrap().front);
assert_eq!(2, session.remaining());
session.skip();
assert_eq!(first, session.current().unwrap().front);
assert!(store.is_empty());
let _ = &mut store;
}
#[test]
fn remove_current_drops_card_without_grading() {
let (mut store, _dir) = empty_store();
let mut session = Session::new(
cards(2),
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
let removed = session.remove_current();
assert_eq!(1, removed.len());
assert_eq!(1, session.remaining());
assert_ne!(removed[0].front, session.current().unwrap().front);
assert!(store.is_empty());
let _ = &mut store;
}
#[test]
fn remove_current_also_drops_cloze_siblings() {
let (store, _dir) = empty_store();
let mut all = vec![
card("deck.txt", 1),
card("deck.txt", 1),
card("deck.txt", 2),
];
all[0].back = vec!["hole a".into()];
all[1].back = vec!["hole b".into()];
let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
0,
);
assert_eq!(3, session.remaining());
let removed = session.remove_current();
assert_eq!(2, removed.len());
assert_eq!(1, session.remaining());
assert_eq!(2, session.current().unwrap().line);
}
#[test]
fn cloze_siblings_are_separated() {
let (store, _dir) = empty_store();
let mut all = Vec::new();
for (line, name) in [(1, "A"), (2, "B")] {
for hole in 1..=2 {
let mut c = card("deck.txt", line);
c.front = format!("{name}{hole}");
c.back = vec![format!("{name} answer {hole}")];
all.push(c);
}
}
let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
0,
);
let mut fronts = Vec::new();
for _ in 0..session.remaining() {
fronts.push(session.current().unwrap().front.clone());
session.skip();
}
assert_eq!(4, fronts.len());
for pair in fronts.windows(2) {
assert_ne!(
pair[0].chars().next(),
pair[1].chars().next(),
"siblings adjacent in queue: {fronts:?}"
);
}
}
#[test]
fn lone_sibling_group_still_fully_queued() {
let (store, _dir) = empty_store();
let mut all = Vec::new();
for hole in 1..=3 {
let mut c = card("deck.txt", 1);
c.back = vec![format!("answer {hole}")];
all.push(c);
}
let session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
0,
);
assert_eq!(3, session.initial_size);
}
#[test]
fn restart_picks_up_newly_due_and_new_cards() {
let (mut store, _dir) = empty_store();
let mut session = Session::new(
cards(4),
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 2,
..Default::default()
},
1000,
);
assert_eq!(2, session.initial_size);
session.grade(&mut store, Grade::Pass, 1000);
session.grade(&mut store, Grade::Pass, 1001);
assert!(session.is_finished());
assert_eq!(2, session.stats.reviews);
assert!(session.restart(&store, 1002));
assert_eq!(2, session.initial_size);
assert_eq!(0, session.stats.reviews);
assert!(!session.is_finished());
}
#[test]
fn restart_with_nothing_due_returns_false_and_keeps_stats() {
let (mut store, _dir) = empty_store();
let mut session = Session::new(
cards(1),
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
session.grade(&mut store, Grade::Pass, 1000);
assert!(session.is_finished());
assert!(!session.restart(&store, 1001));
assert!(session.is_finished());
assert_eq!(1, session.stats.reviews);
}
#[test]
fn has_due_now_tracks_what_restart_would_find() {
let (mut store, _dir) = empty_store();
let mut session = Session::new(
cards(1),
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
assert!(session.has_due_now(&store, 1000));
session.grade(&mut store, Grade::Pass, 1000);
assert!(!session.has_due_now(&store, 1001));
assert!(!session.restart(&store, 1001));
assert!(session.has_due_now(&store, 1000 + 3_600_000));
}
#[test]
fn next_due_at_reports_earliest_due_time() {
let (mut store, _dir) = empty_store();
let all = cards(2);
let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
assert_eq!(None, session.next_due_at(&store)); session.grade(&mut store, Grade::Pass, 1000);
assert_eq!(Some(1000 + 3_600_000), session.next_due_at(&store));
}
#[test]
fn histogram_counts_stages() {
let (mut store, _dir) = empty_store();
let all = cards(4);
store.get_or_insert(all[0].id(), 0).stage = 1;
store.get_or_insert(all[1].id(), 0).stage = 5;
let h = histogram(&all, &store);
assert_eq!([2, 1, 0, 0, 0, 1], h);
}
#[test]
fn is_retired_needs_top_stage_reached_by_passing() {
let (mut store, _dir) = empty_store();
let c = card("deck.txt", 0);
assert!(!is_retired(&c, &store)); let s = store.get_or_insert(c.id(), 0);
s.stage = MAX_STAGE;
s.streak = 1;
assert!(is_retired(&c, &store)); let s = store.get_or_insert(c.id(), 0);
s.stage = MAX_STAGE - 1;
assert!(!is_retired(&c, &store)); }
#[test]
fn has_reviewable_counts_new_and_due_not_cooldown_or_retired() {
let (mut store, _dir) = empty_store();
let sched = SchedulerKind::Leitner.scheduler();
let now = 10_000_000;
assert!(has_reviewable(&cards(1), &store, sched.as_ref(), now));
let c = card("deck.txt", 0);
let s = store.get_or_insert(c.id(), now);
s.stage = 2;
s.streak = 1;
s.stage_entered_ms = now;
let one = std::slice::from_ref(&c);
assert!(!has_reviewable(one, &store, sched.as_ref(), now));
assert!(has_reviewable(one, &store, sched.as_ref(), now + 3_600_000));
store.get_or_insert(c.id(), now).stage = MAX_STAGE;
assert!(!has_reviewable(
std::slice::from_ref(&c),
&store,
sched.as_ref(),
now + 3_600_000
));
}
#[test]
fn retired_card_excluded_even_under_cram() {
let (mut store, _dir) = empty_store();
let all = cards(1);
let s = store.get_or_insert(all[0].id(), 0);
s.stage = MAX_STAGE;
s.streak = 1;
let session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 10,
limit: None,
cram: true,
order: Order::Scheduled,
},
1000,
);
assert!(session.is_finished());
}
}