Add last_review_time to card data for performance and accuracy (#4124)

* Add `last_review_time` to card data

* cargo clippy

* Calculate days elapsed since last review time in add_extract_fsrs_relative_retrievability

* expose last_review_time to Card in Python

* Fix last_review_time assignment in Card class to use last_review_time_secs

* format

* Update last_review_time assignment to exclude filtered preview state in Card class
This commit is contained in:
Jarrett Ye 2025-07-08 01:41:01 +08:00 committed by GitHub
parent 3adcf05ca6
commit 037dfa1bc1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 75 additions and 20 deletions

View file

@ -51,6 +51,7 @@ message Card {
optional FsrsMemoryState memory_state = 20; optional FsrsMemoryState memory_state = 20;
optional float desired_retention = 21; optional float desired_retention = 21;
optional float decay = 22; optional float decay = 22;
optional int64 last_review_time_secs = 23;
string custom_data = 19; string custom_data = 19;
} }

View file

@ -49,6 +49,7 @@ class Card(DeprecatedNamesMixin):
memory_state: FSRSMemoryState | None memory_state: FSRSMemoryState | None
desired_retention: float | None desired_retention: float | None
decay: float | None decay: float | None
last_review_time: int | None
def __init__( def __init__(
self, self,
@ -103,6 +104,11 @@ class Card(DeprecatedNamesMixin):
card.desired_retention if card.HasField("desired_retention") else None card.desired_retention if card.HasField("desired_retention") else None
) )
self.decay = card.decay if card.HasField("decay") else None self.decay = card.decay if card.HasField("decay") else None
self.last_review_time = (
card.last_review_time_secs
if card.HasField("last_review_time_secs")
else None
)
def _to_backend_card(self) -> cards_pb2.Card: def _to_backend_card(self) -> cards_pb2.Card:
# mtime & usn are set by backend # mtime & usn are set by backend

View file

@ -128,7 +128,9 @@ impl Card {
/// This uses card.due and card.ivl to infer the elapsed time. If 'set due /// This uses card.due and card.ivl to infer the elapsed time. If 'set due
/// date' or an add-on has changed the due date, this won't be accurate. /// date' or an add-on has changed the due date, this won't be accurate.
pub(crate) fn days_since_last_review(&self, timing: &SchedTimingToday) -> Option<u32> { pub(crate) fn days_since_last_review(&self, timing: &SchedTimingToday) -> Option<u32> {
if !self.is_due_in_days() { if let Some(last_review_time) = self.last_review_time {
Some(timing.next_day_at.elapsed_days_since(last_review_time) as u32)
} else if !self.is_due_in_days() {
Some( Some(
(timing.next_day_at.0 as u32).saturating_sub(self.original_or_current_due() as u32) (timing.next_day_at.0 as u32).saturating_sub(self.original_or_current_due() as u32)
/ 86_400, / 86_400,

View file

@ -96,6 +96,7 @@ pub struct Card {
pub(crate) memory_state: Option<FsrsMemoryState>, pub(crate) memory_state: Option<FsrsMemoryState>,
pub(crate) desired_retention: Option<f32>, pub(crate) desired_retention: Option<f32>,
pub(crate) decay: Option<f32>, pub(crate) decay: Option<f32>,
pub(crate) last_review_time: Option<TimestampSecs>,
/// JSON object or empty; exposed through the reviewer for persisting custom /// JSON object or empty; exposed through the reviewer for persisting custom
/// state /// state
pub(crate) custom_data: String, pub(crate) custom_data: String,
@ -147,6 +148,7 @@ impl Default for Card {
memory_state: None, memory_state: None,
desired_retention: None, desired_retention: None,
decay: None, decay: None,
last_review_time: None,
custom_data: String::new(), custom_data: String::new(),
} }
} }

View file

@ -107,6 +107,7 @@ impl TryFrom<anki_proto::cards::Card> for Card {
memory_state: c.memory_state.map(Into::into), memory_state: c.memory_state.map(Into::into),
desired_retention: c.desired_retention, desired_retention: c.desired_retention,
decay: c.decay, decay: c.decay,
last_review_time: c.last_review_time_secs.map(TimestampSecs),
custom_data: c.custom_data, custom_data: c.custom_data,
}) })
} }
@ -136,6 +137,7 @@ impl From<Card> for anki_proto::cards::Card {
memory_state: c.memory_state.map(Into::into), memory_state: c.memory_state.map(Into::into),
desired_retention: c.desired_retention, desired_retention: c.desired_retention,
decay: c.decay, decay: c.decay,
last_review_time_secs: c.last_review_time.map(|t| t.0),
custom_data: c.custom_data, custom_data: c.custom_data,
} }
} }

View file

@ -335,6 +335,12 @@ impl Collection {
self.maybe_bury_siblings(&original, &updater.config)?; self.maybe_bury_siblings(&original, &updater.config)?;
let timing = updater.timing; let timing = updater.timing;
let mut card = updater.into_card(); let mut card = updater.into_card();
if !matches!(
answer.current_state,
CardState::Filtered(FilteredState::Preview(_))
) {
card.last_review_time = Some(answer.answered_at.as_secs());
}
if let Some(data) = answer.custom_data.take() { if let Some(data) = answer.custom_data.take() {
card.custom_data = data; card.custom_data = data;
card.validate_custom_data()?; card.validate_custom_data()?;
@ -451,11 +457,14 @@ impl Collection {
)?; )?;
card.set_memory_state(&fsrs, item, config.inner.historical_retention)?; card.set_memory_state(&fsrs, item, config.inner.historical_retention)?;
} }
let days_elapsed = self let days_elapsed = if let Some(last_review_time) = card.last_review_time {
.storage timing.next_day_at.elapsed_days_since(last_review_time) as u32
} else {
self.storage
.time_of_last_review(card.id)? .time_of_last_review(card.id)?
.map(|ts| timing.next_day_at.elapsed_days_since(ts)) .map(|ts| timing.next_day_at.elapsed_days_since(ts))
.unwrap_or_default() as u32; .unwrap_or_default() as u32
};
Some(fsrs.next_states( Some(fsrs.next_states(
card.memory_state.map(Into::into), card.memory_state.map(Into::into),
config.inner.desired_retention, config.inner.desired_retention,

View file

@ -36,6 +36,11 @@ impl Card {
let new_due = (today + days_from_today) as i32; let new_due = (today + days_from_today) as i32;
let fsrs_enabled = self.memory_state.is_some(); let fsrs_enabled = self.memory_state.is_some();
let new_interval = if fsrs_enabled { let new_interval = if fsrs_enabled {
if let Some(last_review_time) = self.last_review_time {
let elapsed_days =
TimestampSecs(next_day_start).elapsed_days_since(last_review_time);
elapsed_days as u32 + days_from_today
} else {
let due = self.original_or_current_due(); let due = self.original_or_current_due();
let due_diff = if is_unix_epoch_timestamp(due) { let due_diff = if is_unix_epoch_timestamp(due) {
let offset = (due as i64 - next_day_start) / 86_400; let offset = (due as i64 - next_day_start) / 86_400;
@ -45,6 +50,7 @@ impl Card {
new_due - due new_due - due
}; };
self.interval.saturating_add_signed(due_diff) self.interval.saturating_add_signed(due_diff)
}
} else if force_reset || !matches!(self.ctype, CardType::Review | CardType::Relearn) { } else if force_reset || !matches!(self.ctype, CardType::Review | CardType::Relearn) {
days_from_today.max(1) days_from_today.max(1)
} else { } else {

View file

@ -30,11 +30,14 @@ impl Collection {
let (average_secs, total_secs) = average_and_total_secs_strings(&revlog); let (average_secs, total_secs) = average_and_total_secs_strings(&revlog);
let timing = self.timing_today()?; let timing = self.timing_today()?;
let seconds_elapsed = self let seconds_elapsed = if let Some(last_review_time) = card.last_review_time {
.storage timing.now.elapsed_secs_since(last_review_time) as u32
} else {
self.storage
.time_of_last_review(card.id)? .time_of_last_review(card.id)?
.map(|ts| timing.now.elapsed_secs_since(ts)) .map(|ts| timing.now.elapsed_secs_since(ts))
.unwrap_or_default() as u32; .unwrap_or_default() as u32
};
let fsrs_retrievability = card let fsrs_retrievability = card
.memory_state .memory_state
.zip(Some(seconds_elapsed)) .zip(Some(seconds_elapsed))

View file

@ -47,6 +47,12 @@ pub(crate) struct CardData {
deserialize_with = "default_on_invalid" deserialize_with = "default_on_invalid"
)] )]
pub(crate) decay: Option<f32>, pub(crate) decay: Option<f32>,
#[serde(
rename = "lrt",
skip_serializing_if = "Option::is_none",
deserialize_with = "default_on_invalid"
)]
pub(crate) last_review_time: Option<TimestampSecs>,
/// A string representation of a JSON object storing optional data /// A string representation of a JSON object storing optional data
/// associated with the card, so v3 custom scheduling code can persist /// associated with the card, so v3 custom scheduling code can persist
@ -63,6 +69,7 @@ impl CardData {
fsrs_difficulty: card.memory_state.as_ref().map(|m| m.difficulty), fsrs_difficulty: card.memory_state.as_ref().map(|m| m.difficulty),
fsrs_desired_retention: card.desired_retention, fsrs_desired_retention: card.desired_retention,
decay: card.decay, decay: card.decay,
last_review_time: card.last_review_time,
custom_data: card.custom_data.clone(), custom_data: card.custom_data.clone(),
} }
} }
@ -169,6 +176,7 @@ mod test {
fsrs_difficulty: Some(1.234567), fsrs_difficulty: Some(1.234567),
fsrs_desired_retention: Some(0.987654), fsrs_desired_retention: Some(0.987654),
decay: Some(0.123456), decay: Some(0.123456),
last_review_time: None,
custom_data: "".to_string(), custom_data: "".to_string(),
}; };
assert_eq!( assert_eq!(

View file

@ -97,6 +97,7 @@ fn row_to_card(row: &Row) -> result::Result<Card, rusqlite::Error> {
memory_state: data.memory_state(), memory_state: data.memory_state(),
desired_retention: data.fsrs_desired_retention, desired_retention: data.fsrs_desired_retention,
decay: data.decay, decay: data.decay,
last_review_time: data.last_review_time,
custom_data: data.custom_data, custom_data: data.custom_data,
}) })
} }

View file

@ -328,7 +328,13 @@ fn add_extract_fsrs_retrievability(db: &Connection) -> rusqlite::Result<()> {
let Ok(due) = ctx.get_raw(1).as_i64() else { let Ok(due) = ctx.get_raw(1).as_i64() else {
return Ok(None); return Ok(None);
}; };
let days_elapsed = if due > 365_000 { let days_elapsed = if let Some(last_review_time) = card_data.last_review_time {
// Use last_review_time to calculate days_elapsed
let Ok(next_day_at) = ctx.get_raw(4).as_i64() else {
return Ok(None);
};
(next_day_at as u32).saturating_sub(last_review_time.0 as u32) / 86_400
} else if due > 365_000 {
// (re)learning card in seconds // (re)learning card in seconds
let Ok(next_day_at) = ctx.get_raw(4).as_i64() else { let Ok(next_day_at) = ctx.get_raw(4).as_i64() else {
return Ok(None); return Ok(None);
@ -396,6 +402,14 @@ fn add_extract_fsrs_relative_retrievability(db: &Connection) -> rusqlite::Result
desired_retrievability = desired_retrievability.max(0.0001); desired_retrievability = desired_retrievability.max(0.0001);
let decay = card_data.decay.unwrap_or(FSRS5_DEFAULT_DECAY); let decay = card_data.decay.unwrap_or(FSRS5_DEFAULT_DECAY);
let days_elapsed = if let Some(last_review_time) =
card_data.last_review_time
{
TimestampSecs(next_day_at).elapsed_days_since(last_review_time) as u32
} else {
days_elapsed
};
let current_retrievability = FSRS::new(None) let current_retrievability = FSRS::new(None)
.unwrap() .unwrap()
.current_retrievability(state.into(), days_elapsed, decay) .current_retrievability(state.into(), days_elapsed, decay)

View file

@ -333,6 +333,7 @@ impl From<CardEntry> for Card {
memory_state: data.memory_state(), memory_state: data.memory_state(),
desired_retention: data.fsrs_desired_retention, desired_retention: data.fsrs_desired_retention,
decay: data.decay, decay: data.decay,
last_review_time: data.last_review_time,
custom_data: data.custom_data, custom_data: data.custom_data,
} }
} }