From 13519a929cf4ecfb565a9384d6bb44d41bce3cea Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 15 May 2021 20:09:50 +1000 Subject: [PATCH] rework various aspects of the test scheduler - Daily limits are no longer inherited - each deck limits its own cards, and the selected deck enforces a maximum limit. - Fetching of review cards now uses a single query, and sorts in advance. In collections with a large number of overdue cards and decks, this is faster than iterating over each deck in turn. - Include interday learning count in review count & review limit, and allow them to be buried. - Warn when parent review limit is lower than child deck in deck options. - Cap the new card limit to the review limit. - Add option to control whether new card fetching short-circuits. --- ftl/core/deck-config.ftl | 58 +++-- pylib/tests/test_schedv2.py | 26 --- rslib/backend.proto | 21 +- rslib/src/deckconfig/mod.rs | 8 +- rslib/src/deckconfig/schema11.rs | 20 +- rslib/src/deckconfig/update.rs | 8 +- rslib/src/decks/counts.rs | 2 + rslib/src/decks/limits.rs | 59 +++++ rslib/src/decks/mod.rs | 1 + rslib/src/decks/tree.rs | 173 +++++++------- rslib/src/notetype/cardgen.rs | 6 +- rslib/src/scheduler/answering/mod.rs | 26 +-- rslib/src/scheduler/new.rs | 16 +- .../src/scheduler/queue/builder/gathering.rs | 86 ++++--- rslib/src/scheduler/queue/builder/mod.rs | 104 ++++++--- rslib/src/scheduler/queue/builder/sorting.rs | 30 +-- rslib/src/scheduler/queue/limits.rs | 219 ------------------ rslib/src/scheduler/queue/mod.rs | 5 +- rslib/src/storage/card/due_cards.sql | 16 +- rslib/src/storage/card/intraday_due.sql | 15 ++ rslib/src/storage/card/mod.rs | 58 +++-- rslib/src/storage/deck/due_counts.sql | 13 +- rslib/src/storage/deck/mod.rs | 52 ++--- ts/deckoptions/AdvancedOptions.svelte | 7 - ts/deckoptions/ConfigEditor.svelte | 2 + ts/deckoptions/DailyLimits.svelte | 2 +- ts/deckoptions/LapseOptions.svelte | 61 +++++ ts/deckoptions/LearningOptions.svelte | 9 +- ts/deckoptions/NewOptions.svelte | 23 +- ts/deckoptions/ReviewOptions.svelte | 17 -- 30 files changed, 551 insertions(+), 592 deletions(-) create mode 100644 rslib/src/decks/limits.rs delete mode 100644 rslib/src/scheduler/queue/limits.rs create mode 100644 rslib/src/storage/card/intraday_due.sql create mode 100644 ts/deckoptions/LapseOptions.svelte diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index e7b01eb3c..e5cd58477 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -21,9 +21,9 @@ deck-config-review-limit-tooltip = The maximum number of review cards to show in a day, if cards are ready for review. -## Learning/Relearning section +## Learning section -deck-config-learning-relearning-title = Learning/Relearning +deck-config-learning-title = Learning deck-config-learning-steps = Learning steps # Please don't translate '5m' or '2d' -deck-config-delay-hint = Delays can be in minutes (eg "5m"), or days (eg "2d"). @@ -33,12 +33,6 @@ deck-config-learning-steps-tooltip = The Good button will advance to the next step, which is 10 minutes by default. Once all steps have been passed, the card will become a review card, and will appear on a different day. { -deck-config-delay-hint } -deck-config-relearning-steps = Relearning steps -deck-config-relearning-steps-tooltip = - Zero or more delays, separated by spaces. By default, pressing the Again - button on a review card will show it again 10 minutes later. If no delays - are provided, the card will have its interval changed, without entering - relearning. { -deck-config-delay-hint } deck-config-interday-step-priority = Interday step priority deck-config-interday-step-priority-tooltip = When to show (re)learning cards that cross a day boundary. deck-config-review-mix-mix-with-reviews = Mix with reviews @@ -56,33 +50,56 @@ deck-config-easy-interval-tooltip = deck-config-new-insertion-order = Insertion order deck-config-new-insertion-order-tooltip = Controls the position (due #) new cards are assigned when you add new cards. - Cards with a lower due # will be shown first when reviewing. Changing this + Cards with a lower due # will be shown first when studying. Changing this option will automatically update the existing position of new cards. -deck-config-new-insertion-order-sequential = Sequential (show oldest cards first) +deck-config-new-insertion-order-sequential = Sequential (oldest cards first) deck-config-new-insertion-order-random = Random +deck-config-new-gather-priority = Gather priority +deck-config-new-gather-priority-tooltip = + Deck gathers cards from each subdeck in order, and stops when the + limit of the selected deck has been exceeded. This is faster, and allows you + to prioritize subdecks that are closer to the top. Position gathers + cards from all decks before they are sorted. This ensures the oldest cards + will be shown first, even if the parent limit is not high enough to see + cards from all decks. +deck-config-new-gather-priority-deck = Deck +deck-config-new-gather-priority-position = Position deck-config-sort-order = Sort order deck-config-sort-order-tooltip = - After today's new cards have been gathered, this option controls the order - they are then presented in. The default is to sort by card template first, to avoid - multiple cards of the same note from being shown in succession. + This option controls how cards are sorted after they have been gathered. + By default, Anki sorts by card template first, to avoid multiple cards of + the same note from being shown in succession. deck-config-sort-order-card-template-then-position = Card template, then position deck-config-sort-order-card-template-then-random = Card template, then random deck-config-sort-order-position = Position (siblings together) deck-config-sort-order-random = Random -deck-config-new-priority = Priority -deck-config-new-priority-tooltip = When to show new cards in a study session. +deck-config-review-priority = Review priority +deck-config-review-priority-tooltip = When to show these cards in relation to review cards. ## Review section deck-config-review-sort-order-tooltip = - After today's review cards have been gathered, this option controls the order - they are then presented in. The default is to sort by due date, then shuffle, so - that if you have a backlog of reviews, the cards that have been waiting longest - will be shown first. The other choices can be useful when you have a large backlog - and want to tackle it in a different way. + The default order fetches cards from each subdeck in turn, stopping when the limit + of the selected deck has been reached. The gathered cards are then shuffled together, + and shown in due date order. Because gathering stops when the parent limit has been + reached, your child decks should have smaller limits if you wish to see cards from + multiple decks at once. + + The other sort options are mainly useful when catching up from a large backlog. + Because they have to sort all the cards first, they can be considerably slower + than the default sort order when many cards are due. deck-config-sort-order-due-date-then-random = Due date, then random deck-config-sort-order-ascending-intervals = Ascending intervals deck-config-sort-order-descending-intervals = Descending intervals + +## Lapses section + +deck-config-relearning-steps = Relearning steps +deck-config-relearning-steps-tooltip = + Zero or more delays, separated by spaces. By default, pressing the Again + button on a review card will show it again 10 minutes later. If no delays + are provided, the card will have its interval changed, without entering + relearning. { -deck-config-delay-hint } deck-config-leech-threshold-tooltip = The number of times Again needs to be pressed on a review card before it is marked as a leech. Leeches are cards that consume a lot of your time, and @@ -185,3 +202,4 @@ deck-config-reviews-too-low = }, your review limit should be at least { $expected }. deck-config-learning-step-above-graduating-interval = The graduating interval should be at least as long as your final learning step. deck-config-good-above-easy = The easy interval should be at least as long as the graduating interval. +deck-config-relearning-steps-above-minimum-interval = The minimum lapse interval should be at least as long as your final relearning step. diff --git a/pylib/tests/test_schedv2.py b/pylib/tests/test_schedv2.py index 6f80be95f..7624a9812 100644 --- a/pylib/tests/test_schedv2.py +++ b/pylib/tests/test_schedv2.py @@ -475,8 +475,6 @@ def review_limits_setup() -> Tuple[anki.collection.Collection, Dict]: def test_review_limits(): - if is_2021(): - pytest.skip("old sched only") col, child = review_limits_setup() tree = col.sched.deck_due_tree().children @@ -499,30 +497,6 @@ def test_review_limits(): assert tree[0].children[0].review_count == 9 # child -def test_review_limits_new(): - if not is_2021(): - pytest.skip("new sched only") - col, child = review_limits_setup() - - tree = col.sched.deck_due_tree().children - assert tree[0].review_count == 5 # parent - assert tree[0].children[0].review_count == 5 # child capped by parent - - # child .counts() are bound by parents - col.decks.select(child["id"]) - col.sched.reset() - assert col.sched.counts() == (0, 0, 5) - - # answering a card in the child should decrement both child and parent count - c = col.sched.getCard() - col.sched.answerCard(c, 3) - assert col.sched.counts() == (0, 0, 4) - - tree = col.sched.deck_due_tree().children - assert tree[0].review_count == 4 # parent - assert tree[0].children[0].review_count == 4 # child - - def test_button_spacing(): col = getEmptyCol() note = col.newNote() diff --git a/rslib/backend.proto b/rslib/backend.proto index 2175d086d..78bc7766a 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -309,9 +309,13 @@ service CardsService { message DeckConfig { message Config { - enum NewCardFetchOrder { - NEW_CARD_FETCH_ORDER_DUE = 0; - NEW_CARD_FETCH_ORDER_RANDOM = 1; + enum NewCardInsertOrder { + NEW_CARD_INSERT_ORDER_DUE = 0; + NEW_CARD_INSERT_ORDER_RANDOM = 1; + } + enum NewCardGatherPriority { + NEW_CARD_GATHER_PRIORITY_DECK = 0; + NEW_CARD_GATHER_PRIORITY_POSITION = 1; } enum NewCardSortOrder { NEW_CARD_SORT_ORDER_TEMPLATE_THEN_DUE = 0; @@ -356,11 +360,13 @@ message DeckConfig { uint32 graduating_interval_good = 18; uint32 graduating_interval_easy = 19; - NewCardFetchOrder new_card_fetch_order = 20; + NewCardInsertOrder new_card_insert_order = 20; + NewCardGatherPriority new_card_gather_priority = 34; NewCardSortOrder new_card_sort_order = 32; + ReviewMix new_mix = 30; + ReviewCardOrder review_order = 33; - ReviewMix new_mix = 30; ReviewMix interday_learning_mix = 31; LeechAction leech_action = 21; @@ -659,7 +665,6 @@ message DeckTreeIn { message DeckTreeNode { int64 deck_id = 1; string name = 2; - repeated DeckTreeNode children = 3; uint32 level = 4; bool collapsed = 5; @@ -668,6 +673,10 @@ message DeckTreeNode { uint32 new_count = 8; bool filtered = 16; + + // low index so key can be packed into a byte, but at bottom + // to make debug output easier to read + repeated DeckTreeNode children = 3; } message RenderExistingCardIn { diff --git a/rslib/src/deckconfig/mod.rs b/rslib/src/deckconfig/mod.rs index 08a53423c..5106aab8e 100644 --- a/rslib/src/deckconfig/mod.rs +++ b/rslib/src/deckconfig/mod.rs @@ -9,7 +9,10 @@ pub use schema11::{DeckConfSchema11, NewCardOrderSchema11}; pub use update::UpdateDeckConfigsIn; pub use crate::backend_proto::deck_config::{ - config::{LeechAction, NewCardFetchOrder, NewCardSortOrder, ReviewCardOrder, ReviewMix}, + config::{ + LeechAction, NewCardGatherPriority, NewCardInsertOrder, NewCardSortOrder, ReviewCardOrder, + ReviewMix, + }, Config as DeckConfigInner, }; @@ -58,7 +61,8 @@ impl Default for DeckConfig { minimum_lapse_interval: 1, graduating_interval_good: 1, graduating_interval_easy: 4, - new_card_fetch_order: NewCardFetchOrder::Due as i32, + new_card_insert_order: NewCardInsertOrder::Due as i32, + new_card_gather_priority: NewCardGatherPriority::Deck as i32, new_card_sort_order: NewCardSortOrder::TemplateThenDue as i32, review_order: ReviewCardOrder::DayThenRandom as i32, new_mix: ReviewMix::MixWithReviews as i32, diff --git a/rslib/src/deckconfig/schema11.rs b/rslib/src/deckconfig/schema11.rs index 609bda66b..33802828f 100644 --- a/rslib/src/deckconfig/schema11.rs +++ b/rslib/src/deckconfig/schema11.rs @@ -10,7 +10,7 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use serde_tuple::Serialize_tuple; use super::{ - DeckConfig, DeckConfigId, DeckConfigInner, NewCardFetchOrder, INITIAL_EASE_FACTOR_THOUSANDS, + DeckConfig, DeckConfigId, DeckConfigInner, NewCardInsertOrder, INITIAL_EASE_FACTOR_THOUSANDS, }; use crate::{serde::default_on_invalid, timestamp::TimestampSecs, types::Usn}; @@ -48,6 +48,8 @@ pub struct DeckConfSchema11 { review_order: i32, #[serde(default)] new_sort_order: i32, + #[serde(default)] + new_gather_priority: i32, #[serde(flatten)] other: HashMap, @@ -213,6 +215,7 @@ impl Default for DeckConfSchema11 { interday_learning_mix: 0, review_order: 0, new_sort_order: 0, + new_gather_priority: 0, } } } @@ -263,10 +266,11 @@ impl From for DeckConfig { minimum_lapse_interval: c.lapse.min_int, graduating_interval_good: c.new.ints.good as u32, graduating_interval_easy: c.new.ints.easy as u32, - new_card_fetch_order: match c.new.order { - NewCardOrderSchema11::Random => NewCardFetchOrder::Random, - NewCardOrderSchema11::Due => NewCardFetchOrder::Due, + new_card_insert_order: match c.new.order { + NewCardOrderSchema11::Random => NewCardInsertOrder::Random, + NewCardOrderSchema11::Due => NewCardInsertOrder::Due, } as i32, + new_card_gather_priority: c.new_gather_priority, new_card_sort_order: c.new_sort_order, review_order: c.review_order, new_mix: c.new_mix, @@ -312,7 +316,7 @@ impl From for DeckConfSchema11 { } } let i = c.inner; - let new_order = i.new_card_fetch_order(); + let new_order = i.new_card_insert_order(); DeckConfSchema11 { id: c.id, mtime: c.mtime_secs, @@ -333,8 +337,8 @@ impl From for DeckConfSchema11 { _unused: 0, }, order: match new_order { - NewCardFetchOrder::Random => NewCardOrderSchema11::Random, - NewCardFetchOrder::Due => NewCardOrderSchema11::Due, + NewCardInsertOrder::Random => NewCardOrderSchema11::Random, + NewCardInsertOrder::Due => NewCardOrderSchema11::Due, }, per_day: i.new_per_day, other: new_other, @@ -365,6 +369,7 @@ impl From for DeckConfSchema11 { interday_learning_mix: i.interday_learning_mix, review_order: i.review_order, new_sort_order: i.new_card_sort_order, + new_gather_priority: i.new_card_gather_priority, } } } @@ -383,6 +388,7 @@ fn clear_other_duplicates(top_other: &mut HashMap) { "interdayLearningMix", "reviewOrder", "newSortOrder", + "newGatherPriority", ] { top_other.remove(*key); } diff --git a/rslib/src/deckconfig/update.rs b/rslib/src/deckconfig/update.rs index e99ec7af9..063c9ef83 100644 --- a/rslib/src/deckconfig/update.rs +++ b/rslib/src/deckconfig/update.rs @@ -151,7 +151,7 @@ impl Collection { let previous_config_id = DeckConfigId(normal.config_id); let previous_order = configs_before_update .get(&previous_config_id) - .map(|c| c.inner.new_card_fetch_order()) + .map(|c| c.inner.new_card_insert_order()) .unwrap_or_default(); // if a selected (sub)deck, or its old config was removed, update deck to point to new config @@ -169,7 +169,7 @@ impl Collection { // if new order differs, deck needs re-sorting let current_order = configs_after_update .get(¤t_config_id) - .map(|c| c.inner.new_card_fetch_order()) + .map(|c| c.inner.new_card_insert_order()) .unwrap_or_default(); if previous_order != current_order { self.sort_deck(deck_id, current_order, usn)?; @@ -184,7 +184,7 @@ impl Collection { #[cfg(test)] mod test { use super::*; - use crate::{collection::open_test_collection, deckconfig::NewCardFetchOrder}; + use crate::{collection::open_test_collection, deckconfig::NewCardInsertOrder}; #[test] fn updating() -> Result<()> { @@ -258,7 +258,7 @@ mod test { assert_eq!(card1_pos(&mut col), 1); reset_card1_pos(&mut col); assert_eq!(card1_pos(&mut col), 0); - input.configs[1].inner.new_card_fetch_order = NewCardFetchOrder::Random as i32; + input.configs[1].inner.new_card_insert_order = NewCardInsertOrder::Random as i32; assert_eq!( col.update_deck_configs(input.clone())?.changes.changes.card, true diff --git a/rslib/src/decks/counts.rs b/rslib/src/decks/counts.rs index 17593dae5..317c251ed 100644 --- a/rslib/src/decks/counts.rs +++ b/rslib/src/decks/counts.rs @@ -30,12 +30,14 @@ impl Collection { days_elapsed: u32, learn_cutoff: u32, limit_to: Option<&str>, + v3: bool, ) -> Result> { self.storage.due_counts( self.scheduler_version(), days_elapsed, learn_cutoff, limit_to, + v3, ) } diff --git a/rslib/src/decks/limits.rs b/rslib/src/decks/limits.rs new file mode 100644 index 000000000..10286ff77 --- /dev/null +++ b/rslib/src/decks/limits.rs @@ -0,0 +1,59 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::collections::HashMap; + +use super::Deck; +use crate::{ + deckconfig::{DeckConfig, DeckConfigId}, + prelude::*, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct RemainingLimits { + pub review: u32, + pub new: u32, +} + +impl RemainingLimits { + pub(crate) fn new(deck: &Deck, config: Option<&DeckConfig>, today: u32) -> Self { + config + .map(|config| { + let (new_today, rev_today) = deck.new_rev_counts(today); + RemainingLimits { + review: ((config.inner.reviews_per_day as i32) - rev_today).max(0) as u32, + new: ((config.inner.new_per_day as i32) - new_today).max(0) as u32, + } + }) + .unwrap_or_default() + } + + pub(crate) fn cap_to(&mut self, limits: RemainingLimits) { + self.review = self.review.min(limits.review); + self.new = self.new.min(limits.new); + } +} + +impl Default for RemainingLimits { + fn default() -> Self { + RemainingLimits { + review: 9999, + new: 9999, + } + } +} + +pub(crate) fn remaining_limits_map<'a>( + decks: impl Iterator, + config: &'a HashMap, + today: u32, +) -> HashMap { + decks + .map(|deck| { + ( + deck.id, + RemainingLimits::new(deck, deck.config_id().and_then(|id| config.get(&id)), today), + ) + }) + .collect() +} diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index 919d6cc63..f6b8553cd 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -5,6 +5,7 @@ mod addupdate; mod counts; mod current; mod filtered; +pub(crate) mod limits; mod name; mod remove; mod reparent; diff --git a/rslib/src/decks/tree.rs b/rslib/src/decks/tree.rs index 12f09f4b5..1f88a240c 100644 --- a/rslib/src/decks/tree.rs +++ b/rslib/src/decks/tree.rs @@ -9,15 +9,18 @@ use std::{ use serde_tuple::Serialize_tuple; use unicase::UniCase; -use super::DueCounts; +use super::{ + limits::{remaining_limits_map, RemainingLimits}, + DueCounts, +}; pub use crate::backend_proto::set_deck_collapsed_in::Scope as DeckCollapseScope; use crate::{ backend_proto::DeckTreeNode, config::SchedulerVersion, ops::OpOutput, prelude::*, undo::Op, }; -fn deck_names_to_tree(names: Vec<(DeckId, String)>) -> DeckTreeNode { +fn deck_names_to_tree(names: impl Iterator) -> DeckTreeNode { let mut top = DeckTreeNode::default(); - let mut it = names.into_iter().peekable(); + let mut it = names.peekable(); add_child_nodes(&mut it, &mut top); @@ -89,26 +92,22 @@ fn add_counts(node: &mut DeckTreeNode, counts: &HashMap) { } /// Apply parent limits to children, and add child counts to parents. -/// Counts are (new, review). -fn apply_limits( +fn apply_limits_v1( node: &mut DeckTreeNode, - today: u32, - decks: &HashMap, - dconf: &HashMap, - parent_limits: (u32, u32), + limits: &HashMap, + parent_limits: RemainingLimits, ) { - let (mut remaining_new, mut remaining_rev) = - remaining_counts_for_deck(DeckId(node.deck_id), today, decks, dconf); - - // cap remaining to parent limits - remaining_new = remaining_new.min(parent_limits.0); - remaining_rev = remaining_rev.min(parent_limits.1); + let mut remaining = limits + .get(&DeckId(node.deck_id)) + .copied() + .unwrap_or_default(); + remaining.cap_to(parent_limits); // apply our limit to children and tally their counts let mut child_new_total = 0; let mut child_rev_total = 0; for child in &mut node.children { - apply_limits(child, today, decks, dconf, (remaining_new, remaining_rev)); + apply_limits_v1(child, limits, remaining); child_new_total += child.new_count; child_rev_total += child.review_count; // no limit on learning cards @@ -116,82 +115,87 @@ fn apply_limits( } // add child counts to our count, capped to remaining limit - node.new_count = (node.new_count + child_new_total).min(remaining_new); - node.review_count = (node.review_count + child_rev_total).min(remaining_rev); + node.new_count = (node.new_count + child_new_total).min(remaining.new); + node.review_count = (node.review_count + child_rev_total).min(remaining.review); } /// Apply parent new limits to children, and add child counts to parents. Unlike -/// v1 and the 2021 scheduler, reviews are not capped by their parents, and we +/// v1, reviews are not capped by their parents, and we /// return the uncapped review amount to add to the parent. -/// Counts are (new, review). -fn apply_limits_v2_old( +fn apply_limits_v2( node: &mut DeckTreeNode, - today: u32, - decks: &HashMap, - dconf: &HashMap, - parent_limits: (u32, u32), + limits: &HashMap, + parent_limits: RemainingLimits, ) -> u32 { let original_rev_count = node.review_count; - - let (mut remaining_new, remaining_rev) = - remaining_counts_for_deck(DeckId(node.deck_id), today, decks, dconf); - - // cap remaining to parent limits - remaining_new = remaining_new.min(parent_limits.0); + let mut remaining = limits + .get(&DeckId(node.deck_id)) + .copied() + .unwrap_or_default(); + remaining.new = remaining.new.min(parent_limits.new); // apply our limit to children and tally their counts let mut child_new_total = 0; let mut child_rev_total = 0; for child in &mut node.children { - child_rev_total += - apply_limits_v2_old(child, today, decks, dconf, (remaining_new, remaining_rev)); + child_rev_total += apply_limits_v2(child, limits, remaining); child_new_total += child.new_count; // no limit on learning cards node.learn_count += child.learn_count; } // add child counts to our count, capped to remaining limit - node.new_count = (node.new_count + child_new_total).min(remaining_new); - node.review_count = (node.review_count + child_rev_total).min(remaining_rev); + node.new_count = (node.new_count + child_new_total).min(remaining.new); + node.review_count = (node.review_count + child_rev_total).min(remaining.review); original_rev_count + child_rev_total } -fn remaining_counts_for_deck( - did: DeckId, - today: u32, - decks: &HashMap, - dconf: &HashMap, +/// Add child counts, then limit to remaining limit. The v3 scheduler does not +/// propagate limits down the tree. Limits for a deck affect only the amount +/// that deck itself will gather. +/// The v3 scheduler also caps the new limit to the remaining review limit, +/// so no new cards will be introduced when there is a backlog that exceeds +/// the review limits. +fn apply_limits_v3( + node: &mut DeckTreeNode, + limits: &HashMap, ) -> (u32, u32) { - if let Some(deck) = decks.get(&did) { - match &deck.kind { - DeckKind::Normal(norm) => { - let (new_today, rev_today) = deck.new_rev_counts(today); - if let Some(conf) = dconf - .get(&DeckConfigId(norm.config_id)) - .or_else(|| dconf.get(&DeckConfigId(1))) - { - let new = (conf.inner.new_per_day as i32) - .saturating_sub(new_today) - .max(0); - let rev = (conf.inner.reviews_per_day as i32) - .saturating_sub(rev_today) - .max(0); - (new as u32, rev as u32) - } else { - // missing dconf and fallback - (0, 0) - } - } - DeckKind::Filtered(_) => { - // filtered decks have no limit - (std::u32::MAX, std::u32::MAX) - } - } - } else { - // top level deck with id 0 - (std::u32::MAX, std::u32::MAX) + let mut remaining = limits + .get(&DeckId(node.deck_id)) + .copied() + .unwrap_or_default(); + + // recurse into children, tallying their counts + let mut child_new_total = 0; + let mut child_rev_total = 0; + for child in &mut node.children { + let child_counts = apply_limits_v3(child, limits); + child_new_total += child_counts.0; + child_rev_total += child_counts.1; + // no limit on learning cards + node.learn_count += child.learn_count; } + + // new limits capped to review limits + remaining.new = remaining.new.min( + remaining + .review + .saturating_sub(node.review_count) + .saturating_sub(child_rev_total), + ); + + // parents want the child total without caps + let out = ( + node.new_count.min(remaining.new) + child_new_total, + node.review_count.min(remaining.review) + child_rev_total, + ); + + // but the current node needs to cap after adding children + node.new_count = (node.new_count + child_new_total).min(remaining.new); + node.review_count = (node.review_count + child_rev_total).min(remaining.review); + + out } fn hide_default_deck(node: &mut DeckTreeNode) { @@ -257,7 +261,7 @@ impl Collection { top_deck_id: Option, ) -> Result { let names = self.storage.get_all_deck_names()?; - let mut tree = deck_names_to_tree(names); + let mut tree = deck_names_to_tree(names.into_iter()); let decks_map = self.storage.get_decks_map()?; @@ -276,27 +280,20 @@ impl Collection { }); let days_elapsed = self.timing_for_timestamp(now)?.days_elapsed; let learn_cutoff = (now.0 as u32) + self.learn_ahead_secs(); - let counts = self.due_counts(days_elapsed, learn_cutoff, limit)?; + let sched_ver = self.scheduler_version(); + let v3 = self.get_bool(BoolKey::Sched2021); + let counts = self.due_counts(days_elapsed, learn_cutoff, limit, v3)?; let dconf = self.storage.get_deck_config_map()?; add_counts(&mut tree, &counts); - if self.scheduler_version() == SchedulerVersion::V2 - && !self.get_bool(BoolKey::Sched2021) - { - apply_limits_v2_old( - &mut tree, - days_elapsed, - &decks_map, - &dconf, - (std::u32::MAX, std::u32::MAX), - ); + let limits = remaining_limits_map(decks_map.values(), &dconf, days_elapsed); + if sched_ver == SchedulerVersion::V2 { + if v3 { + apply_limits_v3(&mut tree, &limits); + } else { + apply_limits_v2(&mut tree, &limits, RemainingLimits::default()); + } } else { - apply_limits( - &mut tree, - days_elapsed, - &decks_map, - &dconf, - (std::u32::MAX, std::u32::MAX), - ); + apply_limits_v1(&mut tree, &limits, RemainingLimits::default()); } } @@ -328,7 +325,9 @@ impl Collection { Ok(()) }) } +} +impl Collection { pub(crate) fn legacy_deck_tree(&mut self) -> Result { let tree = self.deck_tree(Some(TimestampSecs::now()), None)?; Ok(LegacyDueCounts::from(tree)) diff --git a/rslib/src/notetype/cardgen.rs b/rslib/src/notetype/cardgen.rs index cc1ef6355..00a5e62a7 100644 --- a/rslib/src/notetype/cardgen.rs +++ b/rslib/src/notetype/cardgen.rs @@ -311,10 +311,10 @@ impl Collection { .get(&did) .unwrap() .inner - .new_card_fetch_order() + .new_card_insert_order() { - crate::deckconfig::NewCardFetchOrder::Random => Ok(random_position(next_pos)), - crate::deckconfig::NewCardFetchOrder::Due => Ok(next_pos), + crate::deckconfig::NewCardInsertOrder::Random => Ok(random_position(next_pos)), + crate::deckconfig::NewCardInsertOrder::Due => Ok(next_pos), } } diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index 164fcd405..bde335a76 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -261,7 +261,7 @@ impl Collection { if let Some(revlog_partial) = updater.apply_study_state(current_state, answer.new_state)? { self.add_partial_revlog(revlog_partial, usn, &answer)?; } - self.update_deck_stats_from_answer(usn, &answer, &updater)?; + self.update_deck_stats_from_answer(usn, &answer, &updater, original.queue)?; self.maybe_bury_siblings(&original, &updater.config)?; let timing = updater.timing; let mut card = updater.into_card(); @@ -308,26 +308,22 @@ impl Collection { usn: Usn, answer: &CardAnswer, updater: &CardStateUpdater, + from_queue: CardQueue, ) -> Result<()> { + let mut new_delta = 0; + let mut review_delta = 0; + match from_queue { + CardQueue::New => new_delta += 1, + CardQueue::Review => review_delta += 1, + _ => {} + } self.update_deck_stats( updater.timing.days_elapsed, usn, backend_proto::UpdateStatsIn { deck_id: updater.deck.id.0, - new_delta: if matches!(answer.current_state, CardState::Normal(NormalState::New(_))) - { - 1 - } else { - 0 - }, - review_delta: if matches!( - answer.current_state, - CardState::Normal(NormalState::Review(_)) - ) { - 1 - } else { - 0 - }, + new_delta, + review_delta, millisecond_delta: answer.milliseconds_taken as i32, }, ) diff --git a/rslib/src/scheduler/new.rs b/rslib/src/scheduler/new.rs index 65b0a0f3b..30eedd557 100644 --- a/rslib/src/scheduler/new.rs +++ b/rslib/src/scheduler/new.rs @@ -7,7 +7,7 @@ use rand::seq::SliceRandom; use crate::{ card::{CardQueue, CardType}, - deckconfig::NewCardFetchOrder, + deckconfig::NewCardInsertOrder, prelude::*, search::{SortMode, StateKind}, }; @@ -171,9 +171,9 @@ impl Collection { col.sort_deck( deck, if random { - NewCardFetchOrder::Random + NewCardInsertOrder::Random } else { - NewCardFetchOrder::Due + NewCardInsertOrder::Due }, col.usn()?, ) @@ -183,7 +183,7 @@ impl Collection { pub(crate) fn sort_deck( &mut self, deck: DeckId, - order: NewCardFetchOrder, + order: NewCardInsertOrder, usn: Usn, ) -> Result { let cids = self.search_cards(match_all![deck, StateKind::New], SortMode::NoOrder)?; @@ -241,11 +241,11 @@ mod test { } } -impl From for NewCardDueOrder { - fn from(o: NewCardFetchOrder) -> Self { +impl From for NewCardDueOrder { + fn from(o: NewCardInsertOrder) -> Self { match o { - NewCardFetchOrder::Due => NewCardDueOrder::NoteId, - NewCardFetchOrder::Random => NewCardDueOrder::Random, + NewCardInsertOrder::Due => NewCardDueOrder::NoteId, + NewCardInsertOrder::Random => NewCardDueOrder::Random, } } } diff --git a/rslib/src/scheduler/queue/builder/gathering.rs b/rslib/src/scheduler/queue/builder/gathering.rs index 6a8470d98..4180ef162 100644 --- a/rslib/src/scheduler/queue/builder/gathering.rs +++ b/rslib/src/scheduler/queue/builder/gathering.rs @@ -1,62 +1,58 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use super::{super::limits::RemainingLimits, BuryMode, DueCard, NewCard, QueueBuilder}; +use super::{BuryMode, DueCard, NewCard, QueueBuilder}; use crate::{card::CardQueue, prelude::*}; impl QueueBuilder { - /// Assumes cards will arrive sorted in (queue, due) order, so learning - /// cards come first, and reviews come before day-learning and preview cards. + pub(in super::super) fn add_intraday_learning_card( + &mut self, + card: DueCard, + bury_mode: BuryMode, + ) { + self.get_and_update_bury_mode_for_note(card.note_id, bury_mode); + self.learning.push(card); + } + + /// True if limit should be decremented. pub(in super::super) fn add_due_card( &mut self, - limit: &mut RemainingLimits, queue: CardQueue, card: DueCard, bury_mode: BuryMode, ) -> bool { - let bury_reviews = self + let bury_this_card = self .get_and_update_bury_mode_for_note(card.note_id, bury_mode) .map(|mode| mode.bury_reviews) .unwrap_or_default(); - - match queue { - CardQueue::Learn | CardQueue::PreviewRepeat => self.learning.push(card), - CardQueue::DayLearn => { - self.day_learning.push(card); - } - CardQueue::Review => { - if !bury_reviews { - self.review.push(card); - limit.review -= 1; + if bury_this_card { + false + } else { + match queue { + CardQueue::DayLearn => { + self.day_learning.push(card); } + CardQueue::Review => { + self.review.push(card); + } + _ => unreachable!(), } - CardQueue::New - | CardQueue::Suspended - | CardQueue::SchedBuried - | CardQueue::UserBuried => { - unreachable!() - } - } - limit.review != 0 + true + } } - pub(in super::super) fn add_new_card( - &mut self, - limit: &mut RemainingLimits, - card: NewCard, - bury_mode: BuryMode, - ) -> bool { + // True if limit should be decremented. + pub(in super::super) fn add_new_card(&mut self, card: NewCard, bury_mode: BuryMode) -> bool { let previous_bury_mode = self .get_and_update_bury_mode_for_note(card.note_id, bury_mode) .map(|mode| mode.bury_new); // no previous siblings seen? if previous_bury_mode.is_none() { self.new.push(card); - limit.new -= 1; - return limit.new != 0; + return true; } - let bury_new = previous_bury_mode.unwrap(); + let bury_this_card = previous_bury_mode.unwrap(); // Cards will be arriving in (due, card_id) order, with all // siblings sharing the same due number by default. In the @@ -76,10 +72,12 @@ impl QueueBuilder { .unwrap_or(false); if previous_card_was_sibling_with_higher_ordinal { - if bury_new { + if bury_this_card { // When burying is enabled, we replace the existing sibling - // with the lower ordinal one. + // with the lower ordinal one, and skip decrementing the limit. *self.new.last_mut().unwrap() = card; + + false } else { // When burying disabled, we'll want to add this card as well, but we // need to insert it in front of the later-ordinal card(s). @@ -100,17 +98,19 @@ impl QueueBuilder { .next() .unwrap_or(0); self.new.insert(target_idx, card); - limit.new -= 1; + + true } } else { // card has arrived in expected order - add if burying disabled - if !bury_new { + if bury_this_card { + false + } else { self.new.push(card); - limit.new -= 1; + + true } } - - limit.new != 0 } /// If burying is enabled in `new_settings`, existing entry will be updated. @@ -142,11 +142,6 @@ mod test { #[test] fn new_siblings() { let mut builder = QueueBuilder::default(); - let mut limits = RemainingLimits { - review: 0, - new: 100, - }; - let cards = vec![ NewCard { id: CardId(1), @@ -177,7 +172,6 @@ mod test { for card in &cards { builder.add_new_card( - &mut limits, card.clone(), BuryMode { bury_new: true, @@ -194,7 +188,7 @@ mod test { let mut builder = QueueBuilder::default(); for card in &cards { - builder.add_new_card(&mut limits, card.clone(), Default::default()); + builder.add_new_card(card.clone(), Default::default()); } assert_eq!(builder.new[0].id, CardId(1)); diff --git a/rslib/src/scheduler/queue/builder/mod.rs b/rslib/src/scheduler/queue/builder/mod.rs index 9b4066b38..fbd546a8c 100644 --- a/rslib/src/scheduler/queue/builder/mod.rs +++ b/rslib/src/scheduler/queue/builder/mod.rs @@ -11,12 +11,10 @@ use std::collections::{HashMap, VecDeque}; use intersperser::Intersperser; use sized_chain::SizedChain; -use super::{ - limits::{remaining_limits_capped_to_parents, RemainingLimits}, - CardQueues, Counts, LearningQueueEntry, MainQueueEntry, MainQueueEntryKind, -}; +use super::{CardQueues, Counts, LearningQueueEntry, MainQueueEntry, MainQueueEntryKind}; use crate::{ - deckconfig::{NewCardSortOrder, ReviewCardOrder, ReviewMix}, + deckconfig::{NewCardGatherPriority, NewCardSortOrder, ReviewCardOrder, ReviewMix}, + decks::limits::{remaining_limits_map, RemainingLimits}, prelude::*, }; @@ -28,8 +26,9 @@ pub(crate) struct DueCard { pub mtime: TimestampSecs, pub due: i32, pub interval: u32, - pub original_deck_id: DeckId, pub hash: u64, + pub current_deck_id: DeckId, + pub original_deck_id: DeckId, } /// Temporary holder for new cards that will be built into a queue. @@ -85,6 +84,7 @@ pub(super) struct BuryMode { #[derive(Default, Clone, Debug)] pub(super) struct QueueSortOptions { pub(super) new_order: NewCardSortOrder, + pub(super) new_gather_priority: NewCardGatherPriority, pub(super) review_order: ReviewCardOrder, pub(super) day_learn_mix: ReviewMix, pub(super) new_review_mix: ReviewMix, @@ -192,16 +192,17 @@ impl Collection { pub(crate) fn build_queues(&mut self, deck_id: DeckId) -> Result { let now = TimestampSecs::now(); let timing = self.timing_for_timestamp(now)?; - let (decks, parent_count) = self.storage.deck_with_parents_and_children(deck_id)?; + let decks = self.storage.deck_with_children(deck_id)?; + // need full map, since filtered decks may contain cards from decks/ + // outside tree let deck_map = self.storage.get_decks_map()?; let config = self.storage.get_deck_config_map()?; - let sort_options = decks - .get(parent_count) - .unwrap() + let sort_options = decks[0] .config_id() .and_then(|config_id| config.get(&config_id)) .map(|config| QueueSortOptions { new_order: config.inner.new_card_sort_order(), + new_gather_priority: config.inner.new_card_gather_priority(), review_order: config.inner.review_order(), day_learn_mix: config.inner.interday_learning_mix(), new_review_mix: config.inner.new_mix(), @@ -213,9 +214,18 @@ impl Collection { ..Default::default() } }); - let limits = remaining_limits_capped_to_parents(&decks, &config, timing.days_elapsed); - let selected_deck_limits = limits[parent_count]; - let mut queues = QueueBuilder::new(sort_options); + + // fetch remaining limits, and cap to selected deck limits so that we don't + // do more work than necessary + let mut remaining = remaining_limits_map(decks.iter(), &config, timing.days_elapsed); + let selected_deck_limits_at_start = *remaining.get(&deck_id).unwrap(); + let mut selected_deck_limits = selected_deck_limits_at_start; + for limit in remaining.values_mut() { + limit.cap_to(selected_deck_limits); + } + + self.storage.update_active_decks(&decks[0])?; + let mut queues = QueueBuilder::new(sort_options.clone()); let get_bury_mode = |home_deck: DeckId| { deck_map @@ -229,28 +239,70 @@ impl Collection { .unwrap_or_default() }; - for (deck, mut limit) in decks.iter().zip(limits).skip(parent_count) { - if limit.review > 0 { - self.storage.for_each_due_card_in_deck( - timing.days_elapsed, - timing.next_day_at, - deck.id, - |queue, card| { - let bury = get_bury_mode(card.original_deck_id.or(deck.id)); - queues.add_due_card(&mut limit, queue, card, bury) - }, - )?; + // intraday cards first, noting down any notes that will need burying + self.storage + .for_each_intraday_card_in_active_decks(timing.next_day_at, |card| { + let bury = get_bury_mode(card.current_deck_id); + queues.add_intraday_learning_card(card, bury) + })?; + + // reviews and interday learning next + if selected_deck_limits.review != 0 { + self.storage.for_each_review_card_in_active_decks( + timing.days_elapsed, + sort_options.review_order, + |queue, card| { + if selected_deck_limits.review == 0 { + return false; + } + let bury = get_bury_mode(card.original_deck_id.or(card.current_deck_id)); + let limits = remaining.get_mut(&card.current_deck_id).unwrap(); + if limits.review != 0 && queues.add_due_card(queue, card, bury) { + selected_deck_limits.review -= 1; + limits.review -= 1; + } + + true + }, + )?; + } + + // New cards last + for limit in remaining.values_mut() { + limit.new = limit.new.min(limit.review).min(selected_deck_limits.review); + } + selected_deck_limits.new = selected_deck_limits.new.min(selected_deck_limits.review); + let can_exit_early = sort_options.new_gather_priority == NewCardGatherPriority::Deck; + for deck in &decks { + if can_exit_early && selected_deck_limits.new == 0 { + break; } + let limit = remaining.get_mut(&deck.id).unwrap(); if limit.new > 0 { self.storage.for_each_new_card_in_deck(deck.id, |card| { let bury = get_bury_mode(card.original_deck_id.or(deck.id)); - queues.add_new_card(&mut limit, card, bury) + if limit.new != 0 { + if queues.add_new_card(card, bury) { + limit.new -= 1; + selected_deck_limits.new = selected_deck_limits.new.saturating_sub(1); + } + + true + } else { + false + } })?; } } + let final_limits = RemainingLimits { + new: selected_deck_limits_at_start + .new + .min(selected_deck_limits.review), + ..selected_deck_limits_at_start + }; let queues = queues.build( - selected_deck_limits, + final_limits, self.learn_ahead_secs() as i64, deck_id, timing.days_elapsed, diff --git a/rslib/src/scheduler/queue/builder/sorting.rs b/rslib/src/scheduler/queue/builder/sorting.rs index e7cf25f18..1f6c3697e 100644 --- a/rslib/src/scheduler/queue/builder/sorting.rs +++ b/rslib/src/scheduler/queue/builder/sorting.rs @@ -31,23 +31,10 @@ impl QueueBuilder { .for_each(DueCard::hash_id_and_mtime); self.day_learning.sort_unstable_by(day_then_hash); - match self.sort_options.review_order { - ReviewCardOrder::DayThenRandom => { - self.review.iter_mut().for_each(DueCard::hash_id_and_mtime); - self.review.sort_unstable_by(day_then_hash); - } - ReviewCardOrder::IntervalsAscending => { - self.review.sort_unstable_by(intervals_ascending); - } - ReviewCardOrder::IntervalsDescending => { - self.review - .sort_unstable_by(|a, b| intervals_ascending(b, a)); - } // ReviewCardOrder::RelativeOverdue => { - // self.review - // .iter_mut() - // .for_each(|card| card.set_hash_to_relative_overdue(current_day)); - // self.review.sort_unstable_by(due_card_hash) - // } + // other sorting is done in SQL + if self.sort_options.review_order == ReviewCardOrder::DayThenRandom { + self.review.iter_mut().for_each(DueCard::hash_id_and_mtime); + self.review.sort_unstable_by(day_then_hash); } } } @@ -72,15 +59,6 @@ fn day_then_hash(a: &DueCard, b: &DueCard) -> Ordering { (a.due, a.hash).cmp(&(b.due, b.hash)) } -#[allow(dead_code)] -fn due_card_hash(a: &DueCard, b: &DueCard) -> Ordering { - a.hash.cmp(&b.hash) -} - -fn intervals_ascending(a: &DueCard, b: &DueCard) -> Ordering { - (a.interval, a.hash).cmp(&(a.interval, b.hash)) -} - // We sort based on a hash so that if the queue is rebuilt, remaining // cards come back in the same approximate order (mixing + due learning cards // may still result in a different card) diff --git a/rslib/src/scheduler/queue/limits.rs b/rslib/src/scheduler/queue/limits.rs deleted file mode 100644 index dcd93a28e..000000000 --- a/rslib/src/scheduler/queue/limits.rs +++ /dev/null @@ -1,219 +0,0 @@ -// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -use std::collections::HashMap; - -use super::{Deck, DeckKind}; -use crate::deckconfig::{DeckConfig, DeckConfigId}; - -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct RemainingLimits { - pub review: u32, - pub new: u32, -} - -impl RemainingLimits { - pub(crate) fn new(deck: &Deck, config: Option<&DeckConfig>, today: u32) -> Self { - if let Some(config) = config { - let (new_today, rev_today) = deck.new_rev_counts(today); - RemainingLimits { - review: ((config.inner.reviews_per_day as i32) - rev_today).max(0) as u32, - new: ((config.inner.new_per_day as i32) - new_today).max(0) as u32, - } - } else { - RemainingLimits { - review: std::u32::MAX, - new: std::u32::MAX, - } - } - } - - fn limit_to_parent(&mut self, parent: RemainingLimits) { - self.review = self.review.min(parent.review); - self.new = self.new.min(parent.new); - } -} - -pub(super) fn remaining_limits_capped_to_parents( - decks: &[Deck], - config: &HashMap, - today: u32, -) -> Vec { - let mut limits = get_remaining_limits(decks, config, today); - cap_limits_to_parents(decks.iter().map(|d| d.name.as_native_str()), &mut limits); - limits -} - -/// Return the remaining limits for each of the provided decks, in -/// the provided deck order. -fn get_remaining_limits( - decks: &[Deck], - config: &HashMap, - today: u32, -) -> Vec { - decks - .iter() - .map(move |deck| { - // get deck config if not filtered - let config = if let DeckKind::Normal(normal) = &deck.kind { - config.get(&DeckConfigId(normal.config_id)) - } else { - None - }; - RemainingLimits::new(deck, config, today) - }) - .collect() -} - -/// Given a sorted list of deck names and their current limits, -/// cap child limits to their parents. -fn cap_limits_to_parents<'a>( - names: impl IntoIterator, - limits: &'a mut Vec, -) { - let mut parent_limits = vec![]; - let mut last_limit = None; - let mut last_level = 0; - - names - .into_iter() - .zip(limits.iter_mut()) - .for_each(|(name, limits)| { - let level = name.matches('\x1f').count() + 1; - if last_limit.is_none() { - // top-level deck - last_limit = Some(*limits); - last_level = level; - } else { - // add/remove parent limits if descending/ascending - let mut target = level; - while target != last_level { - if target < last_level { - // current deck is at higher level than previous - parent_limits.pop(); - target += 1; - } else { - // current deck is at a lower level than previous. this - // will push the same remaining counts multiple times if - // the deck tree is missing a parent - parent_limits.push(last_limit.unwrap()); - target -= 1; - } - } - - // apply current parent limit - limits.limit_to_parent(*parent_limits.last().unwrap()); - last_level = level; - last_limit = Some(*limits); - } - }) -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn limits() { - let limits_map = vec![ - ( - "A", - RemainingLimits { - review: 100, - new: 20, - }, - ), - ( - "A\x1fB", - RemainingLimits { - review: 50, - new: 30, - }, - ), - ( - "A\x1fC", - RemainingLimits { - review: 10, - new: 10, - }, - ), - ("A\x1fC\x1fD", RemainingLimits { review: 5, new: 30 }), - ( - "A\x1fE", - RemainingLimits { - review: 200, - new: 100, - }, - ), - ]; - let (names, mut limits): (Vec<_>, Vec<_>) = limits_map.into_iter().unzip(); - cap_limits_to_parents(names.into_iter(), &mut limits); - assert_eq!( - &limits, - &[ - RemainingLimits { - review: 100, - new: 20 - }, - RemainingLimits { - review: 50, - new: 20 - }, - RemainingLimits { - review: 10, - new: 10 - }, - RemainingLimits { review: 5, new: 10 }, - RemainingLimits { - review: 100, - new: 20 - } - ] - ); - - // missing parents should not break it - let limits_map = vec![ - ( - "A", - RemainingLimits { - review: 100, - new: 20, - }, - ), - ( - "A\x1fB\x1fC\x1fD", - RemainingLimits { - review: 50, - new: 30, - }, - ), - ( - "A\x1fC", - RemainingLimits { - review: 100, - new: 100, - }, - ), - ]; - - let (names, mut limits): (Vec<_>, Vec<_>) = limits_map.into_iter().unzip(); - cap_limits_to_parents(names.into_iter(), &mut limits); - assert_eq!( - &limits, - &[ - RemainingLimits { - review: 100, - new: 20 - }, - RemainingLimits { - review: 50, - new: 20 - }, - RemainingLimits { - review: 100, - new: 20 - }, - ] - ); - } -} diff --git a/rslib/src/scheduler/queue/mod.rs b/rslib/src/scheduler/queue/mod.rs index 2c3c2f879..8bba17ec7 100644 --- a/rslib/src/scheduler/queue/mod.rs +++ b/rslib/src/scheduler/queue/mod.rs @@ -4,11 +4,10 @@ mod builder; mod entry; mod learning; -mod limits; mod main; pub(crate) mod undo; -use std::collections::VecDeque; +use std::{collections::VecDeque, time::Instant}; pub(crate) use builder::{DueCard, NewCard}; pub(crate) use entry::{QueueEntry, QueueEntryKind}; @@ -171,7 +170,9 @@ impl Collection { self.discard_undo_and_study_queues(); } if self.state.card_queues.is_none() { + let now = Instant::now(); self.state.card_queues = Some(self.build_queues(deck)?); + println!("queue build in {:?}", now.elapsed()); } Ok(self.state.card_queues.as_mut().unwrap()) diff --git a/rslib/src/storage/card/due_cards.sql b/rslib/src/storage/card/due_cards.sql index b5c8ee9fd..1ac3a6fec 100644 --- a/rslib/src/storage/card/due_cards.sql +++ b/rslib/src/storage/card/due_cards.sql @@ -4,16 +4,14 @@ SELECT queue, due, cast(ivl AS integer), cast(mod AS integer), + did, odid FROM cards -WHERE did = ?1 +WHERE did IN ( + SELECT id + FROM active_decks + ) AND ( - ( - queue IN (2, 3) - AND due <= ?2 - ) - OR ( - queue IN (1, 4) - AND due <= ?3 - ) + queue IN (2, 3) + AND due <= ? ) \ No newline at end of file diff --git a/rslib/src/storage/card/intraday_due.sql b/rslib/src/storage/card/intraday_due.sql new file mode 100644 index 000000000..634cacd6b --- /dev/null +++ b/rslib/src/storage/card/intraday_due.sql @@ -0,0 +1,15 @@ +SELECT id, + nid, + due, + cast(mod AS integer), + did, + odid +FROM cards +WHERE did IN ( + SELECT id + FROM active_decks + ) + AND ( + queue IN (1, 4) + AND due <= ? + ) \ No newline at end of file diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 6c3c6433b..c16bbd409 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -14,7 +14,7 @@ use rusqlite::{ use super::ids_to_string; use crate::{ card::{Card, CardId, CardQueue, CardType}, - deckconfig::DeckConfigId, + deckconfig::{DeckConfigId, ReviewCardOrder}, decks::{Deck, DeckId, DeckKind}, error::Result, notes::NoteId, @@ -164,25 +164,52 @@ impl super::SqliteStorage { Ok(()) } - /// Call func() for each due card, stopping when it returns false - /// or no more cards found. - pub(crate) fn for_each_due_card_in_deck( + pub(crate) fn for_each_intraday_card_in_active_decks( + &self, + learn_cutoff: TimestampSecs, + mut func: F, + ) -> Result<()> + where + F: FnMut(DueCard), + { + let mut stmt = self.db.prepare_cached(include_str!("intraday_due.sql"))?; + let mut rows = stmt.query(params![learn_cutoff])?; + while let Some(row) = rows.next()? { + func(DueCard { + id: row.get(0)?, + note_id: row.get(1)?, + due: row.get(2).ok().unwrap_or_default(), + mtime: row.get(3)?, + current_deck_id: row.get(4)?, + original_deck_id: row.get(5)?, + interval: 0, + hash: 0, + }) + } + + Ok(()) + } + + pub(crate) fn for_each_review_card_in_active_decks( &self, day_cutoff: u32, - learn_cutoff: TimestampSecs, - deck: DeckId, + order: ReviewCardOrder, mut func: F, ) -> Result<()> where F: FnMut(CardQueue, DueCard) -> bool, { - let mut stmt = self.db.prepare_cached(include_str!("due_cards.sql"))?; - let mut rows = stmt.query(params![ - // with many subdecks, avoiding named params shaves off a few milliseconds - deck, - day_cutoff, - learn_cutoff - ])?; + let order_clause = match order { + ReviewCardOrder::DayThenRandom => "order by due", + ReviewCardOrder::IntervalsAscending => "order by ivl asc", + ReviewCardOrder::IntervalsDescending => "order by ivl desc", + }; + let mut stmt = self.db.prepare_cached(&format!( + "{} {}", + include_str!("due_cards.sql"), + order_clause + ))?; + let mut rows = stmt.query(params![day_cutoff])?; while let Some(row) = rows.next()? { let queue: CardQueue = row.get(0)?; if !func( @@ -193,8 +220,9 @@ impl super::SqliteStorage { due: row.get(3).ok().unwrap_or_default(), interval: row.get(4)?, mtime: row.get(5)?, + current_deck_id: row.get(6)?, + original_deck_id: row.get(7)?, hash: 0, - original_deck_id: row.get(6)?, }, ) { break; @@ -408,6 +436,8 @@ impl super::SqliteStorage { } pub(crate) fn congrats_info(&self, current: &Deck, today: u32) -> Result { + // FIXME: when v1/v2 are dropped, this line will become obsolete, as it's run + // on queue build by v3 self.update_active_decks(current)?; self.db .prepare(include_str!("congrats.sql"))? diff --git a/rslib/src/storage/deck/due_counts.sql b/rslib/src/storage/deck/due_counts.sql index f93be6523..94e8aa33c 100644 --- a/rslib/src/storage/deck/due_counts.sql +++ b/rslib/src/storage/deck/due_counts.sql @@ -4,7 +4,12 @@ SELECT did, queue = :review_queue AND due <= :day_cutoff ), - -- learning + -- interday learning + sum( + queue = :daylearn_queue + AND due <= :day_cutoff + ), + -- intraday learning sum( ( CASE @@ -15,10 +20,6 @@ SELECT did, queue = :learn_queue AND due < :learn_cutoff ) - OR ( - queue = :daylearn_queue - AND due <= :day_cutoff - ) OR ( queue = :preview_queue AND due <= :learn_cutoff @@ -29,8 +30,6 @@ SELECT did, CASE WHEN queue = :learn_queue AND due < :learn_cutoff THEN left / 1000 - WHEN queue = :daylearn_queue - AND due <= :day_cutoff THEN 1 ELSE 0 END ) diff --git a/rslib/src/storage/deck/mod.rs b/rslib/src/storage/deck/mod.rs index fd26c98b4..482ccca2f 100644 --- a/rslib/src/storage/deck/mod.rs +++ b/rslib/src/storage/deck/mod.rs @@ -1,7 +1,10 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use std::collections::{HashMap, HashSet}; +use std::{ + collections::{HashMap, HashSet}, + iter, +}; use prost::Message; use rusqlite::{named_params, params, Row, NO_PARAMS}; @@ -35,13 +38,24 @@ fn row_to_deck(row: &Row) -> Result { }) } -fn row_to_due_counts(row: &Row) -> Result<(DeckId, DueCounts)> { +fn row_to_due_counts(row: &Row, v3: bool) -> Result<(DeckId, DueCounts)> { + let deck_id = row.get(0)?; + let new = row.get(1)?; + let mut review = row.get(2)?; + let interday: u32 = row.get(3)?; + let intraday: u32 = row.get(4)?; + let learning = if v3 { + review += interday; + intraday + } else { + intraday + interday + }; Ok(( - row.get(0)?, + deck_id, DueCounts { - new: row.get(1)?, - review: row.get(2)?, - learning: row.get(3)?, + new, + review, + learning, }, )) } @@ -193,24 +207,11 @@ impl SqliteStorage { .collect() } - /// Return the provided deck with its parents and children in an ordered list, and - /// the number of parent decks that need to be skipped to get to the chosen deck. - pub(crate) fn deck_with_parents_and_children( - &self, - deck_id: DeckId, - ) -> Result<(Vec, usize)> { + pub(crate) fn deck_with_children(&self, deck_id: DeckId) -> Result> { let deck = self.get_deck(deck_id)?.ok_or(AnkiError::NotFound)?; - let mut parents = self.parent_decks(&deck)?; - parents.reverse(); - let parent_count = parents.len(); - let prefix_start = format!("{}\x1f", deck.name); let prefix_end = format!("{}\x20", deck.name); - parents.push(deck); - - let decks = parents - .into_iter() - .map(Result::Ok) + iter::once(Ok(deck)) .chain( self.db .prepare_cached(concat!( @@ -219,9 +220,7 @@ impl SqliteStorage { ))? .query_and_then(&[prefix_start, prefix_end], row_to_deck)?, ) - .collect::>()?; - - Ok((decks, parent_count)) + .collect() } /// Return the parents of `child`, with the most immediate parent coming first. @@ -252,6 +251,7 @@ impl SqliteStorage { day_cutoff: u32, learn_cutoff: u32, top_deck: Option<&str>, + v3: bool, ) -> Result> { let sched_ver = sched as u8; let mut params = named_params! { @@ -292,7 +292,7 @@ impl SqliteStorage { self.db .prepare_cached(sql)? - .query_and_then_named(¶ms, row_to_due_counts)? + .query_and_then_named(¶ms, |row| row_to_due_counts(row, v3))? .collect() } @@ -323,7 +323,7 @@ impl SqliteStorage { /// Write active decks into temporary active_decks table. pub(crate) fn update_active_decks(&self, current: &Deck) -> Result<()> { self.db.execute_batch(concat!( - "drop table if exists temp.active_decks;", + "drop table if exists active_decks;", "create temporary table active_decks (id integer primary key not null);" ))?; diff --git a/ts/deckoptions/AdvancedOptions.svelte b/ts/deckoptions/AdvancedOptions.svelte index 49da2094e..625b8efe2 100644 --- a/ts/deckoptions/AdvancedOptions.svelte +++ b/ts/deckoptions/AdvancedOptions.svelte @@ -67,10 +67,3 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html defaultValue={defaults.lapseMultiplier} value={$config.lapseMultiplier} on:changed={(evt) => ($config.lapseMultiplier = evt.detail.value)} /> - - diff --git a/ts/deckoptions/ConfigEditor.svelte b/ts/deckoptions/ConfigEditor.svelte index 3e41acb66..16268b34e 100644 --- a/ts/deckoptions/ConfigEditor.svelte +++ b/ts/deckoptions/ConfigEditor.svelte @@ -8,6 +8,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import NewOptions from "./NewOptions.svelte"; import AdvancedOptions from "./AdvancedOptions.svelte"; import ReviewOptions from "./ReviewOptions.svelte"; + import LapseOptions from "./LapseOptions.svelte"; import GeneralOptions from "./GeneralOptions.svelte"; import Addons from "./Addons.svelte"; import type { DeckOptionsState } from "./lib"; @@ -30,6 +31,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + diff --git a/ts/deckoptions/DailyLimits.svelte b/ts/deckoptions/DailyLimits.svelte index e8aaf53b7..6b634d981 100644 --- a/ts/deckoptions/DailyLimits.svelte +++ b/ts/deckoptions/DailyLimits.svelte @@ -13,7 +13,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let parentLimits = state.parentLimits; $: newCardsGreaterThanParent = - $config.newPerDay > $parentLimits.newCards + !state.v3Scheduler && $config.newPerDay > $parentLimits.newCards ? tr.deckConfigDailyLimitWillBeCapped({ cards: $parentLimits.newCards }) : ""; diff --git a/ts/deckoptions/LapseOptions.svelte b/ts/deckoptions/LapseOptions.svelte new file mode 100644 index 000000000..cda0ced36 --- /dev/null +++ b/ts/deckoptions/LapseOptions.svelte @@ -0,0 +1,61 @@ + + + +
+

{tr.schedulingLapses()}

+ + ($config.relearnSteps = evt.detail.value)} /> + + + + + + +
diff --git a/ts/deckoptions/LearningOptions.svelte b/ts/deckoptions/LearningOptions.svelte index b9e018e69..72d11e200 100644 --- a/ts/deckoptions/LearningOptions.svelte +++ b/ts/deckoptions/LearningOptions.svelte @@ -14,7 +14,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let defaults = state.defaults; -

{tr.deckConfigLearningRelearningTitle()}

+

{tr.deckConfigLearningTitle()}

($config.learnSteps = evt.detail.value)} /> - ($config.relearnSteps = evt.detail.value)} /> - {#if state.v3Scheduler} + choices={newInsertOrderChoices} + defaultValue={defaults.newCardInsertOrder} + bind:value={$config.newCardInsertOrder} /> + + diff --git a/ts/deckoptions/ReviewOptions.svelte b/ts/deckoptions/ReviewOptions.svelte index 750529b69..7598ab7a5 100644 --- a/ts/deckoptions/ReviewOptions.svelte +++ b/ts/deckoptions/ReviewOptions.svelte @@ -4,7 +4,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -->
@@ -32,20 +29,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html defaultValue={defaults.reviewOrder} bind:value={$config.reviewOrder} /> - - - -