From ad0dbb563a520baef1a72c0c6b43af845cce8c82 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Fri, 27 Jun 2025 10:44:19 +0100 Subject: [PATCH] Feat/Cmrr target selector (#4116) * backend * Add: Frontend * us * Added: Loss aversion * change proto format * Added: Loss aversion * Added: Future retention targets * update default fail cost multiplier * Future Retention -> Post Abandon Memorized * superfluous as const * Fix: Wrong default * Fix: Wrong import order --- proto/anki/scheduler.proto | 25 +++++ rslib/src/scheduler/fsrs/retention.rs | 107 ++++++++++++++++++- ts/routes/deck-options/FsrsOptions.svelte | 14 ++- ts/routes/deck-options/SimulatorModal.svelte | 94 +++++++++++++++- ts/routes/deck-options/choices.ts | 23 ++++ 5 files changed, 255 insertions(+), 8 deletions(-) diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index 1b7d44a83..5e568aa92 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -402,6 +402,31 @@ message SimulateFsrsReviewRequest { repeated float easy_days_percentages = 10; deck_config.DeckConfig.Config.ReviewCardOrder review_order = 11; optional uint32 suspend_after_lapse_count = 12; + // For CMRR + message CMRRTarget { + message Memorized { + float loss_aversion = 1; + }; + + message Stability {}; + + message FutureMemorized { + int32 days = 1; + }; + + message AverageFutureMemorized { + int32 days = 1; + }; + + oneof kind { + Memorized memorized = 1; + Stability stability = 2; + FutureMemorized future_memorized = 3; + AverageFutureMemorized average_future_memorized = 4; + }; + }; + + optional CMRRTarget target = 13; } message SimulateFsrsReviewResponse { diff --git a/rslib/src/scheduler/fsrs/retention.rs b/rslib/src/scheduler/fsrs/retention.rs index 4c21623bb..29f6b490d 100644 --- a/rslib/src/scheduler/fsrs/retention.rs +++ b/rslib/src/scheduler/fsrs/retention.rs @@ -1,7 +1,9 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +use anki_proto::scheduler::simulate_fsrs_review_request::cmrr_target::Kind; use anki_proto::scheduler::SimulateFsrsReviewRequest; use fsrs::extract_simulator_config; +use fsrs::SimulationResult; use fsrs::SimulatorConfig; use fsrs::FSRS; @@ -14,14 +16,115 @@ pub struct ComputeRetentionProgress { pub total: u32, } +pub fn average_r_power_forgetting_curve( + learn_span: usize, + cards: &[fsrs::Card], + offset: f32, + decay: f32, +) -> f32 { + let factor = 0.9_f32.powf(1.0 / decay) - 1.0; + let exp = decay + 1.0; + let den_factor = factor * exp; + + // Closure equivalent to the inner integral function + let integral_calc = |card: &fsrs::Card| -> f32 { + // Performs element-wise: (s / den_factor) * (1.0 + factor * t / s).powf(exp) + let t1 = learn_span as f32 - card.last_date; + let t2 = t1 + offset; + (card.stability / den_factor) * (1.0 + factor * t2 / card.stability).powf(exp) + - (card.stability / den_factor) * (1.0 + factor * t1 / card.stability).powf(exp) + }; + + // Calculate integral difference and divide by time difference element-wise + cards.iter().map(integral_calc).sum::() / offset +} + impl Collection { pub fn compute_optimal_retention(&mut self, req: SimulateFsrsReviewRequest) -> Result { + // Helper macro to wrap the closure for "CMRRTargetFn"s + macro_rules! wrap { + ($f:expr) => { + Some(fsrs::CMRRTargetFn(std::sync::Arc::new($f))) + }; + } + + let target_type = req.target.unwrap().kind; + + let days_to_simulate = req.days_to_simulate as f32; + + let target = match target_type { + Some(Kind::Memorized(_)) => None, + Some(Kind::FutureMemorized(settings)) => { + wrap!(move |SimulationResult { + cards, + cost_per_day, + .. + }, + w| { + let total_cost = cost_per_day.iter().sum::(); + total_cost + / cards.iter().fold(0., |p, c| { + c.retention_on(w, days_to_simulate + settings.days as f32) + p + }) + }) + } + Some(Kind::AverageFutureMemorized(settings)) => { + wrap!(move |SimulationResult { + cards, + cost_per_day, + .. + }, + w| { + let total_cost = cost_per_day.iter().sum::(); + total_cost + / average_r_power_forgetting_curve( + days_to_simulate as usize, + cards, + settings.days as f32, + -w[20], + ) + }) + } + Some(Kind::Stability(_)) => { + wrap!(move |SimulationResult { + cards, + cost_per_day, + .. + }, + w| { + let total_cost = cost_per_day.iter().sum::(); + total_cost + / cards.iter().fold(0., |p, c| { + p + (c.retention_on(w, days_to_simulate) * c.stability) + }) + }) + } + None => None, + }; + let mut anki_progress = self.new_progress_handler::(); let fsrs = FSRS::new(None)?; if req.days_to_simulate == 0 { invalid_input!("no days to simulate") } - let (config, cards) = self.simulate_request_to_config(&req)?; + let (mut config, cards) = self.simulate_request_to_config(&req)?; + + if let Some(Kind::Memorized(settings)) = target_type { + let loss_aversion = settings.loss_aversion; + + config.relearning_step_transitions[0][0] *= loss_aversion; + config.relearning_step_transitions[1][0] *= loss_aversion; + config.relearning_step_transitions[2][0] *= loss_aversion; + + config.learning_step_transitions[0][0] *= loss_aversion; + config.learning_step_transitions[1][0] *= loss_aversion; + config.learning_step_transitions[2][0] *= loss_aversion; + + config.state_rating_costs[0][0] *= loss_aversion; + config.state_rating_costs[1][0] *= loss_aversion; + config.state_rating_costs[2][0] *= loss_aversion; + } + Ok(fsrs .optimal_retention( &config, @@ -34,7 +137,7 @@ impl Collection { .is_ok() }, Some(cards), - None, + target, )? .clamp(0.7, 0.95)) } diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index a2eb36fe3..2f7858209 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -7,7 +7,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html ComputeRetentionProgress, type ComputeParamsProgress, } from "@generated/anki/collection_pb"; - import { SimulateFsrsReviewRequest } from "@generated/anki/scheduler_pb"; + import { + SimulateFsrsReviewRequest, + SimulateFsrsReviewRequest_CMRRTarget, + SimulateFsrsReviewRequest_CMRRTarget_Memorized, + } from "@generated/anki/scheduler_pb"; import { computeFsrsParams, evaluateParams, @@ -94,6 +98,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html newCardsIgnoreReviewLimit: $newCardsIgnoreReviewLimit, easyDaysPercentages: $config.easyDaysPercentages, reviewOrder: $config.reviewOrder, + target: new SimulateFsrsReviewRequest_CMRRTarget({ + kind: { + case: "memorized", + value: new SimulateFsrsReviewRequest_CMRRTarget_Memorized({ + lossAversion: 1.6, + }), + }, + }), }); const DESIRED_RETENTION_LOW_THRESHOLD = 0.8; diff --git a/ts/routes/deck-options/SimulatorModal.svelte b/ts/routes/deck-options/SimulatorModal.svelte index 64b712560..0fb9c2ab7 100644 --- a/ts/routes/deck-options/SimulatorModal.svelte +++ b/ts/routes/deck-options/SimulatorModal.svelte @@ -18,21 +18,30 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { renderSimulationChart } from "../graphs/simulator"; import { computeOptimalRetention, simulateFsrsReview } from "@generated/backend"; import { runWithBackendProgress } from "@tslib/progress"; - import type { - ComputeOptimalRetentionResponse, - SimulateFsrsReviewRequest, - SimulateFsrsReviewResponse, + import { + SimulateFsrsReviewRequest_CMRRTarget_AverageFutureMemorized, + SimulateFsrsReviewRequest_CMRRTarget_FutureMemorized, + SimulateFsrsReviewRequest_CMRRTarget_Memorized, + SimulateFsrsReviewRequest_CMRRTarget_Stability, + type ComputeOptimalRetentionResponse, + type SimulateFsrsReviewRequest, + type SimulateFsrsReviewResponse, } from "@generated/anki/scheduler_pb"; import type { DeckOptionsState } from "./lib"; import SwitchRow from "$lib/components/SwitchRow.svelte"; import GlobalLabel from "./GlobalLabel.svelte"; import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte"; - import { reviewOrderChoices } from "./choices"; + import { + DEFAULT_CMRR_TARGET, + CMRRTargetChoices, + reviewOrderChoices, + } from "./choices"; import EnumSelectorRow from "$lib/components/EnumSelectorRow.svelte"; import { DeckConfig_Config_LeechAction } from "@generated/anki/deck_config_pb"; import EasyDaysInput from "./EasyDaysInput.svelte"; import Warning from "./Warning.svelte"; import type { ComputeRetentionProgress } from "@generated/anki/collection_pb"; + import Item from "$lib/components/Item.svelte"; export let shown = false; export let state: DeckOptionsState; @@ -41,6 +50,45 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let openHelpModal: (key: string) => void; export let onPresetChange: () => void; + let cmrrTargetType = DEFAULT_CMRR_TARGET; + // All added types must be updated in the proceeding switch statement. + let lastCmrrTargetType = cmrrTargetType; + $: if (simulateFsrsRequest?.target && cmrrTargetType !== lastCmrrTargetType) { + switch (cmrrTargetType) { + case "memorized": + simulateFsrsRequest.target.kind = { + case: "memorized", + value: new SimulateFsrsReviewRequest_CMRRTarget_Memorized({ + lossAversion: 1.6, + }), + }; + break; + case "stability": + simulateFsrsRequest.target.kind = { + case: "stability", + value: new SimulateFsrsReviewRequest_CMRRTarget_Stability({}), + }; + break; + case "futureMemorized": + simulateFsrsRequest.target.kind = { + case: "futureMemorized", + value: new SimulateFsrsReviewRequest_CMRRTarget_FutureMemorized({ + days: 365, + }), + }; + break; + case "averageFutureMemorized": + simulateFsrsRequest.target.kind = { + case: "averageFutureMemorized", + value: new SimulateFsrsReviewRequest_CMRRTarget_AverageFutureMemorized( + { days: 365 }, + ), + }; + break; + } + lastCmrrTargetType = cmrrTargetType; + } + const config = state.currentConfig; let simulateSubgraph: SimulateSubgraph = SimulateSubgraph.count; let tableData: TableDatum[] = []; @@ -410,6 +458,42 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {#if computingRetention}
{computeRetentionProgressString}
{/if} + + + + + {"Target: "} + + + + + {#if simulateFsrsRequest.target?.kind.case === "memorized"} + + + {"Fail Cost Multiplier: "} + + + {/if} + + {#if simulateFsrsRequest.target?.kind.case === "futureMemorized" || simulateFsrsRequest.target?.kind.case === "averageFutureMemorized"} + + + {"Days after simulation end: "} + + + {/if}