Added: Future retention targets

This commit is contained in:
Luc Mcgrady 2025-06-22 21:56:27 +01:00
parent d5e5177166
commit e3695e13ff
No known key found for this signature in database
GPG key ID: 4F3D7A0B17CC3D9C
4 changed files with 109 additions and 6 deletions

View file

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

View file

@ -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::<f32>() / offset
}
impl Collection {
pub fn compute_optimal_retention(&mut self, req: SimulateFsrsReviewRequest) -> Result<f32> {
// 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::<f32>();
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::<f32>();
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::<f32>();
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;

View file

@ -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"}
<SpinBoxFloatRow
bind:value={simulateFsrsRequest.target.kind.value.lossAversion} defaultValue={1}>
bind:value={simulateFsrsRequest.target.kind.value
.lossAversion}
defaultValue={1}
>
<SettingTitle>
{"Fail Cost Multiplier: "}
</SettingTitle>
</SpinBoxFloatRow>
{/if}
{#if simulateFsrsRequest.target?.kind.case === "futureMemorized" || simulateFsrsRequest.target?.kind.case === "averageFutureMemorized"}
<SpinBoxFloatRow
bind:value={simulateFsrsRequest.target.kind.value.days}
defaultValue={365}
step={1}
>
<SettingTitle>
{"Days after simulation end: "}
</SettingTitle>
</SpinBoxFloatRow>
{/if}
</details>
<button

View file

@ -211,6 +211,14 @@ export function CMRRTargetChoices(): Choice<string>[] {
label: "Stability (Experimental)",
value: "stability",
},
{
label: "Future Retention (Experimental)",
value: "futureMemorized",
},
{
label: "Average Future Retention (Experimental)",
value: "averageFutureMemorized",
},
] as const;
}