|
| 1 | +//! 间隔重复调度器(v0.11.2;FSRS-6 主实现,ADR-018)。 |
| 2 | +//! |
| 3 | +//! @ai-context: fsrs crate(Anki 23.10+ 默认调度器算法)——DSR 三变量模型, |
| 4 | +//! 按目标留存率(0.9)反解间隔;纯算法零网络,契合本地优先。 |
| 5 | +//! 裁决记录:SM-2 手写兜底的条件(依赖引入受限)未触发——crate |
| 6 | +//! 引入成功,不保留双实现(YAGNI;接口稳定可后补)。 |
| 7 | +//! @ai-context: 弹性承诺纪律(v4 路线图裁决 4):调度纯函数无 streak 概念, |
| 8 | +//! 欠账卡仅按 due_at 排序呈现——不追债不清零(N10 归零暴政防御)。 |
| 9 | +//! @ai-context: 纯逻辑层(输入状态+评分→输出新状态+到期时刻),DB/UI 无关; |
| 10 | +//! CardState 以 JSON 落 flashcards.state_json(版本演进容忍缺字段)。 |
| 11 | +
|
| 12 | +use fsrs::{MemoryState, FSRS}; |
| 13 | +use serde::{Deserialize, Serialize}; |
| 14 | + |
| 15 | +/// 目标留存率(FSRS 默认推荐值;个性化调参为复习数据积累后的远期扩展)。 |
| 16 | +pub const DESIRED_RETENTION: f32 = 0.9; |
| 17 | +/// 一天的毫秒数。 |
| 18 | +const DAY_MS: u64 = 86_400_000; |
| 19 | + |
| 20 | +/// 复习评分(四档——提取优先:先回忆再看后评分)。 |
| 21 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 22 | +#[serde(rename_all = "lowercase")] |
| 23 | +pub enum Rating { |
| 24 | + Again, |
| 25 | + Hard, |
| 26 | + Good, |
| 27 | + Easy, |
| 28 | +} |
| 29 | + |
| 30 | +impl Rating { |
| 31 | + /// 解析前端评分字符串(非法值 → None,诚实不猜)。 |
| 32 | + pub fn parse(s: &str) -> Option<Rating> { |
| 33 | + match s { |
| 34 | + "again" => Some(Rating::Again), |
| 35 | + "hard" => Some(Rating::Hard), |
| 36 | + "good" => Some(Rating::Good), |
| 37 | + "easy" => Some(Rating::Easy), |
| 38 | + _ => None, |
| 39 | + } |
| 40 | + } |
| 41 | +} |
| 42 | + |
| 43 | +/// 卡片记忆状态(序列化落库;serde(default) 容忍旧格式演进)。 |
| 44 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 45 | +#[serde(rename_all = "camelCase", default)] |
| 46 | +pub struct CardState { |
| 47 | + /// FSRS 稳定性(天——记忆可维持时长的度量) |
| 48 | + pub stability: f32, |
| 49 | + /// FSRS 难度(1-10) |
| 50 | + pub difficulty: f32, |
| 51 | + /// 累计复习次数 |
| 52 | + pub reps: u32, |
| 53 | + /// 遗忘次数(Again 计数——弹性承诺下仅统计不惩罚) |
| 54 | + pub lapses: u32, |
| 55 | + /// 上次复习时刻(Unix 毫秒;新卡 0) |
| 56 | + pub last_review_ms: u64, |
| 57 | +} |
| 58 | + |
| 59 | +impl Default for CardState { |
| 60 | + fn default() -> Self { |
| 61 | + Self { stability: 0.0, difficulty: 0.0, reps: 0, lapses: 0, last_review_ms: 0 } |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +/// 调度输出(新状态 + 间隔天数 + 到期时刻)。 |
| 66 | +#[derive(Debug, Clone, PartialEq)] |
| 67 | +pub struct ScheduleOutcome { |
| 68 | + pub next: CardState, |
| 69 | + pub interval_days: f32, |
| 70 | + pub due_at_ms: u64, |
| 71 | +} |
| 72 | + |
| 73 | +/// 调度主入口(纯函数):新卡 state=None,复习卡 Some。 |
| 74 | +/// |
| 75 | +/// @ai-context: fsrs.next_states 对四个评分一次算全(Again/Hard/Good/Easy), |
| 76 | +/// 取本次评分分支;days_elapsed=距上次复习天数(新卡 0)—— |
| 77 | +/// 欠账卡按真实过期天数演化状态(FSRS 正确处理延迟复习)。 |
| 78 | +pub fn schedule(state: Option<&CardState>, rating: Rating, now_ms: u64) -> ScheduleOutcome { |
| 79 | + // 默认参数恒有效(FSRS::default 同款 expect 口径) |
| 80 | + let fsrs = FSRS::new(&[]).expect("FSRS-6 默认参数恒有效"); |
| 81 | + let (mem, days_elapsed) = match state { |
| 82 | + Some(s) if s.reps > 0 => ( |
| 83 | + Some(MemoryState { stability: s.stability, difficulty: s.difficulty }), |
| 84 | + (now_ms.saturating_sub(s.last_review_ms) / DAY_MS) as u32, |
| 85 | + ), |
| 86 | + _ => (None, 0), |
| 87 | + }; |
| 88 | + let states = fsrs |
| 89 | + .next_states(mem, DESIRED_RETENTION, days_elapsed) |
| 90 | + .expect("有限状态输入恒有效(fsrs 内部 validate)"); |
| 91 | + let item = match rating { |
| 92 | + Rating::Again => states.again, |
| 93 | + Rating::Hard => states.hard, |
| 94 | + Rating::Good => states.good, |
| 95 | + Rating::Easy => states.easy, |
| 96 | + }; |
| 97 | + let base = state.cloned().unwrap_or_default(); |
| 98 | + // 间隔下限 1 天(无学习步设计——当日重学靠 Again 后次日再现,简化 UI) |
| 99 | + let interval_days = item.interval.max(1.0); |
| 100 | + let due_at_ms = now_ms + (interval_days.round() as u64).max(1) * DAY_MS; |
| 101 | + ScheduleOutcome { |
| 102 | + next: CardState { |
| 103 | + stability: item.memory.stability, |
| 104 | + difficulty: item.memory.difficulty, |
| 105 | + reps: base.reps + 1, |
| 106 | + lapses: base.lapses + if rating == Rating::Again { 1 } else { 0 }, |
| 107 | + last_review_ms: now_ms, |
| 108 | + }, |
| 109 | + interval_days, |
| 110 | + due_at_ms, |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +#[cfg(test)] |
| 115 | +#[path = "scheduler_tests.rs"] |
| 116 | +mod tests; |
0 commit comments