Record FSRS difficulty in the review log

Will allow user to see a record of difficulty changes, and allows us
to identify reviews that have been done with FSRS vs SM-2, since the
valid range is different.
This commit is contained in:
Damien Elmes 2023-09-17 09:45:44 +10:00
parent 0301ae1d8a
commit 75de1f9709
8 changed files with 62 additions and 10 deletions

View file

@ -482,7 +482,7 @@ impl RowContext {
self.cards[0]
.memory_state
.as_ref()
.map(|s| format!("{:.0}%", (s.difficulty - 1.0) / 9.0 * 100.0))
.map(|s| format!("{:.0}%", s.difficulty() * 100.0))
.unwrap_or_default()
}

View file

@ -101,10 +101,26 @@ pub struct Card {
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FsrsMemoryState {
/// The expected memory stability, in days.
pub stability: f32,
/// A number in the range 1.0-10.0. Use difficulty() for a normalized
/// number.
pub difficulty: f32,
}
impl FsrsMemoryState {
/// Returns the difficulty normalized to a 0.0-1.0 range.
pub(crate) fn difficulty(&self) -> f32 {
(self.difficulty - 1.0) / 9.0
}
/// Returns the difficulty normalized to a 0.1-1.1 range,
/// which is used in revlog entries.
pub(crate) fn difficulty_shifted(&self) -> f32 {
self.difficulty() + 0.1
}
}
impl Default for Card {
fn default() -> Self {
Self {

View file

@ -48,7 +48,8 @@ pub struct RevlogEntry {
#[serde(rename = "lastIvl", deserialize_with = "deserialize_int_from_number")]
pub last_interval: i32,
/// Card's ease after answering, stored as 10x the %, eg 2500 represents
/// 250%.
/// 250%. When FSRS is active, difficulty is normalized to 100-1100 range,
/// so a 0 difficulty can be distinguished from SM-2 learning.
#[serde(rename = "factor", deserialize_with = "deserialize_int_from_number")]
pub ease_factor: u32,
/// Amount of milliseconds taken to answer the card.

View file

@ -26,7 +26,15 @@ impl CardStateUpdater {
self.card.original_position = None;
self.card.memory_state = None;
RevlogEntryPartial::new(current, next.into(), 0.0, self.secs_until_rollover())
RevlogEntryPartial::new(
current,
next.into(),
self.card
.memory_state
.map(|d| d.difficulty_shifted())
.unwrap_or_default(),
self.secs_until_rollover(),
)
}
pub(super) fn apply_learning_state(
@ -55,7 +63,15 @@ impl CardStateUpdater {
}
}
RevlogEntryPartial::new(current, next.into(), 0.0, self.secs_until_rollover())
RevlogEntryPartial::new(
current,
next.into(),
self.card
.memory_state
.map(|d| d.difficulty_shifted())
.unwrap_or_default(),
self.secs_until_rollover(),
)
}
/// Adds secs + fuzz to current time

View file

@ -42,7 +42,10 @@ impl CardStateUpdater {
RevlogEntryPartial::new(
current,
next.into(),
next.review.ease_factor,
self.card
.memory_state
.map(|d| d.difficulty_shifted())
.unwrap_or(next.review.ease_factor),
self.secs_until_rollover(),
)
}

View file

@ -29,7 +29,10 @@ impl CardStateUpdater {
RevlogEntryPartial::new(
current,
next.into(),
next.ease_factor,
self.card
.memory_state
.map(|d| d.difficulty_shifted())
.unwrap_or(next.ease_factor),
self.secs_until_rollover(),
)
}

View file

@ -15,9 +15,7 @@ impl GraphsContext {
if let Some(state) = card.memory_state {
*difficulty
.eases
.entry(round_to_nearest_five(
(state.difficulty - 1.0) / 9.0 * 100.0,
))
.entry(round_to_nearest_five(state.difficulty() * 100.0))
.or_insert_with(Default::default) += 1;
} else if matches!(card.ctype, CardType::Review | CardType::Relearn) {
*eases

View file

@ -67,12 +67,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
rating: entry.buttonChosen,
ratingClass: ratingClass(entry),
interval: timeSpan(entry.interval),
ease: entry.ease ? `${entry.ease / 10}%` : "",
ease: formatEaseOrDifficulty(entry.ease),
takenSecs: timeSpan(entry.takenSecs, true),
};
}
$: revlogRows = revlog.map(revlogRowFromEntry);
function formatEaseOrDifficulty(ease: number): string {
if (ease === 0) {
return "";
}
const asPct = ease / 10.0;
if (asPct <= 110) {
// FSRS
const unshifted = asPct - 10;
return `D:${unshifted.toFixed(0)}%`;
} else {
// SM-2
return `${asPct.toFixed(0)}%`;
}
}
</script>
{#if revlog.length > 0}