Support rescheduling on weight/retention change

This commit is contained in:
Damien Elmes 2023-10-01 14:59:12 +10:00
parent 0ef28853fd
commit 072cd37b42
14 changed files with 165 additions and 42 deletions

View file

@ -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.

View file

@ -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;
}

View file

@ -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 {

View file

@ -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! {

View file

@ -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(())

View file

@ -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,
};

View file

@ -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> {

View file

@ -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>,

View file

@ -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)?;
}

View file

@ -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 {

View file

@ -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,

View file

@ -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

View file

@ -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 {

View file

@ -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>