// ============================================================================
// lincheck CLI
//
// lincheck check --history --model [--budget-ms N] [--json]
// lincheck minimize --history --model [--json]
// lincheck visualize --history
// lincheck gen --model --processes N --ops N --seed N [--consistent|--random|--inject]
// lincheck selfcheck --model --seeds N [--ops N] [--processes N]
//
// 退出码:0 一致 / 1 违规 / 2 用法错误 / 3 未知
//
// 注:跨包符号必须写全 `@lib.` 前缀——MoonBit 不会自动展开 src 包的导出。
// ============================================================================
///|
/// 退出码
const EXIT_OK : Int32 = 0
///|
const EXIT_VIOLATION : Int32 = 1
///|
const EXIT_USAGE : Int32 = 2
///|
const EXIT_UNKNOWN : Int32 = 3
///|
/// 输入数据错误(文件读不到、JSONL 不合法)。
/// 不复用 1 —— 1 专指「发现违规」,混用会让脚本无法区分
/// 「历史确实违规」和「历史文件根本读不了」。
const EXIT_DATA : Int32 = 4
///|
/// CLI 用法错误(参数缺失、拼错、未知取值)
suberror Failure {
Failure(String)
}
///|
/// 输入数据错误(文件读不到、JSONL 不合法、记录格式错)
///
/// 与 `Failure` 分开,是为了让退出码能区分「你命令打错了」和
/// 「你的数据有问题」——这两件事调用方的处理方式完全不同。
suberror DataFailure {
DataFailure(String)
}
///|
/// 解析后的一次调用
struct Cli {
command : String
model : String
history_path : String
budget_ms : Int32
seeds : Int32
processes : Int32
ops_per_process : Int32
seed : Int32
concurrency : Double
mode : String
json : Bool
}
///|
fn default_cli(cmd : String) -> Cli {
{
command: cmd,
model: "register",
history_path: "",
budget_ms: 5000,
seeds: 100,
processes: 3,
ops_per_process: 4,
seed: 1,
concurrency: 0.5,
mode: "consistent",
json: false,
}
}
// ── 参数解析 ──────────────────────────────────────────────────────────
///|
/// 解析整数(允许前导 -)。手写逐位累加,不依赖标准库解析函数。
///
/// 溢出防护:累加前先判断 `acc` 是否还会溢出(Int32 上限 2147483647),
/// 溢出返回 None 让调用方报「参数值非法」,而不是静默回绕成另一个数。
/// `-2147483648` 单独放行:它的绝对值超 Int32 上限,但取负后合法。
///
/// 标 `pub` 仅为可测性:可执行包没有下游消费者,黑盒测试(主包的
/// `*_test.mbt` 只能看到 pub 符号)需要直接覆盖溢出与符号的边界。
pub fn parse_int_arg(s : String) -> Int32? {
if s.length() == 0 {
return None
}
let mut body = s
let mut neg = false
if s[0] == '-' {
neg = true
body = s.substring(start=1, end=s.length())
}
if body.length() == 0 {
return None
}
if neg && body == "2147483648" {
// 唯一绝对值超 Int32 上限的合法负数:整个数字串恰为 2147483648。
// 必须在累加循环之前放行——循环内的溢出检查会先把它拒成 None,
// 旧实现对此的放行写在循环之后,实际永远到不了。
return Some(-2147483648)
}
// Int32 上限 2147483647:累加前判断 acc 是否还会溢出,溢出返回 None
// 让调用方报「参数值非法」,而不是静默回绕成另一个数。
let mut acc = 0
for ch in body.to_array() {
if !ch.is_ascii_digit() {
return None
}
let d = ch.to_int() - '0'.to_int()
if acc > (2147483647 - d) / 10 {
return None
}
acc = acc * 10 + d
}
if neg {
Some(-acc)
} else {
Some(acc)
}
}
///|
/// 解析 "0.5" / "1" 这类简单浮点。
///
/// 符号必须对整个数值生效:小数部分只按无符号数解析,最后统一乘上
/// 整体符号。直接把负号塞给整数段会让 `-0.5` 变成 `+0.5`(整数段是
/// 0,负号被静默丢掉),`-1.5` 变成 `-0.5`。
///
/// 标 `pub` 仅为可测性,理由同 parse_int_arg。
pub fn parse_double_arg(s : String) -> Double? {
let mut body = s
let mut neg = false
if body.length() > 0 && body[0] == '-' {
neg = true
body = body.substring(start=1, end=body.length())
}
let parts = body.split(".").collect()
if parts.length() == 1 {
match parse_int_arg(body) {
Some(v) => if neg { Some(-v.to_double()) } else { Some(v.to_double()) }
None => None
}
} else if parts.length() == 2 {
let whole = match parse_int_arg(parts[0].to_owned()) {
Some(v) => v
None => return None
}
let frac_s = parts[1].to_owned()
if frac_s.length() == 0 {
return None
}
let frac = match parse_int_arg(frac_s) {
Some(v) => v
None => return None
}
let mut scale = 1.0
let mut i = 0
while i < frac_s.length() {
scale = scale * 10.0
i = i + 1
}
let value = whole.to_double() + frac.to_double() / scale
if neg {
Some(-value)
} else {
Some(value)
}
} else {
None
}
}
///|
/// 打印用法
fn usage() -> Unit {
println("lincheck — 线性一致性检查器")
println("")
println("用法:")
println(
" lincheck check --history --model [--budget-ms N] [--json]",
)
println(" lincheck minimize --history --model [--json]")
println(" lincheck visualize --history ")
println(
" lincheck gen --model --processes N --ops N --seed N [--consistent|--random|--inject] [--concurrency F]",
)
println(
" lincheck selfcheck --model --seeds N [--ops N] [--processes N]",
)
println("")
let names = StringBuilder()
for i, n in @lib.model_names() {
if i > 0 {
names.write_string(" | ")
}
names.write_string(n)
}
println("模型:\{names.to_string()}")
println("")
println(
"退出码:0 线性一致 / 1 发现违规 / 2 用法错误 / 3 结论未知 / 4 输入数据错误",
)
}
// ── 载入历史 ──────────────────────────────────────────────────────────
///|
/// 把解析失败渲染成可读的错误文本(**行号 + 原因**都在里面)。
///
/// 库层的 `History::from_jsonl` 已经算好了「从 1 开始的行号」与具体原因
/// (见 `history.mbt` 的 `parse_op_line`,注释写明「报错位置与编辑器里的行号
/// 一致」),旧实现却用 `Err(_)` 把它们整个丢掉,只留一句
/// `JSONL 解析失败:<路径>` —— 对一个几百行的历史几乎无法定位。
///
/// 标 `pub` 仅为可测性(理由同 `parse_int_arg`):错误文本是这条命令的
/// 可观测输出,主包的 `*_test.mbt` 只能看到 pub 符号。返回 `String` 而不是
/// 直接 raise,是为了让测试能无副作用地断言文本。
pub fn jsonl_error_text(path : String, error : Error) -> String {
match error {
@lib.BadLine(line_no, reason) =>
"JSONL 解析失败:\{path}(第 \{line_no} 行:\{reason})"
_ => "JSONL 解析失败:\{path}"
}
}
///|
fn load_history(path : String) -> @lib.History raise {
if path.length() == 0 {
raise Failure("缺少 --history")
}
let text : String = @fs.read_file_to_string(path) catch {
_ => raise DataFailure("读不到文件:\{path}")
}
match @lib.History::from_jsonl(text) {
Ok(h) => h
Err(e) => raise DataFailure(jsonl_error_text(path, e))
}
}
// ── check ─────────────────────────────────────────────────────────────
///|
/// 违规结果的 JSON 形态
fn json_violation(
v : @lib.Violation,
history : @lib.History,
verdict : String,
) -> String {
let sb = StringBuilder()
sb.write_string("{\"verdict\":\"")
sb.write_string(verdict)
// 可编程区分「真并发违规」与「历史数据自相矛盾」:两者退出码都是 1,
// 光看 verdict 分不出来(LC-2)。见证没能被复现时如实写 unconfirmed-witness。
sb.write_string("\",\"reason\":\"")
sb.write_string(
if verdict == "non-linearizable" {
@lib.violation_reason(v)
} else {
"unconfirmed-witness"
},
)
sb.write_string("\",\"total\":")
sb.write_string(history.length().to_string())
sb.write_string(",\"witness\":[")
for i, op in v.ops {
if i > 0 {
sb.write_string(",")
}
sb.write_string("{\"id\":")
sb.write_string(op.id.to_string())
sb.write_string(",\"process\":")
sb.write_string(op.process.to_string())
sb.write_string(",\"op\":\"")
sb.write_string(@lib.kind_label(op.kind))
sb.write_string("\",\"result\":\"")
sb.write_string(@lib.result_label(op.result))
sb.write_string("\",\"start\":")
sb.write_string(op.start.to_string())
sb.write_string(",\"end\":")
sb.write_string(op.end.to_string())
sb.write_string("}")
}
sb.write_string("],\"depth\":")
sb.write_string(v.depth.to_string())
sb.write_string("}")
sb.to_string()
}
///|
/// 解析 `--model`,未知名字返回可读的错误消息。
///
/// 不静默回退到默认模型:拼错一个字母就静默换模型,会给出一个看起来
/// 权威但完全不对的结论。
fn resolve_model(cli : Cli) -> Result[@lib.AnyModel, String] {
match @lib.model_by_name(cli.model) {
Some(m) => Ok(m)
None =>
Err(
"未知模型 \{cli.model}(可选 \{@lib.model_names().join(", ")})",
)
}
}
///|
/// 解析模型,失败时打印到 stderr 并返回 None 供调用方提前退出。
fn require_model(cli : Cli) -> @lib.AnyModel? {
match resolve_model(cli) {
Ok(m) => Some(m)
Err(msg) => {
eprintln("用法错误: \{msg}")
None
}
}
}
///|
fn cmd_check(cli : Cli) -> Int32 raise {
let cfg = @lib.CheckConfig::{
time_budget_ms: cli.budget_ms,
max_states: 200000,
allow_unknown_results: false,
}
// 先校验参数、再读数据:模型拼错 + 文件缺失同时存在时,
// 应报「用法错误」(2) 而不是「数据错误」(4)——命令是你自己敲错的,
// 修复动作是改命令行,不是修文件。
let model = match require_model(cli) {
Some(m) => m
None => return EXIT_USAGE
}
let history = load_history(cli.history_path)
match @lib.check(history, model, cfg) {
@lib.Linearizable => {
if cli.json {
println("{\"verdict\":\"linearizable\",\"ops\":\{history.length()}}")
} else {
println(
"✓ LINEARIZABLE (\{history.length()} 个操作,模型 \{cli.model})",
)
}
EXIT_OK
}
@lib.Violation(v) => {
if cli.json {
println(json_violation(v, history, "non-linearizable"))
} else {
println(
"✗ NON-LINEARIZABLE (\{v.ops.length()} / \{history.length()} 个操作)",
)
println("")
for op in v.ops {
println(" \{@lib.op_line(op)}")
}
println("")
println(" \{v.explanation}")
}
EXIT_VIOLATION
}
@lib.Unknown(reason) => {
if cli.json {
println("{\"verdict\":\"unknown\",\"reason\":\"\{reason}\"}")
} else {
println("? UNKNOWN \{reason}")
}
EXIT_UNKNOWN
}
}
}
// ── minimize ──────────────────────────────────────────────────────────
///|
/// 带缩进的操作列表
fn render_ops_indented(ops : Array[@lib.Operation]) -> String {
let sb = StringBuilder()
for op in ops {
sb.write_string(" · 操作 ")
sb.write_string(op.id.to_string())
sb.write_string(": ")
sb.write_string(@lib.op_line(op))
sb.write_string("\n")
}
sb.to_string()
}
///|
fn cmd_minimize(cli : Cli) -> Int32 raise {
// 与 cmd_check 同理:用法错误(2)优先于数据错误(4)。
let model = match require_model(cli) {
Some(m) => m
None => return EXIT_USAGE
}
let history = load_history(cli.history_path)
let original_n = history.length()
// --budget-ms 之前被硬编码的 default() 吞掉:用户给 10ms 就是想让大历史
// 快速返回,结果工具默默按 5s 跑 —— 显式参数必须生效。
let cfg : @lib.CheckConfig = {
time_budget_ms: cli.budget_ms,
max_states: 200_000,
allow_unknown_results: false,
}
// 用四态入口区分「线性一致 / 已判定违规且有见证 / 无合格见证 / 未下结论」。
//
// 旧实现用的是 `minimize_witness`(`Violation?`),而它把 `Unknown` 也编码成
// `Some(Violation{ ops = 全部操作 })` —— 于是「预算耗尽」与「已判定违规」在
// 这个返回值上不可区分,CLI 会把一次没跑完的搜索渲染成 `✗ NON-LINEARIZABLE`
// + exit 1,正是 `docs/exit-codes.md` 点名要避免的最坏情形。
match @lib.minimize_outcome(history, model, cfg) {
@lib.Inconclusive(reason) => {
// 与 `check` 同口径:没有结论就是 3,且不得出现 NON-LINEARIZABLE 字样。
if cli.json {
println("{\"verdict\":\"unknown\",\"reason\":\"\{reason}\"}")
} else {
println("? UNKNOWN \{reason}")
println(
" 无法最小化:检查本身没有给出结论,因此不存在可输出的见证。",
)
println(
" 提示:--budget-ms 给得太小或历史过大时会出现这种情况;放开预算后重跑。",
)
}
EXIT_UNKNOWN
}
@lib.NoWitness => {
if cli.json {
println(
"{\"verdict\":\"non-linearizable\",\"total\":\{original_n},\"witness\":[],\"reason\":\"no-explainable-witness\"}",
)
} else {
println("✗ NON-LINEARIZABLE")
println("")
println(
" 最小化:\{original_n} 个操作里找不到可输出的最小见证(历史本身确实违规)。",
)
println("")
println(
" \{@lib.no_explainable_witness_text(original_n, @lib.MIN_WITNESS)}",
)
}
EXIT_VIOLATION
}
@lib.Consistent => {
if cli.json {
println("{\"verdict\":\"linearizable\",\"total\":\{original_n}}")
} else {
println("✓ 历史线性一致,没有反例需要最小化。")
}
EXIT_OK
}
@lib.MinViolation(v) => {
// 硬性验证:最小化产物必须仍然违规。
//
// 这里必须用用户给的预算,不能回落到 `CheckConfig::default()`:那不是
// 「再验证」,而是用另一个(宽松得多)的配置替用户重跑一遍 —— 预算
// 一旦被截断,主检查报 Unknown、再验证却跑通,结论就被偷偷升级了。
let witness = @lib.History::{ operations: v.ops, }
let confirmed = match @lib.check(witness, model, cfg) {
@lib.Violation(_) => true
@lib.Unknown(reason) => {
if cli.json {
println("{\"verdict\":\"unknown\",\"reason\":\"\{reason}\"}")
} else {
println("? UNKNOWN \{reason}")
println(" \{v.explanation}")
}
return EXIT_UNKNOWN
}
_ => false
}
if cli.json {
// 退出码与 JSON 结论必须一致:confirmed=false 意味着连 check 都
// 无法复现这个「见证」,此时宣称 non-linearizable 是把脚本当猴耍
// —— 它会把这个历史记成一个已证实的 bug。未知就写 unknown。
let verdict = if confirmed { "non-linearizable" } else { "unknown" }
println(json_violation(v, history, verdict))
if !confirmed {
println(
"{\"note\":\"witness could not be re-confirmed by check\",\"reason\":\"witness chain broken\"}",
)
}
} else {
println(if confirmed { "✗ NON-LINEARIZABLE" } else { "? UNKNOWN" })
println("")
println(" 最小化:\{original_n} → \{v.ops.length()} 个操作")
println(
" 再验证:\{if confirmed { "仍违规 ✓" } else { "未能复现 ✗" }}",
)
println("")
println(render_ops_indented(v.ops))
println("")
println(" \{v.explanation}")
}
if confirmed {
EXIT_VIOLATION
} else {
EXIT_UNKNOWN
}
}
}
}
// ── visualize ─────────────────────────────────────────────────────────
///|
fn cmd_visualize(cli : Cli) -> Int32 raise {
// 与 cmd_check 同理:用法错误(2)优先于数据错误(4)。
let model = match require_model(cli) {
Some(m) => m
None => return EXIT_USAGE
}
let history = load_history(cli.history_path)
// 与 cmd_check / cmd_minimize 同口径:显式 --budget-ms 必须生效。
//
// 旧实现传的是 `CheckConfig::default()`(5 秒),于是 `--budget-ms 1` 被静默
// 忽略:同一份历史下 `check` 探索 1536 个节点就报 unknown,`visualize` 却跑完
// 135181 个节点并断言违规(退出码 1)。用户以为限了预算,拿到的是另一次
// 搜索的结论 —— 退出码因此与 check 矛盾。
let cfg : @lib.CheckConfig = {
time_budget_ms: cli.budget_ms,
max_states: 200_000,
allow_unknown_results: false,
}
match @lib.check(history, model, cfg) {
@lib.Linearizable => {
println(@lib.render_timeline(history))
EXIT_OK
}
@lib.Violation(v) => {
// render_violation 内部会画见证子集的时间轴;若这里再画一遍
// 全量时间轴,同一条历史会出现两次几乎相同的图。只保留前者。
println(@lib.render_violation(v, history))
EXIT_VIOLATION
}
@lib.Unknown(reason) => {
println(@lib.render_timeline(history))
println("")
println("? 结论未知:\{reason}")
EXIT_UNKNOWN
}
}
}
// ── gen ───────────────────────────────────────────────────────────────
///|
fn cmd_gen(cli : Cli) -> Int32 {
let model = match require_model(cli) {
Some(m) => m
None => return EXIT_USAGE
}
// 规模参数先校验:`processes × ops_per_process` 是用户可控的 Int32 乘积,
// 溢出会让生成器回绕出一个毫不相关的循环上界(最坏的一档回绕成 0,
// 静默产出一份空历史并 exit 0)。用法错误(2)优先于「生成成功」——
// 与 parse_int_arg 对单值溢出的口径一致。
match @lib.total_ops_checked(cli.processes, cli.ops_per_process) {
Some(_) => ()
None => {
eprintln(
"用法错误: --processes \{cli.processes} × --ops \{cli.ops_per_process} 超出 Int32 范围,无法生成历史(每客户端 --ops 条,总数 = processes × ops)",
)
return EXIT_USAGE
}
}
let cfg = @lib.GenConfig::{
processes: cli.processes,
ops_per_process: cli.ops_per_process,
seed: cli.seed,
concurrency: cli.concurrency,
}
let history = match cli.mode {
"random" => @lib.generate_random(model, cfg)
"inject" =>
@lib.inject_violation(
@lib.generate_consistent(model, cfg),
model,
cli.seed,
)
_ => @lib.generate_consistent(model, cfg)
}
println(history.to_jsonl())
EXIT_OK
}
// ── selfcheck ─────────────────────────────────────────────────────────
///|
fn cmd_selfcheck(cli : Cli) -> Int32 {
let model = match require_model(cli) {
Some(m) => m
None => return EXIT_USAGE
}
let total = if cli.seeds < 1 { 1 } else { cli.seeds }
let mut seed = 1
let mut failed = 0
let mut violations = 0
let mut unknowns = 0
let mut injected = 0
let mut injected_missed = 0
while seed <= total {
// 并发度在 0..1 之间轮转,覆盖串行到完全并发
let conc = (seed % 11).to_double() / 10.0
let cfg = @lib.GenConfig::{
processes: cli.processes,
ops_per_process: cli.ops_per_process,
seed,
concurrency: conc,
}
let h = @lib.generate_consistent(model, cfg)
match @lib.check(h, model, @lib.CheckConfig::default()) {
@lib.Linearizable => ()
@lib.Violation(_) => {
violations = violations + 1
failed = failed + 1
}
@lib.Unknown(_) => {
unknowns = unknowns + 1
failed = failed + 1
}
}
// 顺手做注入测试:制造违规,检查器必须发现
let bad = @lib.inject_violation(h, model, seed)
if bad.to_jsonl() != h.to_jsonl() {
injected = injected + 1
match @lib.check(bad, model, @lib.CheckConfig::default()) {
@lib.Violation(_) => ()
_ => injected_missed = injected_missed + 1
}
}
seed = seed + 1
}
println(
"selfcheck model=\{cli.model} seeds=\{total} ops/seed=\{cli.ops_per_process}",
)
println(
" · 合法历史全部通过:\{if failed == 0 { "是 ✓" } else { "否 ✗" }}",
)
println(" - 误判违规:\{violations}")
println(" - 结论未知:\{unknowns}")
println(
" · 注入违规被发现:\{injected - injected_missed} / \{injected}",
)
if failed == 0 && injected_missed == 0 {
println("结果:PASS")
EXIT_OK
} else {
println("结果:FAIL")
EXIT_VIOLATION
}
}
// ── 入口 ──────────────────────────────────────────────────────────────
///|
/// 解析命令行:逐项消费 argv,遇到无法识别的形态返回 None。
///
/// 用递归传递不可变 Cli 值(MoonBit 记录字段默认不可变,需要 mut 才能原地改)。
fn parse_from(cli : Cli, argv : Array[String], i : Int32) -> Cli? {
if i >= argv.length() {
return Some(cli)
}
let flag = argv[i]
if flag == "--json" {
parse_from({ ..cli, json: true, }, argv, i + 1)
} else if flag == "--consistent" {
parse_from({ ..cli, mode: "consistent", }, argv, i + 1)
} else if flag == "--random" {
parse_from({ ..cli, mode: "random", }, argv, i + 1)
} else if flag == "--inject" {
parse_from({ ..cli, mode: "inject", }, argv, i + 1)
} else if i + 1 >= argv.length() {
None
} else {
let value = argv[i + 1]
if flag == "--history" {
parse_from({ ..cli, history_path: value, }, argv, i + 2)
} else if flag == "--model" {
parse_from({ ..cli, model: value, }, argv, i + 2)
} else if flag == "--budget-ms" {
match parse_int_arg(value) {
Some(v) => parse_from({ ..cli, budget_ms: v, }, argv, i + 2)
None => None
}
} else if flag == "--seeds" {
match parse_int_arg(value) {
Some(v) => parse_from({ ..cli, seeds: v, }, argv, i + 2)
None => None
}
} else if flag == "--processes" {
match parse_int_arg(value) {
Some(v) => parse_from({ ..cli, processes: v, }, argv, i + 2)
None => None
}
} else if flag == "--ops" {
match parse_int_arg(value) {
Some(v) => parse_from({ ..cli, ops_per_process: v, }, argv, i + 2)
None => None
}
} else if flag == "--seed" {
match parse_int_arg(value) {
Some(v) => parse_from({ ..cli, seed: v, }, argv, i + 2)
None => None
}
} else if flag == "--concurrency" {
match parse_double_arg(value) {
Some(v) => parse_from({ ..cli, concurrency: v, }, argv, i + 2)
None => None
}
} else {
None
}
}
}
///|
/// 解析命令行;出错返回 None
fn parse_args(cmd : String, argv : Array[String]) -> Cli? {
parse_from(default_cli(cmd), argv, 2)
}
///|
/// 执行一次调用并返回退出码。
///
/// 标 `pub` 仅为可测性,理由同 `parse_int_arg`:可执行包没有下游消费者,而
/// 主包的 `*_test.mbt` 只能看到 pub 符号。退出码是 `docs/exit-codes.md` 的核心
/// 契约(`1` = 有效结论 / `3` = 没有结论),只有直接断言它才能锁住
/// 「预算耗尽不得被渲染成已判定违规」这类缺陷。
pub fn run(argv : Array[String]) -> Int32 {
if argv.length() < 2 {
usage()
return EXIT_USAGE
}
let cmd = argv[1]
if cmd == "help" || cmd == "--help" || cmd == "-h" {
usage()
return EXIT_OK
}
let known = ["check", "minimize", "visualize", "gen", "selfcheck"]
let mut valid = false
for k in known {
if k == cmd {
valid = true
}
}
if !valid {
println("未知子命令:\{cmd}")
println("")
usage()
return EXIT_USAGE
}
match parse_args(cmd, argv) {
None => {
println("参数错误(缺值或数值非法)")
println("")
usage()
EXIT_USAGE
}
Some(c) => {
let result : Int32 = try {
match c.command {
"check" => cmd_check(c)
"minimize" => cmd_minimize(c)
"visualize" => cmd_visualize(c)
"gen" => cmd_gen(c)
_ => cmd_selfcheck(c)
}
} catch {
Failure(msg) => {
println("用法错误:\{msg}")
EXIT_USAGE
}
DataFailure(msg) => {
println("数据错误:\{msg}")
EXIT_DATA
}
_ => {
println("内部错误")
EXIT_UNKNOWN
}
}
result
}
}
}
///|
fn main {
let argv = @env.args()
let code = run(argv)
@sys.exit(code)
}