Merge branch 'main' into editor-3830

This commit is contained in:
Abdo 2025-05-28 06:40:53 +03:00
commit 3ae7484610
14 changed files with 226 additions and 37 deletions

2
Cargo.lock generated
View file

@ -2295,7 +2295,7 @@ dependencies = [
[[package]] [[package]]
name = "fsrs" name = "fsrs"
version = "4.0.0" version = "4.0.0"
source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=092c20bac7d9239a991ae5b561556ad34c706c16#092c20bac7d9239a991ae5b561556ad34c706c16" source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=33ec3ee4d5d73e704633469cf5bf1a42e620a524#33ec3ee4d5d73e704633469cf5bf1a42e620a524"
dependencies = [ dependencies = [
"burn", "burn",
"itertools 0.14.0", "itertools 0.14.0",

View file

@ -37,7 +37,7 @@ rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca"
[workspace.dependencies.fsrs] [workspace.dependencies.fsrs]
# version = "3.0.0" # version = "3.0.0"
git = "https://github.com/open-spaced-repetition/fsrs-rs.git" git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
rev = "092c20bac7d9239a991ae5b561556ad34c706c16" rev = "33ec3ee4d5d73e704633469cf5bf1a42e620a524"
# path = "../open-spaced-repetition/fsrs-rs" # path = "../open-spaced-repetition/fsrs-rs"
[workspace.dependencies] [workspace.dependencies]

View file

@ -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 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. 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-please-save-your-changes-first = Please save your changes first.
deck-config-a-100-day-interval = deck-config-workload-factor-change = Approximate workload: {$factor}x
{ $days -> (compared to {$previousDR}% desired retention)
[one] A 100 day interval will become { $days } day. deck-config-workload-factor-unchanged = The higher this value, the more frequently cards will be shown to you.
*[other] A 100 day interval will become { $days } days. 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 = deck-config-percent-of-reviews =
{ $reviews -> { $reviews ->
[one] { $pct }% of { $reviews } review [one] { $pct }% of { $reviews } review
@ -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. ## 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-time = Review Time/Day
deck-config-fsrs-simulator-y-axis-title-count = Review Count/Day deck-config-fsrs-simulator-y-axis-title-count = Review Count/Day
deck-config-fsrs-simulator-y-axis-title-memorized = Memorized Total deck-config-fsrs-simulator-y-axis-title-memorized = Memorized Total

View file

@ -25,6 +25,8 @@ service DeckConfigService {
returns (collection.OpChanges); returns (collection.OpChanges);
rpc GetIgnoredBeforeCount(GetIgnoredBeforeCountRequest) rpc GetIgnoredBeforeCount(GetIgnoredBeforeCountRequest)
returns (GetIgnoredBeforeCountResponse); returns (GetIgnoredBeforeCountResponse);
rpc GetRetentionWorkload(GetRetentionWorkloadRequest)
returns (GetRetentionWorkloadResponse);
} }
// Implicitly includes any of the above methods that are not listed in the // Implicitly includes any of the above methods that are not listed in the
@ -35,6 +37,17 @@ message DeckConfigId {
int64 dcid = 1; 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 { message GetIgnoredBeforeCountRequest {
string ignore_revlogs_before_date = 1; string ignore_revlogs_before_date = 1;
string search = 2; string search = 2;

View file

@ -682,6 +682,7 @@ exposed_backend_list = [
"simulate_fsrs_review", "simulate_fsrs_review",
# DeckConfigService # DeckConfigService
"get_ignored_before_count", "get_ignored_before_count",
"get_retention_workload",
# CardRenderingService # CardRenderingService
"encode_iri_paths", "encode_iri_paths",
"decode_iri_paths", "decode_iri_paths",

View file

@ -96,6 +96,40 @@ impl crate::services::DeckConfigService for Collection {
total: guard.cards.try_into().unwrap_or(0), 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 { impl From<DeckConfig> for anki_proto::deck_config::DeckConfig {

View file

@ -32,8 +32,10 @@ impl Card {
force_reset: bool, force_reset: bool,
) { ) {
let new_due = (today + days_from_today) as i32; let new_due = (today + days_from_today) as i32;
let new_interval = let fsrs_enabled = self.memory_state.is_some();
if force_reset || !matches!(self.ctype, CardType::Review | CardType::Relearn) { let new_interval = if fsrs_enabled {
self.interval.saturating_add_signed(new_due - self.due)
} else if force_reset || !matches!(self.ctype, CardType::Review | CardType::Relearn) {
days_from_today days_from_today
} else { } else {
self.interval self.interval

View file

@ -30,19 +30,21 @@ 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 days_elapsed = self let seconds_elapsed = self
.storage .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.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(days_elapsed)) .zip(Some(seconds_elapsed))
.zip(Some(card.decay.unwrap_or(FSRS5_DEFAULT_DECAY))) .zip(Some(card.decay.unwrap_or(FSRS5_DEFAULT_DECAY)))
.map(|((state, days), decay)| { .map(|((state, seconds), decay)| {
FSRS::new(None) FSRS::new(None).unwrap().current_retrievability_seconds(
.unwrap() state.into(),
.current_retrievability(state.into(), days, decay) seconds,
decay,
)
}); });
let original_deck = if card.original_deck_id == DeckId(0) { let original_deck = if card.original_deck_id == DeckId(0) {

View file

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

View file

@ -747,6 +747,20 @@ impl super::SqliteStorage {
.get(0)?) .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)] #[cfg(test)]
pub(crate) fn get_all_cards(&self) -> Vec<Card> { pub(crate) fn get_all_cards(&self) -> Vec<Card> {
self.db self.db

View file

@ -23,7 +23,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let percentage = false; export let percentage = false;
let input: HTMLInputElement; let input: HTMLInputElement;
let focused = false; export let focused = false;
let multiplier: number; let multiplier: number;
$: multiplier = percentage ? 100 : 1; $: multiplier = percentage ? 100 : 1;
@ -129,6 +129,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
value={stringValue} value={stringValue}
bind:this={input} bind:this={input}
on:blur={update} on:blur={update}
on:change={update}
on:input={onInput} on:input={onInput}
on:focusin={() => (focused = true)} on:focusin={() => (focused = true)}
on:focusout={() => (focused = false)} on:focusout={() => (focused = false)}

View file

@ -11,6 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { import {
computeFsrsParams, computeFsrsParams,
evaluateParams, evaluateParams,
getRetentionWorkload,
setWantsAbort, setWantsAbort,
} from "@generated/backend"; } from "@generated/backend";
import * as tr from "@generated/ftl"; import * as tr from "@generated/ftl";
@ -26,11 +27,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ParamsInputRow from "./ParamsInputRow.svelte"; import ParamsInputRow from "./ParamsInputRow.svelte";
import ParamsSearchRow from "./ParamsSearchRow.svelte"; import ParamsSearchRow from "./ParamsSearchRow.svelte";
import SimulatorModal from "./SimulatorModal.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 state: DeckOptionsState;
export let openHelpModal: (String) => void; export let openHelpModal: (String) => void;
export let onPresetChange: () => void; export let onPresetChange: () => void;
export let newlyEnabled = false;
const config = state.currentConfig; const config = state.currentConfig;
const defaults = state.defaults; const defaults = state.defaults;
@ -39,6 +44,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: lastOptimizationWarning = $: lastOptimizationWarning =
$daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : ""; $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 computeParamsProgress: ComputeParamsProgress | undefined;
let computingParams = false; let computingParams = false;
@ -47,10 +61,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: computing = computingParams || checkingParams; $: computing = computingParams || checkingParams;
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`; $: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
$: roundedRetention = Number($config.desiredRetention.toFixed(2)); $: roundedRetention = Number($config.desiredRetention.toFixed(2));
$: desiredRetentionWarning = getRetentionWarning( $: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention);
roundedRetention,
fsrsParams($config), 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); $: retentionWarningClass = getRetentionWarningClass(roundedRetention);
$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; $: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
@ -67,23 +94,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
reviewOrder: $config.reviewOrder, reviewOrder: $config.reviewOrder,
}); });
function getRetentionWarning(retention: number, params: number[]): string { const DESIRED_RETENTION_LOW_THRESHOLD = 0.8;
const decay = params.length > 20 ? -params[20] : -0.5; // default decay for FSRS-4.5 and FSRS-5 const DESIRED_RETENTION_HIGH_THRESHOLD = 0.95;
const factor = 0.9 ** (1 / decay) - 1;
const stability = 100; function getRetentionLongShortWarning(retention: number) {
const days = Math.round( if (retention < DESIRED_RETENTION_LOW_THRESHOLD) {
(stability / factor) * (Math.pow(retention, 1 / decay) - 1), return tr.deckConfigDesiredRetentionTooLow();
); } else if (retention > DESIRED_RETENTION_HIGH_THRESHOLD) {
if (days === 100) { return tr.deckConfigDesiredRetentionTooHigh();
} else {
return ""; 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 { function getRetentionWarningClass(retention: number): string {
if (retention < 0.7 || retention > 0.97) { if (retention < 0.7 || retention > 0.97) {
return "alert-danger"; 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"; return "alert-warning";
} else { } else {
return "alert-info"; return "alert-info";
@ -146,6 +194,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setTimeout(() => alert(msg), 200); setTimeout(() => alert(msg), 200);
} else { } else {
$config.fsrsParams6 = resp.params; $config.fsrsParams6 = resp.params;
optimized = true;
} }
if (computeParamsProgress) { if (computeParamsProgress) {
computeParamsProgress.current = computeParamsProgress.total; computeParamsProgress.current = computeParamsProgress.total;
@ -237,12 +286,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={0.7} min={0.7}
max={0.99} max={0.99}
percentage={true} percentage={true}
bind:focused={desiredRetentionFocused}
> >
<SettingTitle on:click={() => openHelpModal("desiredRetention")}> <SettingTitle on:click={() => openHelpModal("desiredRetention")}>
{tr.deckConfigDesiredRetention()} {tr.deckConfigDesiredRetention()}
</SettingTitle> </SettingTitle>
</SpinBoxFloatRow> </SpinBoxFloatRow>
<Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} />
<Warning warning={desiredRetentionWarning} className={retentionWarningClass} /> <Warning warning={desiredRetentionWarning} className={retentionWarningClass} />
<div class="ms-1 me-1"> <div class="ms-1 me-1">
@ -331,4 +382,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
.btn { .btn {
margin-bottom: 0.375rem; 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> </style>

View file

@ -25,6 +25,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let onPresetChange: () => void; export let onPresetChange: () => void;
const fsrs = state.fsrs; const fsrs = state.fsrs;
let newlyEnabled = false;
$: if (!$fsrs) {
newlyEnabled = true;
}
const settings = { const settings = {
fsrs: { fsrs: {
@ -94,6 +98,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{#if $fsrs} {#if $fsrs}
<FsrsOptions <FsrsOptions
{state} {state}
{newlyEnabled}
openHelpModal={(key) => openHelpModal={(key) =>
openHelpModal(Object.keys(settings).indexOf(key))} openHelpModal(Object.keys(settings).indexOf(key))}
{onPresetChange} {onPresetChange}

View file

@ -15,6 +15,7 @@
export let max = 9999; export let max = 9999;
export let step = 0.01; export let step = 0.01;
export let percentage = false; export let percentage = false;
export let focused = false;
</script> </script>
<Row --cols={13}> <Row --cols={13}>
@ -23,7 +24,7 @@
</Col> </Col>
<Col --col-size={6} breakpoint="xs"> <Col --col-size={6} breakpoint="xs">
<ConfigInput> <ConfigInput>
<SpinBox bind:value {min} {max} {step} {percentage} /> <SpinBox bind:value {min} {max} {step} {percentage} bind:focused />
<RevertButton slot="revert" bind:value {defaultValue} /> <RevertButton slot="revert" bind:value {defaultValue} />
</ConfigInput> </ConfigInput>
</Col> </Col>