
hashcards 源码剖析纯文本间隔重复调度系统是如何搭起来的【免费下载链接】hashcardsA plain text-based spaced repetition system.项目地址: https://gitcode.com/GitHub_Trending/ha/hashcardshashcards 是一个纯文本间隔重复spaced repetition卡片系统卡片写进 Markdown 文件复习状态存在本地 SQLiteFSRS 算法决定每张卡下一次何时出现。本文按代码实际路径走读一遍从drill命令的最小运行链路到 FSRS 公式内部回答一个问题一行 Markdown 文本是怎么变成某天该复习这张卡的。一次 hashcards drill 的完整生命周期在放了一堆.md文件的目录下执行hashcards drill代码路径很直白src/main.rs 调用entrypoint()clap 解析完参数后组装ServerConfig交给 src/cmd/drill/server.rs 的start_server。后者依次做五件事用目录构建Collection解析全部卡片、打开hashcards.db、把数据库里没有的新卡补齐、查出今天到期的卡、用自研 PRNG 洗牌、把每张卡的记忆状态加载进内存缓存然后启动 axum 服务。浏览器里看到的页面本质上就是队列头部那张卡你每点一次按钮就是一次对根路径的 POST处理完再重定向回首页。整个学习会话 本地 web server 一个全局可变状态。这个状态被包在ArcMutexMutableState里排他访问——它不是多用户服务单用户独占就够所以不需要任何锁外的并发设计。解析器手写状态机把 Markdown 切成卡片src/parser.rs 用逐行状态机解析不是正则更没有引入完整 Markdown 解析库。每行先做一次极简分类if line.starts_with(Q:) { Line::StartQuestion(line_content(line)) } else if line.starts_with(A:) { Line::StartAnswer(line_content(line)) } else if line.starts_with(C:) { Line::StartCloze(line_content(line)) }然后用State枚举Start / ReadingQuestion / ReadingAnswer / ReadingCloze / End配合行类型推进攒满一张卡就封装成Card。几个细节值得看裸文本行会拼进当前问题或答案所以支持多行卡面A:之后直接接Q:就能开新卡新行本身充当上一张卡的边界省掉了分隔线填空题的[...]按字节扫描专门处理 Markdown 图片的[。FSRS 用两个数建模记忆稳定性 S记忆能维持多久和难度 D遗忘倾向回忆概率随时间流逝衰减pub fn retrievability(t: Interval, s: Stability) - Recall { (1.0 F * (t / s)).powf(C) } pub fn interval(r_d: Recall, s: Stability) - Interval { (s / F) * (r_d.powf(1.0 / C) - 1.0) }第一个函数回答隔了 t 天现在还能回忆多少第二个是它的反解给定目标回忆率要等多少天。FSRS 的 19 个权重是写死的常量Whashcards 不做参数拟合——这个简化后面取舍一节再谈。用户评分Forgot/Hard/Good/Easy后状态更新集中在 src/types/performance.rs 的update_performance新卡直接取W里的初始稳定性和难度老卡先用上面的公式算出当前回忆率再更新 S 和 D。一个细节值得学评 Forgot 时interval_days直接置 0due date 就是当天——刚忘的卡同一天内会再次出现允许当场重学而不是被推到明天。为什么用内容哈希而不是自增 ID数据库中每张卡的主键不是自增整数而是卡面文本的 blake3 哈希实现在 src/types/card.rsmatch self { CardContent::Basic { question, answer } { hasher.update(bBasic); hasher.update(question.as_bytes()); hasher.update(answer.as_bytes()); } CardContent::Cloze { text, start, end } { hasher.update(bCloze); hasher.update(text.as_bytes()); hasher.update(start.to_le_bytes()); hasher.update(end.to_le_bytes()); } }这种内容寻址直接决定了系统两个行为Markdown 是唯一事实来源。src/schema.sql 的 cards 表只存哈希加状态卡面内容每次现解析数据库丢了也不丢卡片。改字即新卡。内容变一个字符哈希就变旧状态成为可清理的孤儿orphans 子命令学习进度重置。这是文本为源付出的显式代价README 里写明。同一条填空题拆出的多张卡还共享一个family_hash对完整文本取哈希会话用它实现 bury siblings同一句话的多个挖空今天只出一张避免连刷五遍同一段话。复习数据不直接落库会话缓存与延迟写入src/cmd/drill/post.rs 的处理逻辑里评分从不直接写 SQLite。会话开始时把所有到期卡的状态从库读进内存Cache会话期间只改缓存和队列Forgot/Hard 的卡会被塞回队尾稍后重出直到队列清空或用户点 Endfinish_session才一次性落盘fn finish_session(mutable: mut MutableState, state: ServerState) - Fallible() { let reviews: VecReviewRecord reviews.into_iter().map(Review::into_record).collect(); mutable.db.save_session(state.session_started_at, session_ended_at, reviews)?; for (card_hash, performance) in mutable.cache.iter() { mutable.db.update_card_performance(*card_hash, *performance)?; } Ok(()) }这样 Undo 只需把卡片放回队首、缓存回读数据库里的旧值中途放弃会话则什么都不写。数据流至此闭环Markdown → 解析出带哈希的 Card → 补新卡入库 → 选到期卡 → 洗牌 → 缓存托管状态 → 评分只改缓存 → 会话结束统一写 sessions/reviews 表并更新 cards 表。每张卡的状态只在一个函数里变化数据库只是会话结束时缓存的快照。三个设计取舍固定权重不做在线学习。完整 FSRS 实现通常按用户行为拟合参数hashcards 硬编码 19 个权重算法本体是几百行纯函数加测试向量例如验证0.9 回忆率下 interval 恰好等于 stability。用一点个性化换确定性和可测试性对本地工具是合理的。批量写而非逐评写。代价是进程中途崩溃会丢这次会话的评分。项目的立场很清楚内容在文本文件里评分记录丢失是可接受损失。手写 PRNG 而非依赖 rand。src/rng.rs 的TinyRng注释直言completely insecure一个三行的 LCG 只用来洗牌不值得为此引入一个依赖。hashcards 最值得借鉴的不是某个算法而是内容在文本、状态在数据库、两者靠哈希关联这套边界划分。想做自己的领域复习工具时解析、调度、持久化这三块几乎是照着抄就能起步的骨架。【免费下载链接】hashcardsA plain text-based spaced repetition system.项目地址: https://gitcode.com/GitHub_Trending/ha/hashcards创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考