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:
parent
5216fa959e
commit
52ce6e6a6b
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue