diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index e133de1b6..fada761be 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -410,9 +410,19 @@ message SimulateFsrsReviewRequest { 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; }; }; diff --git a/rslib/src/scheduler/fsrs/retention.rs b/rslib/src/scheduler/fsrs/retention.rs index b26fa25e0..99b9cdf4b 100644 --- a/rslib/src/scheduler/fsrs/retention.rs +++ b/rslib/src/scheduler/fsrs/retention.rs @@ -16,6 +16,29 @@ 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 @@ -31,17 +54,48 @@ impl Collection { 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, .. }, - params| { + w| { let total_cost = cost_per_day.iter().sum::(); total_cost / cards.iter().fold(0., |p, c| { - p + (c.retention_on(params, days_to_simulate) * c.stability) + p + (c.retention_on(w, days_to_simulate) * c.stability) }) }) } @@ -55,12 +109,9 @@ impl Collection { } let (mut config, cards) = self.simulate_request_to_config(&req)?; - dbg!(&target_type); if let Some(Kind::Memorized(settings)) = target_type { let loss_aversion = settings.loss_aversion; - dbg!(&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; diff --git a/ts/routes/deck-options/SimulatorModal.svelte b/ts/routes/deck-options/SimulatorModal.svelte index fc57c4650..b9e4db414 100644 --- a/ts/routes/deck-options/SimulatorModal.svelte +++ b/ts/routes/deck-options/SimulatorModal.svelte @@ -19,6 +19,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { computeOptimalRetention, simulateFsrsReview } from "@generated/backend"; import { runWithBackendProgress } from "@tslib/progress"; import { + SimulateFsrsReviewRequest_CMRRTarget_AverageFutureMemorized, + SimulateFsrsReviewRequest_CMRRTarget_FutureMemorized, SimulateFsrsReviewRequest_CMRRTarget_Memorized, SimulateFsrsReviewRequest_CMRRTarget_Stability, type ComputeOptimalRetentionResponse, @@ -49,6 +51,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 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) { @@ -66,6 +69,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 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; } @@ -454,12 +473,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {#if simulateFsrsRequest.target?.kind.case === "memorized"} + bind:value={simulateFsrsRequest.target.kind.value + .lossAversion} + defaultValue={1} + > {"Fail Cost Multiplier: "} {/if} + + {#if simulateFsrsRequest.target?.kind.case === "futureMemorized" || simulateFsrsRequest.target?.kind.case === "averageFutureMemorized"} + + + {"Days after simulation end: "} + + + {/if}