// recall CLI
//
// 合同:API.md 第 7 节。
//
// recall replay --log --scheduler sm2|fsrs|leitner [--json]
// recall compare --log --a sm2 --b fsrs
// recall simulate --scheduler fsrs --days 90 --new-per-day 20 --review-limit 200 --policy realistic
// recall stats --log
// recall curve --log --buckets 10
//
// 退出码:0 成功 / 1 数据错误 / 2 用法错误。
//
// 实现说明:
// - 手写参数解析(`--key value` / `--flag`),不引 argparse——
// 依赖越少,CLI 越不容易因为版本漂移而坏。
// - MoonBit 的 `fn main` 只能返回 Unit,退出码靠 `@sys.exit`。
// - MoonBit 不支持多行字符串字面量,用法文本用数组拼。
// ---------------------------------------------------------------------------
// 参数解析
// ---------------------------------------------------------------------------
///|
/// 解析后的命令行参数。
struct Args {
/// `--key value` 形式
params : Map[String, String]
/// `--flag` 形式(无值)
flags : Map[String, Bool]
}
///|
/// 手写解析,支持三种形式:
/// - `--key value` :下一项不以 `--` 开头时当作值
/// - `--key=value` :按**第一个** `=` 切分(值里可以再含 `=`)
/// - `--flag` :无值,记为布尔 flag
///
/// `--key=`(右侧为空)记入 params 且值为空串:这样数值参数会经
/// `get_opt → Some("") → parse_*_loose("") = None` 走到「用法错误 exit 2」,
/// 而不是静默回退默认值;布尔参数仍靠 `Args::has`(params 或 flags 命中即真)
/// 正常工作,`--json=` 与 `--json` 等效。
///
/// **重复参数**:同一 key 给多次时**后者胜出**(last-wins),
/// 两种形式共用这一规则,也不再另行报错——避免打断既有脚本。
///
/// RC-1(2026-10-27):旧实现只按空格切分,`--days=10` 会整体落进
/// flags(键值一起丢失),参数静默回退默认值且退出码 0:
/// `replay --scheduler=sm2` 实跑 fsrs、`simulate --days=10` 实跑 90 天。
fn parse_args(argv : Array[String]) -> Args {
let params : Map[String, String] = Map([])
let flags : Map[String, Bool] = Map([])
let mut i = 0
while i < argv.length() {
let tok = argv[i]
if tok.has_prefix("--") {
let body = tok.substring(start=2, end=tok.length())
// --- 等号形式:按第一个 '=' 切成 key=value ---
match body.find("=") {
Some(eq) => {
let key = body.substring(start=0, end=eq)
let value = body.substring(start=eq + 1, end=body.length())
// 空值也写入 params(值为空串),让调用方有机会报「提供了但非法」;
// 静默丢弃才是缺陷。
params[key] = value
i = i + 1
continue
}
None => ()
}
let has_next = i + 1 < argv.length()
if has_next && !argv[i + 1].has_prefix("--") {
params[body] = argv[i + 1]
i = i + 2
} else {
flags[body] = true
i = i + 1
}
} else {
// 位置参数忽略(子命令已在 main 里取走)
i = i + 1
}
}
{ params, flags, }
}
///|
fn Args::get(self : Args, key : String, fallback : String) -> String {
match self.params.get(key) {
Some(v) => v
None => fallback
}
}
///|
/// 参数存在性查询:None = 未提供,Some = 提供了(可能值不合法)。
/// 数值参数需要区分这两种情况——前者用默认值,后者报用法错误。
fn Args::get_opt(self : Args, key : String) -> String? {
self.params.get(key)
}
///|
///|
fn Args::has(self : Args, key : String) -> Bool {
match self.params.get(key) {
Some(_) => true
None =>
match self.flags.get(key) {
Some(_) => true
None => false
}
}
}
// ---------------------------------------------------------------------------
// 宽松的数字解析(不抛异常,非法值返回 None)
//
// moonbitlang/core/string 的 parse_* 是 `raise` 风格,
// 在 CLI 里到处 try/catch 很啰嗦,这里自己实现一份"探测式"解析。
// ---------------------------------------------------------------------------
///|
fn digit_of(c : UInt16) -> Int32 {
if c >= '0' && c <= '9' {
c.to_int() - 48
} else {
-1
}
}
///|
/// 解析整数。允许前导 +/−,不允许其它字符。
///
/// 累加用 Int64 承载,但结果必须落在 32 位有符号范围内才返回——
/// 否则 `--days 4294967297` 会被 `.to_int()` 截断成 1、`--days 2^63`
/// 被截断成 0,超长窗口静默当成短窗口跑完。越界返回 None,
/// 由调用方走「用法错误 exit 2」路径。
fn parse_int_loose(s : String) -> Int32? {
let t = s.trim_space().to_owned()
if t.length() == 0 {
return None
}
let mut i = 0
let mut neg = false
if t[0] == '-' {
neg = true
i = 1
} else if t[0] == '+' {
i = 1
}
if i >= t.length() {
return None
}
let mut acc : Int64 = 0L
while i < t.length() {
let dg = digit_of(t[i])
if dg < 0 {
return None
}
acc = acc * 10L + dg.to_int64()
// 越界即失败:等收完全部数字再判断会被 Int32 截断吞掉错误
//(例:4294967297 截断后恰好等于 1,落回合法范围)。
if acc > 2147483648L {
return None
}
i = i + 1
}
let signed = if neg { -acc } else { acc }
if signed < -2147483648L || signed > 2147483647L {
None
} else {
Some(signed.to_int())
}
}
///|
/// 解析 64 位整数。与 `parse_int_loose` 同一套「探测式」规则,
/// 但结果允许落在 Int64 全域(±(2^63-1))——`--seed` 用它解析,
/// 否则 `--seed 3000000000` 会被 Int32 解析误报非法。
/// 累加用 Int64 承载,溢出预检 `acc > (max - dg) / 10`(等收完再判
/// 会被回绕吞掉错误);幅值 2^63(即字面 -9223372036854775808)
/// 无法在 Int64 累加器中表达,按越界拒绝——种子取到端点没有实际意义。
fn parse_int64_loose(s : String) -> Int64? {
let t = s.trim_space().to_owned()
if t.length() == 0 {
return None
}
let mut i = 0
let mut neg = false
if t[0] == '-' {
neg = true
i = 1
} else if t[0] == '+' {
i = 1
}
if i >= t.length() {
return None
}
let limit : Int64 = 9223372036854775807L
let mut acc : Int64 = 0L
while i < t.length() {
let dg = digit_of(t[i])
if dg < 0 {
return None
}
// 溢出预检:acc*10+dg > limit ⟺ acc > (limit-dg)/10(整数除法)
if acc > (limit - dg.to_int64()) / 10L {
return None
}
acc = acc * 10L + dg.to_int64()
i = i + 1
}
if neg {
// -2^63 是唯一合法的负下界;acc <= 2^63-1 时取反安全,
// 唯一例外 2^63-1 取反得 -(2^63-1),仍合法,故无需额外检查。
Some(-acc)
} else {
Some(acc)
}
}
///|
/// 解析浮点。支持 `1`、`1.5`、`-0.25`、`+2.`。
///
/// **整数部分带溢出预检(RC-5,2026-10-27)**:累加用 Int64 承载,
/// 与 `parse_int_loose` / `parse_int64_loose` 同一套「快过界就拒绝」做法。
///
/// 为什么必须预检:旧实现只有 `int_part = int_part * 10L + dg`,超长数字
/// 静默回绕;小数部分随后照常拼上,于是回绕后的整数 + 用户的小数位
/// 构成一个"看起来合法"的值:
/// 18446744073709551616.7 (= 2^64 + 0.7) → 0.70
/// 36893488147419103232.9 (= 2*2^64 + 0.9) → 0.90
/// `--retention` 是决定间隔倍率的一等参数(dr=0.7 与 0.9 差 2.7 倍间隔),
/// 把它静默改写成另一个合法值,比静默改写 `--days` 更严重。
///
/// 上界取 `(2^63-1 - 9) / 10`,即"再多接一位十进制数字仍不越界"的临界值;
/// 超出即返回 None,由调用方走「用法错误 exit 2」。
fn parse_double_loose(s : String) -> Double? {
let t = s.trim_space().to_owned()
if t.length() == 0 {
return None
}
let mut i = 0
let mut neg = false
if t[0] == '-' {
neg = true
i = 1
} else if t[0] == '+' {
i = 1
}
// 与 parse_int64_loose 同法的溢出预检(见其注释的推导)
let limit : Int64 = 9223372036854775807L
let mut int_part : Int64 = 0L
let mut any = false
while i < t.length() {
let dg = digit_of(t[i])
if dg < 0 {
break
}
// acc*10+dg > limit ⟺ acc > (limit-dg)/10(整数除法)
if int_part > (limit - dg.to_int64()) / 10L {
return None
}
int_part = int_part * 10L + dg.to_int64()
any = true
i = i + 1
}
let mut value = int_part.to_double()
if i < t.length() && t[i] == '.' {
i = i + 1
let mut scale = 0.1
while i < t.length() {
let dg = digit_of(t[i])
if dg < 0 {
break
}
value = value + dg.to_double() * scale
scale = scale / 10.0
any = true
i = i + 1
}
}
if !any || i != t.length() {
return None
}
if neg {
Some(-value)
} else {
Some(value)
}
}
// ---------------------------------------------------------------------------
// 格式化
//
// 不用 Double::to_string(它会给出 0.9000000000000001 这类尾巴,
// 而且某些值会走科学计数法),自己按位数四舍五入。
// 注意 Int 只有 32 位,所以缩放用 Int64。
// ---------------------------------------------------------------------------
///|
/// 定点小数格式化,四舍五入到 `digits` 位。
///
/// 防护:定点换算 `v * 10^digits` 用 Int64 承载,超出 Int64 表示范围
/// (约 9.22e18)时会静默饱和成 92233720368547758.07 这类垃圾值——
/// 合法输入(`elapsed_days` 允许任意有限 Double,如 1e300)也会触发。
/// 超出定点安全范围时退回 `Double::to_string`(指数形式),长度受控
/// 且量级诚实。
fn fmt_float(x : Double, digits : Int32) -> String {
// Int64 定点安全上界:留出 0.5 的进位余量后仍远小于 2^63-1
let fixed_limit = 9.0e18
let scale_dbl = @math.pow(10.0, digits.to_double())
let mag = if x < 0.0 { -x } else { x }
if mag.is_nan() || mag * scale_dbl > fixed_limit {
// 非有限或定点溢出:交给 Double 自己的字符串化(指数形式)
return x.to_string()
}
let neg = x < 0.0
let v = if neg { -x } else { x }
let mut scale : Int64 = 1L
let mut i = 0
while i < digits {
scale = scale * 10L
i = i + 1
}
let rounded = @math.floor(v * scale.to_double() + 0.5).to_int64()
let ip = rounded / scale
let fp = rounded % scale
let body = if digits == 0 {
ip.to_string()
} else {
let fs = fp.to_string()
let sb = StringBuilder()
sb.write_string(ip.to_string())
sb.write_string(".")
let mut pad = 0
while pad < digits - fs.length() {
sb.write_string("0")
pad = pad + 1
}
sb.write_string(fs)
sb.to_string()
}
if neg {
"-" + body
} else {
body
}
}
///|
/// 百分比格式化(0.9123 → "91.23%")。
fn fmt_pct(x : Double) -> String {
fmt_float(x * 100.0, 2) + "%"
}
///|
/// 左对齐、右侧补空格到 `width`。
fn pad_right(s : String, width : Int32) -> String {
let sb = StringBuilder()
sb.write_string(s)
let mut n = width - s.length()
while n > 0 {
sb.write_string(" ")
n = n - 1
}
sb.to_string()
}
///|
/// 右对齐、左侧补空格到 `width`。
fn pad_left(s : String, width : Int32) -> String {
let sb = StringBuilder()
let mut n = width - s.length()
while n > 0 {
sb.write_string(" ")
n = n - 1
}
sb.write_string(s)
sb.to_string()
}
///|
/// 横向分隔线。
fn hr(width : Int32) -> String {
let sb = StringBuilder()
let mut i = 0
while i < width {
sb.write_string("-")
i = i + 1
}
sb.to_string()
}
///|
/// 简易条形图(用 # 表示),用于积压趋势。
fn bar(value : Double, max_value : Double, width : Int32) -> String {
let cells = if max_value <= 0.0 {
0
} else {
(value / max_value * width.to_double()).to_int()
}
let n = if cells < 0 { 0 } else if cells > width { width } else { cells }
let sb = StringBuilder()
let mut i = 0
while i < n {
sb.write_string("#")
i = i + 1
}
sb.to_string()
}
// ---------------------------------------------------------------------------
// 用法文本
// ---------------------------------------------------------------------------
///|
/// 用法说明。数组拼装,因为 MoonBit 没有多行字符串。
fn usage_text() -> String {
let lines = [
"recall - 间隔重复调度引擎 (SM-2 / FSRS-5 / Leitner)", "", "用法:",
" recall replay --log FILE --scheduler sm2|fsrs|leitner [--json|--anki]",
" recall compare --log FILE --a sm2 --b fsrs", " recall simulate --scheduler fsrs [--days 90] [--new-per-day 20]",
" [--review-limit 200] [--policy realistic] [--seed N]", " recall advice --scheduler fsrs [--days 90] [--review-limit 200]",
" [--target-backlog 0] [--policy realistic] [--retention 0.9] [--seed N]",
" recall stats --log FILE", " recall curve --log FILE [--buckets 10]",
"", "选项:", " --log 复习日志路径 (JSONL, 每行一个事件)",
" --scheduler 调度器: sm2 | fsrs | leitner", " --a / --b compare 的左右两侧调度器",
" --days 模拟天数 (默认 90, 上限 100000)", " --new-per-day 每天新增卡片数 (默认 20)",
" --review-limit 每天复习上限, 0 表示不限 (默认 200)", " --policy 评分策略: optimistic | realistic | pessimistic (默认 realistic)",
" --buckets 保持率曲线分桶数 (默认 10, 上限 10000)", " --retention 目标保持率 (默认 0.9, fsrs 有效区间 [0.5,0.99], 越界夹到边界)",
" --target-backlog advice 的积压目标, 张 (默认 0 = 清零)", " --seed 模拟的伪随机种子 (默认 20240101, 64 位整数)",
" --json 以 JSON 输出 (仅 replay, 与 --anki 同给时被忽略)",
" --anki 以 Anki 文本导入格式(TSV)输出最终状态 (仅 replay, 优先于 --json)",
"", "参数写法: --key value 与 --key=value 等价;--flag 表示布尔开",
" 同一参数重复给出时后者生效 (last-wins)", "", "退出码: 0 成功 / 1 数据错误 / 2 用法错误",
"",
]
let sb = StringBuilder()
for line in lines {
sb.write_string(line)
sb.write_string("\n")
}
sb.to_string()
}
// ---------------------------------------------------------------------------
// 保持率曲线标签
// ---------------------------------------------------------------------------
///|
/// 曲线桶标签。常规桶是左闭右开 `[lo, hi)`;但全零间隔样本
/// (retention_curve 的 max_elapsed==0 分支)只有一桶 (0, 0),
/// `[0.0, 0.0)` 是数学上的空区间——读者会以为曲线丢了数据。
/// 此时打闭区间 `[0.0, 0.0]` 并注明语义。
fn zero_interval_label(row : (Double, Double, Double)) -> String {
if row.0 == 0.0 && row.1 == 0.0 {
return "[0.0, 0.0](全部为零间隔)"
}
"[" + fmt_float(row.0, 1) + ", " + fmt_float(row.1, 1) + ")"
}
// ---------------------------------------------------------------------------
// 参数生效值/守卫(对抗性审计 P3:打印值必须等于生效值)
// ---------------------------------------------------------------------------
///|
/// `--retention` 的**生效值**:fsrs 会被 `Fsrs::with_retention`
/// 夹到 [0.5,0.99]、无效值回退 0.9(scheduler.mbt),其它调度器
/// 没有该参数、原样透传。打印参数行前先过这里,保证「打印值=生效值」。
fn effective_retention(sched_name : String, requested : Double) -> Double {
if sched_name != "fsrs" {
return requested
}
if requested.is_nan() || requested <= 0.0 || requested >= 1.0 {
0.9
} else if requested < 0.5 {
0.5
} else if requested > 0.99 {
0.99
} else {
requested
}
}
///|
/// `--retention` 请求值与生效值不一致时的提示行;一致时返回空串。
/// 复现 `with_retention` 的夹取逻辑而不是调用它——那条路径在 lib 里,
/// 这里只负责把同一决策如实告诉用户。
fn retention_hint(sched_name : String, requested : Double) -> String {
if sched_name != "fsrs" {
return ""
}
let eff = effective_retention(sched_name, requested)
if eff == requested {
return ""
}
if requested.is_nan() || requested <= 0.0 || requested >= 1.0 {
"提示: --retention \{fmt_float(requested, 2)} 无效(合法区间 (0,1)),已按默认 0.9 生效"
} else {
"提示: --retention \{fmt_float(requested, 2)} 超出 [0.5,0.99],已按 \{fmt_float(eff, 2)} 生效"
}
}
///|
/// `--days` 的量级守卫:负数非法;比照 `--buckets` 的 >10000 上限思路,
/// 这里上限 100000(simulate 是 O(days×卡片) 的模拟,天文天数 =
/// 天文运行时间与输出行数)。合法返回空串,非法返回错误消息。
fn days_range_error(days : Int32) -> String {
if days < 0 {
return "用法错误: --days 必须非负"
}
if days > 100000 {
return "用法错误: --days 必须 <= 100000(模拟成本与天数十万倍成正比),得到 \{days}"
}
""
}
///|
/// replay 的 `--anki` 与 `--json` 同给时,`--json` 分支永远走不到
/// (anki 在前并 return)。不改变「按 --anki 输出」的优先级,
/// 但补一行提示让用户知道 --json 被忽略了。
fn replay_conflict_hint(has_anki : Bool, has_json : Bool) -> String {
if has_anki && has_json {
return "提示: --anki 与 --json 同时给出,按 --anki 输出(--json 被忽略)"
}
""
}
// ---------------------------------------------------------------------------
// 日志读取
// ---------------------------------------------------------------------------
///|
/// 读文本文件,失败返回 None(不抛)。
fn read_text(path : String) -> String? {
let r = Some(@fs.read_file_to_string(path)) catch { _ => None }
r
}
///|
/// 加载并解析日志。任何失败都打印原因并返回 None(调用方转成退出码 1)。
fn load_log(path : String) -> @lib.ReviewLog? {
match read_text(path) {
None => {
println("数据错误: 无法读取文件 " + path)
None
}
Some(text) => {
let parsed = Some(@lib.ReviewLog::from_jsonl(text)) catch {
@lib.BadLine(ln, why) => {
println(
"数据错误: 第 " +
ln.to_string() +
" 行解析失败 — " +
why,
)
None
}
@lib.BadTimestamp(ln, raw) => {
println(
"数据错误: 第 " +
ln.to_string() +
" 行时间戳非法 — " +
raw,
)
None
}
_ => {
println("数据错误: JSONL 解析失败")
None
}
}
match parsed {
None => None
Some(result) =>
match result {
Ok(log) => Some(log)
// Err 分支必须打印原因:core 的 from_jsonl 把错误包在 Result 里,
// 不 raise 也不打印,静默返回 None 会让用户只看到退出码 1。
Err(@lib.BadLine(ln, why)) => {
println(
"数据错误: 第 " +
ln.to_string() +
" 行解析失败 — " +
why,
)
None
}
Err(@lib.BadTimestamp(ln, raw)) => {
println(
"数据错误: 第 " +
ln.to_string() +
" 行时间戳非法 — " +
raw,
)
None
}
}
}
}
}
}
// ---------------------------------------------------------------------------
// 子命令:replay
// ---------------------------------------------------------------------------
///|
fn cmd_replay(args : Args) -> Int32 {
let path = args.get("log", "")
if path.length() == 0 {
println("用法错误: replay 需要 --log FILE")
return 2
}
let sched_name = args.get("scheduler", "fsrs")
if !@lib.scheduler_name_valid(sched_name) {
println(
"用法错误: 未知调度器 " +
sched_name +
"(可选 sm2 / fsrs / leitner)",
)
return 2
}
let log = match load_log(path) {
Some(v) => v
None => return 1
}
let s = @lib.AnyScheduler::by_name(sched_name)
let res = @lib.replay(log, s)
let conflict = replay_conflict_hint(args.has("anki"), args.has("json"))
if conflict.length() > 0 {
println(conflict)
}
if args.has("anki") {
// Anki 文本导入格式(TSV):File > Import 直接可用
let states : Array[@lib.CardState] = []
let ids : Array[String] = []
for cid in res.final_states.keys() {
ids.push(cid)
}
ids.sort()
for cid in ids {
match res.final_states.get(cid) {
Some(st) => states.push(st)
None => ()
}
}
println(@lib.export_anki_tsv(states, s.name()))
return 0
}
if args.has("json") {
let ids : Array[String] = []
for cid in res.final_states.keys() {
ids.push(cid)
}
ids.sort()
let sb = StringBuilder()
sb.write_string("{\"scheduler\":\"")
sb.write_string(s.name())
sb.write_string("\",\"cards\":")
sb.write_string(res.final_states.length().to_string())
sb.write_string(",\"total_reviews\":")
sb.write_string(res.total_reviews.to_string())
sb.write_string(",\"total_lapses\":")
sb.write_string(res.total_lapses.to_string())
sb.write_string(",\"interval_error_days\":")
sb.write_string(fmt_float(res.interval_error_days, 4))
sb.write_string(",\"final_intervals\":{")
let mut first = true
for cid in ids {
let iv = match res.final_states.get(cid) {
Some(st) => st.interval_days
None => 0.0
}
if !first {
sb.write_string(",")
}
first = false
// R2 修复:card_id 直接拼接会产生非法 JSON —— id 含引号(a"b)时
// 输出 {"a"b":1.00}。用 core 的 Json::string 做正确转义,它产出的
// 字符串自带引号,直接作为键写入。
sb.write_string(Json::string(cid).stringify())
sb.write_string(":")
sb.write_string(fmt_float(iv, 2))
}
sb.write_string("}}")
println(sb.to_string())
} else {
println("调度器: " + s.name())
println(
"日志: " + path + " (" + log.length().to_string() + " 个事件)",
)
println("卡片: " + res.final_states.length().to_string() + " 张")
println("复习: " + res.total_reviews.to_string() + " 次")
println("遗忘: " + res.total_lapses.to_string() + " 次")
println(
"平均间隔偏差: " + fmt_float(res.interval_error_days, 2) + " 天",
)
println("")
println(
pad_right("卡片", 12) +
pad_left("间隔(天)", 12) +
pad_left("稳定性", 12) +
pad_left(
if sched_name == "sm2" {
"EF"
} else if sched_name == "leitner" {
"盒号"
} else {
"难度"
},
9,
) +
pad_left("复习", 7) +
pad_left("遗忘", 7) +
" 阶段",
)
println(hr(72))
let ids : Array[String] = []
for cid in res.final_states.keys() {
ids.push(cid)
}
ids.sort()
for cid in ids {
let st = match res.final_states.get(cid) {
Some(v) => v
None => continue
}
println(
pad_right(cid, 12) +
pad_left(fmt_float(st.interval_days, 2), 12) +
pad_left(fmt_float(st.stability, 2), 12) +
pad_left(fmt_float(st.difficulty, 2), 9) +
pad_left(st.reps.to_string(), 7) +
pad_left(st.lapses.to_string(), 7) +
" " +
st.phase.label(),
)
}
}
0
}
// ---------------------------------------------------------------------------
// 子命令:compare
// ---------------------------------------------------------------------------
///|
fn cmd_compare(args : Args) -> Int32 {
let path = args.get("log", "")
if path.length() == 0 {
println("用法错误: compare 需要 --log FILE")
return 2
}
let a_name = args.get("a", "sm2")
let b_name = args.get("b", "fsrs")
if !@lib.scheduler_name_valid(a_name) {
println("用法错误: 未知调度器 --a " + a_name)
return 2
}
if !@lib.scheduler_name_valid(b_name) {
println("用法错误: 未知调度器 --b " + b_name)
return 2
}
let log = match load_log(path) {
Some(v) => v
None => return 1
}
let a = @lib.AnyScheduler::by_name(a_name)
let b = @lib.AnyScheduler::by_name(b_name)
let c = @lib.compare(log, a, b)
println("对比: " + c.a_name + " vs " + c.b_name)
println(
"日志: " +
path +
" (" +
log.length().to_string() +
" 事件 / " +
c.per_card.length().to_string() +
" 卡片)",
)
println("")
println(
pad_right("指标", 22) +
" " +
pad_left(c.a_name, 14) +
" " +
pad_left(c.b_name, 14) +
" " +
pad_left("差值", 14),
)
println(hr(68))
println(
pad_right("最终间隔总和(天)", 22) +
" " +
pad_left(fmt_float(c.a_total_interval, 1), 14) +
" " +
pad_left(fmt_float(c.b_total_interval, 1), 14) +
" " +
pad_left(fmt_float(c.b_total_interval - c.a_total_interval, 1), 14),
)
println(
pad_right("遗忘次数", 22) +
" " +
pad_left(c.a_lapses.to_string(), 14) +
" " +
pad_left(c.b_lapses.to_string(), 14) +
" " +
pad_left((c.b_lapses - c.a_lapses).to_string(), 14),
)
let n = c.per_card.length()
let a_avg = if n == 0 { 0.0 } else { c.a_total_interval / n.to_double() }
let b_avg = if n == 0 { 0.0 } else { c.b_total_interval / n.to_double() }
println(
pad_right("平均间隔(天)", 22) +
" " +
pad_left(fmt_float(a_avg, 1), 14) +
" " +
pad_left(fmt_float(b_avg, 1), 14) +
" " +
pad_left(fmt_float(b_avg - a_avg, 1), 14),
)
// 「触及间隔上限」的卡数:这是判断指标是否可比的**前提**。
// interval_days 一旦顶到 max_interval_days()(100 年),说明该算法认为
// 这张卡 100 年后才需要复习 —— 在真实场景里等价于「永远不再复习」。
// 这类卡会把 total_interval 拉到失真的量级,导致「间隔总和越大越好」的
// 直觉彻底失效(SM-2 就常常这样失控,而 FSRS 不会)。
let cap = @lib.max_interval_days()
let mut a_at_cap = 0
let mut b_at_cap = 0
for row in c.per_card {
if row.1 >= cap {
a_at_cap = a_at_cap + 1
}
if row.2 >= cap {
b_at_cap = b_at_cap + 1
}
}
println(
pad_right("触及上限(100年)卡数", 22) +
" " +
pad_left(a_at_cap.to_string(), 14) +
" " +
pad_left(b_at_cap.to_string(), 14) +
" " +
pad_left((b_at_cap - a_at_cap).to_string(), 14),
)
println("")
println("逐卡对比")
println(
pad_right("卡片", 14) +
" " +
pad_left(c.a_name, 14) +
" " +
pad_left(c.b_name, 14) +
" " +
pad_left("差值", 14),
)
println(hr(60))
for row in c.per_card {
println(
pad_right(row.0, 14) +
" " +
pad_left(fmt_float(row.1, 1), 14) +
" " +
pad_left(fmt_float(row.2, 1), 14) +
" " +
pad_left(fmt_float(row.2 - row.1, 1), 14),
)
}
let ratio = if c.a_total_interval > 0.0 {
c.b_total_interval / c.a_total_interval
} else {
1.0
}
if n > 0 && c.a_total_interval > 0.0 {
println("")
println(
"→ " +
c.b_name +
" 的最终间隔是 " +
c.a_name +
" 的 " +
fmt_float(ratio, 2) +
" 倍",
)
}
// --- 解读提示:只在"有卡顶到上限"或"比值极端"时才出现 ---
// 目的不是评判算法,而是阻止"间隔总和越大 = 算法越好"这个错误推断。
let a_capped = a_at_cap > 0
let b_capped = b_at_cap > 0
if a_capped || b_capped {
println("")
println("⚠ 指标解读")
if a_capped {
println(
" " +
c.a_name +
" 有 " +
a_at_cap.to_string() +
"/" +
n.to_string() +
" 张卡触及 100 年上限:该算法认为它们 100 年后才需复习,",
)
println(
" 在真实场景中等价于「永远不再复习」。这些卡把间隔总和拉得很大,",
)
println(
" 因此「间隔总和/平均间隔」在此**不能**直接当作优劣比较。",
)
}
if b_capped {
println(
" " +
c.b_name +
" 有 " +
b_at_cap.to_string() +
"/" +
n.to_string() +
" 张卡触及 100 年上限,同样不可直接比较。",
)
}
println(
" 更可比的指标是「触及上限卡数」(越少越好)与「遗忘次数」。",
)
} else if ratio > 10.0 || ratio < 0.1 {
println("")
println(
"⚠ 两侧间隔相差超过 10 倍,总量指标可能被少数极端卡主导,",
)
println(" 建议结合逐卡对比与遗忘次数一起看。")
}
0
}
// ---------------------------------------------------------------------------
// 子命令:simulate
// ---------------------------------------------------------------------------
///|
fn cmd_simulate(args : Args) -> Int32 {
let sched_name = args.get("scheduler", "fsrs")
if !@lib.scheduler_name_valid(sched_name) {
println("用法错误: 未知调度器 " + sched_name)
return 2
}
let policy = args.get("policy", "realistic")
if !@lib.grade_policy_valid(policy) {
println(
"用法错误: 未知评分策略 " +
policy +
"(可选 optimistic / realistic / pessimistic)",
)
return 2
}
// 数值参数区分「未提供」(用默认)与「提供了但不是数字」(用法错误)。
// 旧实现把 --days abc 静默当作 90 天跑完并 exit 0——用户以为模拟的是
// 自己要的时间窗口,实际不是。
let days = match args.get_opt("days") {
None => 90
Some(v) =>
match parse_int_loose(v) {
Some(n) => n
None => {
println("用法错误: --days 需要一个整数,得到 '\{v}'")
return 2
}
}
}
let new_per_day = match args.get_opt("new-per-day") {
None => 20
Some(v) =>
match parse_int_loose(v) {
Some(n) => n
None => {
println(
"用法错误: --new-per-day 需要一个整数,得到 '\{v}'",
)
return 2
}
}
}
let review_limit = match args.get_opt("review-limit") {
None => 200
Some(v) =>
match parse_int_loose(v) {
Some(n) => n
None => {
println(
"用法错误: --review-limit 需要一个整数,得到 '\{v}'",
)
return 2
}
}
}
let target_retention = match args.get_opt("retention") {
None => 0.9
Some(v) =>
match parse_double_loose(v) {
Some(n) => n
None => {
println(
"用法错误: --retention 需要一个小数,得到 '\{v}'",
)
return 2
}
}
}
let seed = match args.get_opt("seed") {
None => 20240101L
Some(v) =>
match parse_int64_loose(v) {
Some(n) => n
None => {
println("用法错误: --seed 需要一个整数,得到 '\{v}'")
return 2
}
}
}
// 量级守卫:负数与天文天数都在这里拦下
let days_err = days_range_error(days)
if days_err.length() > 0 {
println(days_err)
return 2
}
if new_per_day < 0 {
println("用法错误: --new-per-day 必须非负")
return 2
}
if review_limit < 0 {
println("用法错误: --review-limit 必须非负(0 表示不限)")
return 2
}
let cfg : @lib.SimConfig = {
days,
new_per_day,
review_limit,
desired_retention: target_retention,
grade_fn: @lib.grade_policy_with_seed(policy, seed),
seed,
}
// R1 修复:retention 现在真正传给调度器。SM-2/Leitner 没有这个参数
// (算法局限),此时如实告知而不是沉默地忽略。fsrs 越界被夹时,
// 参数行打印**生效值**并附提示行——打印请求值会让用户以为 0.3 生效了。
let s = @lib.AnyScheduler::by_name_with_retention(
sched_name, target_retention,
)
if sched_name != "fsrs" && target_retention != 0.9 {
println(
"提示: " +
sched_name +
" 调度器没有目标保持率参数(算法局限)," +
"--retention " +
fmt_float(target_retention, 2) +
" 未生效(仅 FSRS 支持)",
)
}
let retention_hint_line = retention_hint(sched_name, target_retention)
if retention_hint_line.length() > 0 {
println(retention_hint_line)
}
let effective_r = effective_retention(sched_name, target_retention)
let res = @lib.simulate(s, cfg)
println("模拟: " + s.name() + " / " + policy + " 策略")
println(
"参数: " +
days.to_string() +
" 天, 每天新增 " +
new_per_day.to_string() +
" 张, 复习上限 " +
(if review_limit == 0 { "不限" } else { review_limit.to_string() }) +
", 目标保持率 " +
fmt_float(effective_r, 2) +
", 种子 " +
seed.to_string(),
)
println("")
println(
pad_right("天", 8) +
pad_left("新卡", 8) +
pad_left("到期", 8) +
pad_left("已复习", 9) +
pad_left("记住", 8) +
pad_left("积压", 8) +
" 积压趋势",
)
println(hr(78))
// 长模拟按步长抽样,避免 90 行刷屏;但首尾必出
let step = if days <= 20 { 1 } else if days <= 45 { 3 } else { 7 }
let max_b = if res.max_backlog == 0 { 1 } else { res.max_backlog }
for d in res.days {
let show = d.day == 0 || d.day == days - 1 || d.day % step == 0
if show {
println(
pad_right("#" + (d.day + 1).to_string(), 8) +
pad_left(d.new_cards.to_string(), 8) +
pad_left(d.due_cards.to_string(), 8) +
pad_left(d.reviewed.to_string(), 9) +
pad_left(d.retained.to_string(), 8) +
pad_left(d.backlog.to_string(), 8) +
" " +
bar(d.backlog.to_double(), max_b.to_double(), 20),
)
}
}
println("")
println("总复习次数: " + res.total_reviews.to_string())
println("最大积压: " + res.max_backlog.to_string() + " 张")
println("最终积压: " + res.final_backlog.to_string() + " 张")
if res.final_backlog > 0 {
println("")
println(
"→ " +
days.to_string() +
" 天后仍有 " +
res.final_backlog.to_string() +
" 张卡在排队等待复习",
)
}
0
}
// ---------------------------------------------------------------------------
// 子命令:stats
// ---------------------------------------------------------------------------
///|
fn cmd_stats(args : Args) -> Int32 {
let path = args.get("log", "")
if path.length() == 0 {
println("用法错误: stats 需要 --log FILE")
return 2
}
let log = match load_log(path) {
Some(v) => v
None => return 1
}
let rows = @lib.compute_stats(log)
let overall = @lib.overall_stats(rows)
println("统计: " + path)
println(
" " +
log.length().to_string() +
" 个事件 / " +
overall.cards.to_string() +
" 张卡片",
)
println("")
println(
pad_right("卡片", 14) +
pad_left("复习", 8) +
pad_left("遗忘", 8) +
pad_left("均间隔", 10) +
pad_left("最大间隔", 11) +
pad_left("保持率", 10),
)
println(hr(61))
for r in rows {
println(
pad_right(r.card_id, 14) +
pad_left(r.reviews.to_string(), 8) +
pad_left(r.lapses.to_string(), 8) +
pad_left(fmt_float(r.mean_interval, 2), 10) +
pad_left(fmt_float(r.max_interval, 2), 11) +
pad_left(fmt_pct(r.retention_rate), 10),
)
}
println(hr(61))
println(
pad_right("合计", 14) +
pad_left(overall.reviews.to_string(), 8) +
pad_left(overall.lapses.to_string(), 8) +
pad_left(fmt_float(overall.mean_interval, 2), 10) +
pad_left("", 11) +
pad_left(fmt_pct(overall.retention_rate), 10),
)
0
}
// ---------------------------------------------------------------------------
// 子命令:curve
// ---------------------------------------------------------------------------
///|
fn cmd_curve(args : Args) -> Int32 {
let path = args.get("log", "")
if path.length() == 0 {
println("用法错误: curve 需要 --log FILE")
return 2
}
// 数值参数区分「未提供」(用默认)与「提供了但不是数字」(用法错误),
// 与 --days / --new-per-day 等保持同一模式。
// 旧实现经 get_int 把非法值静默回退成默认 10 并 exit 0——
// 用户以为画的是自己要的桶数,实际不是。
let buckets = match args.get_opt("buckets") {
None => 10
Some(v) =>
match parse_int_loose(v) {
Some(n) => n
None => {
println("用法错误: --buckets 需要一个整数,得到 '\{v}'")
return 2
}
}
}
if buckets < 1 {
println("用法错误: --buckets 必须 >= 1")
return 2
}
// 上界 10000:retention_curve 会按桶数分配两个 Int 数组
//(Array::make(n_buckets, 0)),--buckets 2000000000 这类值会让
// wasm 分配直接 unreachable 崩溃(exit 1 + 栈回溯),而不是可读的
// 用法错误。真实曲线用不到上万桶,超出即视为误输入。
if buckets > 10000 {
println("用法错误: --buckets 必须 <= 10000,得到 \{buckets}")
return 2
}
let log = match load_log(path) {
Some(v) => v
None => return 1
}
let curve = @lib.retention_curve(log, buckets)
println("保持率曲线: " + path + "(" + buckets.to_string() + " 桶)")
println("")
if curve.length() == 0 {
// R5:空结果有两种原因,混在一句里会误导——「每卡只有一次复习」
// 和「日志为空」是不同的问题。另外说明分桶语义:每个桶是左闭右开
// `[lo, hi)`(最后一桶闭区间覆盖最大值),所以 elapsed 恰好等于
// 桶下界的样本归入该桶。与 compute_stats(直接对事件算)数值不同
// 的原因是按桶聚合,不是边界归属模糊。
println(
"(保持率曲线为空:分桶需要同卡多次复习产生的正间隔样本;",
)
println(
" 日志为空、每卡仅一次复习时结果为空。桶为左闭右开 [lo, hi),",
)
println(" 最后一桶为闭区间以覆盖最大间隔)")
return 0
}
// 标签精度跟着桶宽走:桶宽 < 1 天时 1 位小数会把 [x, x+w) 打印成
// 同一个数(如 10000 桶时每桶只有 0.018 天),看起来像标签写错。
let width0 = curve[0].1 - curve[0].0
let digits = if width0 >= 1.0 {
1
} else if width0 >= 0.1 {
2
} else if width0 >= 0.01 {
3
} else {
4
}
let labels : Array[String] = []
for row in curve {
// 全零间隔的单桶 (0,0) 打成 "[0.0, 0.0)" 会是空区间记号
if row.0 == 0.0 && row.1 == 0.0 {
labels.push(zero_interval_label(row))
} else {
labels.push(
"[" + fmt_float(row.0, digits) + ", " + fmt_float(row.1, digits) + ")",
)
}
}
let mut label_width = 16
for label in labels {
if label.length() + 1 > label_width {
label_width = label.length() + 1
}
}
println(
pad_right("间隔区间(天)", label_width) +
pad_left("保持率", 10) +
" 曲线",
)
println(hr(62))
for i, row in curve {
let cells = (row.2 * 30.0).to_int()
let sb = StringBuilder()
let mut j = 0
while j < cells {
sb.write_string("#")
j = j + 1
}
println(
pad_right(labels[i], label_width) +
pad_left(fmt_pct(row.2), 10) +
" " +
sb.to_string(),
)
}
// 单调性自检
let mut prev = 2.0
let mut monotone = true
for row in curve {
if row.2 > prev {
monotone = false
}
prev = row.2
}
println("")
if monotone {
println("→ 曲线单调不增,符合遗忘规律")
} else {
println("→ 曲线出现回升(样本噪声,或分桶过细)")
}
0
}
// ---------------------------------------------------------------------------
// 子命令:advice(负载优化建议)
// ---------------------------------------------------------------------------
///|
/// `recall advice` — 搜索「窗口内积压不超标的最大每日新卡数」。
///
/// 从上界向下线性扫描(积压对 new_per_day 非单调,不能二分),
/// 每次 probe 是一次完整模拟,结果确定性可复现。
fn cmd_advice(args : Args) -> Int32 {
let sched_name = args.get("scheduler", "fsrs")
if !@lib.scheduler_name_valid(sched_name) {
println("用法错误: 未知调度器 " + sched_name)
return 2
}
let policy = args.get("policy", "realistic")
if !@lib.grade_policy_valid(policy) {
println("用法错误: 未知评分策略 " + policy)
return 2
}
let days = match args.get_opt("days") {
None => 90
Some(v) =>
match parse_int_loose(v) {
Some(n) => n
None => {
println("用法错误: --days 需要一个整数,得到 '\{v}'")
return 2
}
}
}
let review_limit = match args.get_opt("review-limit") {
None => 200
Some(v) =>
match parse_int_loose(v) {
Some(n) => n
None => {
println(
"用法错误: --review-limit 需要一个整数,得到 '\{v}'",
)
return 2
}
}
}
let target_retention = match args.get_opt("retention") {
None => 0.9
Some(v) =>
match parse_double_loose(v) {
Some(n) => n
None => {
println(
"用法错误: --retention 需要一个小数,得到 '\{v}'",
)
return 2
}
}
}
let seed = match args.get_opt("seed") {
None => 20240101L
Some(v) =>
match parse_int64_loose(v) {
Some(n) => n
None => {
println("用法错误: --seed 需要一个整数,得到 '\{v}'")
return 2
}
}
}
let target_backlog = match args.get_opt("target-backlog") {
None => 0
Some(v) =>
match parse_int_loose(v) {
Some(n) => n
None => {
println(
"用法错误: --target-backlog 需要一个整数,得到 '\{v}'",
)
return 2
}
}
}
if days <= 0 {
println("用法错误: --days 必须为正")
return 2
}
// advice 每次 probe 是一次完整模拟(上限 100 次扫描),量级守卫
// 与 simulate 同阈值——advice 的成本还要再乘扫描次数。
if days > 100000 {
println(
"用法错误: --days 必须 <= 100000(advice 每次探测都是完整模拟),得到 \{days}",
)
return 2
}
if review_limit < 0 {
println("用法错误: --review-limit 必须非负(0 表示不限)")
return 2
}
if target_backlog < 0 {
println("用法错误: --target-backlog 必须非负")
return 2
}
let s = @lib.AnyScheduler::by_name_with_retention(
sched_name, target_retention,
)
if sched_name != "fsrs" && target_retention != 0.9 {
println(
"提示: " +
sched_name +
" 调度器没有目标保持率参数(算法局限),--retention 未生效(仅 FSRS 支持)",
)
}
// fsrs 越界夹取的提示与 simulate 同一条路径;参数行打印生效值。
let retention_hint_line = retention_hint(sched_name, target_retention)
if retention_hint_line.length() > 0 {
println(retention_hint_line)
}
let effective_r = effective_retention(sched_name, target_retention)
let req : @lib.AdviceRequest = {
days,
review_limit,
desired_retention: target_retention,
policy_factory: fn() { @lib.grade_policy_with_seed(policy, seed) },
seed,
target_backlog,
}
let r = @lib.load_advice(s, req)
println("负载建议: " + s.name() + " / " + policy + " 策略")
println(
"参数: " +
days.to_string() +
" 天窗口, 复习上限 " +
(if review_limit == 0 { "不限" } else { review_limit.to_string() }) +
", 积压目标 " +
target_backlog.to_string() +
" 张, 目标保持率 " +
fmt_float(effective_r, 2) +
", 种子 " +
seed.to_string(),
)
println("")
println("→ 推荐每天新卡: " + r.new_per_day.to_string() + " 张")
println("")
println("依据(该速率下的模拟):")
println(
" 最终积压 " +
r.final_backlog.to_string() +
" 张(目标 ≤ " +
target_backlog.to_string() +
"), 总复习 " +
r.simulation.total_reviews.to_string() +
" 次, 最大积压 " +
r.simulation.max_backlog.to_string() +
" 张",
)
match r.next_step_backlog {
Some(nb) =>
println(
" 每天多学 1 张 → 最终积压 " +
nb.to_string() +
" 张, 超出目标",
)
None => ()
}
println("")
println(r.summary)
// 附积压趋势摘要(首/中/尾三天)
let dl = r.simulation.days
if dl.length() >= 3 {
let mid = dl[dl.length() / 2]
println("")
println(
"积压轨迹: 首日 " +
dl[0].backlog.to_string() +
" → 中段 " +
mid.backlog.to_string() +
" → 末日 " +
dl[dl.length() - 1].backlog.to_string() +
" 张",
)
}
0
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
///|
fn usage_error(msg : String) -> Unit {
println("用法错误: " + msg)
println("")
println(usage_text())
}
///|
fn main {
let argv = @env.args()
// argv[0] 是程序自身,跳过
let rest : Array[String] = []
let mut i = 1
while i < argv.length() {
rest.push(argv[i])
i = i + 1
}
if rest.length() == 0 {
println(usage_text())
@sys.exit(2)
}
let sub = rest[0]
let tail : Array[String] = []
let mut j = 1
while j < rest.length() {
tail.push(rest[j])
j = j + 1
}
let args = parse_args(tail)
let code = match sub {
"replay" => cmd_replay(args)
"compare" => cmd_compare(args)
"simulate" => cmd_simulate(args)
"advice" => cmd_advice(args)
"stats" => cmd_stats(args)
"curve" => cmd_curve(args)
"help" => {
println(usage_text())
0
}
"--help" => {
println(usage_text())
0
}
"-h" => {
println(usage_text())
0
}
_ => {
usage_error("未知子命令 " + sub)
2
}
}
if code != 0 {
@sys.exit(code)
}
}