mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
Support rescheduling on weight/retention change
This commit is contained in:
parent
0ef28853fd
commit
072cd37b42
14 changed files with 165 additions and 42 deletions
|
@ -339,6 +339,7 @@ deck-config-fsrs-on-all-clients =
|
|||
not work correctly if one of your clients is older.
|
||||
deck-config-set-optimal-retention = Set desired retention to { $num }
|
||||
deck-config-complete = { $num }% complete.
|
||||
deck-config-reschedule-cards-on-change = Reschedule cards on change
|
||||
|
||||
## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.
|
||||
|
||||
|
|
|
@ -139,7 +139,9 @@ message DeckConfig {
|
|||
bool bury_reviews = 28;
|
||||
bool bury_interday_learning = 29;
|
||||
|
||||
float desired_retention = 37; // for fsrs
|
||||
// for fsrs
|
||||
float desired_retention = 37;
|
||||
bool reschedule_fsrs_cards = 39;
|
||||
|
||||
bytes other = 255;
|
||||
}
|
||||
|
|
|
@ -70,6 +70,7 @@ const DEFAULT_DECK_CONFIG_INNER: DeckConfigInner = DeckConfigInner {
|
|||
fsrs_weights: vec![],
|
||||
desired_retention: 0.9,
|
||||
other: Vec::new(),
|
||||
reschedule_fsrs_cards: false,
|
||||
};
|
||||
|
||||
impl Default for DeckConfig {
|
||||
|
|
|
@ -71,6 +71,8 @@ pub struct DeckConfSchema11 {
|
|||
desired_retention: f32,
|
||||
#[serde(default)]
|
||||
stop_timer_on_answer: bool,
|
||||
#[serde(default)]
|
||||
reschedule_fsrs_cards: bool,
|
||||
|
||||
#[serde(flatten)]
|
||||
other: HashMap<String, Value>,
|
||||
|
@ -260,6 +262,7 @@ impl Default for DeckConfSchema11 {
|
|||
bury_interday_learning: false,
|
||||
fsrs_weights: vec![],
|
||||
desired_retention: 0.9,
|
||||
reschedule_fsrs_cards: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -331,6 +334,7 @@ impl From<DeckConfSchema11> for DeckConfig {
|
|||
bury_interday_learning: c.bury_interday_learning,
|
||||
fsrs_weights: c.fsrs_weights,
|
||||
desired_retention: c.desired_retention,
|
||||
reschedule_fsrs_cards: c.reschedule_fsrs_cards,
|
||||
other: other_bytes,
|
||||
},
|
||||
}
|
||||
|
@ -425,6 +429,7 @@ impl From<DeckConfig> for DeckConfSchema11 {
|
|||
bury_interday_learning: i.bury_interday_learning,
|
||||
fsrs_weights: i.fsrs_weights,
|
||||
desired_retention: i.desired_retention,
|
||||
reschedule_fsrs_cards: i.reschedule_fsrs_cards,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -449,6 +454,7 @@ static RESERVED_DECKCONF_KEYS: Set<&'static str> = phf_set! {
|
|||
"fsrsWeights",
|
||||
"desiredRetention",
|
||||
"stopTimerOnAnswer",
|
||||
"rescheduleFsrsCards"
|
||||
};
|
||||
|
||||
static RESERVED_DECKCONF_NEW_KEYS: Set<&'static str> = phf_set! {
|
||||
|
|
|
@ -16,7 +16,7 @@ use fsrs::DEFAULT_WEIGHTS;
|
|||
use crate::config::StringKey;
|
||||
use crate::decks::NormalDeck;
|
||||
use crate::prelude::*;
|
||||
use crate::scheduler::fsrs::memory_state::WeightsAndDesiredRetention;
|
||||
use crate::scheduler::fsrs::memory_state::UpdateMemoryStateRequest;
|
||||
use crate::search::JoinSearches;
|
||||
use crate::search::SearchNode;
|
||||
|
||||
|
@ -123,19 +123,19 @@ impl Collection {
|
|||
.collect())
|
||||
}
|
||||
|
||||
fn update_deck_configs_inner(&mut self, mut input: UpdateDeckConfigsRequest) -> Result<()> {
|
||||
require!(!input.configs.is_empty(), "config not provided");
|
||||
fn update_deck_configs_inner(&mut self, mut req: UpdateDeckConfigsRequest) -> Result<()> {
|
||||
require!(!req.configs.is_empty(), "config not provided");
|
||||
let configs_before_update = self.storage.get_deck_config_map()?;
|
||||
let mut configs_after_update = configs_before_update.clone();
|
||||
|
||||
// handle removals first
|
||||
for dcid in &input.removed_config_ids {
|
||||
for dcid in &req.removed_config_ids {
|
||||
self.remove_deck_config_inner(*dcid)?;
|
||||
configs_after_update.remove(dcid);
|
||||
}
|
||||
|
||||
// add/update provided configs
|
||||
for conf in &mut input.configs {
|
||||
for conf in &mut req.configs {
|
||||
let weight_len = conf.inner.fsrs_weights.len();
|
||||
if weight_len != 0 && weight_len != 17 {
|
||||
return Err(AnkiError::FsrsWeightsInvalid);
|
||||
|
@ -145,11 +145,11 @@ impl Collection {
|
|||
}
|
||||
|
||||
// get selected deck and possibly children
|
||||
let selected_deck_ids: HashSet<_> = if input.apply_to_children {
|
||||
let selected_deck_ids: HashSet<_> = if req.apply_to_children {
|
||||
let deck = self
|
||||
.storage
|
||||
.get_deck(input.target_deck_id)?
|
||||
.or_not_found(input.target_deck_id)?;
|
||||
.get_deck(req.target_deck_id)?
|
||||
.or_not_found(req.target_deck_id)?;
|
||||
self.storage
|
||||
.child_decks(&deck)?
|
||||
.iter()
|
||||
|
@ -157,18 +157,18 @@ impl Collection {
|
|||
.map(|d| d.id)
|
||||
.collect()
|
||||
} else {
|
||||
[input.target_deck_id].iter().cloned().collect()
|
||||
[req.target_deck_id].iter().cloned().collect()
|
||||
};
|
||||
|
||||
// loop through all normal decks
|
||||
let usn = self.usn()?;
|
||||
let today = self.timing_today()?.days_elapsed;
|
||||
let selected_config = input.configs.last().unwrap();
|
||||
let selected_config = req.configs.last().unwrap();
|
||||
let mut decks_needing_memory_recompute: HashMap<DeckConfigId, Vec<SearchNode>> =
|
||||
Default::default();
|
||||
let fsrs_toggled = self.get_config_bool(BoolKey::Fsrs) != input.fsrs;
|
||||
let fsrs_toggled = self.get_config_bool(BoolKey::Fsrs) != req.fsrs;
|
||||
if fsrs_toggled {
|
||||
self.set_config_bool_inner(BoolKey::Fsrs, input.fsrs)?;
|
||||
self.set_config_bool_inner(BoolKey::Fsrs, req.fsrs)?;
|
||||
}
|
||||
for deck in self.storage.get_all_decks()? {
|
||||
if let Ok(normal) = deck.normal() {
|
||||
|
@ -181,6 +181,7 @@ impl Collection {
|
|||
.map(|c| c.inner.new_card_insert_order())
|
||||
.unwrap_or_default();
|
||||
let previous_weights = previous_config.map(|c| &c.inner.fsrs_weights);
|
||||
let previous_retention = previous_config.map(|c| c.inner.desired_retention);
|
||||
|
||||
// if a selected (sub)deck, or its old config was removed, update deck to point
|
||||
// to new config
|
||||
|
@ -189,7 +190,7 @@ impl Collection {
|
|||
{
|
||||
let mut updated = deck.clone();
|
||||
updated.normal_mut()?.config_id = selected_config.id.0;
|
||||
update_deck_limits(updated.normal_mut()?, &input.limits, today);
|
||||
update_deck_limits(updated.normal_mut()?, &req.limits, today);
|
||||
self.update_deck_inner(&mut updated, deck, usn)?;
|
||||
selected_config.id
|
||||
} else {
|
||||
|
@ -207,7 +208,11 @@ impl Collection {
|
|||
|
||||
// if weights differ, memory state needs to be recomputed
|
||||
let current_weights = current_config.map(|c| &c.inner.fsrs_weights);
|
||||
if fsrs_toggled || previous_weights != current_weights {
|
||||
let current_retention = current_config.map(|c| c.inner.desired_retention);
|
||||
if fsrs_toggled
|
||||
|| previous_weights != current_weights
|
||||
|| previous_retention != current_retention
|
||||
{
|
||||
decks_needing_memory_recompute
|
||||
.entry(current_config_id)
|
||||
.or_default()
|
||||
|
@ -219,13 +224,18 @@ impl Collection {
|
|||
}
|
||||
|
||||
if !decks_needing_memory_recompute.is_empty() {
|
||||
let input: Vec<(Option<WeightsAndDesiredRetention>, Vec<SearchNode>)> =
|
||||
let input: Vec<(Option<UpdateMemoryStateRequest>, Vec<SearchNode>)> =
|
||||
decks_needing_memory_recompute
|
||||
.into_iter()
|
||||
.map(|(conf_id, search)| {
|
||||
let weights = configs_after_update.get(&conf_id).and_then(|c| {
|
||||
if input.fsrs {
|
||||
Some((c.inner.fsrs_weights.clone(), c.inner.desired_retention))
|
||||
if req.fsrs {
|
||||
Some(UpdateMemoryStateRequest {
|
||||
weights: c.inner.fsrs_weights.clone(),
|
||||
desired_retention: c.inner.desired_retention,
|
||||
max_interval: c.inner.maximum_review_interval,
|
||||
reschedule: c.inner.reschedule_fsrs_cards,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
@ -236,10 +246,10 @@ impl Collection {
|
|||
self.update_memory_state(input)?;
|
||||
}
|
||||
|
||||
self.set_config_string_inner(StringKey::CardStateCustomizer, &input.card_state_customizer)?;
|
||||
self.set_config_string_inner(StringKey::CardStateCustomizer, &req.card_state_customizer)?;
|
||||
self.set_config_bool_inner(
|
||||
BoolKey::NewCardsIgnoreReviewLimit,
|
||||
input.new_cards_ignore_review_limit,
|
||||
req.new_cards_ignore_review_limit,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
|
|
|
@ -89,17 +89,23 @@ impl Collection {
|
|||
pub(crate) fn log_manually_scheduled_review(
|
||||
&mut self,
|
||||
card: &Card,
|
||||
original: &Card,
|
||||
original_interval: u32,
|
||||
usn: Usn,
|
||||
) -> Result<()> {
|
||||
let ease_factor = u32::try_from(
|
||||
card.memory_state
|
||||
.map(|s| ((s.difficulty_shifted() * 1000.) as u16))
|
||||
.unwrap_or(card.ease_factor),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let entry = RevlogEntry {
|
||||
id: RevlogId::new(),
|
||||
cid: card.id,
|
||||
usn,
|
||||
button_chosen: 0,
|
||||
interval: i32::try_from(card.interval).unwrap_or(i32::MAX),
|
||||
last_interval: i32::try_from(original.interval).unwrap_or(i32::MAX),
|
||||
ease_factor: u32::from(card.ease_factor),
|
||||
last_interval: i32::try_from(original_interval).unwrap_or(i32::MAX),
|
||||
ease_factor,
|
||||
taken_millis: 0,
|
||||
review_kind: RevlogReviewKind::Manual,
|
||||
};
|
||||
|
|
|
@ -367,7 +367,7 @@ impl Collection {
|
|||
let days_elapsed = self
|
||||
.storage
|
||||
.time_of_last_review(card.id)?
|
||||
.map(|ts| ts.elapsed_days_since(timing.next_day_at))
|
||||
.map(|ts| timing.next_day_at.elapsed_days_since(ts))
|
||||
.unwrap_or_default() as u32;
|
||||
Some(fsrs.next_states(
|
||||
card.memory_state.map(Into::into),
|
||||
|
@ -464,6 +464,12 @@ pub mod test_helpers {
|
|||
}
|
||||
}
|
||||
|
||||
impl Card {
|
||||
pub(crate) fn get_fuzz_factor(&self) -> Option<f32> {
|
||||
get_fuzz_factor(get_fuzz_seed(self))
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a consistent seed for a given card at a given number of reps.
|
||||
/// If in test environment, disable fuzzing.
|
||||
fn get_fuzz_seed(card: &Card) -> Option<u64> {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
// 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 anki_proto::scheduler::ComputeMemoryStateResponse;
|
||||
use fsrs::FSRSItem;
|
||||
use fsrs::FSRS;
|
||||
|
@ -11,6 +13,7 @@ use crate::prelude::*;
|
|||
use crate::revlog::RevlogEntry;
|
||||
use crate::scheduler::fsrs::weights::single_card_revlog_to_items;
|
||||
use crate::scheduler::fsrs::weights::Weights;
|
||||
use crate::scheduler::states::fuzz::with_review_fuzz;
|
||||
use crate::search::JoinSearches;
|
||||
use crate::search::Negated;
|
||||
use crate::search::SearchNode;
|
||||
|
@ -22,7 +25,13 @@ pub struct ComputeMemoryProgress {
|
|||
pub total_cards: u32,
|
||||
}
|
||||
|
||||
pub(crate) type WeightsAndDesiredRetention = (Weights, f32);
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct UpdateMemoryStateRequest {
|
||||
pub weights: Weights,
|
||||
pub desired_retention: f32,
|
||||
pub max_interval: u32,
|
||||
pub reschedule: bool,
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
/// For each provided set of weights, locate cards with the provided search,
|
||||
|
@ -32,26 +41,71 @@ impl Collection {
|
|||
/// memory state should be removed.
|
||||
pub(crate) fn update_memory_state(
|
||||
&mut self,
|
||||
entries: Vec<(Option<WeightsAndDesiredRetention>, Vec<SearchNode>)>,
|
||||
entries: Vec<(Option<UpdateMemoryStateRequest>, Vec<SearchNode>)>,
|
||||
) -> Result<()> {
|
||||
let timing = self.timing_today()?;
|
||||
let usn = self.usn()?;
|
||||
for (weights_and_desired_retention, search) in entries {
|
||||
for (req, search) in entries {
|
||||
let search = SearchBuilder::any(search.into_iter())
|
||||
.and(SearchNode::State(StateKind::New).negated());
|
||||
let revlog = self.revlog_for_srs(search)?;
|
||||
let reschedule = req.as_ref().map(|e| e.reschedule).unwrap_or_default();
|
||||
let last_reviews = if reschedule {
|
||||
Some(get_last_reviews(&revlog))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let items = fsrs_items_for_memory_state(revlog, timing.next_day_at);
|
||||
let desired_retention = weights_and_desired_retention.as_ref().map(|w| w.1);
|
||||
let fsrs = FSRS::new(weights_and_desired_retention.as_ref().map(|w| &w.0[..]))?;
|
||||
let desired_retention = req.as_ref().map(|w| w.desired_retention);
|
||||
let fsrs = FSRS::new(req.as_ref().map(|w| &w.weights[..]))?;
|
||||
let mut progress = self.new_progress_handler::<ComputeMemoryProgress>();
|
||||
progress.update(false, |s| s.total_cards = items.len() as u32)?;
|
||||
for (idx, (card_id, item)) in items.into_iter().enumerate() {
|
||||
progress.update(true, |state| state.current_cards = idx as u32 + 1)?;
|
||||
let mut card = self.storage.get_card(card_id)?.or_not_found(card_id)?;
|
||||
let original = card.clone();
|
||||
if weights_and_desired_retention.is_some() {
|
||||
card.set_memory_state(&fsrs, item);
|
||||
if let Some(req) = &req {
|
||||
card.set_memory_state(&fsrs, item.clone());
|
||||
card.desired_retention = desired_retention;
|
||||
// if rescheduling
|
||||
if let Some(reviews) = &last_reviews {
|
||||
// and we have a last review time for the card
|
||||
if let Some(last_review) = reviews.get(&card.id) {
|
||||
let days_elapsed =
|
||||
timing.next_day_at.elapsed_days_since(*last_review) as i32;
|
||||
// and the card's not new
|
||||
if let Some(state) = &card.memory_state {
|
||||
// or in (re)learning
|
||||
if card.ctype == CardType::Review {
|
||||
// reschedule it
|
||||
let original_interval = card.interval;
|
||||
let interval = fsrs.next_interval(
|
||||
Some(state.stability),
|
||||
card.desired_retention.unwrap(),
|
||||
0,
|
||||
) as f32;
|
||||
card.interval = with_review_fuzz(
|
||||
card.get_fuzz_factor(),
|
||||
interval,
|
||||
1,
|
||||
req.max_interval,
|
||||
);
|
||||
let due = if card.original_due != 0 {
|
||||
&mut card.original_due
|
||||
} else {
|
||||
&mut card.due
|
||||
};
|
||||
*due = (timing.days_elapsed as i32) - days_elapsed
|
||||
+ card.interval as i32;
|
||||
self.log_manually_scheduled_review(
|
||||
&card,
|
||||
original_interval,
|
||||
usn,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
card.memory_state = None;
|
||||
card.desired_retention = None;
|
||||
|
@ -117,6 +171,25 @@ pub(crate) fn fsrs_items_for_memory_state(
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Return a map of cards to the last time they were reviewed.
|
||||
fn get_last_reviews(revlogs: &[RevlogEntry]) -> HashMap<CardId, TimestampSecs> {
|
||||
let mut out = HashMap::new();
|
||||
revlogs
|
||||
.iter()
|
||||
.group_by(|r| r.cid)
|
||||
.into_iter()
|
||||
.for_each(|(card_id, group)| {
|
||||
let mut last_ts = TimestampSecs::zero();
|
||||
for entry in group.into_iter().filter(|r| r.button_chosen >= 1) {
|
||||
last_ts = entry.id.as_secs();
|
||||
}
|
||||
if last_ts != TimestampSecs::zero() {
|
||||
out.insert(card_id, last_ts);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// When calculating memory state, only the last FSRSItem is required.
|
||||
pub(crate) fn single_card_revlog_to_item(
|
||||
entries: Vec<RevlogEntry>,
|
||||
|
|
|
@ -168,7 +168,7 @@ impl Collection {
|
|||
position += 1;
|
||||
}
|
||||
if log {
|
||||
col.log_manually_scheduled_review(&card, &original, usn)?;
|
||||
col.log_manually_scheduled_review(&card, original.interval, usn)?;
|
||||
}
|
||||
col.update_card_inner(&mut card, original, usn)?;
|
||||
}
|
||||
|
|
|
@ -134,7 +134,7 @@ impl Collection {
|
|||
let original = card.clone();
|
||||
let days_from_today = distribution.sample(&mut rng);
|
||||
card.set_due_date(today, days_from_today, ease_factor, spec.force_reset);
|
||||
col.log_manually_scheduled_review(&card, &original, usn)?;
|
||||
col.log_manually_scheduled_review(&card, original.interval, usn)?;
|
||||
col.update_card_inner(&mut card, original, usn)?;
|
||||
}
|
||||
if let Some(key) = context {
|
||||
|
|
|
@ -33,12 +33,21 @@ impl<'a> StateContext<'a> {
|
|||
/// Apply fuzz, respecting the passed bounds.
|
||||
/// Caller must ensure reasonable bounds.
|
||||
pub(crate) fn with_review_fuzz(&self, interval: f32, minimum: u32, maximum: u32) -> u32 {
|
||||
if let Some(fuzz_factor) = self.fuzz_factor {
|
||||
let (lower, upper) = constrained_fuzz_bounds(interval, minimum, maximum);
|
||||
(lower as f32 + fuzz_factor * ((1 + upper - lower) as f32)).floor() as u32
|
||||
} else {
|
||||
(interval.round() as u32).clamp(minimum, maximum)
|
||||
}
|
||||
with_review_fuzz(self.fuzz_factor, interval, minimum, maximum)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_review_fuzz(
|
||||
fuzz_factor: Option<f32>,
|
||||
interval: f32,
|
||||
minimum: u32,
|
||||
maximum: u32,
|
||||
) -> u32 {
|
||||
if let Some(fuzz_factor) = fuzz_factor {
|
||||
let (lower, upper) = constrained_fuzz_bounds(interval, minimum, maximum);
|
||||
(lower as f32 + fuzz_factor * ((1 + upper - lower) as f32)).floor() as u32
|
||||
} else {
|
||||
(interval.round() as u32).clamp(minimum, maximum)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -61,7 +70,7 @@ fn constrained_fuzz_bounds(interval: f32, minimum: u32, maximum: u32) -> (u32, u
|
|||
(lower, upper)
|
||||
}
|
||||
|
||||
fn fuzz_bounds(interval: f32) -> (u32, u32) {
|
||||
pub(crate) fn fuzz_bounds(interval: f32) -> (u32, u32) {
|
||||
let delta = fuzz_delta(interval);
|
||||
(
|
||||
(interval - delta).round() as u32,
|
||||
|
|
|
@ -30,7 +30,7 @@ impl Collection {
|
|||
let days_elapsed = self
|
||||
.storage
|
||||
.time_of_last_review(card.id)?
|
||||
.map(|ts| ts.elapsed_days_since(timing.next_day_at))
|
||||
.map(|ts| timing.next_day_at.elapsed_days_since(ts))
|
||||
.unwrap_or_default() as u32;
|
||||
let fsrs_retrievability = card
|
||||
.memory_state
|
||||
|
|
|
@ -29,7 +29,7 @@ impl TimestampSecs {
|
|||
}
|
||||
|
||||
pub fn elapsed_days_since(self, other: TimestampSecs) -> u64 {
|
||||
(other.0 - self.0).max(0) as u64 / 86_400
|
||||
(self.0 - other.0).max(0) as u64 / 86_400
|
||||
}
|
||||
|
||||
pub fn as_millis(self) -> TimestampMillis {
|
||||
|
|
|
@ -16,6 +16,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
} from "@tslib/backend";
|
||||
import * as tr from "@tslib/ftl";
|
||||
import { runWithBackendProgress } from "@tslib/progress";
|
||||
import SwitchRow from "components/SwitchRow.svelte";
|
||||
|
||||
import SettingTitle from "../components/SettingTitle.svelte";
|
||||
import type { DeckOptionsState } from "./lib";
|
||||
|
@ -223,6 +224,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</WeightsInputRow>
|
||||
</div>
|
||||
|
||||
<div class="m-2">
|
||||
<SwitchRow bind:value={$config.rescheduleFsrsCards} defaultValue={false}>
|
||||
<SettingTitle>
|
||||
{tr.deckConfigRescheduleCardsOnChange()}
|
||||
</SettingTitle>
|
||||
</SwitchRow>
|
||||
</div>
|
||||
|
||||
<div class="m-2">
|
||||
<details>
|
||||
<summary>{tr.deckConfigComputeOptimalWeights()}</summary>
|
||||
|
|
Loading…
Reference in a new issue