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
This commit is contained in:
Jarrett Ye 2024-09-27 17:32:40 +08:00 committed by GitHub
parent f3b0afcc62
commit 79592730ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 394 additions and 4 deletions

View file

@ -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

View file

@ -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;

View file

@ -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<RevlogEntry>,
) -> Result<Vec<anki_proto::stats::card_stats_response::StatsRevlogEntry>> {
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,
}
}

View file

@ -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;
</script>
<Container breakpoint="md" --gutter-inline="1rem" --gutter-block="0.5rem">
@ -24,7 +26,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{#if showRevlog}
<Row>
<Revlog revlog={stats.revlog} />
<Revlog revlog={stats.revlog} {fsrsEnabled} />
</Row>
{/if}
{#if fsrsEnabled}
<Row>
<ForgettingCurve revlog={stats.revlog} />
</Row>
{/if}
{:else}

View file

@ -0,0 +1,85 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { type CardStatsResponse_StatsRevlogEntry as RevlogEntry } from "@generated/anki/stats_pb";
import * as tr from "@generated/ftl";
import Graph from "../graphs/Graph.svelte";
import NoDataOverlay from "../graphs/NoDataOverlay.svelte";
import AxisTicks from "../graphs/AxisTicks.svelte";
import { writable } from "svelte/store";
import InputBox from "../graphs/InputBox.svelte";
import { prepareData, renderForgettingCurve, TimeRange } from "./forgetting-curve";
import { defaultGraphBounds } from "../graphs/graph-helpers";
import HoverColumns from "../graphs/HoverColumns.svelte";
export let revlog: RevlogEntry[];
let svg = null as HTMLElement | SVGElement | null;
const bounds = defaultGraphBounds();
const timeRange = writable(TimeRange.AllTime);
const title = tr.cardStatsFsrsForgettingCurveTitle();
const data = prepareData(revlog, TimeRange.AllTime);
$: renderForgettingCurve(revlog, $timeRange, svg as SVGElement, bounds);
</script>
<div class="forgetting-curve">
<InputBox>
<div class="time-range-selector">
<label>
<input type="radio" bind:group={$timeRange} value={TimeRange.Week} />
{tr.cardStatsFsrsForgettingCurveFirstWeek()}
</label>
<label>
<input type="radio" bind:group={$timeRange} value={TimeRange.Month} />
{tr.cardStatsFsrsForgettingCurveFirstMonth()}
</label>
{#if data.length > 0 && data.some((point) => point.daysSinceFirstLearn > 365)}
<label>
<input
type="radio"
bind:group={$timeRange}
value={TimeRange.Year}
/>
{tr.cardStatsFsrsForgettingCurveFirstYear()}
</label>
{/if}
<label>
<input type="radio" bind:group={$timeRange} value={TimeRange.AllTime} />
{tr.cardStatsFsrsForgettingCurveAllTime()}
</label>
</div>
</InputBox>
<Graph {title}>
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
<AxisTicks {bounds} />
<HoverColumns />
<NoDataOverlay {bounds} />
</svg>
</Graph>
</div>
<style>
.forgetting-curve {
width: 100%;
max-width: 50em;
}
.time-range-selector {
display: flex;
justify-content: space-around;
margin-bottom: 1em;
width: 100%;
max-width: 50em;
}
.time-range-selector label {
display: flex;
align-items: center;
}
.time-range-selector input {
margin-right: 0.5em;
}
</style>

View file

@ -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}
</div>
</div>
{#if fsrsEnabled}{/if}
</div>
{/if}

View file

@ -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<SVGGElement>(".x-ticks")
.call((selection) => selection.transition(trans).call(axisBottom(x).ticks(5).tickSizeOuter(0)))
.attr("direction", "ltr");
svg.select<SVGGElement>(".y-ticks")
.attr("transform", `translate(${bounds.marginLeft},0)`)
.call((selection) => selection.transition(trans).call(axisLeft(y).tickSizeOuter(0)))
.attr("direction", "ltr");
const lineGenerator = line<DataPoint>()
.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()}<br>
${tr.cardStatsReviewLogElapsedTime()}: ${
timeSpan(d.elapsedDaysSinceLastReview * 86400)
}<br>${tr.cardStatsFsrsRetrievability()}: ${d.retrievability.toFixed(2)}%<br>${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();
});
}