☰
hashcards 源码剖析:纯文本间隔重复调度系统是如何搭起来的
2026/10/24 2:14:38 网站建设 项目流程

hashcards 源码剖析:纯文本间隔重复调度系统是如何搭起来的

【免费下载链接】hashcardsA plain text-based spaced repetition system.项目地址: https://gitcode.com/GitHub_Trending/ha/hashcards

hashcards 是一个纯文本间隔重复(spaced repetition)卡片系统:卡片写进 Markdown 文件,复习状态存在本地 SQLite,FSRS 算法决定每张卡下一次何时出现。本文按代码实际路径走读一遍,从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 + 一个全局可变状态。这个状态被包在Arc<Mutex<MutableState>>里排他访问——它不是多用户服务,单用户独占就够,所以不需要任何锁外的并发设计。

解析器:手写状态机把 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 调度:下一次复习日是怎么算出来的

算法实现在 src/fsrs.rs](https://link.gitcode.com/i/233e16a366f2b9fdfeffecd291c3f7e9)。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 个权重是写死的常量W,hashcards 不做参数拟合——这个简化后面取舍一节再谈。

用户评分(Forgot/Hard/Good/Easy)后,状态更新集中在 src/types/performance.rs 的update_performance:新卡直接取W里的初始稳定性和难度;老卡先用上面的公式算出当前回忆率,再更新 S 和 D。一个细节值得学:评 Forgot 时interval_days直接置 0,due date 就是当天——刚忘的卡同一天内会再次出现,允许当场重学,而不是被推到明天。

为什么用内容哈希,而不是自增 ID

数据库中每张卡的主键不是自增整数,而是卡面文本的 blake3 哈希,实现在 src/types/card.rs:

match &self { CardContent::Basic { question, answer } => { hasher.update(b"Basic"); hasher.update(question.as_bytes()); hasher.update(answer.as_bytes()); } CardContent::Cloze { text, start, end } => { hasher.update(b"Cloze"); hasher.update(text.as_bytes()); hasher.update(&start.to_le_bytes()); hasher.update(&end.to_le_bytes()); } }

这种"内容寻址"直接决定了系统两个行为:

  1. Markdown 是唯一事实来源。src/schema.sql 的 cards 表只存哈希加状态,卡面内容每次现解析,数据库丢了也不丢卡片。
  2. 改字即新卡。内容变一个字符,哈希就变,旧状态成为可清理的孤儿(orphans 子命令),学习进度重置。这是"文本为源"付出的显式代价,README 里写明。

同一条填空题拆出的多张卡还共享一个family_hash(对完整文本取哈希),会话用它实现 bury siblings:同一句话的多个挖空今天只出一张,避免连刷五遍同一段话。

复习数据不直接落库:会话缓存与延迟写入

src/cmd/drill/post.rs 的处理逻辑里,评分从不直接写 SQLite。会话开始时把所有到期卡的状态从库读进内存Cache;会话期间只改缓存和队列(Forgot/Hard 的卡会被塞回队尾稍后重出);直到队列清空或用户点 End,finish_session才一次性落盘:

fn finish_session(mutable: &mut MutableState, state: &ServerState) -> Fallible<()> { let reviews: Vec<ReviewRecord> = 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询