From e97a3a15a7e3f878f8a415bb51b9c3b16d661508 Mon Sep 17 00:00:00 2001 From: user1823 <92206575+user1823@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:35:55 +0000 Subject: [PATCH 1/9] Feat/Prioritize previously attempted cards in intraday learning queue --- rslib/src/scheduler/queue/builder/mod.rs | 65 ++++++++++++++++++++++-- rslib/src/scheduler/queue/entry.rs | 1 + rslib/src/scheduler/queue/learning.rs | 2 + rslib/src/storage/card/due_cards.sql | 3 +- rslib/src/storage/card/intraday_due.sql | 3 +- rslib/src/storage/card/mod.rs | 2 + 6 files changed, 71 insertions(+), 5 deletions(-) diff --git a/rslib/src/scheduler/queue/builder/mod.rs b/rslib/src/scheduler/queue/builder/mod.rs index 064220bce..1cd29744d 100644 --- a/rslib/src/scheduler/queue/builder/mod.rs +++ b/rslib/src/scheduler/queue/builder/mod.rs @@ -38,6 +38,7 @@ pub(crate) struct DueCard { pub current_deck_id: DeckId, pub original_deck_id: DeckId, pub kind: DueCardKind, + pub reps: u32, } #[derive(Debug, Clone, Copy)] @@ -87,6 +88,7 @@ impl From for LearningQueueEntry { due: TimestampSecs(c.due as i64), id: c.id, mtime: c.mtime, + reps: c.reps, } } } @@ -274,9 +276,21 @@ fn merge_new( } } -fn sort_learning(mut learning: Vec) -> VecDeque { - learning.sort_unstable_by(|a, b| a.due.cmp(&b.due)); - learning.into_iter().map(LearningQueueEntry::from).collect() +fn sort_learning(learning: Vec) -> VecDeque { + // Prioritize intraday learning cards that were previously attempted + // (reps > 0) before never-attempted cards (reps == 0). Preserve due-time + // ordering within each group. + let (mut previously_attempted, mut never_attempted): (Vec, Vec) = + learning.into_iter().partition(|c| c.reps > 0); + + previously_attempted.sort_unstable_by(|a, b| a.due.cmp(&b.due)); + never_attempted.sort_unstable_by(|a, b| a.due.cmp(&b.due)); + + previously_attempted + .into_iter() + .chain(never_attempted) + .map(LearningQueueEntry::from) + .collect() } impl Collection { @@ -543,4 +557,49 @@ mod test { col.set_current_deck(child.id).unwrap(); assert_eq!(col.card_queue_len(), 0); } + + #[test] + fn intraday_learning_prioritizes_previously_attempted() -> Result<()> { + let mut col = Collection::new(); + + // create two notes with one card each + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + let mut n1 = nt.new_note(); + n1.set_field(0, "one")?; + col.add_note(&mut n1, DeckId(1))?; + let mut n2 = nt.new_note(); + n2.set_field(0, "two")?; + col.add_note(&mut n2, DeckId(1))?; + + // fetch their cards and make them intraday learning cards + let mut c1 = col.storage.get_card_by_ordinal(n1.id, 0)?.unwrap(); + let mut c2 = col.storage.get_card_by_ordinal(n2.id, 0)?.unwrap(); + + c1.queue = CardQueue::Learn; + c2.queue = CardQueue::Learn; + + // c1: never attempted, due earlier (0) + c1.due = 0; + c1.reps = 0; + // c2: previously attempted, due later (600) + c2.due = 600; + c2.reps = 1; + + let id1 = c1.id; + let id2 = c2.id; + + col.update_cards_maybe_undoable(vec![c1, c2], false)?; + + // build queues and inspect intraday learning order + let queues = col.build_queues(DeckId(1))?; + let ids: Vec<_> = queues.intraday_learning.iter().map(|e| e.id).collect(); + + // ensure the previously attempted card appears before the never-attempted one + assert!( + ids.iter().position(|&id| id == id2).unwrap() + < ids.iter().position(|&id| id == id1).unwrap() + ); + + Ok(()) + } } diff --git a/rslib/src/scheduler/queue/entry.rs b/rslib/src/scheduler/queue/entry.rs index c9e587747..1ca9de478 100644 --- a/rslib/src/scheduler/queue/entry.rs +++ b/rslib/src/scheduler/queue/entry.rs @@ -55,6 +55,7 @@ impl From<&Card> for QueueEntry { due: TimestampSecs(card.due as i64), id: card.id, mtime: card.mtime, + reps: card.reps, }); } CardQueue::New => MainQueueEntryKind::New, diff --git a/rslib/src/scheduler/queue/learning.rs b/rslib/src/scheduler/queue/learning.rs index 79d0304c9..8e2d210b4 100644 --- a/rslib/src/scheduler/queue/learning.rs +++ b/rslib/src/scheduler/queue/learning.rs @@ -12,6 +12,7 @@ pub(crate) struct LearningQueueEntry { pub due: TimestampSecs, pub id: CardId, pub mtime: TimestampSecs, + pub reps: u32, } impl CardQueues { @@ -71,6 +72,7 @@ impl CardQueues { due: TimestampSecs(card.due as i64), id: card.id, mtime: card.mtime, + reps: card.reps, }; Some(self.requeue_learning_entry(entry)) diff --git a/rslib/src/storage/card/due_cards.sql b/rslib/src/storage/card/due_cards.sql index 0c29b5a31..149cc0f27 100644 --- a/rslib/src/storage/card/due_cards.sql +++ b/rslib/src/storage/card/due_cards.sql @@ -4,7 +4,8 @@ SELECT id, cast(ivl AS integer), cast(mod AS integer), did, - odid + odid, + reps FROM cards WHERE did IN ( SELECT id diff --git a/rslib/src/storage/card/intraday_due.sql b/rslib/src/storage/card/intraday_due.sql index 634cacd6b..a24874659 100644 --- a/rslib/src/storage/card/intraday_due.sql +++ b/rslib/src/storage/card/intraday_due.sql @@ -3,7 +3,8 @@ SELECT id, due, cast(mod AS integer), did, - odid + odid, + reps FROM cards WHERE did IN ( SELECT id diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 3a5066ff4..787b4b5d8 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -267,6 +267,7 @@ impl super::SqliteStorage { mtime: row.get(3)?, current_deck_id: row.get(4)?, original_deck_id: row.get(5)?, + reps: row.get(6)?, kind: DueCardKind::Learning, }) } @@ -306,6 +307,7 @@ impl super::SqliteStorage { mtime: row.get(4)?, current_deck_id: row.get(5)?, original_deck_id: row.get(6)?, + reps: row.get(7)?, kind, })? { break; From fd4e8457d836b17716dee5a71e7da4cf76a7d2ab Mon Sep 17 00:00:00 2001 From: user1823 <92206575+user1823@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:04:25 +0530 Subject: [PATCH 2/9] Sort never attempted cards randomly --- rslib/src/scheduler/queue/builder/mod.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/rslib/src/scheduler/queue/builder/mod.rs b/rslib/src/scheduler/queue/builder/mod.rs index 1cd29744d..c4126db9d 100644 --- a/rslib/src/scheduler/queue/builder/mod.rs +++ b/rslib/src/scheduler/queue/builder/mod.rs @@ -9,7 +9,9 @@ mod sorting; use std::collections::HashMap; use std::collections::VecDeque; +use std::hash::Hasher; +use fnv::FnvHasher; use intersperser::Intersperser; use sized_chain::SizedChain; @@ -278,13 +280,19 @@ fn merge_new( fn sort_learning(learning: Vec) -> VecDeque { // Prioritize intraday learning cards that were previously attempted - // (reps > 0) before never-attempted cards (reps == 0). Preserve due-time - // ordering within each group. + // (reps > 0) before never-attempted cards (reps == 0). Sort previously + // attempted cards by due-time and never-attempted cards deterministically + // by an FNV hash of their id. let (mut previously_attempted, mut never_attempted): (Vec, Vec) = learning.into_iter().partition(|c| c.reps > 0); previously_attempted.sort_unstable_by(|a, b| a.due.cmp(&b.due)); - never_attempted.sort_unstable_by(|a, b| a.due.cmp(&b.due)); + + never_attempted.sort_unstable_by_key(|c| { + let mut hasher = FnvHasher::default(); + hasher.write_i64(c.id.0); + hasher.finish() + }); previously_attempted .into_iter() From 5e7cf1f9df5b2f02cc03d7ee795a95978b7b22a8 Mon Sep 17 00:00:00 2001 From: user1823 <92206575+user1823@users.noreply.github.com> Date: Tue, 6 Jan 2026 18:07:21 +0530 Subject: [PATCH 3/9] Update iterators to use filter instead of take_while After the changes in sort_learning, the learning queue is no longer sorted purely by the due timestamp when the queue contains never-attempted cards. This breaks take_while and skip_while, which stop at the first card that doesn't match the condition, potentially skipping cards that are actually due now. Using filter has a potential performance impact because all intraday learning cards must now be processed instead of just the ones that are within the cutoff. But, in practice, the impact is negligible because filtering is extremely fast and manual testing with large queues showed no noticeable difference. The correct behavior is worth the minimal trade-off. --- rslib/src/scheduler/queue/learning.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/rslib/src/scheduler/queue/learning.rs b/rslib/src/scheduler/queue/learning.rs index 8e2d210b4..baed0943d 100644 --- a/rslib/src/scheduler/queue/learning.rs +++ b/rslib/src/scheduler/queue/learning.rs @@ -21,7 +21,7 @@ impl CardQueues { let cutoff = self.current_learning_cutoff; self.intraday_learning .iter() - .take_while(move |e| e.due <= cutoff) + .filter(move |e| e.due <= cutoff) } /// Intraday learning cards that can be shown after the main queue is empty. @@ -30,8 +30,7 @@ impl CardQueues { let ahead_cutoff = self.current_learn_ahead_cutoff(); self.intraday_learning .iter() - .skip_while(move |e| e.due <= cutoff) - .take_while(move |e| e.due <= ahead_cutoff) + .filter(move |e| e.due > cutoff && e.due <= ahead_cutoff) } /// Increase the cutoff to the current time, and increase the learning count @@ -47,8 +46,7 @@ impl CardQueues { let new_learning_cards = self .intraday_learning .iter() - .skip_while(|e| e.due <= last_ahead_cutoff) - .take_while(|e| e.due <= new_ahead_cutoff) + .filter(|e| e.due > last_ahead_cutoff && e.due <= new_ahead_cutoff) .count(); self.counts.learning += new_learning_cards; From bef595ae13eaba80cad569b1b6461555b651f379 Mon Sep 17 00:00:00 2001 From: user1823 <92206575+user1823@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:27:31 +0530 Subject: [PATCH 4/9] Also update queue/builder/mod.rs --- rslib/src/scheduler/queue/builder/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rslib/src/scheduler/queue/builder/mod.rs b/rslib/src/scheduler/queue/builder/mod.rs index c4126db9d..1613f60cd 100644 --- a/rslib/src/scheduler/queue/builder/mod.rs +++ b/rslib/src/scheduler/queue/builder/mod.rs @@ -194,7 +194,7 @@ impl QueueBuilder { let cutoff = now.adding_secs(learn_ahead_secs); let learn_count = intraday_learning .iter() - .take_while(|e| e.due <= cutoff) + .filter(|e| e.due <= cutoff) .count() + self.day_learning.len(); From 9cf59e4a5de7fe93df486f7bfcc34cb8888946de Mon Sep 17 00:00:00 2001 From: user1823 <92206575+user1823@users.noreply.github.com> Date: Tue, 6 Jan 2026 23:50:35 +0530 Subject: [PATCH 5/9] Prevent "not at top of queue" error Because take_while was replaced by filter in intraday_now_iter, the learning card that is shown first may not be at the top of the queue. --- rslib/src/scheduler/queue/mod.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/rslib/src/scheduler/queue/mod.rs b/rslib/src/scheduler/queue/mod.rs index 1352d6735..f869fd85a 100644 --- a/rslib/src/scheduler/queue/mod.rs +++ b/rslib/src/scheduler/queue/mod.rs @@ -159,17 +159,13 @@ impl CardQueues { /// Remove the provided card from the top of the queues and /// adjust the counts. If it was not at the top, return an error. fn pop_entry(&mut self, id: CardId) -> Result { - // This ignores the current cutoff, so may match if the provided - // learning card is not yet due. It should not happen in normal - // practice, but does happen in the Python unit tests, as they answer - // learning cards early. - if self - .intraday_learning - .front() - .filter(|e| e.id == id) - .is_some() - { - Ok(self.pop_intraday_learning().unwrap().into()) + if let Some(pos) = self.intraday_learning.iter().position(|e| e.id == id) { + let entry = self.intraday_learning.remove(pos).unwrap(); + // FIXME: + // under normal circumstances this should not go below 0, but currently + // the Python unit tests answer learning cards before they're due + self.counts.learning = self.counts.learning.saturating_sub(1); + Ok(entry.into()) } else if self.main.front().filter(|e| e.id == id).is_some() { Ok(self.pop_main().unwrap().into()) } else { From d9e68075ac481e618aaccc3dd4a60599b71038bd Mon Sep 17 00:00:00 2001 From: user1823 <92206575+user1823@users.noreply.github.com> Date: Tue, 6 Jan 2026 23:52:14 +0530 Subject: [PATCH 6/9] Remove unused pop_intraday_learning method --- rslib/src/scheduler/queue/learning.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/rslib/src/scheduler/queue/learning.rs b/rslib/src/scheduler/queue/learning.rs index baed0943d..997348147 100644 --- a/rslib/src/scheduler/queue/learning.rs +++ b/rslib/src/scheduler/queue/learning.rs @@ -116,16 +116,6 @@ impl CardQueues { self.main.is_empty() } - /// Remove the head of the intraday learning queue, and update counts. - pub(super) fn pop_intraday_learning(&mut self) -> Option { - self.intraday_learning.pop_front().inspect(|_head| { - // FIXME: - // under normal circumstances this should not go below 0, but currently - // the Python unit tests answer learning cards before they're due - self.counts.learning = self.counts.learning.saturating_sub(1); - }) - } - /// Add an undone entry to the top of the intraday learning queue. pub(super) fn push_intraday_learning(&mut self, entry: LearningQueueEntry) { self.intraday_learning.push_front(entry); From c924df0d53a22ba79893bf9df0604678d1eb04e5 Mon Sep 17 00:00:00 2001 From: user1823 <92206575+user1823@users.noreply.github.com> Date: Tue, 6 Jan 2026 23:53:33 +0530 Subject: [PATCH 7/9] Format --- rslib/src/scheduler/queue/builder/mod.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/rslib/src/scheduler/queue/builder/mod.rs b/rslib/src/scheduler/queue/builder/mod.rs index 1613f60cd..bd56161a9 100644 --- a/rslib/src/scheduler/queue/builder/mod.rs +++ b/rslib/src/scheduler/queue/builder/mod.rs @@ -192,12 +192,8 @@ impl QueueBuilder { let intraday_learning = sort_learning(self.learning); let now = TimestampSecs::now(); let cutoff = now.adding_secs(learn_ahead_secs); - let learn_count = intraday_learning - .iter() - .filter(|e| e.due <= cutoff) - .count() - + self.day_learning.len(); - + let learn_count = + intraday_learning.iter().filter(|e| e.due <= cutoff).count() + self.day_learning.len(); let review_count = self.review.len(); let new_count = self.new.len(); From 94b7ac03d693c054991cc64f07d2a7a040a0ca4c Mon Sep 17 00:00:00 2001 From: user1823 <92206575+user1823@users.noreply.github.com> Date: Tue, 6 Jan 2026 23:55:47 +0530 Subject: [PATCH 8/9] Remove test The test wasn't much helpful. --- rslib/src/scheduler/queue/builder/mod.rs | 46 ------------------------ 1 file changed, 46 deletions(-) diff --git a/rslib/src/scheduler/queue/builder/mod.rs b/rslib/src/scheduler/queue/builder/mod.rs index bd56161a9..282248001 100644 --- a/rslib/src/scheduler/queue/builder/mod.rs +++ b/rslib/src/scheduler/queue/builder/mod.rs @@ -561,49 +561,3 @@ mod test { col.set_current_deck(child.id).unwrap(); assert_eq!(col.card_queue_len(), 0); } - - #[test] - fn intraday_learning_prioritizes_previously_attempted() -> Result<()> { - let mut col = Collection::new(); - - // create two notes with one card each - let nt = col.get_notetype_by_name("Basic")?.unwrap(); - let mut n1 = nt.new_note(); - n1.set_field(0, "one")?; - col.add_note(&mut n1, DeckId(1))?; - let mut n2 = nt.new_note(); - n2.set_field(0, "two")?; - col.add_note(&mut n2, DeckId(1))?; - - // fetch their cards and make them intraday learning cards - let mut c1 = col.storage.get_card_by_ordinal(n1.id, 0)?.unwrap(); - let mut c2 = col.storage.get_card_by_ordinal(n2.id, 0)?.unwrap(); - - c1.queue = CardQueue::Learn; - c2.queue = CardQueue::Learn; - - // c1: never attempted, due earlier (0) - c1.due = 0; - c1.reps = 0; - // c2: previously attempted, due later (600) - c2.due = 600; - c2.reps = 1; - - let id1 = c1.id; - let id2 = c2.id; - - col.update_cards_maybe_undoable(vec![c1, c2], false)?; - - // build queues and inspect intraday learning order - let queues = col.build_queues(DeckId(1))?; - let ids: Vec<_> = queues.intraday_learning.iter().map(|e| e.id).collect(); - - // ensure the previously attempted card appears before the never-attempted one - assert!( - ids.iter().position(|&id| id == id2).unwrap() - < ids.iter().position(|&id| id == id1).unwrap() - ); - - Ok(()) - } -} From 833c1c4e7c43daabd98409041364afa8c1706980 Mon Sep 17 00:00:00 2001 From: user1823 <92206575+user1823@users.noreply.github.com> Date: Tue, 6 Jan 2026 23:59:55 +0530 Subject: [PATCH 9/9] Fix --- rslib/src/scheduler/queue/builder/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rslib/src/scheduler/queue/builder/mod.rs b/rslib/src/scheduler/queue/builder/mod.rs index 282248001..86f82fbc6 100644 --- a/rslib/src/scheduler/queue/builder/mod.rs +++ b/rslib/src/scheduler/queue/builder/mod.rs @@ -561,3 +561,4 @@ mod test { col.set_current_deck(child.id).unwrap(); assert_eq!(col.card_queue_len(), 0); } +}