Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
37b932f
Feat/90% desired retention warning
Luc-Mcgrady May 11, 2025
3e1d95b
Merge branch 'main' into Feat/90%-dr-message
Luc-Mcgrady May 15, 2025
c0f1e26
Update ftl/core/deck-config.ftl
Luc-Mcgrady May 15, 2025
51d9ab5
show on newly enabled
Luc-Mcgrady May 19, 2025
8171fae
Show warning on focus
Luc-Mcgrady May 22, 2025
989494c
Never hide warning
Luc-Mcgrady May 22, 2025
6538645
Display relative change
Luc-Mcgrady May 22, 2025
4641b1d
Add: Separate warning for too long and short
Luc-Mcgrady May 22, 2025
8d13f63
Merge branch 'main' into Feat/90%-dr-message
Luc-Mcgrady May 22, 2025
8f6224f
Revert unchanged text changes
Luc-Mcgrady May 22, 2025
5376b5a
interval -> workload
Luc-Mcgrady May 22, 2025
b1488e4
Remove dead code
Luc-Mcgrady May 22, 2025
d9dd4d4
fsrs-rs/@L-M-Sherlock's workload calculation
Luc-Mcgrady May 22, 2025
ee1419b
Added: delay
Luc-Mcgrady May 22, 2025
c6eeaa0
CONSTANT_CASE
Luc-Mcgrady May 22, 2025
03b42a9
Fix: optimized state
Luc-Mcgrady May 22, 2025
46ce8af
Removed "Processing"
Luc-Mcgrady May 22, 2025
ee711d9
Remove dead code
Luc-Mcgrady May 22, 2025
c0cd197
1 digit precision
Luc-Mcgrady May 22, 2025
4b70930
bump fsrs-rs
Luc-Mcgrady May 23, 2025
1fbcbf7
typo
Luc-Mcgrady May 23, 2025
1da93b6
Apply suggestions from code review
Luc-Mcgrady May 23, 2025
65a31b7
Improve rounding
Luc-Mcgrady May 23, 2025
c709cef
improve comment
Luc-Mcgrady May 23, 2025
0ba35cb
rounding <1%
Luc-Mcgrady May 23, 2025
6272145
decrease rounding precision
Luc-Mcgrady May 23, 2025
a49d852
bump ts-fsrs
Luc-Mcgrady May 23, 2025
8282a1e
use actual cost values
Luc-Mcgrady May 23, 2025
f51e914
./check
Luc-Mcgrady May 23, 2025
817386c
typo
Luc-Mcgrady May 25, 2025
2bd9c34
include relearning
Luc-Mcgrady May 25, 2025
2a5fe77
change factor wording
Luc-Mcgrady May 25, 2025
247453a
simplify sql
Luc-Mcgrady May 25, 2025
cb74dec
./check
Luc-Mcgrady May 25, 2025
dc1489f
Apply suggestions from code review
Luc-Mcgrady May 25, 2025
ae30fa9
Fix: missing search_cids
Luc-Mcgrady May 25, 2025
cc3eb83
Merge remote-tracking branch 'upstream/main' into Feat/90%-dr-message
Luc-Mcgrady May 26, 2025
ea13c74
@dae's style patch
Luc-Mcgrady May 26, 2025
bb59156
Fix: Doesn't update on arrow keys change
Luc-Mcgrady May 26, 2025
4ec35ce
force two lines
Luc-Mcgrady May 26, 2025
e2a95c7
center two lines
Luc-Mcgrady May 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions ftl/core/deck-config.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -470,11 +470,12 @@ deck-config-compute-optimal-retention-tooltip4 =
willing to invest more study time to achieve it. Setting your desired retention lower than the minimum
is not recommended, as it will lead to a higher workload, because of the high forgetting rate.
deck-config-please-save-your-changes-first = Please save your changes first.
deck-config-a-100-day-interval =
{ $days ->
[one] A 100 day interval will become { $days } day.
*[other] A 100 day interval will become { $days } days.
}
deck-config-workload-factor-change = Approximate workload: {$factor}x
(compared to {$previousDR}% desired retention)
deck-config-workload-factor-unchanged = The higher this value, the more frequently cards will be shown to you.
deck-config-desired-retention-too-low = Your desired retention is very low, which can lead to very long intervals.
deck-config-desired-retention-too-high = Your desired retention is very high, which can lead to very short intervals.

deck-config-percent-of-reviews =
{ $reviews ->
[one] { $pct }% of { $reviews } review
Expand Down Expand Up @@ -512,6 +513,12 @@ deck-config-fsrs-simulator-radio-memorized = Memorized

## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.

deck-config-a-100-day-interval =
{ $days ->
[one] A 100 day interval will become { $days } day.
*[other] A 100 day interval will become { $days } days.
}

deck-config-fsrs-simulator-y-axis-title-time = Review Time/Day
deck-config-fsrs-simulator-y-axis-title-count = Review Count/Day
deck-config-fsrs-simulator-y-axis-title-memorized = Memorized Total
Expand Down
13 changes: 13 additions & 0 deletions proto/anki/deck_config.proto
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ service DeckConfigService {
returns (collection.OpChanges);
rpc GetIgnoredBeforeCount(GetIgnoredBeforeCountRequest)
returns (GetIgnoredBeforeCountResponse);
rpc GetRetentionWorkload(GetRetentionWorkloadRequest)
returns (GetRetentionWorkloadResponse);
}

// Implicitly includes any of the above methods that are not listed in the
Expand All @@ -35,6 +37,17 @@ message DeckConfigId {
int64 dcid = 1;
}

message GetRetentionWorkloadRequest {
repeated float w = 1;
string search = 2;
float before = 3;
float after = 4;
}

message GetRetentionWorkloadResponse {
float factor = 1;
}

message GetIgnoredBeforeCountRequest {
string ignore_revlogs_before_date = 1;
string search = 2;
Expand Down
1 change: 1 addition & 0 deletions qt/aqt/mediasrv.py
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@ def handle_on_main() -> None:
"simulate_fsrs_review",
# DeckConfigService
"get_ignored_before_count",
"get_retention_workload",
]


Expand Down
34 changes: 34 additions & 0 deletions rslib/src/deckconfig/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,40 @@ impl crate::services::DeckConfigService for Collection {
total: guard.cards.try_into().unwrap_or(0),
})
}

fn get_retention_workload(
&mut self,
input: anki_proto::deck_config::GetRetentionWorkloadRequest,
) -> Result<anki_proto::deck_config::GetRetentionWorkloadResponse> {
const LEARN_SPAN: usize = 1000;

let guard =
self.search_cards_into_table(&input.search, crate::search::SortMode::NoOrder)?;
let (pass_cost, fail_cost, learn_cost) = guard.col.storage.get_costs_for_retention()?;

let before = fsrs::expected_workload(
&input.w,
input.before,
LEARN_SPAN,
pass_cost,
fail_cost,
0.,
input.before,
)? + learn_cost;
let after = fsrs::expected_workload(
&input.w,
input.after,
LEARN_SPAN,
pass_cost,
fail_cost,
0.,
input.after,
)? + learn_cost;

Ok(anki_proto::deck_config::GetRetentionWorkloadResponse {
factor: after / before,
})
}
}

impl From<DeckConfig> for anki_proto::deck_config::DeckConfig {
Expand Down
49 changes: 49 additions & 0 deletions rslib/src/storage/card/get_costs_for_retention.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
WITH searched_revlogs AS (
SELECT *
FROM revlog
WHERE ease > 0
AND cid IN search_cids
ORDER BY id DESC -- Use the last 10_000 reviews
LIMIT 10000
), average_pass AS (
SELECT AVG(time)
FROM searched_revlogs
WHERE ease > 1
),
lapse_count AS (
SELECT COUNT(time) AS lapse_count
FROM searched_revlogs
WHERE ease = 1
AND type = 1
),
fail_sum AS (
SELECT SUM(time) AS total_fail_time
FROM searched_revlogs
WHERE (
ease = 1
AND type = 1
)
OR type = 2
),
-- (sum(Relearning) + sum(Lapses)) / count(Lapses)
average_fail AS (
SELECT total_fail_time * 1.0 / NULLIF(lapse_count, 0) AS avg_fail_time
FROM fail_sum,
lapse_count
),
-- Can lead to cards with partial learn histories skewing the time
summed_learns AS (
SELECT cid,
SUM(time) AS total_time
FROM searched_revlogs
WHERE searched_revlogs.type = 0
GROUP BY cid
),
average_learn AS (
SELECT AVG(total_time) AS avg_learn_time
FROM summed_learns
)
SELECT *
FROM average_pass,
average_fail,
average_learn;
14 changes: 14 additions & 0 deletions rslib/src/storage/card/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,20 @@ impl super::SqliteStorage {
.get(0)?)
}

pub(crate) fn get_costs_for_retention(&self) -> Result<(f32, f32, f32)> {
let mut statement = self
.db
.prepare(include_str!("get_costs_for_retention.sql"))?;
let mut query = statement.query(params![])?;
let row = query.next()?.unwrap();

Ok((
row.get(0).unwrap_or(7000.),
row.get(1).unwrap_or(23_000.),
row.get(2).unwrap_or(30_000.),
))
}

#[cfg(test)]
pub(crate) fn get_all_cards(&self) -> Vec<Card> {
self.db
Expand Down
3 changes: 2 additions & 1 deletion ts/lib/components/SpinBox.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let percentage = false;

let input: HTMLInputElement;
let focused = false;
export let focused = false;
let multiplier: number;
$: multiplier = percentage ? 100 : 1;

Expand Down Expand Up @@ -129,6 +129,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
value={stringValue}
bind:this={input}
on:blur={update}
on:change={update}
on:input={onInput}
on:focusin={() => (focused = true)}
on:focusout={() => (focused = false)}
Expand Down
90 changes: 75 additions & 15 deletions ts/routes/deck-options/FsrsOptions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import {
computeFsrsParams,
evaluateParams,
getRetentionWorkload,
setWantsAbort,
} from "@generated/backend";
import * as tr from "@generated/ftl";
Expand All @@ -26,11 +27,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ParamsInputRow from "./ParamsInputRow.svelte";
import ParamsSearchRow from "./ParamsSearchRow.svelte";
import SimulatorModal from "./SimulatorModal.svelte";
import { UpdateDeckConfigsMode } from "@generated/anki/deck_config_pb";
import {
GetRetentionWorkloadRequest,
UpdateDeckConfigsMode,
} from "@generated/anki/deck_config_pb";

export let state: DeckOptionsState;
export let openHelpModal: (String) => void;
export let onPresetChange: () => void;
export let newlyEnabled = false;

const config = state.currentConfig;
const defaults = state.defaults;
Expand All @@ -39,6 +44,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html

$: lastOptimizationWarning =
$daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : "";
let desiredRetentionFocused = false;
let desiredRetentionEverFocused = false;
let optimized = false;
const startingDesiredRetention = $config.desiredRetention.toFixed(2);
$: if (desiredRetentionFocused) {
desiredRetentionEverFocused = true;
}
$: showDesiredRetentionTooltip =
newlyEnabled || desiredRetentionEverFocused || optimized;

let computeParamsProgress: ComputeParamsProgress | undefined;
let computingParams = false;
Expand All @@ -47,10 +61,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: computing = computingParams || checkingParams;
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
$: roundedRetention = Number($config.desiredRetention.toFixed(2));
$: desiredRetentionWarning = getRetentionWarning(
roundedRetention,
fsrsParams($config),
);
$: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention);

let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
const WORKLOAD_UPDATE_DELAY_MS = 100;

let desiredRetentionChangeInfo = "";
$: {
clearTimeout(timeoutId);
if (showDesiredRetentionTooltip) {
timeoutId = setTimeout(() => {
getRetentionChangeInfo(roundedRetention, fsrsParams($config));
}, WORKLOAD_UPDATE_DELAY_MS);
} else {
desiredRetentionChangeInfo = "";
}
}

$: retentionWarningClass = getRetentionWarningClass(roundedRetention);

$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
Expand All @@ -67,23 +94,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
reviewOrder: $config.reviewOrder,
});

function getRetentionWarning(retention: number, params: number[]): string {
const decay = params.length > 20 ? -params[20] : -0.5; // default decay for FSRS-4.5 and FSRS-5
const factor = 0.9 ** (1 / decay) - 1;
const stability = 100;
const days = Math.round(
(stability / factor) * (Math.pow(retention, 1 / decay) - 1),
);
if (days === 100) {
const DESIRED_RETENTION_LOW_THRESHOLD = 0.8;
const DESIRED_RETENTION_HIGH_THRESHOLD = 0.95;

function getRetentionLongShortWarning(retention: number) {
if (retention < DESIRED_RETENTION_LOW_THRESHOLD) {
return tr.deckConfigDesiredRetentionTooLow();
} else if (retention > DESIRED_RETENTION_HIGH_THRESHOLD) {
return tr.deckConfigDesiredRetentionTooHigh();
} else {
return "";
}
return tr.deckConfigA100DayInterval({ days });
}

async function getRetentionChangeInfo(retention: number, params: number[]) {
if (+startingDesiredRetention == roundedRetention) {
desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorUnchanged();
return;
}
const request = new GetRetentionWorkloadRequest({
w: params,
search: defaultparamSearch,
before: +startingDesiredRetention,
after: retention,
});
const resp = await getRetentionWorkload(request);
desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorChange({
factor: resp.factor.toFixed(2),
previousDr: (+startingDesiredRetention * 100).toString(),
});
}

function getRetentionWarningClass(retention: number): string {
if (retention < 0.7 || retention > 0.97) {
return "alert-danger";
} else if (retention < 0.8 || retention > 0.95) {
} else if (
retention < DESIRED_RETENTION_LOW_THRESHOLD ||
retention > DESIRED_RETENTION_HIGH_THRESHOLD
) {
return "alert-warning";
} else {
return "alert-info";
Expand Down Expand Up @@ -146,6 +194,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setTimeout(() => alert(msg), 200);
} else {
$config.fsrsParams6 = resp.params;
optimized = true;
}
if (computeParamsProgress) {
computeParamsProgress.current = computeParamsProgress.total;
Expand Down Expand Up @@ -237,12 +286,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={0.7}
max={0.99}
percentage={true}
bind:focused={desiredRetentionFocused}
>
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</SpinBoxFloatRow>

<Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} />
<Warning warning={desiredRetentionWarning} className={retentionWarningClass} />

<div class="ms-1 me-1">
Expand Down Expand Up @@ -331,4 +382,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
.btn {
margin-bottom: 0.375rem;
}

:global(.two-line) {
white-space: pre-wrap;
min-height: calc(2ch + 30px);
box-sizing: content-box;
display: flex;
align-content: center;
flex-wrap: wrap;
}
</style>
5 changes: 5 additions & 0 deletions ts/routes/deck-options/FsrsOptionsOuter.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let onPresetChange: () => void;

const fsrs = state.fsrs;
let newlyEnabled = false;
$: if (!$fsrs) {
newlyEnabled = true;
}

const settings = {
fsrs: {
Expand Down Expand Up @@ -94,6 +98,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{#if $fsrs}
<FsrsOptions
{state}
{newlyEnabled}
openHelpModal={(key) =>
openHelpModal(Object.keys(settings).indexOf(key))}
{onPresetChange}
Expand Down
3 changes: 2 additions & 1 deletion ts/routes/deck-options/SpinBoxFloatRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
export let max = 9999;
export let step = 0.01;
export let percentage = false;
export let focused = false;
</script>

<Row --cols={13}>
Expand All @@ -23,7 +24,7 @@
</Col>
<Col --col-size={6} breakpoint="xs">
<ConfigInput>
<SpinBox bind:value {min} {max} {step} {percentage} />
<SpinBox bind:value {min} {max} {step} {percentage} bind:focused />
<RevertButton slot="revert" bind:value {defaultValue} />
</ConfigInput>
</Col>
Expand Down