Feat/FSRS-5 (#3298)

* Feat/FSRS-5

* adapt the SimulatorConfig of FSRS-5

* update parameters from FSRS-4.5

* udpate to FSRS-rs v1.1.0

* ./ninja fix:minilints

* pass ci

* update cargo-deny to 0.14.24

* udpate to FSRS-rs v1.1.1

* update to fsrs-rs v1.1.2
This commit is contained in:
Jarrett Ye 2024-07-21 22:02:24 +08:00 committed by GitHub
parent 5216fa959e
commit 52ce6e6a6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 110 additions and 236 deletions

4
Cargo.lock generated
View File

@ -1901,9 +1901,9 @@ dependencies = [
[[package]]
name = "fsrs"
version = "0.6.4"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70cec685337af48789e58cea6ef59ee9f01289d1083428b03fe14e76b98c817c"
checksum = "347ee5ad2666861567827d8c8ed8a97c924d943a9af8e50f24b2691dda95cb59"
dependencies = [
"burn",
"itertools 0.12.1",

View File

@ -35,7 +35,7 @@ git = "https://github.com/ankitects/linkcheck.git"
rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca"
[workspace.dependencies.fsrs]
version = "0.6.4"
version = "1.1.2"
# git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
# rev = "58ca25ed2bc4bb1dc376208bbcaed7f5a501b941"
# path = "../open-spaced-repetition/fsrs-rs"

View File

@ -1252,7 +1252,7 @@
},
{
"name": "fsrs",
"version": "0.6.4",
"version": "1.1.2",
"authors": "Open Spaced Repetition",
"repository": "https://github.com/open-spaced-repetition/fsrs-rs",
"license": "BSD-3-Clause",

View File

@ -402,27 +402,26 @@ message ComputeOptimalRetentionResponse {
float optimal_retention = 1;
}
message OptimalRetentionParameters {
double recall_secs_hard = 1;
double recall_secs_good = 2;
double recall_secs_easy = 3;
double forget_secs = 4;
double learn_secs = 5;
double first_rating_probability_again = 6;
double first_rating_probability_hard = 7;
double first_rating_probability_good = 8;
double first_rating_probability_easy = 9;
double review_rating_probability_hard = 10;
double review_rating_probability_good = 11;
double review_rating_probability_easy = 12;
}
message GetOptimalRetentionParametersRequest {
string search = 1;
}
message GetOptimalRetentionParametersResponse {
OptimalRetentionParameters params = 1;
uint32 deck_size = 1;
uint32 learn_span = 2;
float max_cost_perday = 3;
float max_ivl = 4;
repeated float learn_costs = 5;
repeated float review_costs = 6;
repeated float first_rating_prob = 7;
repeated float review_rating_prob = 8;
repeated float first_rating_offsets = 9;
repeated float first_session_lens = 10;
float forget_rating_offset = 11;
float forget_session_len = 12;
float loss_aversion = 13;
uint32 learn_limit = 14;
uint32 review_limit = 15;
}
message EvaluateWeightsRequest {

View File

@ -159,12 +159,19 @@ impl Collection {
// add/update provided configs
for conf in &mut req.configs {
let weight_len = conf.inner.fsrs_weights.len();
if weight_len == 17 {
if weight_len == 19 {
for i in 0..19 {
if !conf.inner.fsrs_weights[i].is_finite() {
return Err(AnkiError::FsrsWeightsInvalid);
}
}
} else if weight_len == 17 {
for i in 0..17 {
if !conf.inner.fsrs_weights[i].is_finite() {
return Err(AnkiError::FsrsWeightsInvalid);
}
}
conf.inner.fsrs_weights.extend_from_slice(&[0.0, 0.0])
} else if weight_len != 0 {
return Err(AnkiError::FsrsWeightsInvalid);
}

View File

@ -374,7 +374,7 @@ mod tests {
item.starting_state.map(Into::into),
Some(FsrsMemoryState {
stability: 99.999954,
difficulty: 5.899495,
difficulty: 5.6932373,
}),
);
let mut card = Card {
@ -385,8 +385,8 @@ mod tests {
assert_int_eq(
card.memory_state,
Some(FsrsMemoryState {
stability: 248.64981,
difficulty: 5.872157,
stability: 248.64305,
difficulty: 5.7909784,
}),
);
// but if there's only a single revlog entry, we'll fall back on current card
@ -411,7 +411,7 @@ mod tests {
card.memory_state,
Some(FsrsMemoryState {
stability: 122.99994,
difficulty: 7.5038733,
difficulty: 7.334526,
}),
);
Ok(())

View File

@ -2,14 +2,12 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use anki_proto::scheduler::ComputeOptimalRetentionRequest;
use anki_proto::scheduler::OptimalRetentionParameters;
use fsrs::extract_simulator_config;
use fsrs::SimulatorConfig;
use fsrs::FSRS;
use itertools::Itertools;
use crate::prelude::*;
use crate::revlog::RevlogEntry;
use crate::revlog::RevlogReviewKind;
use crate::search::SortMode;
#[derive(Default, Clone, Copy, Debug)]
@ -42,23 +40,17 @@ impl Collection {
&SimulatorConfig {
deck_size,
learn_span: req.days_to_simulate as usize,
max_cost_perday: f64::MAX,
max_ivl: req.max_interval as f64,
recall_costs: [p.recall_secs_hard, p.recall_secs_good, p.recall_secs_easy],
forget_cost: p.forget_secs,
learn_cost: p.learn_secs,
first_rating_prob: [
p.first_rating_probability_again,
p.first_rating_probability_hard,
p.first_rating_probability_good,
p.first_rating_probability_easy,
],
review_rating_prob: [
p.review_rating_probability_hard,
p.review_rating_probability_good,
p.review_rating_probability_easy,
],
loss_aversion: req.loss_aversion,
max_cost_perday: f32::MAX,
max_ivl: req.max_interval as f32,
learn_costs: p.learn_costs,
review_costs: p.review_costs,
first_rating_prob: p.first_rating_prob,
review_rating_prob: p.review_rating_prob,
first_rating_offsets: p.first_rating_offsets,
first_session_lens: p.first_session_lens,
forget_rating_offset: p.forget_rating_offset,
forget_session_len: p.forget_session_len,
loss_aversion: req.loss_aversion as f32,
learn_limit,
review_limit: usize::MAX,
},
@ -71,173 +63,44 @@ impl Collection {
.is_ok()
},
)?
.clamp(0.75, 0.95) as f32)
.clamp(0.75, 0.95))
}
pub fn get_optimal_retention_parameters(
&mut self,
revlogs: Vec<RevlogEntry>,
) -> Result<OptimalRetentionParameters> {
let mut first_rating_count = revlogs
.iter()
.group_by(|r| r.cid)
.into_iter()
.map(|(_cid, group)| {
group
.into_iter()
.find(|r| r.review_kind == RevlogReviewKind::Learning && r.button_chosen >= 1)
})
.filter(|r| r.is_some())
.counts_by(|r| r.unwrap().button_chosen);
for button_chosen in 1..=4 {
first_rating_count.entry(button_chosen).or_insert(0);
}
let total_first = first_rating_count.values().sum::<usize>() as f64;
let weight = total_first / (50.0 + total_first);
const DEFAULT_FIRST_RATING_PROB: [f64; 4] = [0.256, 0.084, 0.483, 0.177];
let first_rating_prob = if total_first > 0.0 {
let mut arr = DEFAULT_FIRST_RATING_PROB;
first_rating_count
.iter()
.for_each(|(&button_chosen, &count)| {
let index = button_chosen as usize - 1;
arr[index] = (count as f64 / total_first) * weight
+ DEFAULT_FIRST_RATING_PROB[index] * (1.0 - weight);
});
arr
} else {
DEFAULT_FIRST_RATING_PROB
};
let mut review_rating_count = revlogs
.iter()
.filter(|r| r.review_kind == RevlogReviewKind::Review && r.button_chosen != 1)
.counts_by(|r| r.button_chosen);
for button_chosen in 2..=4 {
review_rating_count.entry(button_chosen).or_insert(0);
}
let total_reviews = review_rating_count.values().sum::<usize>() as f64;
let weight = total_reviews / (50.0 + total_reviews);
const DEFAULT_REVIEW_RATING_PROB: [f64; 3] = [0.224, 0.632, 0.144];
let review_rating_prob = if total_reviews > 0.0 {
let mut arr = DEFAULT_REVIEW_RATING_PROB;
review_rating_count
.iter()
.filter(|(&button_chosen, ..)| button_chosen >= 2)
.for_each(|(&button_chosen, &count)| {
let index = button_chosen as usize - 2;
arr[index] = (count as f64 / total_reviews) * weight
+ DEFAULT_REVIEW_RATING_PROB[index] * (1.0 - weight);
});
arr
} else {
DEFAULT_REVIEW_RATING_PROB
};
let recall_costs = {
const DEFAULT: [f64; 4] = [18.0, 11.8, 7.3, 5.7];
let mut arr = DEFAULT;
revlogs
.iter()
.filter(|r| {
r.review_kind == RevlogReviewKind::Review
&& r.button_chosen > 0
&& r.taken_millis > 0
&& r.taken_millis < 1200000 // 20 minutes
})
.sorted_by(|a, b| a.button_chosen.cmp(&b.button_chosen))
.group_by(|r| r.button_chosen)
.into_iter()
.for_each(|(button_chosen, group)| {
let group_vec = group.into_iter().map(|r| r.taken_millis).collect_vec();
let weight = group_vec.len() as f64 / (50.0 + group_vec.len() as f64);
let index = button_chosen as usize - 1;
arr[index] = median_secs(&group_vec) * weight + DEFAULT[index] * (1.0 - weight);
});
arr
};
let learn_cost = {
const DEFAULT: f64 = 22.8;
let revlogs_filter = revlogs
.iter()
.filter(|r| {
r.review_kind == RevlogReviewKind::Learning
&& r.button_chosen >= 1
&& r.taken_millis > 0
&& r.taken_millis < 1200000 // 20 minutes
})
.map(|r| r.taken_millis);
let group_vec = revlogs_filter.collect_vec();
let weight = group_vec.len() as f64 / (50.0 + group_vec.len() as f64);
median_secs(&group_vec) * weight + DEFAULT * (1.0 - weight)
};
let forget_cost = {
const DEFAULT: f64 = 18.0;
let review_kind_to_total_millis = revlogs
.iter()
.filter(|r| {
r.button_chosen > 0 && r.taken_millis > 0 && r.taken_millis < 1200000
// 20 minutes
})
.sorted_by(|a, b| a.cid.cmp(&b.cid).then(a.id.cmp(&b.id)))
.group_by(|r| r.review_kind)
/*
for example:
o x x o o x x x o o x x o x
|<->| |<--->| |<->| |<>|
x means forgotten, there are 4 consecutive sets of internal relearning in this card.
So each group is counted separately, and each group is summed up internally.(following code)
Finally averaging all groups, so sort by cid and id.
*/
.into_iter()
.map(|(review_kind, group)| {
let total_millis: u32 = group.into_iter().map(|r| r.taken_millis).sum();
(review_kind, total_millis)
})
.collect_vec();
let mut group_sec_by_review_kind: [Vec<_>; 5] = Default::default();
for (review_kind, sec) in review_kind_to_total_millis.into_iter() {
group_sec_by_review_kind[review_kind as usize].push(sec)
}
let recall_cost =
median_secs(&group_sec_by_review_kind[RevlogReviewKind::Review as usize]);
let relearn_group = &group_sec_by_review_kind[RevlogReviewKind::Relearning as usize];
let weight = relearn_group.len() as f64 / (50.0 + relearn_group.len() as f64);
(median_secs(relearn_group) + recall_cost) * weight + DEFAULT * (1.0 - weight)
};
let params = OptimalRetentionParameters {
recall_secs_hard: recall_costs[1],
recall_secs_good: recall_costs[2],
recall_secs_easy: recall_costs[3],
forget_secs: forget_cost,
learn_secs: learn_cost,
first_rating_probability_again: first_rating_prob[0],
first_rating_probability_hard: first_rating_prob[1],
first_rating_probability_good: first_rating_prob[2],
first_rating_probability_easy: first_rating_prob[3],
review_rating_probability_hard: review_rating_prob[0],
review_rating_probability_good: review_rating_prob[1],
review_rating_probability_easy: review_rating_prob[2],
};
) -> Result<SimulatorConfig> {
let fsrs_revlog: Vec<fsrs::RevlogEntry> = revlogs.into_iter().map(|r| r.into()).collect();
let params =
extract_simulator_config(fsrs_revlog, self.timing_today()?.next_day_at.into(), true);
Ok(params)
}
}
fn median_secs(group: &[u32]) -> f64 {
let length = group.len();
if length > 0 {
let mut group_vec = group.to_vec();
group_vec.sort_unstable();
let median_millis = if length % 2 == 0 {
let mid = length / 2;
(group_vec[mid - 1] + group_vec[mid]) as f64 / 2.0
} else {
group_vec[length / 2] as f64
};
median_millis / 1000.0
} else {
0.0
impl From<crate::revlog::RevlogReviewKind> for fsrs::RevlogReviewKind {
fn from(kind: crate::revlog::RevlogReviewKind) -> Self {
match kind {
crate::revlog::RevlogReviewKind::Learning => fsrs::RevlogReviewKind::Learning,
crate::revlog::RevlogReviewKind::Review => fsrs::RevlogReviewKind::Review,
crate::revlog::RevlogReviewKind::Relearning => fsrs::RevlogReviewKind::Relearning,
crate::revlog::RevlogReviewKind::Filtered => fsrs::RevlogReviewKind::Filtered,
crate::revlog::RevlogReviewKind::Manual => fsrs::RevlogReviewKind::Manual,
}
}
}
impl From<crate::revlog::RevlogEntry> for fsrs::RevlogEntry {
fn from(entry: crate::revlog::RevlogEntry) -> Self {
fsrs::RevlogEntry {
id: entry.id.into(),
cid: entry.cid.into(),
usn: entry.usn.into(),
button_chosen: entry.button_chosen,
interval: entry.interval,
last_interval: entry.last_interval,
ease_factor: entry.ease_factor,
taken_millis: entry.taken_millis,
review_kind: entry.review_kind.into(),
}
}
}

View File

@ -26,22 +26,16 @@ impl Collection {
let config = SimulatorConfig {
deck_size: req.deck_size as usize,
learn_span: req.days_to_simulate as usize,
max_cost_perday: f64::MAX,
max_ivl: req.max_interval as f64,
recall_costs: [p.recall_secs_hard, p.recall_secs_good, p.recall_secs_easy],
forget_cost: p.forget_secs,
learn_cost: p.learn_secs,
first_rating_prob: [
p.first_rating_probability_again,
p.first_rating_probability_hard,
p.first_rating_probability_good,
p.first_rating_probability_easy,
],
review_rating_prob: [
p.review_rating_probability_hard,
p.review_rating_probability_good,
p.review_rating_probability_easy,
],
max_cost_perday: f32::MAX,
max_ivl: req.max_interval as f32,
learn_costs: p.learn_costs,
review_costs: p.review_costs,
first_rating_prob: p.first_rating_prob,
review_rating_prob: p.review_rating_prob,
first_rating_offsets: p.first_rating_offsets,
first_session_lens: p.first_session_lens,
forget_rating_offset: p.forget_rating_offset,
forget_session_len: p.forget_session_len,
loss_aversion: 1.0,
learn_limit: req.new_limit as usize,
review_limit: req.review_limit as usize,
@ -54,8 +48,8 @@ impl Collection {
daily_time_cost,
) = simulate(
&config,
&req.weights.iter().map(|w| *w as f64).collect_vec(),
req.desired_retention as f64,
&req.weights,
req.desired_retention,
None,
Some(
cards
@ -65,13 +59,10 @@ impl Collection {
),
);
Ok(SimulateFsrsReviewResponse {
accumulated_knowledge_acquisition: accumulated_knowledge_acquisition
.iter()
.map(|x| *x as f32)
.collect_vec(),
accumulated_knowledge_acquisition: accumulated_knowledge_acquisition.to_vec(),
daily_review_count: daily_review_count.iter().map(|x| *x as u32).collect_vec(),
daily_new_count: daily_new_count.iter().map(|x| *x as u32).collect_vec(),
daily_time_cost: daily_time_cost.iter().map(|x| *x as f32).collect_vec(),
daily_time_cost: daily_time_cost.to_vec(),
})
}
}
@ -83,10 +74,10 @@ impl Card {
let due = card.original_or_current_due();
let relative_due = due - days_elapsed;
Some(fsrs::Card {
difficulty: state.difficulty as f64,
stability: state.stability as f64,
last_date: (relative_due - card.interval as i32) as f64,
due: relative_due as f64,
difficulty: state.difficulty,
stability: state.stability,
last_date: (relative_due - card.interval as i32) as f32,
due: relative_due as f32,
})
}
None => None,

View File

@ -307,10 +307,24 @@ impl crate::services::SchedulerService for Collection {
.col
.storage
.get_revlog_entries_for_searched_cards_in_card_order()?;
self.get_optimal_retention_parameters(revlogs)
.map(|params| GetOptimalRetentionParametersResponse {
params: Some(params),
})
let simulator_config = self.get_optimal_retention_parameters(revlogs)?;
Ok(GetOptimalRetentionParametersResponse {
deck_size: simulator_config.deck_size as u32,
learn_span: simulator_config.learn_span as u32,
max_cost_perday: simulator_config.max_cost_perday,
max_ivl: simulator_config.max_ivl,
learn_costs: simulator_config.learn_costs.to_vec(),
review_costs: simulator_config.review_costs.to_vec(),
first_rating_prob: simulator_config.first_rating_prob.to_vec(),
review_rating_prob: simulator_config.review_rating_prob.to_vec(),
first_rating_offsets: simulator_config.first_rating_offsets.to_vec(),
first_session_lens: simulator_config.first_session_lens.to_vec(),
forget_rating_offset: simulator_config.forget_rating_offset,
forget_session_len: simulator_config.forget_session_len,
loss_aversion: simulator_config.loss_aversion,
learn_limit: simulator_config.learn_limit as u32,
review_limit: simulator_config.review_limit as u32,
})
}
fn compute_memory_state(&mut self, input: cards::CardId) -> Result<ComputeMemoryStateResponse> {

View File

@ -209,7 +209,7 @@ fn sveltekit_temp_file(path: &str) -> bool {
}
fn check_cargo_deny() -> Result<()> {
Command::run("cargo install cargo-deny@0.14.12")?;
Command::run("cargo install cargo-deny@0.14.24")?;
Command::run("cargo deny check")?;
Ok(())
}