From 79592730eea138fff3fd8292422b0146ebc9755e Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Fri, 27 Sep 2024 17:32:40 +0800 Subject: [PATCH] Feat/forgetting curve in card info (#3437) * add elapsed time into revlog-table * add stability into revlog-table * add Graph of forgetting curve * fix eslint * add radio buttons of timeRange * add revlog filter && return [] for new card * format * translatable string & disable if using SM-2 * elapsedTime should skip manual or filtered review * add HoverColumns * fix eslint * add stability to tooltip & use timeSpan * reuse translatable strings * distinguish daysSinceFirstLearn and elapsedDaysSinceLastReview * Date x-axis & toLocaleString * Temporarily hide elapsed/stability columns (dae) https://github.com/ankitects/anki/pull/3437#issuecomment-2378851900 --- ftl/core/card-stats.ftl | 6 + proto/anki/stats.proto | 1 + rslib/src/stats/card.rs | 45 ++++- ts/routes/card-info/CardInfo.svelte | 9 +- ts/routes/card-info/ForgettingCurve.svelte | 85 ++++++++ ts/routes/card-info/Revlog.svelte | 32 ++- ts/routes/card-info/forgetting-curve.ts | 220 +++++++++++++++++++++ 7 files changed, 394 insertions(+), 4 deletions(-) create mode 100644 ts/routes/card-info/ForgettingCurve.svelte create mode 100644 ts/routes/card-info/forgetting-curve.ts diff --git a/ftl/core/card-stats.ftl b/ftl/core/card-stats.ftl index ee452104d..ece08b20a 100644 --- a/ftl/core/card-stats.ftl +++ b/ftl/core/card-stats.ftl @@ -23,11 +23,17 @@ card-stats-review-log-type-review = Review card-stats-review-log-type-relearn = Relearn card-stats-review-log-type-filtered = Filtered card-stats-review-log-type-manual = Manual +card-stats-review-log-elapsed-time = Elapsed Time card-stats-no-card = (No card to display.) card-stats-custom-data = Custom Data card-stats-fsrs-stability = Stability card-stats-fsrs-difficulty = Difficulty card-stats-fsrs-retrievability = Retrievability +card-stats-fsrs-forgetting-curve-title = Forgetting Curve +card-stats-fsrs-forgetting-curve-first-week = First Week +card-stats-fsrs-forgetting-curve-first-month = First Month +card-stats-fsrs-forgetting-curve-first-year = First Year +card-stats-fsrs-forgetting-curve-all-time = All Time ## Window Titles diff --git a/proto/anki/stats.proto b/proto/anki/stats.proto index 1a02c2d5e..db4a426eb 100644 --- a/proto/anki/stats.proto +++ b/proto/anki/stats.proto @@ -31,6 +31,7 @@ message CardStatsResponse { // per mill uint32 ease = 5; float taken_secs = 6; + optional cards.FsrsMemoryState memory_state = 7; } repeated StatsRevlogEntry revlog = 1; int64 card_id = 2; diff --git a/rslib/src/stats/card.rs b/rslib/src/stats/card.rs index 1ef8775d9..3c257af1a 100644 --- a/rslib/src/stats/card.rs +++ b/rslib/src/stats/card.rs @@ -7,6 +7,8 @@ use crate::card::CardQueue; use crate::card::CardType; use crate::prelude::*; use crate::revlog::RevlogEntry; +use crate::scheduler::fsrs::memory_state::single_card_revlog_to_item; +use crate::scheduler::fsrs::weights::ignore_revlogs_before_ms_from_config; use crate::scheduler::timing::is_unix_epoch_timestamp; impl Collection { @@ -70,7 +72,7 @@ impl Collection { total_secs, card_type: nt.get_template(card.template_idx)?.name.clone(), notetype: nt.name.clone(), - revlog: revlog.iter().rev().map(stats_revlog_entry).collect(), + revlog: self.stats_revlog_entries_with_memory_state(&card, revlog)?, memory_state: card.memory_state.map(Into::into), fsrs_retrievability, custom_data: card.custom_data, @@ -113,6 +115,46 @@ impl Collection { ), }) } + + fn stats_revlog_entries_with_memory_state( + self: &mut Collection, + card: &Card, + revlog: Vec, + ) -> Result> { + let deck_id = card.original_deck_id.or(card.deck_id); + let deck = self.get_deck(deck_id)?.or_not_found(card.deck_id)?; + let conf_id = DeckConfigId(deck.normal()?.config_id); + let config = self + .storage + .get_deck_config(conf_id)? + .or_not_found(conf_id)?; + let historical_retention = config.inner.historical_retention; + let fsrs = FSRS::new(Some(&config.inner.fsrs_weights))?; + let next_day_at = self.timing_today()?.next_day_at; + let ignore_before = ignore_revlogs_before_ms_from_config(&config)?; + + let mut result = Vec::new(); + let mut accumulated_revlog = Vec::new(); + + for entry in revlog { + accumulated_revlog.push(entry.clone()); + let item = single_card_revlog_to_item( + &fsrs, + accumulated_revlog.clone(), + next_day_at, + historical_retention, + ignore_before, + )?; + let mut card_clone = card.clone(); + card_clone.set_memory_state(&fsrs, item, historical_retention)?; + + let mut stats_entry = stats_revlog_entry(&entry); + stats_entry.memory_state = card_clone.memory_state.map(Into::into); + result.push(stats_entry); + } + + Ok(result.into_iter().rev().collect()) + } } fn average_and_total_secs_strings(revlog: &[RevlogEntry]) -> (f32, f32) { @@ -138,6 +180,7 @@ fn stats_revlog_entry( interval: entry.interval_secs(), ease: entry.ease_factor, taken_secs: entry.taken_millis as f32 / 1000., + memory_state: None, } } diff --git a/ts/routes/card-info/CardInfo.svelte b/ts/routes/card-info/CardInfo.svelte index b0651dc5b..44599c489 100644 --- a/ts/routes/card-info/CardInfo.svelte +++ b/ts/routes/card-info/CardInfo.svelte @@ -11,9 +11,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import CardInfoPlaceholder from "./CardInfoPlaceholder.svelte"; import CardStats from "./CardStats.svelte"; import Revlog from "./Revlog.svelte"; + import ForgettingCurve from "./ForgettingCurve.svelte"; export let stats: CardStatsResponse | null = null; export let showRevlog: boolean = true; + export let fsrsEnabled: boolean = stats?.memoryState != null; @@ -24,7 +26,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {#if showRevlog} - + + + {/if} + {#if fsrsEnabled} + + {/if} {:else} diff --git a/ts/routes/card-info/ForgettingCurve.svelte b/ts/routes/card-info/ForgettingCurve.svelte new file mode 100644 index 000000000..c01eede93 --- /dev/null +++ b/ts/routes/card-info/ForgettingCurve.svelte @@ -0,0 +1,85 @@ + + + +
+ +
+ + + {#if data.length > 0 && data.some((point) => point.daysSinceFirstLearn > 365)} + + {/if} + +
+
+ + + + + + + +
+ + diff --git a/ts/routes/card-info/Revlog.svelte b/ts/routes/card-info/Revlog.svelte index d1ef7a9ed..75eb6e5bc 100644 --- a/ts/routes/card-info/Revlog.svelte +++ b/ts/routes/card-info/Revlog.svelte @@ -7,8 +7,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { RevlogEntry_ReviewKind as ReviewKind } from "@generated/anki/stats_pb"; import * as tr2 from "@generated/ftl"; import { timeSpan, Timestamp } from "@tslib/time"; + import { filterRevlogByReviewKind } from "./forgetting-curve"; export let revlog: RevlogEntry[]; + export let fsrsEnabled: boolean = false; function reviewKindClass(entry: RevlogEntry): string { switch (entry.reviewKind) { @@ -54,9 +56,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html interval: string; ease: string; takenSecs: string; + elapsedTime: string; + stability: string; } - function revlogRowFromEntry(entry: RevlogEntry): RevlogRow { + function revlogRowFromEntry(entry: RevlogEntry, elapsedTime: string): RevlogRow { const timestamp = new Timestamp(Number(entry.time)); return { @@ -69,10 +73,33 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html interval: timeSpan(entry.interval), ease: formatEaseOrDifficulty(entry.ease), takenSecs: timeSpan(entry.takenSecs, true), + elapsedTime, + stability: entry.memoryState?.stability + ? timeSpan(entry.memoryState.stability * 86400) + : "", }; } - $: revlogRows = revlog.map(revlogRowFromEntry); + $: revlogRows = revlog.map((entry, index) => { + let prevValidEntry: RevlogEntry | undefined; + let i = index + 1; + while (i < revlog.length) { + if (filterRevlogByReviewKind(revlog[i])) { + prevValidEntry = revlog[i]; + break; + } + i++; + } + + let elapsedTime = "N/A"; + if (filterRevlogByReviewKind(entry)) { + elapsedTime = prevValidEntry + ? timeSpan(Number(entry.time) - Number(prevValidEntry.time)) + : "0"; + } + + return revlogRowFromEntry(entry, elapsedTime); + }); function formatEaseOrDifficulty(ease: number): string { if (ease === 0) { @@ -145,6 +172,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {/each} + {#if fsrsEnabled}{/if} {/if} diff --git a/ts/routes/card-info/forgetting-curve.ts b/ts/routes/card-info/forgetting-curve.ts new file mode 100644 index 000000000..0db60c78e --- /dev/null +++ b/ts/routes/card-info/forgetting-curve.ts @@ -0,0 +1,220 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { + type CardStatsResponse_StatsRevlogEntry as RevlogEntry, + RevlogEntry_ReviewKind, +} from "@generated/anki/stats_pb"; +import * as tr from "@generated/ftl"; +import { timeSpan } from "@tslib/time"; +import { axisBottom, axisLeft, line, max, min, pointer, scaleLinear, scaleTime, select } from "d3"; +import { type GraphBounds, setDataAvailable } from "../graphs/graph-helpers"; +import { hideTooltip, showTooltip } from "../graphs/tooltip-utils.svelte"; + +const FACTOR = 19 / 81; +const DECAY = -0.5; +const MIN_POINTS = 100; + +function forgettingCurve(stability: number, daysElapsed: number): number { + return Math.pow((daysElapsed / stability) * FACTOR + 1.0, DECAY); +} + +interface DataPoint { + date: Date; + daysSinceFirstLearn: number; + elapsedDaysSinceLastReview: number; + retrievability: number; + stability: number; +} + +export enum TimeRange { + Week, + Month, + Year, + AllTime, +} + +function filterDataByTimeRange(data: DataPoint[], range: TimeRange): DataPoint[] { + const maxDays = { + [TimeRange.Week]: 7, + [TimeRange.Month]: 30, + [TimeRange.Year]: 365, + [TimeRange.AllTime]: Infinity, + }[range]; + + return data.filter((point) => point.daysSinceFirstLearn <= maxDays); +} + +export function filterRevlogByReviewKind(entry: RevlogEntry): boolean { + return ( + entry.reviewKind !== RevlogEntry_ReviewKind.MANUAL + && (entry.reviewKind !== RevlogEntry_ReviewKind.FILTERED || entry.ease !== 0) + ); +} + +export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) { + const data: DataPoint[] = []; + let lastReviewTime = 0; + let lastStability = 0; + + revlog + .filter((entry) => filterRevlogByReviewKind(entry)) + .toReversed() + .forEach((entry, index) => { + const reviewTime = Number(entry.time); + if (index === 0) { + lastReviewTime = reviewTime; + lastStability = entry.memoryState?.stability || 0; + data.push({ + date: new Date(reviewTime * 1000), + daysSinceFirstLearn: 0, + elapsedDaysSinceLastReview: 0, + retrievability: 100, + stability: lastStability, + }); + return; + } + + const totalDaysElapsed = (reviewTime - lastReviewTime) / (24 * 60 * 60); + const step = Math.min(1, totalDaysElapsed / MIN_POINTS); + for (let i = 0; i < Math.max(MIN_POINTS, totalDaysElapsed); i++) { + const elapsedDays = (i + 1) * step; + const retrievability = forgettingCurve(lastStability, elapsedDays); + data.push({ + date: new Date((lastReviewTime + elapsedDays * 86400) * 1000), + daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn + step, + elapsedDaysSinceLastReview: elapsedDays, + retrievability: retrievability * 100, + stability: lastStability, + }); + } + + data.push({ + date: new Date((lastReviewTime + totalDaysElapsed * 86400) * 1000), + daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn, + retrievability: 100, + elapsedDaysSinceLastReview: 0, + stability: lastStability, + }); + + lastReviewTime = reviewTime; + lastStability = entry.memoryState?.stability || 0; + }); + + if (data.length === 0) { + return []; + } + + const now = Date.now() / 1000; + const totalDaysSinceLastReview = Math.floor((now - lastReviewTime) / (24 * 60 * 60)); + const step = Math.min(1, totalDaysSinceLastReview / MIN_POINTS); + for (let i = 0; i < Math.max(MIN_POINTS, totalDaysSinceLastReview); i++) { + const elapsedDays = (i + 1) * step; + const retrievability = forgettingCurve(lastStability, elapsedDays); + data.push({ + date: new Date((lastReviewTime + elapsedDays * 86400) * 1000), + daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn + step, + elapsedDaysSinceLastReview: elapsedDays, + retrievability: retrievability * 100, + stability: lastStability, + }); + } + const filteredData = filterDataByTimeRange(data, timeRange); + return filteredData; +} + +export function renderForgettingCurve( + revlog: RevlogEntry[], + timeRange: TimeRange, + svgElem: SVGElement, + bounds: GraphBounds, +) { + const data = prepareData(revlog, timeRange); + const svg = select(svgElem); + const trans = svg.transition().duration(600) as any; + + if (data.length === 0) { + setDataAvailable(svg, false); + return; + } else { + setDataAvailable(svg, true); + } + + svg.select(".forgetting-curve-line").remove(); + svg.select(".hover-columns").remove(); + + const xMin = min(data, d => d.date); + const xMax = max(data, d => d.date); + const x = scaleTime() + .domain([xMin!, xMax!]) + .range([bounds.marginLeft, bounds.width - bounds.marginRight]); + const yMin = Math.max( + 0, + 100 - 1.2 * (100 - Math.min(...data.map((d) => d.retrievability))), + ); + const y = scaleLinear() + .domain([yMin, 100]) + .range([bounds.height - bounds.marginBottom, bounds.marginTop]); + + svg.select(".x-ticks") + .call((selection) => selection.transition(trans).call(axisBottom(x).ticks(5).tickSizeOuter(0))) + .attr("direction", "ltr"); + + svg.select(".y-ticks") + .attr("transform", `translate(${bounds.marginLeft},0)`) + .call((selection) => selection.transition(trans).call(axisLeft(y).tickSizeOuter(0))) + .attr("direction", "ltr"); + + const lineGenerator = line() + .x((d) => x(d.date)) + .y((d) => y(d.retrievability)); + + svg.append("path") + .datum(data) + .attr("class", "forgetting-curve-line") + .attr("fill", "none") + .attr("stroke", "steelblue") + .attr("stroke-width", 1.5) + .attr("d", lineGenerator); + + const focusLine = svg.append("line") + .attr("class", "focus-line") + .attr("y1", bounds.marginTop) + .attr("y2", bounds.height - bounds.marginBottom) + .attr("stroke", "black") + .attr("stroke-width", 1) + .style("opacity", 0); + + function tooltipText(d: DataPoint): string { + return `Date: ${d.date.toLocaleString()}
+ ${tr.cardStatsReviewLogElapsedTime()}: ${ + timeSpan(d.elapsedDaysSinceLastReview * 86400) + }
${tr.cardStatsFsrsRetrievability()}: ${d.retrievability.toFixed(2)}%
${tr.cardStatsFsrsStability()}: ${ + timeSpan(d.stability * 86400) + }`; + } + + // hover/tooltip + svg.append("g") + .attr("class", "hover-columns") + .selectAll("rect") + .data(data) + .join("rect") + .attr("x", d => x(d.date) - 1) + .attr("y", bounds.marginTop) + .attr("width", 2) + .attr("height", bounds.height - bounds.marginTop - bounds.marginBottom) + .attr("fill", "transparent") + .on("mousemove", (event: MouseEvent, d: DataPoint) => { + const [x1, y1] = pointer(event, document.body); + focusLine.attr("x1", x(d.date) - 1).attr("x2", x(d.date) + 1).style( + "opacity", + 1, + ); + showTooltip(tooltipText(d), x1, y1); + }) + .on("mouseout", () => { + focusLine.style("opacity", 0); + hideTooltip(); + }); +}