///|
/// `kiln` command line.
///
/// Three verbs, no configuration files, no global state:
/// gen generate a paper and its answer key
/// check re-verify every item in the pool and report
/// pool inspect the candidate pool without drawing a paper
const VERSION : String = "0.1.0"
///|
fn usage() -> Unit {
println("kiln " + VERSION + " — 可验证命题引擎")
println("")
println("用法:")
println(" kiln gen --schema [options]")
println(" kiln check --schema [--verbose]")
println(" kiln pool --schema [--limit N] [--show N]")
println(" kiln verify --file ")
println(
" kiln parallel --schema --file [--seed S] [--out DIR]",
)
println(" kiln help | --version")
println("")
println("gen 选项:")
println(
" --count N 题量(默认 10),与 --levels 二选一",
)
println(" --levels 1:2,2:3 按难度配额组卷")
println(
" --seed S 随机种子(默认 42),同种子同卷",
)
println(" --out DIR 输出目录(默认 out)")
println(" --title T 卷面标题(默认取 schema 的 title)")
println(
" --max-per-tag N 同一结构标签最多出现几次(默认 3)",
)
println(
" --exclude FILE 排除清单:贴过的题目指纹,一行一个",
)
println(
" --record FILE 把本次用到的指纹追加进清单,供下次排除",
)
}
///|
fn flag(argv : Array[String], name : String) -> String? {
let mut i = 0
while i + 1 < argv.length() {
if argv[i] == name {
return Some(argv[i + 1])
}
i = i + 1
}
None
}
///|
fn has_flag(argv : Array[String], name : String) -> Bool {
let mut found = false
for a in argv {
if a == name {
found = true
}
}
found
}
///|
fn flag_int(argv : Array[String], name : String, fallback : Int32) -> Int32 {
match flag(argv, name) {
Some(v) =>
match @kiln.parse_int_opt(v) {
Some(n) => n
None => fallback
}
None => fallback
}
}
///|
fn parse_levels(s : String) -> Array[(Int32, Int32)] {
let out : Array[(Int32, Int32)] = []
for part in @kiln.split_on(s, ',') {
let kv = @kiln.split_on(part, ':')
if kv.length() == 2 {
match (@kiln.parse_int_opt(kv[0]), @kiln.parse_int_opt(kv[1])) {
(Some(level), Some(count)) => out.push((level, count))
_ => ()
}
}
}
out
}
///|
fn load_schema_file(path : String) -> Result[@kiln.Schema, String] {
let src = @fs.read_file_to_string(path) catch {
_ => return Err("无法读取文件:\{path}")
}
@kiln.load_schema(src)
}
///|
/// Read a fingerprint ledger. Missing file means "nothing handed out yet", which
/// is the correct behaviour for the first run of a new term.
fn read_ledger(path : String) -> Array[String] {
let out : Array[String] = []
if !@fs.path_exists(path) {
return out
}
let text = @fs.read_file_to_string(path) catch { _ => return out }
for line in @kiln.split_on(text, '\n') {
let t = @kiln.trim(line)
if t != "" && !t.has_prefix("#") {
out.push(t)
}
}
out
}
///|
/// Append fingerprints to a ledger, keeping the file sorted and unique so it
/// stays diffable and does not grow without bound.
fn write_ledger(
path : String,
previous : Array[String],
added : Array[String],
) -> Bool {
let merged : Array[String] = []
for f in previous {
if !merged.contains(f) {
merged.push(f)
}
}
for f in added {
if !merged.contains(f) {
merged.push(f)
}
}
let sb = StringBuilder()
sb.write_string("# kiln ledger: fingerprints already handed out\n")
for f in merged {
sb.write_string(f)
sb.write_string("\n")
}
try {
@fs.write_string_to_file(path, sb.to_string())
true
} catch {
_ => false
}
}
///|
fn ensure_dir(path : String) -> Unit {
try {
if !@fs.path_exists(path) {
@fs.create_dir(path)
}
} catch {
_ => ()
}
}
///|
fn save(path : String, content : String) -> Bool {
try {
@fs.write_string_to_file(path, content)
true
} catch {
_ => false
}
}
///|
/// Independent re-check: rebuild the polynomial from the certificate and run the
/// verifier again. This is the same path a third party would take.
fn reverify(pool : @kiln.Pool) -> (Int32, Int32) {
let mut ok = 0
let mut bad = 0
for item in pool.items {
let cert = item.certificate
let verdict = match item.family {
FQuadratic => {
let p = @kiln.rebuild_quadratic(cert)
let (v, _) = @kiln.verify_quadratic(p, item.answers)
v
}
FRational => {
let y = cert.payload
let num = @kiln.Poly::new([y[0], y[1]])
let den = @kiln.Poly::new([y[2], y[3]])
let (v, _) = @kiln.verify_rational(num, den, y[4], item.answers)
v
}
}
if verdict.is_well_posed() {
ok = ok + 1
} else {
bad = bad + 1
}
}
(ok, bad)
}
///|
fn print_pool_report(schema : @kiln.Schema, pool : @kiln.Pool) -> Unit {
println("schema \{schema.name}")
println(
"candidates \{pool.attempted} (rejected \{pool.rejected}, duplicates \{pool.duplicates})",
)
println("unique \{pool.size()}")
println("levels \{pool.level_histogram()}")
if pool.rejects.length() > 0 {
let seen : Array[String] = []
for r in pool.rejects {
if !seen.contains(r) {
seen.push(r)
}
}
println("rejects \{seen.length()} distinct reason(s)")
for r in seen {
println(" - \{r}")
}
}
}
///|
fn cmd_pool(argv : Array[String]) -> Unit {
let path = match flag(argv, "--schema") {
Some(p) => p
None => {
println("缺少 --schema")
usage()
return
}
}
let schema = match load_schema_file(path) {
Ok(s) => s
Err(e) => {
println("读入失败:\{e}")
return
}
}
let limit = flag_int(argv, "--limit", 0)
let show = flag_int(argv, "--show", 3)
let pool = @kiln.generate(schema, limit)
print_pool_report(schema, pool)
let (ok, bad) = reverify(pool)
println("reverify \{ok} ok, \{bad} failed")
if show > 0 {
println("")
println("sample:")
let mut n = 0
for item in pool.items {
if n < show {
println(
" \{n + 1}. [\{item.level_label()}] \{item.question} → \{item.answer_text()}",
)
n = n + 1
}
}
}
}
///|
fn cmd_check(argv : Array[String]) -> Unit {
let path = match flag(argv, "--schema") {
Some(p) => p
None => {
println("缺少 --schema")
usage()
return
}
}
let schema = match load_schema_file(path) {
Ok(s) => s
Err(e) => {
println("读入失败:\{e}")
return
}
}
let pool = @kiln.generate(schema, 0)
print_pool_report(schema, pool)
let (ok, bad) = reverify(pool)
println("reverify \{ok} ok, \{bad} failed")
if has_flag(argv, "--verbose") {
println("")
for item in pool.items {
println(
"\{item.fingerprint} [\{item.level_label()}] \{item.answer_text()}",
)
}
}
if bad > 0 {
// CI depends on a non-zero exit here: a verifier that always exits 0 is a
// verifier nobody can gate on.
abort("kiln check: \{bad} 道题未通过独立复核")
}
println("结果 全部通过独立复核")
}
///|
fn cmd_gen(argv : Array[String]) -> Unit {
let path = match flag(argv, "--schema") {
Some(p) => p
None => {
println("缺少 --schema")
usage()
return
}
}
let schema = match load_schema_file(path) {
Ok(s) => s
Err(e) => {
println("读入失败:\{e}")
return
}
}
let seed = flag_int(argv, "--seed", 42)
let count = flag_int(argv, "--count", 10)
let out = match flag(argv, "--out") {
Some(d) => d
None => "out"
}
let title = match flag(argv, "--title") {
Some(t) => t
None => if schema.title == "" { schema.name } else { schema.title }
}
let levels = match flag(argv, "--levels") {
Some(s) => parse_levels(s)
None => []
}
let max_per_tag = flag_int(argv, "--max-per-tag", 3)
let exclude_path = flag(argv, "--exclude")
let record_path = flag(argv, "--record")
let ledger = match exclude_path {
Some(p) => read_ledger(p)
None => []
}
let pool = @kiln.generate(schema, 0)
print_pool_report(schema, pool)
let bp = if levels.length() > 0 {
@kiln.Blueprint::with_levels(levels, seed)
.capping(max_per_tag)
.excluding(ledger)
} else {
@kiln.Blueprint::new(count, seed).capping(max_per_tag).excluding(ledger)
}
let paper = match @kiln.assemble(pool, schema.name, bp) {
Ok(p) => p
Err(e) => {
println("")
println("组卷失败:\{e}")
println("提示:放宽题池 domain 或降低该难度配额。")
return
}
}
let (ok, bad) = reverify(pool)
println("")
println(
"paper \{paper.size()} 题 · 结构 \{paper.request} · seed \{seed}",
)
println("reverify \{ok} ok, \{bad} failed")
if ledger.length() > 0 {
println("excluded \{ledger.length()} 道已用过的题")
}
ensure_dir(out)
let ws = @kiln.render_worksheet(paper, title)
let key = @kiln.render_answer_key(paper, title)
let md = @kiln.render_markdown(paper, title)
let paper_json = @kiln.paper_to_json_string(paper, title)
let f1 = save("\{out}/worksheet.html", ws)
let f2 = save("\{out}/answers.html", key)
let f3 = save("\{out}/worksheet.md", md)
let f4 = save("\{out}/paper.json", paper_json)
println("")
println("\{if f1 { "ok " } else { "fail" }} \{out}/worksheet.html")
println("\{if f2 { "ok " } else { "fail" }} \{out}/answers.html")
println("\{if f3 { "ok " } else { "fail" }} \{out}/worksheet.md")
println("\{if f4 { "ok " } else { "fail" }} \{out}/paper.json")
match record_path {
Some(p) => {
let used : Array[String] = []
for it in paper.items {
used.push(it.fingerprint)
}
if write_ledger(p, ledger, used) {
println(
"recorded \{p}(累计 \{ledger.length() + used.length()} 条)",
)
} else {
println("recorded 写入失败:\{p}")
}
}
None => ()
}
println("")
for i, item in paper.items {
println("\{i + 1}. [\{item.level_label()}] \{item.question}")
}
}
///|
/// Independent verification of a paper file.
///
/// Note what this does *not* do: it never reads the schema and never re-runs the
/// generator. It only takes each item's payload and answers, and re-derives the
/// answer from scratch. If someone hands you a paper and a forged answer key,
/// this is the command that catches it.
fn cmd_verify(argv : Array[String]) -> Unit {
let path = match flag(argv, "--file") {
Some(p) => p
None => {
println("缺少 --file")
usage()
abort("kiln verify: 未指定文件")
}
}
let text = @fs.read_file_to_string(path) catch {
_ => {
println("无法读取:\{path}")
abort("kiln verify: 无法读取文件")
}
}
let pf = match @kiln.paper_from_json_string(text) {
Ok(p) => p
Err(e) => {
println("格式错误:\{e}")
abort("kiln verify: 文件格式不合法")
}
}
let report = @kiln.verify_paper_file(pf)
println("format \{pf.format}")
println("schema \{pf.schema_name}")
println("title \{pf.title}")
println("seed \{pf.seed}")
println("items \{report.total}")
println("passed \{report.passed}")
println("failed \{report.failures.length()}")
for f in report.failures {
println(" " + f)
}
if !report.all_passed() {
abort(
"kiln verify: \{report.failures.length()} 道题未通过独立复核",
)
}
println("结果 全部题目可由凭证独立复核")
}
///|
/// Build a parallel form (B 卷) from an existing paper file (A 卷).
fn cmd_parallel(argv : Array[String]) -> Unit {
let schema_path = match flag(argv, "--schema") {
Some(p) => p
None => {
println("缺少 --schema")
usage()
abort("kiln parallel: 未指定 schema")
}
}
let a_path = match flag(argv, "--file") {
Some(p) => p
None => {
println("缺少 --file")
usage()
abort("kiln parallel: 未指定 A 卷文件")
}
}
let schema = match load_schema_file(schema_path) {
Ok(s) => s
Err(e) => {
println("读入失败:\{e}")
abort("kiln parallel: schema 读入失败")
}
}
let a_text = @fs.read_file_to_string(a_path) catch {
_ => {
println("无法读取:\{a_path}")
abort("kiln parallel: A 卷文件无法读取")
}
}
let a = match @kiln.paper_from_json_string(a_text) {
Ok(pf) => pf
Err(e) => {
println("格式错误:\{e}")
abort("kiln parallel: A 卷格式不合法")
}
}
let seed = flag_int(argv, "--seed", 42)
let out = match flag(argv, "--out") {
Some(d) => d
None => "out-b"
}
let pool = @kiln.generate(schema, 0)
let b = match @kiln.parallel_paper_file(pool, a, seed) {
Ok(p) => p
Err(e) => {
println("")
println("平行卷失败:\{e}")
abort("kiln parallel: 无法构造平行卷")
}
}
let title = if schema.title == "" { schema.name } else { schema.title }
ensure_dir(out)
let ws = @kiln.render_worksheet(b, title + " · B卷")
let key = @kiln.render_answer_key(b, title + " · B卷")
let json = @kiln.paper_to_json_string(b, title + " · B卷")
let f1 = save("\{out}/worksheet.html", ws)
let f2 = save("\{out}/answers.html", key)
let f3 = save("\{out}/paper.json", json)
println("")
println(
"paper 平行卷 \{b.size()} 题 · 结构 \{b.request} · seed \{seed}",
)
println("\{if f1 { "ok " } else { "fail" }} \{out}/worksheet.html")
println("\{if f2 { "ok " } else { "fail" }} \{out}/answers.html")
println("\{if f3 { "ok " } else { "fail" }} \{out}/paper.json")
println("")
for i, item in b.items {
println("\{i + 1}. [\{item.level_label()}] \{item.question}")
}
}
///|
/// Find the verb anywhere in `argv`.
///
/// The wasm runner passes only the user arguments, while the JS runner passes
/// `node`, the script path and then the arguments. Scanning for the verb keeps
/// the same binary usable on both without backend-specific branching.
fn find_cmd(argv : Array[String]) -> String {
let mut i = 0
while i < argv.length() {
let a = argv[i]
if a == "gen" ||
a == "check" ||
a == "pool" ||
a == "verify" ||
a == "parallel" ||
a == "help" {
return a
}
i = i + 1
}
""
}
///|
fn main {
let argv = @env.args()
let mut wants_version = false
for a in argv {
if a == "--version" || a == "-v" {
wants_version = true
}
}
let cmd = find_cmd(argv)
if wants_version {
println("kiln " + VERSION)
return
}
if cmd == "gen" {
cmd_gen(argv)
} else if cmd == "check" {
cmd_check(argv)
} else if cmd == "pool" {
cmd_pool(argv)
} else if cmd == "verify" {
cmd_verify(argv)
} else if cmd == "parallel" {
cmd_parallel(argv)
} else {
usage()
}
}